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:

  1. Parses Accept entries (type + q + specificity), keeping client order.
  2. For each representation it produces (text/html, text/markdown), finds the most specific matching entry (so q=0 rejections 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.
  3. chosen === 'text/markdown' → rewrites the URL to the .md sibling and serves that from env.ASSETS, forcing Content-Type: text/markdown and appending Vary: Accept. If the .md sibling is missing, falls through to HTML only when HTML is still acceptable; otherwise returns 406.
  4. chosen === 'text/html' (or missing Accept / */*) → serves the HTML, appends Vary: Accept, and probes for a .md sibling via a HEAD request. If one exists, adds Link: </path>.md; rel="alternate"; type="text/markdown" so Codex-style agents discover it.
  5. chosen === null with an explicit Accept406 Not Acceptable.

Gotchas

  • HEAD probe cost. The Link-header step costs one extra env.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 with 404s on the .md fetch separately.
  • Trailing-slash variants. Some build tools emit /about.md instead of /about/index.md. Adjust markdownPath() to match your actual output layout — or extend it to try both.
  • Pages directory shape. If your build doesn’t emit .md siblings for every HTML page (e.g., only for blog posts), the HEAD probe correctly returns 404 and no Link header is emitted. HTML requests still work.
  • Cache rules. Cloudflare’s edge cache honors Vary: Accept if 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