Cloudflare Workers
Static site on Workers Assets with a thin negotiation Worker — proper q-values, Vary: Accept, 406, and Link: rel="alternate". Bring your own Markdown build step.
This recipe is for sites that ship pre-generated HTML and Markdown to Workers Assets (Hugo, 11ty, VitePress, static Astro, your own build, etc.) and want a small Worker out front to serve the right representation per request.
If you’d rather not write any code, enable Cloudflare’s managed Markdown for Agents toggle — it does origin-side HTML → Markdown conversion at the edge with zero Worker code. This recipe is for when you want explicit control: authored Markdown rather than auto-converted, custom Link headers, or stricter RFC 9110 behavior.
Assumptions
Your build emits both representations side-by-side in dist/ (or
wherever your assets directory points):
dist/
├── index.html
├── index.md
├── about/
│ ├── index.html
│ └── index.md
└── blog/
├── hello-world/
│ ├── index.html
│ └── index.md
└── ...
We don’t prescribe how the .md files are generated — write them by
hand, emit them from your static site generator, or convert the HTML
post-build with turndown, @wcj/html-to-markdown-cli, or similar.
The Worker only cares that the file exists.
wrangler.jsonc
{
"name": "my-site",
"main": "src/worker.ts",
"compatibility_date": "2026-04-18",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
// Worker sees every request — no asset-first shortcut.
// Without this, Cloudflare serves matching files from `dist/`
// directly and the negotiation logic never runs.
"run_worker_first": ["*"]
}
}
The run_worker_first: ["*"] flag is the key detail. By default
Workers Assets short-circuits the Worker for any URL that matches a
file in the assets bucket — so if /about/index.html exists, the
Worker never runs and you can’t negotiate. run_worker_first flips
that: the Worker handles every request and decides when to call
env.ASSETS.fetch().
src/worker.ts
type AcceptEntry = { type: string; q: number; specificity: number };
function parseAccept(header: string): AcceptEntry[] {
return header
.split(',')
.map((raw) => {
const parts = raw.trim().split(';').map((s) => s.trim());
const type = parts[0].toLowerCase();
if (!type) return null;
let q = 1;
for (const param of parts.slice(1)) {
const [name, value] = param.split('=').map((s) => s.trim());
if (name === 'q') {
const parsed = Number(value);
if (!Number.isNaN(parsed)) q = Math.max(0, Math.min(1, parsed));
}
}
const specificity = type === '*/*' ? 0 : type.endsWith('/*') ? 1 : 2;
return { type, q, specificity };
})
.filter((e): e is AcceptEntry => e !== null);
// Preserve client order — position is used for tiebreaks below.
}
function matches(entry: AcceptEntry, candidate: string): boolean {
if (entry.type === '*/*') return true;
if (entry.type.endsWith('/*')) return candidate.startsWith(entry.type.slice(0, -1));
return entry.type === candidate;
}
function preferredType(header: string | null, produces: string[]): string | null {
if (!header) return produces[0] ?? null;
const entries = parseAccept(header);
if (entries.length === 0) return produces[0] ?? null;
let bestType: string | null = null;
let bestQ = -1;
let bestPosition = Infinity;
for (const candidate of produces) {
// For each candidate, find the *most specific* matching range.
// RFC 9110 §12.5.1: more specific media ranges override less
// specific ones regardless of q. Without this, `text/html;q=0,
// */*;q=1` would incorrectly allow text/html via the wildcard.
let matched: AcceptEntry | null = null;
let matchedPosition = Infinity;
for (let idx = 0; idx < entries.length; idx++) {
const e = entries[idx];
if (!matches(e, candidate)) continue;
if (
matched === null ||
e.specificity > matched.specificity ||
(e.specificity === matched.specificity && idx < matchedPosition)
) {
matched = e;
matchedPosition = idx;
}
}
if (matched === null) continue;
const matchedQ: number = matched.q;
if (matchedQ <= 0) continue; // explicit rejection
// Across candidates: highest q wins; tie-break on client order
// so `Accept: text/markdown, text/html, */*` picks text/markdown.
if (matchedQ > bestQ || (matchedQ === bestQ && matchedPosition < bestPosition)) {
bestQ = matchedQ;
bestPosition = matchedPosition;
bestType = candidate;
}
}
return bestType;
}
function appendVaryAccept(headers: Headers): void {
const existing = headers.get('vary');
if (!existing) {
headers.set('Vary', 'Accept');
return;
}
const tokens = existing.split(',').map((s) => s.trim().toLowerCase());
if (!tokens.includes('accept')) {
headers.set('Vary', `${existing}, Accept`);
}
}
// Map a request URL to its .md sibling:
// / → /index.md
// /about → /about/index.md (or /about.md — see note below)
// /blog/hello → /blog/hello/index.md
function markdownPath(pathname: string): string {
const clean = pathname.replace(/\/$/, '') || '/';
if (clean === '/') return '/index.md';
return `${clean}/index.md`;
}
export default {
async fetch(request: Request, env: { ASSETS: Fetcher }): Promise<Response> {
const url = new URL(request.url);
// Hand static assets and API routes straight back to the bucket.
const STATIC_EXT = /\.(?:css|js|mjs|map|png|jpe?g|webp|gif|svg|avif|ico|woff2?|ttf|otf|eot|xml|txt|json|pdf|mp4|webm|mp3|wav|ogg|zip)$/i;
if (STATIC_EXT.test(url.pathname) || url.pathname.startsWith('/api/')) {
return env.ASSETS.fetch(request);
}
const chosen = preferredType(
request.headers.get('accept'),
['text/html', 'text/markdown'],
);
// Client explicitly rejected everything we produce (q=0 on both).
if (chosen === null && request.headers.get('accept')) {
const res = new Response(
'Not Acceptable\n\nAvailable: text/html, text/markdown\n',
{ status: 406, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
);
appendVaryAccept(res.headers);
return res;
}
if (chosen === 'text/markdown') {
const mdUrl = new URL(url);
mdUrl.pathname = markdownPath(url.pathname);
const mdReq = new Request(mdUrl.toString(), request);
const mdRes = await env.ASSETS.fetch(mdReq);
if (mdRes.status === 200) {
const res = new Response(mdRes.body, mdRes);
res.headers.set('Content-Type', 'text/markdown; charset=utf-8');
appendVaryAccept(res.headers);
return res;
}
// No .md sibling. Only fall through to HTML if the client's
// Accept actually allows it. `Accept: text/markdown` (implicit
// wildcard) or `text/markdown, */*` does; `text/markdown,
// text/html;q=0` does not — return 406 in that case.
if (!preferredType(request.headers.get('accept'), ['text/html'])) {
const res = new Response(
'Not Acceptable\n\nMarkdown sibling missing and HTML is not acceptable.\n',
{ status: 406, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
);
appendVaryAccept(res.headers);
return res;
}
}
// HTML path: advertise the .md sibling if one exists.
const htmlRes = await env.ASSETS.fetch(request);
const res = new Response(htmlRes.body, htmlRes);
appendVaryAccept(res.headers);
if (res.headers.get('content-type')?.includes('text/html')) {
const mdUrl = new URL(url);
mdUrl.pathname = markdownPath(url.pathname);
const head = await env.ASSETS.fetch(new Request(mdUrl.toString(), { method: 'HEAD' }));
if (head.status === 200) {
const linkValue = `<${mdUrl.pathname}>; rel="alternate"; type="text/markdown"`;
const existing = res.headers.get('link');
res.headers.set('Link', existing ? `${existing}, ${linkValue}` : linkValue);
}
}
return res;
},
};
How it routes
For every non-asset request the Worker:
- Parses
Acceptentries (type + q + specificity), keeping client order. - For each representation it produces (
text/html,text/markdown), finds the most specific matching entry (soq=0rejections on specific types aren’t overridden by a wildcard), then picks the candidate with the highest q — ties broken by the client’s original order. chosen === 'text/markdown'→ rewrites the URL to the.mdsibling and serves that fromenv.ASSETS, forcingContent-Type: text/markdownand appendingVary: Accept. If the.mdsibling is missing, falls through to HTML only when HTML is still acceptable; otherwise returns406.chosen === 'text/html'(or missing Accept /*/*) → serves the HTML, appendsVary: Accept, and probes for a.mdsibling via aHEADrequest. If one exists, addsLink: </path>.md; rel="alternate"; type="text/markdown"so Codex-style agents discover it.chosen === nullwith an explicit Accept →406 Not Acceptable.
Gotchas
HEADprobe cost. The Link-header step costs one extraenv.ASSETS.fetch()per HTML request. Assets fetches are cheap but not free — if you’re willing to trust the build, you can skip the probe and always emit the Link header, then deal with404s on the.mdfetch separately.- Trailing-slash variants. Some build tools emit
/about.mdinstead of/about/index.md. AdjustmarkdownPath()to match your actual output layout — or extend it to try both. - Pages directory shape. If your build doesn’t emit
.mdsiblings for every HTML page (e.g., only for blog posts), the HEAD probe correctly returns404and no Link header is emitted. HTML requests still work. - Cache rules. Cloudflare’s edge cache honors
Vary: Acceptif you opt in via a Cache Rule. Without that, both representations share the same cache key and agents can poison the cache for browsers (and vice versa). See the Vary guide.
Verify
# HTML (default) — with Link advertising the .md sibling
curl -sI https://your-site.com/about/
# Content-Type: text/html; charset=utf-8
# Vary: Accept
# Link: </about/index.md>; rel="alternate"; type="text/markdown"
# Markdown
curl -sI -H "Accept: text/markdown" https://your-site.com/about/
# Content-Type: text/markdown; charset=utf-8
# Vary: Accept
# Explicit rejection
curl -sI -H "Accept: application/pdf" https://your-site.com/about/
# HTTP/2 406