# 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](/guides/cloudflare-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`

```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`

```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 Accept** → `406 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 `404`s 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](/guides/vary-accept).

## Verify

```bash
# 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
```