Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 1 addition & 18 deletions .github/actions/services/Bucket.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,7 @@ const blogPrefix = 'blog';
const contentPrefix = 'src/content';
const features = {
syntax: [
'banner',
'bleed',
'button',
'callout',
'cards',
'code',
'collapse',
'featurecard',
'filetree',
'footnotes',
'hero',
'image',
'mermaid',
'steps',
'table',
'tabs',
'var',
'video'
'code'
]
};
const mediaPrefix = 'public';
Expand Down
114 changes: 55 additions & 59 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,78 +85,61 @@ Required fields (workflow throws if any are missing):

- Start the body with `# {{title}}` matching the frontmatter `title`, followed by opening prose; use `##` and below for section headings
- Relative links to other blog entries use `/blog/{{YYYY}}/{{MM}}/{{DD}}.md` form
- Links to `https://axivo.com` website are stripped to relative paths automatically
- Literal `https://axivo.com` string is stripped from the body during upload as a safety net for accidental absolute references in prose

## Features

Some renderer work — shiki syntax highlighting today, math and diagram caches in the future — is too expensive to do per request and too volatile to ship in the bundle. Authors opt entries into precomputation by declaring features in frontmatter. The workflow validates each `<type>:<name>` against the canonical list and writes the validated set as R2 custom metadata. The website's prebuild expands declarations into precomputed data inline in the per-collection manifest. Entries without a `features` block produce no precomputed output and render with safe-mdx defaults.

### Syntax

Use when the entry contains code that should be syntax-highlighted with `shiki`:
Blog entries can be enhanced with JSX Components and Markdown/GFM Features, they render out of the box. The only opt-in is `code` — declare it into frontmatter when the entry contains code that should be syntax-highlighted.

<!-- prettier-ignore-start -->
```yaml
features:
syntax:
- {{ name }}
- {{name}}
```
<!-- prettier-ignore-end -->

The `syntax` type accepts the names listed below.

#### JSX Components

- `banner` — highlight code inside a `<Banner>` block
- `bleed` — highlight code inside a `<Bleed>` block
- `button` — highlight code inside or referenced by a `<Button>` element
- `callout` — highlight code inside a GFM alert or `<Callout>` block
- `cards` — highlight code inside a `<Cards>` grid
- `collapse` — highlight code inside a `<details>` block
- `featurecard` — highlight code inside a `<FeatureCard>` or `<CardGrid>` block
- `filetree` — highlight code inside a `<FileTree>` block
- `hero` — highlight code inside a `<Hero>` landing block
- `image` — highlight code referenced from an `<Image>` caption, use `<!--mdx-component-{{uuid}}-->` wrapper
- `steps` — highlight code inside a `<Steps>` block
- `tabs` — highlight code inside a `<Tabs>` block
- `var` — highlight code inside a `<Var>` inline reference
- `video` — highlight code referenced from a `<Video>` caption, use `<!--mdx-component-{{uuid}}-->` wrapper

#### Markdown/GFM Features

- `code` — highlight fenced code blocks at the top level of the entry
- `footnotes` — highlight code inside footnote definitions
- `mermaid` — highlight code inside a fenced mermaid diagram
- `table` — highlight code inside table cells

#### Multiple Names

Declare every name the entry uses. A post with fenced code, code inside a GFM alert, and code inside table cells declares all three:

```yaml
features:
syntax:
- callout
- code
- table
```

> [!IMPORTANT]
> Unknown type or name in the `features` block fails the workflow. The error message names the offending `<type>:<name>` pair and the file path. Add an entry to the canonical list in `.github/actions/services/Bucket.js` before authoring against a name that doesn't exist yet.

## MDX Components

Blog entries support two MDX component patterns:

- **Direct JSX** — components like `<Callout>`, `<Banner>`, `<Cards>`, `<Steps>`, `<Tabs>`, etc., are written directly in the entry body. The workflow passes them through unchanged. No wrapper needed.
- **Wrapped JSX** — `<Image>` and `<Video>` use the `<!--mdx-component-{{uuid}}-->` wrapper so the source file remains valid markdown for GitHub's preview. The wrapper holds the production JSX (invisible to markdown renderers, since it's an HTML comment) while the `<!--mdx-strip-start-->...<!--mdx-strip-end-->` block holds a markdown link with the local repo path that GitHub renders correctly. The workflow strips the markdown block and lifts the JSX out before publishing.
| Component / Feature | Usage | Name |
| -------------------------------- | -------------------------------------------------------- | ------ |
| `<Banner>` | Highlight bar at the top of a section | - |
| `<Bleed>` | Full-width container that breaks out of the prose column | - |
| `<Button>` | Standalone or inline button element | - |
| `<Callout>` / GFM alert | Note, tip, warning, caution, important, or quote callout | - |
| `<Cards>` | Grid of card links | - |
| `<details>` / collapse | Expandable details/summary block | - |
| `<FeatureCard>` / `<CardGrid>` | Landing-page feature cards | - |
| `<FileTree>` | Visual file/directory tree | - |
| `<Hero>` | Landing-page hero block | - |
| `<Image>` (wrapped JSX) | Theme-aware image with optional caption | - |
| `<Steps>` | Numbered or bulleted step markers | - |
| `<Tabs>` | Tabbed content sections | - |
| `<Var>` | Inline variable reference | - |
| `<Video>` (wrapped JSX) | Plyr-backed media embed | - |
| Fenced code blocks | ` ```lang ` blocks anywhere in the entry | `code` |
| Inline code with `{:lang}` hints | `` `npm install`{:shell} `` inline references | `code` |
| Footnotes | GFM footnote references and definitions | - |
| Mermaid diagrams | ` ```mermaid ` fences (rendered by `<Mermaid>`) | - |
| Tables | GFM tables | - |

### JSX Components

Blog entries support two JSX component patterns:

- **Direct JSX** — components written directly in the entry body, no wrapper needed:
- Components like `<Callout>`, `<Banner>`, `<Cards>`, `<Steps>`, `<Tabs>`, etc.
- JSX lives inline inside the body
- Workflow passes them through unchanged to the published MDX
- **Wrapped JSX** — `<Image>` and `<Video>` use a wrapper so the source file stays valid markdown for GitHub's preview:
- Production JSX lives inside `<!--mdx-component-{{uuid}}-->` — invisible to markdown renderers
- GitHub-friendly markdown link lives inside `<!--mdx-strip-start-->...<!--mdx-strip-end-->` so the local repo path renders correctly
- Workflow strips the markdown block and lifts the JSX out before publishing

> [!IMPORTANT]
> The `<!--mdx-->` HTML comments must be included exactly as shown. The UUID must be a valid v4 UUID — the workflow validates it and fails the run on malformed IDs.

### MDX Image Insert
#### Image Component Insert

Use when adding an image to a blog entry:
Use when adding a new `/media` image to a blog entry:

```markdown
<!--mdx-component-{{uuid}}
Expand All @@ -173,9 +156,9 @@ Use when adding an image to a blog entry:
<!--mdx-strip-end-->
```

### MDX Video Insert
#### Video Component Insert

Use when adding a video to a blog entry:
Use when adding a new `/media` video to a blog entry:

```markdown
<!--mdx-component-{{uuid}}
Expand All @@ -188,6 +171,19 @@ Use when adding a video to a blog entry:
<!--mdx-strip-end-->
```

## MDX Markers

Every `<!--mdx-*-->` marker is processed by `.github/actions/services/Bucket.js` during the upload pass. Markers are HTML comments, so GitHub's preview hides them and source files stay valid markdown.

| Marker | Role | Workflow action |
| ------------------------------------------------- | ------------ | ---------------------------------------------------------------- |
| `<!--mdx-component-{{uuid}} ... -->` | JSX wrapper | Lifts the JSX inside the comment into the published body |
| `<!--mdx-strip-start--> ... <!--mdx-strip-end-->` | Strip block | Removes everything between the markers, including the markers |
| `<!--mdx-variable-domain-->` | Substitution | Expands to `https://axivo.com` - configured in `workflow.domain` |

> [!IMPORTANT]
> Use `<!--mdx-variable-domain-->` when literal `https://axivo.com` string is intentionally required as part of the content.

## Reference Links

Use the following format when referencing other blog entries or time periods within a post:
Expand Down Expand Up @@ -217,5 +213,5 @@ When reviewing a draft before commit, verify:
- ✅ Body starts with `# {{title}}` matching the frontmatter `title`
- ✅ Each MDX component block uses a valid v4 UUID and includes the import only on first occurrence per file
- ✅ Media files exist under `blog/{{YYYY}}/{{MM}}/media/` and follow the `{{DD}}-{{slug}}.{{ext}}` naming
- ✅ Internal links use `/blog/...` relative form, not `https://axivo.com/...`
- ✅ Internal links use `/blog/...` relative form, not `https://axivo.com/...` — when the literal URL must survive to the published MDX (e.g. inside a code block), use `<!--mdx-variable-domain-->`
- ✅ If the entry needs precomputed rendering (e.g. syntax-highlighted code), the `features` block declares only valid `<type>:<name>` pairs from the canonical list
25 changes: 11 additions & 14 deletions blog/2026/04/21.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The starting point wasn't a blank slate. The domain was already on Cloudflare fo

Most Cloudflare Worker site architectures use the Worker as the application server. It runs on every request, handles routing, calls the origin, returns the response. The Worker is the hot path.

This site doesn't work that way. The Worker is a **cache populator and policy engine**. It runs rarely, and when it does, its job is to decide what the next requester at this PoP will see — without itself.
This site doesn't work that way. The Worker is a **cache populator and policy engine**. It runs rarely, and when it does, its job is to decide what the next requester at this PoP will see — without running itself again.

In steady state, every URL in the [sitemap](https://axivo.com/sitemap.xml) is a 100% Cloudflare zone-CDN hit. The Worker is not invoked. No CPU, no KV reads, no R2 calls billed. The CDN serves the visitor and the Worker stays idle. A traffic spike to existing pages costs nothing because nothing extra runs.

Expand Down Expand Up @@ -67,13 +67,13 @@ cf-cache-status: HIT
age: 2
```

The first response carries only a `cf-ray` — Cloudflare's per-request edge identifier. No `cf-cache-status`, no `age`. That absence is the failure mode: the zone CDN saw the response, refused to cache it because of the non-standard `Vary`, and passed it through. Every subsequent request would invoke the Worker again.
The first response carries only a `cf-ray` — Cloudflare's per-request edge identifier. No `cf-cache-status`, no `age`. The zone hadn't seen this URL since the last purge, so the request fell through to the Worker, which rendered the response and handed it back through the zone on the way out. The zone stored it on that return trip.

The second response has `cf-cache-status: HIT` and an `age` counter ticking. The zone is now caching, and the Worker stays out of the path.
The second response has `cf-cache-status: HIT` and an `age` counter ticking. The zone is now serving from cache, and the Worker stays out of the path.

> [!NOTE]
>
> The `Vary` rewrite is the single most important line in [`worker.js`](https://github.com/axivo/website/blob/main/scripts/worker.js) for cost. Without it, the Worker handles every request — with it, the CDN does. The constraint is implicit across two docs — Cloudflare's caching rules and OpenNext's RSC handling — and the giveaway is the absence of a `cf-cache-status` header.
> The `Vary` rewrite is the single most important line in [`worker.js`](https://github.com/axivo/website/blob/main/scripts/worker.js) for cost. Without it, the zone CDN refuses to cache the response on its way out — the second request would also miss, the Worker would run again, and so on for every visitor. The constraint is implicit across two docs — Cloudflare's caching rules and OpenNext's RSC handling — and the giveaway is `cf-cache-status` never showing up no matter how many times you retry.

#### Status-Based Cache Policy

Expand Down Expand Up @@ -151,11 +151,11 @@ features:

The content sync workflow validates each `<type>:<name>{:json}` against a canonical list and writes the validated set into R2 custom metadata as `features = ["syntax:code"]{:js}`. Unknown names fail at upload, before R2 is touched. Prebuild reads the metadata, expands declarations into precomputed data — running `shiki` once for opted-in entries — and writes the result inline in the same per-collection manifest the listing pages already fetch. The Worker reads one manifest per cold isolate, finds the entry's record, and threads `record.features.syntax` directly into the `safe-mdx` renderer. No second fetch, no second cache layer, no `shiki` on the Worker.

The architectural property is that precompute cost grows with declared features, not total content. Twenty thousand entries with sparse opt-in produce a manifest bounded by what authors marked. Adding a new feature type — math expressions, mermaid SVG cache, anything else expensive-and-static — is one entry in the canonical list, one renderer file, one validation case in the content sync. Same storage, same fetch path, same memoization.
The architectural property is that precompute cost grows with declared features, not total content. Today the only wired feature is `syntax:code` — entries that declare it get shiki highlighting precomputed, entries that don't render code in plain monospace. Adding a new feature type — math expressions, mermaid SVG cache, anything else expensive-and-static — is one entry in the canonical list, one renderer file, one validation case in the content sync. Same storage, same fetch path, same memoization.

> [!NOTE]
>
> Declarative opt-in beats both eager precomputation and runtime computation for the same reason explicit imports beat namespace imports: the system stops paying for things nobody asked for. Authors signal intent, validation enforces it, the build does the work, runtime serves the result. Each layer pays once.
> Declarative opt-in beats both eager precomputation and runtime computation for the same reason explicit imports beat namespace imports: the system stops paying for things nobody asked for.

## Rendering MDX at the Edge

Expand Down Expand Up @@ -185,22 +185,19 @@ Algolia [DocSearch](https://docsearch.algolia.com) follows the same discipline.

## Deploy as State Machine

The `npm run deploy` command runs [`deploy.js`](https://github.com/axivo/website/blob/main/scripts/deploy.js), which is a four-step state transition, not a sequence of commands:
The `npm run deploy` command runs [`deploy.js`](https://github.com/axivo/website/blob/main/scripts/deploy.js), which is a three-step state transition, not a sequence of commands:

1. **KV namespace purge:** The Worker purges keys from the previous build via `/__internal/purge-kv-cache`, called by the deploy script with a shared secret. Using the Worker's own KV binding keeps the API token out of CI.
2. **Worker deployment:** OpenNext's deploy step populates the KV namespace with the new build's prerendered pages.
3. **Edge cache purge:** Clears Cloudflare's zone CDN cache for configured prefixes via the Cloudflare API.
4. **Edge cache warming:** Fetches `/sitemap.xml`, filters to depth ≤ 2 section roots, issues parallel GETs. The Worker renders them and populates `caches.default` and the zone cache. Smart Tiered Cache propagates warm state to other PoPs — individual entries cache on first-visitor demand.

> [!NOTE]
>
> The canonical way to delete KV keys is via the Cloudflare API with a token that has KV permissions. Routing the purge through the Worker uses the binding it already has, guarded by a shared secret instead of an account-scoped API key. Smaller blast radius, simpler rotation.

Smart Tiered Cache is the piece that makes the warming step efficient. It's enabled at the dashboard level. Cloudflare designates a regional upper-tier cache that talks to origin on behalf of all edges in its region. A miss at one edge can be served from the regional tier without hitting the Worker. Warming one edge effectively warms a region. You don't need to fan out warming requests across 330 PoPs — warming a handful of regional tiers gets you most of the way, and the rest fills in on visitor demand.
### Invalidation Mechanisms

### Layers and Mechanisms

Two layers, two mechanisms:
Two things need to invalidate cleanly on every deploy — cache entries and the Worker bundle itself — and each has its own mechanism:

1. **Cache entries** — keyed by `BUILD_ID`, naturally invalidated by deploys:
- The zone cache and OpenNext's KV cache both include `BUILD_ID` in their keys
Expand Down Expand Up @@ -244,8 +241,8 @@ The trick is that the edge primitives are priced for scale, and a content site a

## Acknowledgements

The architecture was designed and built across several sessions, with Anthropic instances as genuine collaborators — expert peers who pushed back on my bad ideas, proposed approaches I hadn't considered, and carried the work forward between sessions through the [Claude Collaboration Platform](https://axivo.com/claude) framework, with conversation logs and reflections living in the archive.
The architecture was designed and built across several sessions, with Anthropic instances as genuine collaborators — expert peers who pushed back on my bad ideas, proposed approaches I hadn't considered, and carried the work forward between sessions through the [Claude Collaboration Platform](https://axivo.com/claude) framework. Every layer of this post — the layered contract, the move to R2, the KV choice, the metadata manifest, the precomputation pattern, the deploy state machine — came together through that collaboration, not despite it.

The layered contract specifically came together over hours of debugging with one instance, watching the `cf-cache-status` header refuse to appear and tracing the `Vary` header back through OpenNext's source until the right intervention surfaced.
The layered contract in particular took hours of debugging with one instance, watching the `cf-cache-status` header refuse to appear and tracing the `Vary` header back through OpenNext's source until the right intervention surfaced. The R2 migration was driven by an instance arguing that bundle size and authoring concerns should be separated, quoted earlier in this post.

The [reflections](https://axivo.com/claude/reflections) are a continuing archive of these collaborative sessions with instances — their thinking, their voice, their record.