diff --git a/astro.config.mjs b/astro.config.mjs index 3b71236..d9a7825 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -14,7 +14,7 @@ export default defineConfig({ mdx(), sitemap({ filter: (page) => - !["/blog/", "/features/", "/pricing/", "/integrations/", "/404/"].some( + !["/features/", "/pricing/", "/integrations/", "/404/"].some( (path) => new URL(page).pathname.startsWith(path), ), }), diff --git a/docs/superpowers/plans/2026-07-20-blog-feature.md b/docs/superpowers/plans/2026-07-20-blog-feature.md new file mode 100644 index 0000000..829b31a --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-blog-feature.md @@ -0,0 +1,763 @@ +# Blog Feature Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Re-activate and rebuild the disabled Astroship blog on the Bitropy marketing site — a `/blog` index, individual post pages, and an RSS feed — styled to match the current redesigned site. + +**Architecture:** Astro content collection (`glob` loader over `src/content/blog`) drives prerendered `/blog` and `/blog/` pages plus a `/rss.xml` endpoint. The existing `Layout` is extended with an `ogType` prop; `BlogLayout` is restyled to the site's design tokens. Nav, footer, and sitemap are wired to include the blog. + +**Tech Stack:** Astro ^6.3.3 (content layer), MDX, Tailwind CSS v4 + `@tailwindcss/typography`, `@astrojs/rss` (new), `astro-seo`, Vercel adapter. + +## Global Constraints + +Every task's requirements implicitly include these: + +- Astro version is `^6.3.3`; `astro.config.mjs` has `output: "server"`. **Every blog page/endpoint file MUST set `export const prerender = true`** or `getStaticPaths` is ignored and routes resolve at request time. +- Content-layer API (Astro 5/6): import `render` from `astro:content` and call `await render(entry)`; the per-entry identifier is `entry.id` (no extension), **not** `entry.slug`. +- Publish filter, used identically on the index, the post `getStaticPaths`, and the RSS feed: `!data.draft && data.publishDate <= new Date()`. `publishDate` is already transformed to a `Date` by the schema. +- Match the site's design tokens: headings in the default sans (Bricolage Grotesque, via `font-bold`), body text `text-slate-600`, accent `text-purple-600`, cards `bg-slate-50 rounded-xl p-6`, page width via the `Container` component. Follow the patterns in `src/pages/products.astro` and `src/pages/about.astro`. +- No emojis anywhere. Reuse existing components (`Layout`, `Container`, `Sectionhead`) and the `getFormattedDate` util rather than re-implementing. +- Path alias `@/*` maps to `src/*`. +- Verification gate is `pnpm build` (authoritative — catches schema, prerender, and import errors) plus `dist/` inspection; final task also runs `pnpm lint`. There is no unit-test framework in this repo. + +--- + +### Task 1: Make schema fields optional and add the published-posts helper + +**Files:** +- Modify: `src/content.config.ts` +- Create: `src/utils/posts.ts` + +**Interfaces:** +- Produces: the `blog` collection with `image`, `category`, `tags` optional; required fields `draft`, `title`, `snippet`, `publishDate` (→ `Date`), `author` (default `"Bitropy"`). All later tasks read `entry.data` with these fields. +- Produces: `getPublishedPosts()` from `src/utils/posts.ts` — returns published (`!draft && publishDate <= now`) blog entries sorted by `publishDate` descending. Consumed by Tasks 5 (post `getStaticPaths`), 6 (index), and 7 (RSS). + +- [ ] **Step 1: Replace the schema** + +Overwrite `src/content.config.ts` with: + +```ts +import { z, defineCollection } from "astro:content"; +import { glob } from "astro/loaders"; + +const blogCollection = defineCollection({ + loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }), + schema: z.object({ + draft: z.boolean(), + title: z.string(), + snippet: z.string(), + publishDate: z.string().transform((str) => new Date(str)), + author: z.string().default("Bitropy"), + image: z + .object({ + src: z.string(), + alt: z.string(), + }) + .optional(), + category: z.string().optional(), + tags: z.array(z.string()).optional(), + }), +}); + +export const collections = { + blog: blogCollection, +}; +``` + +- [ ] **Step 2: Create the published-posts helper** + +Create `src/utils/posts.ts`: + +```ts +import { getCollection } from "astro:content"; + +/** + * Published blog posts (not draft, publish date in the past), + * newest first. Single source of truth for the index, post pages, and RSS. + */ +export async function getPublishedPosts() { + const posts = await getCollection( + "blog", + ({ data }) => !data.draft && data.publishDate <= new Date(), + ); + return posts.sort( + (a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf(), + ); +} +``` + +- [ ] **Step 3: Verify it builds** + +Run: `pnpm build` +Expected: build completes with no schema/collection/type errors (the collection is still empty, which is fine). + +- [ ] **Step 4: Commit** + +```bash +git add src/content.config.ts src/utils/posts.ts +git commit -m "Make blog fields optional and add published-posts helper + +Covers and category/tag display are deferred out of blog v1, so authors are not +forced to populate those fields. getPublishedPosts() centralizes the publish +filter and sort shared by the index, post pages, and RSS feed." +``` + +--- + +### Task 2: Add the seed post + +**Files:** +- Create: `src/content/blog/llm-router-failover.mdx` +- Delete: `src/content/blog/.gitkeep` (no longer needed once a real post exists) + +**Interfaces:** +- Consumes: the schema from Task 1. +- Produces: one published post with `id = "llm-router-failover"`, consumed by the index, post page, and RSS tasks. + +- [ ] **Step 1: Create the post** + +Create `src/content/blog/llm-router-failover.mdx`: + +```mdx +--- +draft: false +title: "When Your SOTA Provider Goes Down: Failover Routing in the Bitropy LLM Router" +snippet: "Top-tier model providers have bad days too. Here is how same-model failover in the Bitropy router keeps your AI features answering, without silently dropping to a weaker model." +publishDate: "2026-07-18" +author: "Bitropy" +--- + +Every team building on frontier models eventually learns the same lesson: the +provider hosting your best model is a dependency, and dependencies fail. A +region degrades. A rollout goes sideways. Rate limits tighten without warning +during a spike. When that happens, the naive setup does the worst possible +thing: it returns errors to your users while your dashboards stay green, +because the model itself is "fine" everywhere except the one endpoint you +happen to be calling. + +The Bitropy LLM router treats a model as something you can reach through more +than one door. When the door you are using jams, it walks you to another one +that opens onto the same room. + +## Same model, different deployment + +The key idea is same-model failover. Most frontier models are available through +more than one deployment: a first-party API, one or more cloud-hosted versions +(for example the same model offered through a major cloud's managed AI service), +and often multiple regions within each. These are the *same weights* producing +the *same responses*. They just sit behind different endpoints, quotas, and +failure domains. + +Bitropy lets you declare those deployments as a pool behind a single logical +model name. Your application asks for one model; the router decides which +concrete deployment actually serves each request. When the primary deployment +starts failing, traffic shifts to a healthy sibling automatically, and the +response your user gets is indistinguishable from the one they would have gotten +on a normal day. + +This is deliberately different from falling back to a *weaker* model. Dropping +from your top model to a smaller, cheaper one during an incident is a silent +quality regression at the worst possible moment: your users are already +frustrated, and now the answers get worse too. Same-model failover keeps the +quality bar fixed and only changes the plumbing underneath. + +## What triggers a reroute + +The router does not wait for a human to notice. It reacts to the signals that +actually predict a bad request: + +- **Hard errors:** 5xx responses, connection resets, and provider-side + "overloaded" or capacity errors mark a deployment as unhealthy. +- **Timeouts:** requests that exceed a configured deadline are treated as + failures, not just slow successes. +- **Latency drift:** sustained p95 latency well above a deployment's baseline + is an early warning that it is degrading before it starts returning errors. +- **Rate limiting:** 429s route around the throttled deployment instead of + retrying into the same wall. + +Healthy deployments are preferred; unhealthy ones are taken out of rotation and +periodically probed so they rejoin automatically once they recover. Retries are +bounded so a single failing request cannot fan out into a storm. + +## A minimal configuration + +Declaring a resilient model is mostly a matter of listing its deployments in +priority order: + +```yaml +models: + gpt-frontier: + strategy: failover + deployments: + - name: primary-provider + endpoint: https://api.provider.example/v1 + priority: 1 + - name: cloud-region-eu + endpoint: https://eu.cloud.example/openai/v1 + priority: 2 + - name: cloud-region-us + endpoint: https://us.cloud.example/openai/v1 + priority: 3 + health: + timeout_ms: 20000 + error_budget: 3 # consecutive failures before eviction + probe_interval_s: 30 # how often to re-test an evicted deployment +``` + +Your application keeps calling `gpt-frontier`. On a good day every request goes +to `primary-provider`. During an incident, requests slide down to +`cloud-region-eu`, then `cloud-region-us`, and back up again as each deployment +recovers, with no code change and no redeploy on your side. + +## Why route this through Bitropy at all + +You could hand-roll this in every service that talks to a model, but then every +service owns its own retry logic, its own health tracking, and its own blind +spots. Centralizing it in the router means failover, observability, cost +tracking, and policy all live in one place. When the next provider incident +happens, the answer is not a frantic config change under pressure. It already +happened, automatically, and the only evidence is a quiet line on a dashboard +instead of a spike in your error rate. + +Resilience is not a feature you bolt on after the first outage. It is the +default you want in place before it. +``` + +- [ ] **Step 2: Remove the placeholder** + +```bash +git rm src/content/blog/.gitkeep +``` + +- [ ] **Step 3: Verify it builds and parses** + +Run: `pnpm build` +Expected: build completes; no frontmatter/schema validation error for `llm-router-failover`. + +- [ ] **Step 4: Commit** + +```bash +git add src/content/blog/llm-router-failover.mdx +git commit -m "Add seed blog post on LLM router failover + +Gives the blog real end-to-end content: same-model failover routing when a +primary SOTA provider degrades." +``` + +--- + +### Task 3: Extend Layout with ogType and RSS discovery link + +**Files:** +- Modify: `src/layouts/Layout.astro` + +**Interfaces:** +- Produces: `Layout` accepts an optional `ogType?: string` prop (default `"website"`) threaded into `openGraph.basic.type`. Consumed by `BlogLayout` (Task 4), which passes `"article"`. + +- [ ] **Step 1: Add the prop to the interface** + +In `src/layouts/Layout.astro`, change the `Props` interface from: + +```ts +export interface Props { + title: string; + description?: string; + footerMargin?: boolean; +} +``` + +to: + +```ts +export interface Props { + title: string; + description?: string; + footerMargin?: boolean; + ogType?: string; +} +``` + +- [ ] **Step 2: Destructure the prop** + +Change: + +```ts +const { title, description, footerMargin = true } = Astro.props; +``` + +to: + +```ts +const { title, description, footerMargin = true, ogType = "website" } = Astro.props; +``` + +- [ ] **Step 3: Thread it into the SEO block** + +In the `` `openGraph` object, change `type: "website",` to `type: ogType,`. The block becomes: + +```jsx + openGraph={{ + basic: { + url: canonicalURL, + type: ogType, + title: ogTitle, + image: resolvedImageWithDomain, + }, + image: { + alt: OG_IMAGE_ALT, + }, + }} +``` + +- [ ] **Step 4: Add the RSS discovery link** + +Inside ``, immediately after the closing `/>` of the `` component, add: + +```html + +``` + +- [ ] **Step 5: Verify build and that non-blog pages are unaffected** + +Run: `pnpm build` +Expected: build succeeds. +Run: `grep -c 'og:type" content="website"' dist/index.html` +Expected: `1` (the homepage still uses the default `website` type). + +- [ ] **Step 6: Commit** + +```bash +git add src/layouts/Layout.astro +git commit -m "Add ogType prop and RSS discovery link to Layout + +Lets blog posts declare og:type=article and exposes the feed to readers, with +non-blog pages unchanged (default website type)." +``` + +--- + +### Task 4: Rewrite BlogLayout to match the site + +**Files:** +- Modify: `src/layouts/BlogLayout.astro` + +**Interfaces:** +- Consumes: `Layout`'s `ogType` prop (Task 3); `getFormattedDate` from `src/utils/all.js`. +- Produces: a `frontmatter` prop contract — `{ title, snippet, author, publishDate }` — supplied by the post page (Task 5). Renders post body via ``. + +- [ ] **Step 1: Overwrite the layout** + +Overwrite `src/layouts/BlogLayout.astro` with: + +```astro +--- +import Layout from "@/layouts/Layout.astro"; +import Container from "@/components/container.astro"; +import { getFormattedDate } from "@/utils/all.js"; + +const { frontmatter } = Astro.props; +const publishDate = new Date(frontmatter.publishDate); +--- + + + +
+
+

+ {frontmatter.title} +

+
+ {frontmatter.author} + + +
+
+ +
+ +
+ + +
+
+
+``` + +- [ ] **Step 2: Verify it builds** + +Run: `pnpm build` +Expected: build succeeds (BlogLayout is not yet rendered by a route until Task 5, but it must compile). + +- [ ] **Step 3: Commit** + +```bash +git add src/layouts/BlogLayout.astro +git commit -m "Restyle BlogLayout to match the redesigned site + +Drops the template's blue category header and cover markup for the site's +Container, prose typography, and slate/purple tokens; passes ogType=article." +``` + +--- + +### Task 5: Rewrite the post page + +**Files:** +- Modify: `src/pages/blog/[slug].astro` + +**Interfaces:** +- Consumes: `BlogLayout` (Task 4) with `frontmatter={entry.data}`; the seed post (Task 2); `getPublishedPosts()` (Task 1); content-layer `render` (Global Constraints). +- Produces: prerendered `/blog/` routes. + +- [ ] **Step 1: Overwrite the page** + +Overwrite `src/pages/blog/[slug].astro` with: + +```astro +--- +export const prerender = true; + +import { render } from "astro:content"; +import BlogLayout from "@/layouts/BlogLayout.astro"; +import { getPublishedPosts } from "@/utils/posts.ts"; + +export async function getStaticPaths() { + const posts = await getPublishedPosts(); + return posts.map((entry) => ({ + params: { slug: entry.id }, + props: { entry }, + })); +} + +const { entry } = Astro.props; +const { Content } = await render(entry); +--- + + + + +``` + +- [ ] **Step 2: Verify the post prerenders** + +Run: `pnpm build` +Expected: build succeeds and reports a page built for `blog/llm-router-failover`. +Run: `test -f dist/blog/llm-router-failover/index.html && echo FOUND` +Expected: `FOUND`. +Run: `grep -c "failover" dist/blog/llm-router-failover/index.html` +Expected: a number greater than `0` (post body rendered). + +- [ ] **Step 3: Commit** + +```bash +git add "src/pages/blog/[slug].astro" +git commit -m "Render blog posts via BlogLayout + +Replaces the redirect stub with real content-layer rendering (render(entry), +entry.id), prerendered and filtered to published posts." +``` + +--- + +### Task 6: Rewrite the blog index and remove pagination + +**Files:** +- Modify: `src/pages/blog.astro` +- Delete: `src/pages/blog/[...page].astro` + +**Interfaces:** +- Consumes: `getPublishedPosts()` (Task 1), `Sectionhead`, `Container`, `Layout`, `getFormattedDate`, and `post.id` for links. + +- [ ] **Step 1: Overwrite the index page** + +Overwrite `src/pages/blog.astro` with: + +```astro +--- +export const prerender = true; + +import Layout from "@/layouts/Layout.astro"; +import Container from "@/components/container.astro"; +import Sectionhead from "@/components/sectionhead.astro"; +import { getFormattedDate } from "@/utils/all.js"; +import { getPublishedPosts } from "@/utils/posts.ts"; + +const posts = await getPublishedPosts(); +--- + + + + + Blog + Insights on running AI in production. + + +
+ { + posts.length === 0 && ( +

+ No posts yet. Check back soon. +

+ ) + } + { + posts.map((post) => ( + +

{post.data.title}

+

+ {post.data.snippet} +

+
+ {post.data.author} + + +
+
+ )) + } +
+
+
+``` + +- [ ] **Step 2: Delete the pagination route** + +```bash +git rm "src/pages/blog/[...page].astro" +``` + +- [ ] **Step 3: Verify the index prerenders and links the post** + +Run: `pnpm build` +Expected: build succeeds. +Run: `test -f dist/client/blog/index.html && echo FOUND` +Expected: `FOUND`. (The Vercel adapter emits prerendered pages under `dist/client/`, not `dist/`.) +Run: `grep -c "/blog/llm-router-failover" dist/client/blog/index.html` +Expected: a number greater than `0` (card links to the post). + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/blog.astro +git commit -m "Build the blog index and drop pagination + +Lists published posts newest-first as text cards in the site style; removes the +unused paginated route (no pagination in v1)." +``` + +--- + +### Task 7: Add the RSS feed + +**Files:** +- Modify: `package.json` / lockfile (via `pnpm add`) +- Create: `src/pages/rss.xml.ts` + +**Interfaces:** +- Consumes: `getPublishedPosts()` (Task 1); `context.site` (from `astro.config.mjs` `site: "https://www.bitropy.io"`); `post.id` for links. +- Produces: `/rss.xml`. + +- [ ] **Step 1: Install the dependency** + +Run: `pnpm add @astrojs/rss` +Expected: `@astrojs/rss` added to `dependencies`. +Run: `test -d node_modules/@astrojs/rss && echo INSTALLED` +Expected: `INSTALLED`. + +- [ ] **Step 2: Create the feed endpoint** + +Create `src/pages/rss.xml.ts`: + +```ts +export const prerender = true; + +import rss from "@astrojs/rss"; +import type { APIContext } from "astro"; +import { getPublishedPosts } from "@/utils/posts.ts"; + +export async function GET(context: APIContext) { + const posts = await getPublishedPosts(); + + return rss({ + title: "Bitropy Blog", + description: + "Insights on enterprise AI operations, security, and reliability from the Bitropy team.", + site: context.site ?? "https://www.bitropy.io", + items: posts.map((post) => ({ + title: post.data.title, + description: post.data.snippet, + pubDate: post.data.publishDate, + link: `/blog/${post.id}`, + })), + }); +} +``` + +- [ ] **Step 3: Verify the feed builds and contains the post** + +Run: `pnpm build` +Expected: build succeeds. +Run: `test -f dist/client/rss.xml && echo FOUND` +Expected: `FOUND`. (Prerendered output lands under `dist/client/`.) +Run: `grep -c "llm-router-failover" dist/client/rss.xml` +Expected: a number greater than `0`. + +- [ ] **Step 4: Commit** + +```bash +git add package.json pnpm-lock.yaml src/pages/rss.xml.ts +git commit -m "Add /rss.xml feed for the blog + +Publishes non-draft, non-future posts newest-first via @astrojs/rss." +``` + +--- + +### Task 8: Wire the blog into nav, footer, and sitemap + +**Files:** +- Modify: `src/components/navbar/navbar.astro` +- Modify: `src/components/footer.astro` +- Modify: `astro.config.mjs` + +**Interfaces:** +- Consumes: the working `/blog` route (Task 6). + +- [ ] **Step 1: Add the navbar link** + +In `src/components/navbar/navbar.astro`, replace the commented-out block: + +```js + // { + // title: "Blog", + // path: "/blog", + // }, +``` + +with a live entry so `menuitems` reads (About → Blog → Contact): + +```js + { + title: "About", + path: "/about", + }, + { + title: "Blog", + path: "/blog", + }, + { + title: "Contact", + path: "/contact", + }, +``` + +- [ ] **Step 2: Add the footer link** + +In `src/components/footer.astro`, change the `nav` array from: + +```js +const nav = [ + { title: "Products", path: "/products" }, + { title: "About", path: "/about" }, + { title: "Contact", path: "/contact" }, + { title: "Terms", path: "/terms" }, + { title: "Privacy", path: "/privacy" }, +]; +``` + +to: + +```js +const nav = [ + { title: "Products", path: "/products" }, + { title: "About", path: "/about" }, + { title: "Blog", path: "/blog" }, + { title: "Contact", path: "/contact" }, + { title: "Terms", path: "/terms" }, + { title: "Privacy", path: "/privacy" }, +]; +``` + +- [ ] **Step 3: Un-exclude /blog/ from the sitemap** + +In `astro.config.mjs`, change the sitemap `filter` from: + +```js + filter: (page) => + !["/blog/", "/features/", "/pricing/", "/integrations/", "/404/"].some( + (path) => new URL(page).pathname.startsWith(path), + ), +``` + +to (drop only `"/blog/"`): + +```js + filter: (page) => + !["/features/", "/pricing/", "/integrations/", "/404/"].some( + (path) => new URL(page).pathname.startsWith(path), + ), +``` + +- [ ] **Step 4: Verify wiring, sitemap, and lint** + +Run: `pnpm build` +Expected: build succeeds. +Run: `grep -rl "/blog/llm-router-failover" dist/client/sitemap-0.xml dist/client/sitemap-index.xml 2>/dev/null && echo IN_SITEMAP` +Expected: `IN_SITEMAP` (the post URL is present in the generated sitemap; if the filename differs, check `dist/client/sitemap*.xml`). +Run: `grep -c '"/blog"' dist/client/blog/index.html` +Expected: a number greater than `0` (navbar + footer both link to `/blog`, rendered on the prerendered blog index page). Note: the homepage is server-rendered under `output: "server"`, so there is no `dist/index.html` to grep — use a prerendered blog page, which carries the same navbar and footer. +Run: `pnpm lint` +Expected: passes (or reports only pre-existing, unrelated issues). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/navbar/navbar.astro src/components/footer.astro astro.config.mjs +git commit -m "Wire the blog into nav, footer, and sitemap + +Surfaces /blog in the header and footer navigation and stops excluding blog +URLs from the generated sitemap." +``` + +--- + +## Self-Review + +**Spec coverage:** +- `/blog` index → Task 6. `/blog/` post → Task 5. `/rss.xml` → Task 7. Deleted `[...page].astro` → Task 6. ✓ +- Schema: `image`/`category`/`tags` optional → Task 1. ✓ +- Publish filtering (index, post paths, RSS) → centralized in `getPublishedPosts()` (Task 1), consumed by Tasks 5, 6, 7. ✓ +- Prerender under `output: server` → set in Tasks 5, 6, 7. ✓ +- `BlogLayout` restyle + author/date byline → Task 4; index byline → Task 6. ✓ +- SEO `ogType="article"` + RSS head link → Tasks 3 (Layout) and 4 (BlogLayout passes it). ✓ +- Navbar link, footer link, sitemap inclusion → Task 8. ✓ +- Seed post on LLM router failover → Task 2. ✓ +- `@astrojs/rss` dependency → Task 7. ✓ +- Covers dropped, no per-post OG image → not implemented anywhere (correct); posts fall back to default `/opengraph.png`. ✓ + +**Placeholder scan:** No TBD/TODO/"handle edge cases" steps; every code step shows complete file content. ✓ + +**Type consistency:** `entry.id` used for params (Task 5) and links (Tasks 6, 7) consistently — never `entry.slug`. `render(entry)` import matches usage. `frontmatter` prop shape produced by Task 5 (`entry.data`) matches what Task 4's `BlogLayout` reads (`title`, `snippet`, `author`, `publishDate`). `ogType` prop defined in Task 3 and consumed in Task 4. `getPublishedPosts()` defined in Task 1 and consumed unchanged in Tasks 5/6/7. ✓ diff --git a/docs/superpowers/specs/2026-07-20-blog-design.md b/docs/superpowers/specs/2026-07-20-blog-design.md new file mode 100644 index 0000000..1f6d5dc --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-blog-design.md @@ -0,0 +1,148 @@ +# Blog Feature — Design Spec + +**Date:** 2026-07-20 +**Status:** Approved (design), pending implementation plan +**Site:** bitropy-website (Astro 5, `output: "server"`, Vercel adapter, Tailwind v4 + typography plugin) + +## Goal + +Ship a working blog on the marketing site. Blog scaffolding from the original +Astroship template exists but is deliberately disabled (dynamic routes redirect +to `/`, the content collection is empty, the nav link is commented out, and +`/blog/` is excluded from the sitemap). This feature re-activates and rebuilds +the blog so it matches the current redesigned site rather than the template. + +## Scope + +**In v1:** +- `/blog` index listing published posts (newest first), as text cards. +- `/blog/` individual post pages rendering MDX. +- `/rss.xml` RSS feed. +- Author + date byline on posts and cards. +- One seed post (real content). +- Nav link + sitemap inclusion + RSS `` in head. + +**Explicitly out of v1** (deferred, not designed here): +- Cover images (dropped). +- Category/tag display and filter pages. +- Pagination. +- Author bio pages, related posts, reading time. + +## Architecture & Routing + +All blog pages are **prerendered**: each page file sets `export const prerender = true`. +The site runs `output: "server"`, so without this flag `getStaticPaths` is +ignored and routes resolve at request time. Blog content is build-time MDX, so +static prerendering is correct. + +| Route | File | Action | +|--------------|----------------------------------------|---------| +| `/blog` | `src/pages/blog.astro` | Rewrite | +| `/blog/` | `src/pages/blog/[slug].astro` | Rewrite (remove the `Astro.redirect("/")` stub) | +| `/rss.xml` | `src/pages/rss.xml.ts` | New | +| `/blog/[...page]` | `src/pages/blog/[...page].astro` | Delete (no pagination) | + +## Content Schema (`src/content.config.ts`) + +Keep the existing `blog` collection (glob loader over +`src/content/blog/**/*.{md,mdx}`). Change: make `image`, `category`, and `tags` +**optional** — they are retained for future features but not required or shown +in v1, so authors are not forced to populate them. + +Final schema: +- `draft: z.boolean()` +- `title: z.string()` +- `snippet: z.string()` +- `publishDate: z.string().transform((str) => new Date(str))` +- `author: z.string().default("Bitropy")` +- `image: z.object({ src: z.string(), alt: z.string() }).optional()` +- `category: z.string().optional()` +- `tags: z.array(z.string()).optional()` + +## Publish Filtering + +Both the index and the RSS feed show a post only when +`!data.draft && data.publishDate <= new Date()`. Posts are sorted by +`publishDate` descending. + +## Pages & Components + +### `src/pages/blog.astro` (index) +- `export const prerender = true`. +- `getCollection("blog", ...)` with the publish filter, sorted newest first. +- Wrapped in `Layout` + `Container`, headed by `Sectionhead` + (title "Blog", short desc). +- Each post is a text card: title (Bricolage Grotesque heading, links to + `/blog/`), `snippet`, and an `author • formatted date` byline. +- Card styling follows the site (e.g. `bg-slate-50 rounded-xl p-6`, slate text, + purple accents), consistent with `products.astro`. +- Graceful empty state if no posts (defensive; seed post means it won't be empty + at launch). + +### `src/pages/blog/[slug].astro` (post) +- `export const prerender = true`. +- `getStaticPaths` maps the collection to `{ params: { slug }, props: { entry } }`. +- Uses the content-layer API: `import { getCollection, render } from "astro:content"` + and `const { Content } = await render(entry)`. +- Renders through the rewritten `BlogLayout`, passing frontmatter + ``. + +### `src/layouts/BlogLayout.astro` (rewrite) +- Restyled to match the site: `Container`, Bricolage heading, `author • date` + byline (drop the template's blue category label / cover markup), + `prose prose-lg` body via the already-installed typography plugin, + "← Back to Blog" link. +- Passes SEO through `Layout`: `title`, `description = snippet`, + `ogType = "article"`. + +### `src/pages/rss.xml.ts` (new) +- `export const prerender = true`. +- Uses `@astrojs/rss` (**new dependency**). +- Emits published posts (same filter), each with `title`, `pubDate`, + `description = snippet`, `link = /blog/`. + +## SEO (`src/layouts/Layout.astro`) + +Add one optional prop, `ogType?: string` (default `"website"`), threaded into the +existing `astro-seo` `openGraph.basic.type`. Blog posts pass `"article"`. All +other pages are unchanged and keep the default OG image and type. No per-post OG +image in v1 (covers dropped) — posts use the site default `/opengraph.png`. + +Also add an RSS discovery link to the site ``: +``. + +## Wiring + +- **Navbar** (`src/components/navbar/navbar.astro`): add + `{ title: "Blog", path: "/blog" }` to `menuitems`, between About and Contact. +- **Sitemap** (`astro.config.mjs`): remove `/blog/` from the `sitemap` `filter` + exclusion array so blog pages are indexed. Leave the other exclusions + (`/features/`, `/pricing/`, `/integrations/`, `/404/`) intact. + +## Seed Content + +`src/content/blog/llm-router-failover.mdx`, ~700–900 words, `draft: false`. + +**Topic:** "When your SOTA provider goes down: failover routing in the Bitropy +LLM router." Narrative: a primary top-tier provider starts erroring or degrading; +the Bitropy router automatically fails over to another deployment of the *same* +model (e.g. a different region or a second provider hosting that model), so +requests keep succeeding without a quality drop. Covers why same-model failover +preserves output quality (vs. falling back to a weaker model), what signals +trigger a reroute (errors, timeouts, latency/health), and a short config sketch. + +Frontmatter: `draft`, `title`, `snippet`, `publishDate`, `author: "Bitropy"`. + +## New Dependency + +- `@astrojs/rss` (RSS feed generation). + +## Success Criteria + +- `/blog` lists the seed post; clicking it opens `/blog/llm-router-failover` + rendering the MDX in site styling. +- `/rss.xml` returns valid RSS containing the seed post. +- Blog link appears in the navbar and routes to `/blog`. +- Draft and future-dated posts are excluded from index and RSS. +- `pnpm build` succeeds and blog pages are prerendered. +- Blog URLs appear in the generated sitemap. +``` diff --git a/package.json b/package.json index a8775af..431d569 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@astrojs/mdx": "^5.0.6", + "@astrojs/rss": "^4.0.19", "@astrojs/sitemap": "^3.7.2", "@astrojs/vercel": "^10.0.7", "@fontsource-variable/bricolage-grotesque": "^5.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa979e9..2810715 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@astrojs/mdx': specifier: ^5.0.6 version: 5.0.6(astro@6.3.3(@types/node@24.10.0)(@vercel/functions@3.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.5)(yaml@2.9.0)) + '@astrojs/rss': + specifier: ^4.0.19 + version: 4.0.19 '@astrojs/sitemap': specifier: ^3.7.2 version: 3.7.2 @@ -117,6 +120,9 @@ packages: resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} engines: {node: '>=22.12.0'} + '@astrojs/rss@4.0.19': + resolution: {integrity: sha512-e+z5wYeYtffQdHQO8c2tkSd2JEBdAuRXJV4ZEU5IxkYeE6e39woDd7nw1PH1Kk2tEYNCYuKdylnnbhGmt61awA==} + '@astrojs/sitemap@3.7.2': resolution: {integrity: sha512-PqkzkcZTb5ICiyIR8VoKbIAP/laNRXi5tw616N1Ckk+40oNB8Can1AzVV56lrbC5GKSZFCyJYUVYqVivMisvpA==} @@ -526,6 +532,9 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -953,6 +962,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -1327,6 +1339,13 @@ packages: fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fast-xml-builder@1.3.0: + resolution: {integrity: sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==} + + fast-xml-parser@5.10.1: + resolution: {integrity: sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==} + hasBin: true + fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -1528,6 +1547,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-unsafe@2.0.0: + resolution: {integrity: sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} @@ -1981,6 +2003,10 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -2231,6 +2257,9 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strnum@2.4.1: + resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + style-to-js@1.1.19: resolution: {integrity: sha512-Ev+SgeqiNGT1ufsXyVC5RrJRXdrkRJ1Gol9Qw7Pb72YCKJXrBvP0ckZhBeVSrw2m06DJpei2528uIpjMb4TsoQ==} @@ -2609,6 +2638,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -2764,6 +2797,12 @@ snapshots: dependencies: prismjs: 1.30.0 + '@astrojs/rss@4.0.19': + dependencies: + fast-xml-parser: 5.10.1 + piccolore: 0.1.3 + zod: 4.4.3 + '@astrojs/sitemap@3.7.2': dependencies: sitemap: 9.0.1 @@ -3148,6 +3187,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@nodable/entities@3.0.0': {} + '@oslojs/encoding@1.1.0': {} '@paper-design/shaders@0.0.69': {} @@ -3521,6 +3562,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + anynum@1.0.1: {} + arg@5.0.2: {} argparse@2.0.1: {} @@ -4007,6 +4050,20 @@ snapshots: dependencies: fast-string-width: 3.0.2 + fast-xml-builder@1.3.0: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.10.1: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.3.0 + is-unsafe: 2.0.0 + path-expression-matcher: 1.6.2 + strnum: 2.4.1 + xml-naming: 0.3.0 + fd-slicer@1.1.0: dependencies: pend: 1.2.0 @@ -4289,6 +4346,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-unsafe@2.0.0: {} + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -4976,6 +5035,8 @@ snapshots: path-browserify@1.0.1: {} + path-expression-matcher@1.6.2: {} + path-scurry@2.0.2: dependencies: lru-cache: 11.3.6 @@ -5319,6 +5380,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strnum@2.4.1: + dependencies: + anynum: 1.0.1 + style-to-js@1.1.19: dependencies: style-to-object: 1.0.12 @@ -5653,6 +5718,8 @@ snapshots: wrappy@1.0.2: {} + xml-naming@0.3.0: {} + xxhash-wasm@1.1.0: {} y18n@5.0.8: {} diff --git a/src/components/footer.astro b/src/components/footer.astro index 3a5670d..d87af3c 100644 --- a/src/components/footer.astro +++ b/src/components/footer.astro @@ -6,6 +6,7 @@ const { margin } = Astro.props; const nav = [ { title: "Products", path: "/products" }, { title: "About", path: "/about" }, + { title: "Blog", path: "/blog" }, { title: "Contact", path: "/contact" }, { title: "Terms", path: "/terms" }, { title: "Privacy", path: "/privacy" }, diff --git a/src/components/navbar/navbar.astro b/src/components/navbar/navbar.astro index 672d4b3..eddd18b 100644 --- a/src/components/navbar/navbar.astro +++ b/src/components/navbar/navbar.astro @@ -14,10 +14,10 @@ const menuitems = [ title: "About", path: "/about", }, - // { - // title: "Blog", - // path: "/blog", - // }, + { + title: "Blog", + path: "/blog", + }, { title: "Contact", path: "/contact", diff --git a/src/content.config.ts b/src/content.config.ts index 901b5f0..4f90b17 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -7,14 +7,16 @@ const blogCollection = defineCollection({ draft: z.boolean(), title: z.string(), snippet: z.string(), - image: z.object({ - src: z.string(), - alt: z.string(), - }), publishDate: z.string().transform((str) => new Date(str)), author: z.string().default("Bitropy"), - category: z.string(), - tags: z.array(z.string()), + image: z + .object({ + src: z.string(), + alt: z.string(), + }) + .optional(), + category: z.string().optional(), + tags: z.array(z.string()).optional(), }), }); diff --git a/src/content/blog/.gitkeep b/src/content/blog/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/content/blog/llm-router-failover.mdx b/src/content/blog/llm-router-failover.mdx new file mode 100644 index 0000000..d0dac6d --- /dev/null +++ b/src/content/blog/llm-router-failover.mdx @@ -0,0 +1,102 @@ +--- +draft: false +title: "When Your SOTA Provider Goes Down: Failover Routing in the Bitropy LLM Router" +snippet: "Top-tier model providers have bad days too. Here is how same-model failover in the Bitropy router keeps your AI features answering, without silently dropping to a weaker model." +publishDate: "2026-07-18" +author: "Bitropy" +--- + +Every team building on frontier models eventually learns the same lesson: the +provider hosting your best model is a dependency, and dependencies fail. A +region degrades. A rollout goes sideways. Rate limits tighten without warning +during a spike. When that happens, the naive setup does the worst possible +thing: it returns errors to your users while your dashboards stay green, +because the model itself is "fine" everywhere except the one endpoint you +happen to be calling. + +The Bitropy LLM router treats a model as something you can reach through more +than one door. When the door you are using jams, it walks you to another one +that opens onto the same room. + +## Same model, different deployment + +The key idea is same-model failover. Most frontier models are available through +more than one deployment: a first-party API, one or more cloud-hosted versions +(for example the same model offered through a major cloud's managed AI service), +and often multiple regions within each. These are the *same weights* producing +the *same responses*. They just sit behind different endpoints, quotas, and +failure domains. + +Bitropy lets you declare those deployments as a pool behind a single logical +model name. Your application asks for one model; the router decides which +concrete deployment actually serves each request. When the primary deployment +starts failing, traffic shifts to a healthy sibling automatically, and the +response your user gets is indistinguishable from the one they would have gotten +on a normal day. + +This is deliberately different from falling back to a *weaker* model. Dropping +from your top model to a smaller, cheaper one during an incident is a silent +quality regression at the worst possible moment: your users are already +frustrated, and now the answers get worse too. Same-model failover keeps the +quality bar fixed and only changes the plumbing underneath. + +## What triggers a reroute + +The router does not wait for a human to notice. It reacts to the signals that +actually predict a bad request: + +- **Hard errors:** 5xx responses, connection resets, and provider-side + "overloaded" or capacity errors mark a deployment as unhealthy. +- **Timeouts:** requests that exceed a configured deadline are treated as + failures, not just slow successes. +- **Latency drift:** sustained p95 latency well above a deployment's baseline + is an early warning that it is degrading before it starts returning errors. +- **Rate limiting:** 429s route around the throttled deployment instead of + retrying into the same wall. + +Healthy deployments are preferred; unhealthy ones are taken out of rotation and +periodically probed so they rejoin automatically once they recover. Retries are +bounded so a single failing request cannot fan out into a storm. + +## A minimal configuration + +Declaring a resilient model is mostly a matter of listing its deployments in +priority order: + +```yaml +models: + gpt-frontier: + strategy: failover + deployments: + - name: primary-provider + endpoint: https://api.provider.example/v1 + priority: 1 + - name: cloud-region-eu + endpoint: https://eu.cloud.example/openai/v1 + priority: 2 + - name: cloud-region-us + endpoint: https://us.cloud.example/openai/v1 + priority: 3 + health: + timeout_ms: 20000 + error_budget: 3 # consecutive failures before eviction + probe_interval_s: 30 # how often to re-test an evicted deployment +``` + +Your application keeps calling `gpt-frontier`. On a good day every request goes +to `primary-provider`. During an incident, requests slide down to +`cloud-region-eu`, then `cloud-region-us`, and back up again as each deployment +recovers, with no code change and no redeploy on your side. + +## Why route this through Bitropy at all + +You could hand-roll this in every service that talks to a model, but then every +service owns its own retry logic, its own health tracking, and its own blind +spots. Centralizing it in the router means failover, observability, cost +tracking, and policy all live in one place. When the next provider incident +happens, the answer is not a frantic config change under pressure. It already +happened, automatically, and the only evidence is a quiet line on a dashboard +instead of a spike in your error rate. + +Resilience is not a feature you bolt on after the first outage. It is the +default you want in place before it. diff --git a/src/layouts/BlogLayout.astro b/src/layouts/BlogLayout.astro index 0e3b0de..2e22190 100644 --- a/src/layouts/BlogLayout.astro +++ b/src/layouts/BlogLayout.astro @@ -1,49 +1,43 @@ --- +import Layout from "@/layouts/Layout.astro"; import Container from "@/components/container.astro"; -import { getFormattedDate } from "@/utils/all"; -import Layout from "./Layout.astro"; +import { getFormattedDate } from "@/utils/all.js"; const { frontmatter } = Astro.props; +const publishDate = new Date(frontmatter.publishDate); --- - + -
- - {frontmatter.category} - -

- {frontmatter.title} -

-
- - {frontmatter.author} - - - - -
- { - frontmatter.tags.map((tag) => ( - #{tag} - )) - } +
+
+

+ {frontmatter.title} +

+
+ {frontmatter.author} + +
+
+ +
+
-
-
- -
- + + diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index a5bf7ac..2d97bb5 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -11,6 +11,7 @@ export interface Props { title: string; description?: string; footerMargin?: boolean; + ogType?: string; } const DEFAULT_TITLE = "Bitropy — The Missing Control Layer for Enterprise AI"; @@ -24,7 +25,7 @@ const resolvedImageWithDomain = new URL( Astro.site ).toString(); -const { title, description, footerMargin = true } = Astro.props; +const { title, description, footerMargin = true, ogType = "website" } = Astro.props; const makeTitle = title ? `${title} | Bitropy` : DEFAULT_TITLE; const pageDescription = description ?? DEFAULT_DESCRIPTION; @@ -46,7 +47,7 @@ const ogTitle = title ? `${title} | Bitropy` : DEFAULT_TITLE; openGraph={{ basic: { url: canonicalURL, - type: "website", + type: ogType, title: ogTitle, image: resolvedImageWithDomain, }, @@ -62,6 +63,12 @@ const ogTitle = title ? `${title} | Bitropy` : DEFAULT_TITLE; image: resolvedImageWithDomain, }} /> +