From c63ec67844a0f53c3e454a66d20923425af70848 Mon Sep 17 00:00:00 2001 From: Darek Dwornikowski Date: Mon, 20 Jul 2026 11:29:47 +0200 Subject: [PATCH 01/14] Add blog feature design spec Reactivating the disabled Astroship blog scaffolding, rebuilt to match the redesigned marketing site. Captures the v1 scope (text cards + RSS, covers and taxonomy deferred) before implementation. --- .../specs/2026-07-20-blog-design.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-20-blog-design.md 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. +``` From 39d87d9a2fdc20ae36f194ba347210d61f95d192 Mon Sep 17 00:00:00 2001 From: Darek Dwornikowski Date: Mon, 20 Jul 2026 11:37:40 +0200 Subject: [PATCH 02/14] Add blog feature implementation plan Task-by-task plan to rebuild the blog: schema, seed post, layout/SEO, post + index pages, RSS feed, and nav/footer/sitemap wiring. --- .../plans/2026-07-20-blog-feature.md | 750 ++++++++++++++++++ 1 file changed, 750 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-20-blog-feature.md 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..66d70e6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-blog-feature.md @@ -0,0 +1,750 @@ +# 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 + +**Files:** +- Modify: `src/content.config.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. + +- [ ] **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: Verify it builds** + +Run: `pnpm build` +Expected: build completes with no schema/collection errors (the collection is still empty, which is fine). + +- [ ] **Step 3: Commit** + +```bash +git add src/content.config.ts +git commit -m "Make blog cover image and taxonomy fields optional + +Covers and category/tag display are deferred out of the blog v1, so authors +should not be forced to populate those fields." +``` + +--- + +### 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); the publish filter and content-layer API (Global Constraints). +- Produces: prerendered `/blog/` routes. + +- [ ] **Step 1: Overwrite the page** + +Overwrite `src/pages/blog/[slug].astro` with: + +```astro +--- +export const prerender = true; + +import { getCollection, render } from "astro:content"; +import BlogLayout from "@/layouts/BlogLayout.astro"; + +export async function getStaticPaths() { + const posts = await getCollection( + "blog", + ({ data }) => !data.draft && data.publishDate <= new Date(), + ); + 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: the publish filter, `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 { getCollection } from "astro:content"; +import { getFormattedDate } from "@/utils/all.js"; + +const posts = ( + await getCollection( + "blog", + ({ data }) => !data.draft && data.publishDate <= new Date(), + ) +).sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf()); +--- + + + + + 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/blog/index.html && echo FOUND` +Expected: `FOUND`. +Run: `grep -c "/blog/llm-router-failover" dist/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: the publish filter; `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 { getCollection } from "astro:content"; +import type { APIContext } from "astro"; + +export async function GET(context: APIContext) { + const posts = ( + await getCollection( + "blog", + ({ data }) => !data.draft && data.publishDate <= new Date(), + ) + ).sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf()); + + 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/rss.xml && echo FOUND` +Expected: `FOUND`. +Run: `grep -c "llm-router-failover" dist/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/sitemap-0.xml dist/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/sitemap*.xml`). +Run: `grep -c '"/blog"' dist/index.html` +Expected: a number greater than `0` (navbar/footer link to `/blog` rendered on the homepage). +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) → Tasks 5, 6, 7 all use `!draft && publishDate <= now`. ✓ +- 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. Publish-filter predicate identical across Tasks 5/6/7. ✓ From b3987e4b689d90b3e270e8702b9dda4e3b3ea7cd Mon Sep 17 00:00:00 2001 From: Darek Dwornikowski Date: Mon, 20 Jul 2026 11:53:27 +0200 Subject: [PATCH 03/14] Refine blog plan: centralize publish filter in getPublishedPosts helper Removes verbatim triplication of the publish filter+sort across the index, post pages, and RSS feed. --- .../plans/2026-07-20-blog-feature.md | 77 +++++++++++-------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/docs/superpowers/plans/2026-07-20-blog-feature.md b/docs/superpowers/plans/2026-07-20-blog-feature.md index 66d70e6..2d87470 100644 --- a/docs/superpowers/plans/2026-07-20-blog-feature.md +++ b/docs/superpowers/plans/2026-07-20-blog-feature.md @@ -22,13 +22,15 @@ Every task's requirements implicitly include these: --- -### Task 1: Make schema fields optional +### 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** @@ -62,19 +64,42 @@ export const collections = { }; ``` -- [ ] **Step 2: Verify it builds** +- [ ] **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 errors (the collection is still empty, which is fine). +Expected: build completes with no schema/collection/type errors (the collection is still empty, which is fine). -- [ ] **Step 3: Commit** +- [ ] **Step 4: Commit** ```bash -git add src/content.config.ts -git commit -m "Make blog cover image and taxonomy fields optional +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 the blog v1, so authors -should not be forced to populate those fields." +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." ``` --- @@ -398,7 +423,7 @@ Container, prose typography, and slate/purple tokens; passes ogType=article." - Modify: `src/pages/blog/[slug].astro` **Interfaces:** -- Consumes: `BlogLayout` (Task 4) with `frontmatter={entry.data}`; the seed post (Task 2); the publish filter and content-layer API (Global Constraints). +- 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** @@ -409,14 +434,12 @@ Overwrite `src/pages/blog/[slug].astro` with: --- export const prerender = true; -import { getCollection, render } from "astro:content"; +import { render } from "astro:content"; import BlogLayout from "@/layouts/BlogLayout.astro"; +import { getPublishedPosts } from "@/utils/posts.ts"; export async function getStaticPaths() { - const posts = await getCollection( - "blog", - ({ data }) => !data.draft && data.publishDate <= new Date(), - ); + const posts = await getPublishedPosts(); return posts.map((entry) => ({ params: { slug: entry.id }, props: { entry }, @@ -460,7 +483,7 @@ entry.id), prerendered and filtered to published posts." - Delete: `src/pages/blog/[...page].astro` **Interfaces:** -- Consumes: the publish filter, `Sectionhead`, `Container`, `Layout`, `getFormattedDate`, and `post.id` for links. +- Consumes: `getPublishedPosts()` (Task 1), `Sectionhead`, `Container`, `Layout`, `getFormattedDate`, and `post.id` for links. - [ ] **Step 1: Overwrite the index page** @@ -473,15 +496,10 @@ export const prerender = true; import Layout from "@/layouts/Layout.astro"; import Container from "@/components/container.astro"; import Sectionhead from "@/components/sectionhead.astro"; -import { getCollection } from "astro:content"; import { getFormattedDate } from "@/utils/all.js"; +import { getPublishedPosts } from "@/utils/posts.ts"; -const posts = ( - await getCollection( - "blog", - ({ data }) => !data.draft && data.publishDate <= new Date(), - ) -).sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf()); +const posts = await getPublishedPosts(); --- !data.draft && data.publishDate <= new Date(), - ) - ).sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf()); + const posts = await getPublishedPosts(); return rss({ title: "Bitropy Blog", @@ -736,7 +749,7 @@ URLs from the generated sitemap." **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) → Tasks 5, 6, 7 all use `!draft && publishDate <= now`. ✓ +- 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). ✓ @@ -747,4 +760,4 @@ URLs from the generated sitemap." **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. Publish-filter predicate identical across Tasks 5/6/7. ✓ +**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. ✓ From f3fad68554846c5cfaaba5a1992951557d6a4aff Mon Sep 17 00:00:00 2001 From: Darek Dwornikowski Date: Mon, 20 Jul 2026 11:55:31 +0200 Subject: [PATCH 04/14] 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. --- src/content.config.ts | 14 ++++++++------ src/utils/posts.ts | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 src/utils/posts.ts 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/utils/posts.ts b/src/utils/posts.ts new file mode 100644 index 0000000..ba73729 --- /dev/null +++ b/src/utils/posts.ts @@ -0,0 +1,15 @@ +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(), + ); +} From 67c66ad0ec9351da35b12914e1083d773e54b60d Mon Sep 17 00:00:00 2001 From: Darek Dwornikowski Date: Mon, 20 Jul 2026 11:58:31 +0200 Subject: [PATCH 05/14] 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. --- src/content/blog/.gitkeep | 0 src/content/blog/llm-router-failover.mdx | 102 +++++++++++++++++++++++ 2 files changed, 102 insertions(+) delete mode 100644 src/content/blog/.gitkeep create mode 100644 src/content/blog/llm-router-failover.mdx 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. From cf58b816037baeae0452c8691ffea58d0ef63317 Mon Sep 17 00:00:00 2001 From: Darek Dwornikowski Date: Mon, 20 Jul 2026 12:01:27 +0200 Subject: [PATCH 06/14] 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). --- src/layouts/Layout.astro | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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, }} /> +