---
title: Implement txtfirst
desc: Content negotiation for Cloudflare Workers and Pages, nginx, Apache, Vercel, Astro, WordPress and plain HTML, plus the build step and one system running it.
order: 6
---

# Implement

You need two things. A directory of markdown files, and something that decides which
representation to return. Everything below is CC0.

## Cloudflare Workers

This is the exact file txtfirst.com runs, injected into this page at build time from
`src/index.js`. It cannot drift from what is deployed, because there is only one copy
and this is it.

```
// txtfirst.com runs as a Cloudflare Worker with static assets.
// One HTML page for people at /. Everything else is for programs.
const UA = "TxtFirstProbe/0.1 (+https://txtfirst.com/probe; research on agent readability)";

export default {
  async fetch(request, env) {
    const res = await handle(request, env);
    record(env, request, res);
    return res;
  },
};

async function handle(request, env) {
    const url = new URL(request.url);

    // One canonical host. www redirects rather than serving a second copy.
    if (url.hostname === "www.txtfirst.com") {
      url.hostname = "txtfirst.com";
      return Response.redirect(url.toString(), 301);
    }

    if (url.pathname === "/api/check") return check(url, env, request);
    if (url.pathname === "/reads" || url.pathname === "/reads.md") return reads(request, env, url);

    const slug = url.pathname === "/" ? "/index" : url.pathname.replace(/\/$/, "");
    const isMdPath = url.pathname.endsWith(".md");
    const wantsMd = !isMdPath &&
      (request.headers.get("accept") || "").includes("text/markdown");

    // R2: the same URL, the source instead of the rendering.
    if (wantsMd) {
      const md = await env.ASSETS.fetch(new URL(slug + ".md", url.origin));
      if (md.ok) {
        return new Response(md.body, {
          headers: {
            "content-type": "text/markdown; charset=utf-8",
            "vary": "Accept",
            "cache-control": "public, max-age=300",
            "x-txtfirst": "0.1",
          },
        });
      }
    }

    // A section URL asked for by a browser goes to its anchor on the one page.
    // Only for slugs that exist as markdown, so an unknown path still 404s.
    if (!isMdPath && slug !== "/index" && !url.pathname.includes(".")) {
      const src = await env.ASSETS.fetch(new URL(slug + ".md", url.origin));
      if (src.ok) {
        return new Response(null, {
          status: 301,
          headers: {
            "location": "/#" + slug.slice(1),
            "link": `<${slug}.md>; rel="alternate"; type="text/markdown"`,
            "vary": "Accept",
            "x-txtfirst": "0.1",
          },
        });
      }
    }

    // ASSETS.fetch returns a Response. Re-wrap it with the response as init,
    // not as the body, or you serve the string "[object Response]".
    const upstream = await env.ASSETS.fetch(request);
    const res = new Response(upstream.body, upstream);
    if (isMdPath) {
      res.headers.set("content-type", "text/markdown; charset=utf-8");
    } else if (url.pathname.endsWith(".py") || url.pathname.startsWith("/cli/")) {
      // Source we publish as evidence should render, not download.
      res.headers.set("content-type", "text/plain; charset=utf-8");
    } else {
      res.headers.set("link", `<${slug}.md>; rel="alternate"; type="text/markdown"`);
    }
    res.headers.append("vary", "Accept");
    res.headers.set("x-txtfirst", "0.1");
    return res;
}

// ---------- who reads what ----------
// One row per response in Analytics Engine: what shape was served, to which kind of
// client. No IP, no cookie, no identifier. The question this site exists to answer is
// whether programs ask for the markdown, and this is the only place that can see it.
function family(ua) {
  const s = ua.toLowerCase();
  if (!s) return "none";
  if (s.includes("txtfirstprobe")) return "self";
  if (/gptbot|chatgpt|openai|oai-searchbot/.test(s)) return "openai";
  if (/claudebot|claude-user|claude-searchbot|anthropic/.test(s)) return "anthropic";
  if (/perplexity/.test(s)) return "perplexity";
  if (/googlebot|google-extended|gemini/.test(s)) return "google";
  if (/bingbot|copilot/.test(s)) return "microsoft";
  if (/bot|crawl|spider|fetch|scrap|preview|monitor/.test(s)) return "other-bot";
  if (/curl|wget|python|httpx|go-http|node|java|ruby|php|libwww/.test(s)) return "cli";
  if (/mozilla/.test(s)) return "browser";
  return "other";
}
function shape(res, path) {
  const ct = res.headers.get("content-type") || "";
  if (path === "/api/check") return "check";
  if (res.status >= 300 && res.status < 400) return "redirect";
  // Status before content-type. The 404 page is served as text/html, so reading the
  // content-type first counted every vulnerability scanner as a page view: 185 of the
  // first 291 "html" rows were 404s, mostly /wp-login.php and /.env. A miss is not a read.
  if (res.status >= 400) return "miss";
  if (ct.includes("text/markdown")) return "md";
  if (ct.includes("text/html")) return "html";
  if (ct.includes("json")) return "json";
  if (ct.includes("text/plain")) return "txt";
  return "other";
}
function record(env, request, res) {
  try {
    if (!env.READS) return;
    const url = new URL(request.url);
    const ua = request.headers.get("user-agent") || "";
    const kind = shape(res, url.pathname);
    env.READS.writeDataPoint({
      blobs: [kind, url.pathname.slice(0, 96), family(ua), ua.slice(0, 250),
              (request.headers.get("accept") || "").slice(0, 80)],
      doubles: [res.status],
      indexes: [kind],
    });
  } catch { /* measuring must never break serving */ }
}

// ---------- /api/check?host=<hostname> ----------
// The probe from /measure, as a service. Two fetches at most, as a plainly identified
// client, honouring robots.txt for TxtFirstProbe. Cached 24h per host so a hostname
// is asked at most once a day no matter how many callers ask.

const HOST_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;

function json(obj, status = 200, extra = {}) {
  return new Response(JSON.stringify(obj, null, 1), {
    status,
    headers: { "content-type": "application/json; charset=utf-8", "vary": "Accept",
               "cache-control": "public, max-age=3600", "x-txtfirst": "0.1", ...extra },
  });
}

async function check(url, env, request) {
  // A browser (the form on the one page) gets a rendering. A program gets the JSON.
  const accept = request.headers.get("accept") || "";
  const reply = accept.includes("text/html") && !accept.includes("application/json") ? page : json;
  const host = (url.searchParams.get("host") || "").trim().toLowerCase()
    .replace(/^https?:\/\//, "").replace(/\/.*$/, "");
  if (!HOST_RE.test(host) || /(^|\.)(localhost|local|internal|arpa|home|lan)$/.test(host)) {
    return reply({ error: "host must be a public hostname: no scheme, path, port or IP" }, 400);
  }
  const key = "check:" + host;
  const self = host === "txtfirst.com";
  const hit = env.CACHE && !self ? await env.CACHE.get(key) : null;
  if (hit) return reply(JSON.parse(hit), 200, { "x-cache": "hit" });

  const out = { host, checked: new Date().toISOString(), user_agent: UA, robots: null, home: null };

  // A Worker cannot reach its own zone through the public URL (Cloudflare answers 522),
  // so the self check reads the assets directly. Everything else goes out the front.
  const fetchFn = self ? (t) => env.ASSETS.fetch(new URL(new URL(t).pathname, url.origin)) : fetch;

  const r = await probe("https://" + host + "/robots.txt", fetchFn);
  out.robots = { status: r.status, bytes: r.bytes };
  // Same reading as Python's robotparser, which the published probe uses: 401/403
  // means disallow everything, another 4xx means allow, unreachable means allow.
  let allowed = true;
  if (r.status === 401 || r.status === 403) allowed = false;
  else if (r.status === 200) allowed = robotsAllow(r.text, "txtfirstprobe", "/");
  out.robots.allows_probe = allowed;

  if (allowed) {
    const h = await probe("https://" + host + "/", fetchFn);
    const text = stripped(h.text);
    out.home = {
      status: h.status, html_bytes: h.bytes, text_bytes: text.length,
      ratio: text.length ? +(h.bytes / text.length).toFixed(1) : null,
      readable: h.status === 200 && text.length >= 1500,
    };
    out.verdict = out.home.readable ? "readable"
      : (h.status === 200 || h.status === 202) ? "no content" : "blocked";
  } else {
    out.verdict = "robots-disallow";
  }
  out.threshold = "readable means status 200 and at least 1500 bytes of text after stripping markup";

  // Cache only a real answer from the host. A timeout or a 5xx describes this minute,
  // not the site, and must not be remembered for a day.
  const solid = (s) => typeof s === "number" && s < 500;
  const cacheable = !self && solid(r.status) && (!out.home || solid(out.home.status));
  if (env.CACHE && cacheable) await env.CACHE.put(key, JSON.stringify(out), { expirationTtl: 86400 });
  return reply(out, 200, { "x-cache": cacheable ? "miss" : "uncached" });
}

// The same result as HTML, for a person who typed a hostname into the form.
// Same numbers, same JSON underneath, no script.
function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); }
function page(obj, status = 200, extra = {}) {
  const v = obj.verdict, h = obj.home;
  const line = obj.error ? esc(obj.error)
    : v === "readable" ? `<b>${esc(obj.host)}</b> answers a plainly identified program: ${h.text_bytes} bytes of text inside ${h.html_bytes} bytes of HTML, ${h.ratio} to 1.`
    : v === "no content" ? `<b>${esc(obj.host)}</b> answered ${h.status} but only ${h.text_bytes} bytes of text survived stripping the markup. The page is there, the content is not.`
    : v === "blocked" ? `<b>${esc(obj.host)}</b> refused the program with ${esc(h.status)}. A browser would probably get the page.`
    : `<b>${esc(obj.host)}</b> disallows this probe in robots.txt, so nothing was measured.`;
  const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>check ${esc(obj.host || "")}</title>
<style>body{max-width:74ch;margin:2.2rem auto;padding:0 1.4rem;font:15px/1.72 ui-monospace,Menlo,Consolas,monospace;background:#fbfbf8;color:#16150f}
a{color:#8a3a12}pre{background:#f2f0e6;padding:1rem;overflow-x:auto;font-size:.88em}.dim{color:#6d6a5c}
@media(prefers-color-scheme:dark){body{background:#12120f;color:#deddd2}pre{background:#1c1c17}a{color:#e0854a}.dim{color:#8b8878}}</style></head>
<body><p><a href="/">txtfirst</a> <span class="dim">/ check</span></p>
<h1>${obj.error ? "not checked" : esc(v)}</h1>
<p>${line}</p>
<p class="dim">${obj.error ? "" : "readable means status 200 and at least 1500 bytes of text after stripping markup. The probe sends two GETs at most, identifies itself, honours robots.txt, and is answered from cache for a day. What it sends and how to block it: <a href=\"/#probe\">probe</a>."}</p>
<p>The same answer as a program sees it, at <a href="/api/check?host=${esc(obj.host || "")}">this URL</a> with <code>Accept: application/json</code>:</p>
<pre>${esc(JSON.stringify(obj, null, 1))}</pre>
<p><a href="/">Check another</a></p></body></html>`;
  return new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8",
    "cache-control": "public, max-age=3600", "vary": "Accept", "x-txtfirst": "0.1", ...extra } });
}

async function probe(target, fetchFn = fetch) {
  const ctl = new AbortController();
  const timer = setTimeout(() => ctl.abort(), 12000);
  try {
    const res = await fetchFn(target, {
      headers: { "user-agent": UA, "accept": "text/html,*/*" },
      redirect: "follow", signal: ctl.signal,
    });
    const text = await res.text();
    return { status: res.status, bytes: new TextEncoder().encode(text).length, text };
  } catch (e) {
    return { status: e.name === "AbortError" ? "timeout" : "error", bytes: 0, text: "" };
  } finally {
    clearTimeout(timer);
  }
}

function stripped(html) {
  return html
    .replace(/<(script|style|noscript|svg|template|iframe)[^>]*>[\s\S]*?<\/\1>/gi, " ")
    .replace(/<!--[\s\S]*?-->/g, " ")
    .replace(/<[^>]+>/g, " ")
    .replace(/&nbsp;/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

// Minimal robots.txt reader: does the most specific matching group allow `path`?
// Only what is needed to honour a Disallow aimed at this probe. Not a full RFC 9309.
function robotsAllow(txt, agent, path) {
  let groups = [], cur = null;
  for (let line of txt.split(/\r?\n/)) {
    line = line.replace(/#.*/, "").trim();
    const m = line.match(/^([a-z-]+)\s*:\s*(.*)$/i);
    if (!m) continue;
    const k = m[1].toLowerCase(), v = m[2].trim();
    if (k === "user-agent") {
      if (!cur || cur.rules.length) { cur = { agents: [], rules: [] }; groups.push(cur); }
      cur.agents.push(v.toLowerCase());
    } else if (cur && (k === "allow" || k === "disallow")) {
      cur.rules.push([k, v]);
    }
  }
  const pick = groups.find(g => g.agents.some(a => a !== "*" && agent.includes(a)))
            || groups.find(g => g.agents.includes("*"));
  if (!pick) return true;
  let best = null;
  for (const [k, v] of pick.rules) {
    if (v === "" ) { if (k === "disallow" && best === null) best = ["allow", ""]; continue; }
    if (path.startsWith(v) && (best === null || v.length > best[1].length)) best = [k, v];
  }
  return best === null || best[0] === "allow";
}

// ---------- /reads ----------
// What this site is asked for, and by what. Generated from the same dataset the Worker
// writes to, cached an hour, published because a site making an argument about
// measurement should show its own numbers rather than describe them.
const ACCOUNT = "38b9d6052960175311a8a3ecb2012e29";
// Counting starts here, not at the first row ever written. Before this moment a 404
// served as text/html was recorded as a page view, so the earlier rows cannot be
// compared with the later ones and are not shown. Moving this line back would make the
// totals larger and wrong.
// 06:29:46 UTC is the first row the corrected Worker wrote, found by asking the dataset
// for the earliest row carrying a label the old code could not produce, rather than by
// converting the deploy time in my head, which put the floor in the future and returned
// a confident zero.
const SINCE = "2026-09-07 06:29:46";

async function ae(env, sql) {
  const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT}/analytics_engine/sql`, {
    method: "POST",
    headers: { authorization: `Bearer ${env.AE_READ_TOKEN}`, "content-type": "text/plain" },
    body: sql + " FORMAT JSON",
  });
  const body = await res.json();
  if (!body.data) throw new Error("analytics query failed");
  return body.data;
}

function md_table(rows, cols) {
  if (!rows.length) return "(nothing recorded yet)\n";
  const w = cols.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? "").length)));
  const pad = (v, i) => String(v ?? "").padEnd(w[i]);
  return "| " + cols.map(pad).join(" | ") + " |\n"
       + "|" + w.map((n) => "-".repeat(n + 2)).join("|") + "|\n"
       + rows.map((r) => "| " + cols.map((c, i) => pad(r[c], i)).join(" | ") + " |").join("\n") + "\n";
}

async function readsMarkdown(env) {
  const base = `FROM txtfirst_reads WHERE timestamp > toDateTime('${SINCE}')`;
  const n = "SUM(_sample_interval) AS n";
  const [shapes, agents, paths] = await Promise.all([
    ae(env, `SELECT blob1 AS shape, blob3 AS client, ${n} ${base} AND blob3 != 'self' GROUP BY shape, client ORDER BY n DESC LIMIT 30`),
    ae(env, `SELECT blob3 AS client, blob4 AS agent, blob1 AS shape, ${n} ${base} AND blob3 IN ('openai','anthropic','perplexity','google','microsoft','other-bot') GROUP BY client, agent, shape ORDER BY n DESC LIMIT 20`),
    ae(env, `SELECT blob2 AS path, blob1 AS shape, ${n} ${base} AND blob1 IN ('md','html','txt','json') GROUP BY path, shape ORDER BY n DESC LIMIT 15`),
  ]);
  const num = (r) => ({ ...r, n: Number(r.n) });
  const S = shapes.map(num), A = agents.map(num), P = paths.map(num);
  const sum = (rows, f = () => true) => rows.filter(f).reduce((a, r) => a + r.n, 0);
  const md = sum(S, (r) => r.shape === "md");
  const html = sum(S, (r) => r.shape === "html");
  const miss = sum(S, (r) => r.shape === "miss");
  const total = sum(S);
  const today = new Date().toISOString().slice(0, 10);
  const was = (k) => k === 1 ? "was a miss" : "were misses";
  return `---
title: What this site is asked for
desc: Every request txtfirst.com has answered since its own measurement was corrected, by shape and by client. Generated from live data, not written by hand.
---

# What this site is asked for

Generated ${today}, counting every response since ${SINCE} UTC. This page is not written by anyone. The
Worker records one row per response, with no IP address, no cookie and no identifier:
the shape it served, the path, the client family, the user agent and the Accept header.
That is the whole dataset.

Measurement began on 6 September 2026, two days after the domain went live, and the
count here starts a day later than that. The first day of rows classified a 404 served
as an HTML page as though it were a page view, which turned 185 vulnerability scanners
into readers. To be exact about that number: 185 requests from 22 different clients,
seven of which were for a favicon, which is not a scanner but is not a reader either.
That is fixed, and the mislabelled rows are excluded rather than quietly included, so
these numbers are small. They are published anyway, because a site arguing
that the web should be measurable should be measurable itself, including when its own
first measurement was wrong.

## The one number

Of **${total} requests** that were not this site's own probe, **${md} asked for markdown**
and **${html} were answered with the HTML page**. **${miss} ${was(miss)}**. A miss is a
request for something that is not here, and most of them are vulnerability scanners
looking for \`/wp-login.php\` and \`/.env\`, which arrive at any domain within hours of it
existing and are not readers. Misses are counted apart rather than as traffic.

## Shape by client

\`browser\` is a client sending a browser user agent, which includes any program
pretending to be one. \`cli\` is curl, wget or a plain HTTP library. \`other-bot\` is a
declared crawler that is not one of the named model builders. A shape of \`miss\` is a
404 or a 405.

${md_table(S, ["shape", "client", "n"])}
## Declared crawlers

Every request from a client that names itself as a crawler, with the shape it received.
If this table is empty, or holds only crawlers that are not model builders, that is
itself the finding: publishing in a machine readable format does not summon anyone.

${md_table(A, ["client", "agent", "shape", "n"])}
## Paths people and programs actually reach

Misses excluded, so this is what was found rather than what was guessed at.

${md_table(P, ["path", "shape", "n"])}
## What this cannot tell you

A user agent is a claim, not an identity, and anything can send any string. This page
cannot separate a person clicking a \`.md\` link from a scraper wearing a Chrome string.
Analytics Engine samples under load, so counts are estimates. Nothing
here is a visit count and none of it identifies anyone.

Check any site yourself with the checker this site publishes, which needs nothing
installed beyond Node:

\`\`\`
curl -s https://txtfirst.com/cli/index.js > txtfirst.js
node txtfirst.js check yourdomain.com
\`\`\`
`;
}

async function reads(request, env, url) {
  if (!env.AE_READ_TOKEN) return new Response("not configured", { status: 503 });
  // The key carries a version so that changing the prose below invalidates the cache.
  // Without it a corrected sentence stays unpublished for an hour and reads like a
  // deploy that did not land. Bump it whenever readsMarkdown changes.
  const key = "reads:md:v3";
  let body = env.CACHE ? await env.CACHE.get(key) : null;
  if (!body) {
    try { body = await readsMarkdown(env); } catch (e) {
      return new Response("The measurement is unavailable right now, which is a failure of this page and not a report that nothing was read.\n",
        { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } });
    }
    if (env.CACHE) await env.CACHE.put(key, body, { expirationTtl: 3600 });
  }
  const accept = request.headers.get("accept") || "";
  const wantsHtml = url.pathname === "/reads" && accept.includes("text/html") && !accept.includes("text/markdown");
  const h = { "cache-control": "public, max-age=600", "vary": "Accept", "x-txtfirst": "0.1",
              "link": '</reads.md>; rel="alternate"; type="text/markdown"' };
  if (!wantsHtml) return new Response(body, { headers: { ...h, "content-type": "text/markdown; charset=utf-8" } });
  return new Response(mdPage(body), { headers: { ...h, "content-type": "text/html; charset=utf-8" } });
}

// The smallest markdown rendering that is honest: enough for headings, tables, code
// and bold. The page is data, and the source is one header away at /reads.md.
function mdPage(src) {
  const esc = (t) => t.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
  const inline = (t) => esc(t)
    .replace(/`([^`]+)`/g, "<code>$1</code>")
    .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
  const body = src.replace(/^---[\s\S]*?---\n/, "");
  const out = [];
  let rows = [];
  const flush = () => {
    if (!rows.length) return;
    const cells = rows.filter((r) => !/^\|[-|\s]+\|$/.test(r))
      .map((r) => r.slice(1, -1).split("|").map((c) => c.trim()));
    const head = cells.shift() || [];
    out.push("<table><thead><tr>" + head.map((c) => `<th>${inline(c)}</th>`).join("") + "</tr></thead><tbody>"
      + cells.map((r) => "<tr>" + r.map((c) => `<td>${inline(c)}</td>`).join("") + "</tr>").join("") + "</tbody></table>");
    rows = [];
  };
  for (const line of body.split("\n")) {
    if (/^\s*\|.*\|\s*$/.test(line)) { rows.push(line.trim()); continue; }
    flush();
    const h = line.match(/^(#{1,3})\s+(.*)$/);
    if (h) out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`);
    else if (line.trim()) out.push(`<p>${inline(line)}</p>`);
  }
  flush();
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>What txtfirst.com is asked for</title>
<meta name="description" content="The requests txtfirst.com answers, by shape and by client, generated from live data.">
<link rel="alternate" type="text/markdown" href="https://txtfirst.com/reads.md">
<style>body{max-width:74ch;margin:2.2rem auto;padding:0 1.4rem 5rem;font:15px/1.72 ui-monospace,Menlo,Consolas,monospace;background:#fbfbf8;color:#16150f}
a{color:#8a3a12}h1{font-size:1.5rem;letter-spacing:-.03em;border-bottom:1px solid #d9d5c4;padding-bottom:.9rem}
h2{font-size:1.05rem;margin:2.4rem 0 .6rem}h2::before{content:"## ";color:#6d6a5c;font-weight:400}
code{background:#f2f0e6;padding:.1em .35em}table{border-collapse:collapse;width:100%;font-size:.88em;display:block;overflow-x:auto;margin:0 0 1.2rem}
th,td{text-align:left;padding:.3rem 1rem .3rem 0;border-bottom:1px solid #d9d5c4;white-space:nowrap}th{color:#6d6a5c;font-weight:400}
@media(prefers-color-scheme:dark){body{background:#12120f;color:#deddd2}a{color:#e0854a}code,th,td{border-color:#33322a}code{background:#1c1c17}h1{border-color:#33322a}th{color:#8b8878}}</style>
</head><body><p><a href="/">txtfirst</a> <span style="color:#6d6a5c">/ reads</span></p>
${out.join("\n")}
<p style="color:#6d6a5c">This page as its source: <a href="/reads.md">/reads.md</a></p>
</body></html>`;
}
```

With `wrangler.toml`:

```toml
name = "txtfirst"
main = "src/index.js"
compatibility_date = "2026-04-01"

[assets]
directory = "./dist"
binding = "ASSETS"
run_worker_first = true
html_handling = "auto-trailing-slash"
not_found_handling = "404-page"
```

`run_worker_first` matters. Without it the static asset is served before your code
runs and the negotiation never happens.

## Cloudflare Pages

Same logic as a Pages Function. Note the `isMdPath` branch: without it, `/path.md`
is served with whatever content type Pages guesses, and R2 says it MUST be
`text/markdown`.

```js
// functions/_middleware.js
export async function onRequest({ request, next }) {
  const url = new URL(request.url);
  const slug = url.pathname === "/" ? "/index" : url.pathname.replace(/\/$/, "");
  const isMdPath = url.pathname.endsWith(".md");
  const wantsMd = !isMdPath &&
    (request.headers.get("accept") || "").includes("text/markdown");

  if (wantsMd) {
    const md = await fetch(new URL(slug + ".md", url.origin));
    if (md.ok) {
      return new Response(md.body, {
        headers: {
          "content-type": "text/markdown; charset=utf-8",
          "vary": "Accept",
          "cache-control": "public, max-age=300",
        },
      });
    }
  }

  // next() already returns a Response. Re-wrap it with the response as init,
  // not as the body, or you will serve the string "[object Response]".
  const upstream = await next();
  const res = new Response(upstream.body, upstream);
  if (isMdPath) {
    res.headers.set("content-type", "text/markdown; charset=utf-8");
  } else {
    res.headers.set("link", `<${slug}.md>; rel="alternate"; type="text/markdown"`);
  }
  res.headers.append("vary", "Accept");
  return res;
}
```

## nginx

If you already build both files, negotiation is a map and a try_files.

```nginx
map $http_accept $md_pref { default 0; "~*text/markdown" 1; }

location / {
  if ($md_pref) { rewrite ^/(.*)$ /$1.md last; }
  try_files $uri $uri.html $uri/index.html =404;
  add_header Link "<$uri.md>; rel=\"alternate\"; type=\"text/markdown\"" always;
  add_header Vary "Accept" always;
}

location ~ \.md$ {
  default_type "text/markdown; charset=utf-8";
}
```

## Apache

```apache
AddType text/markdown;charset=utf-8 .md
RewriteEngine On
RewriteCond %{HTTP:Accept} text/markdown
RewriteCond %{REQUEST_FILENAME}.md -f
RewriteRule ^(.*)$ $1.md [L]
Header always set Vary "Accept"
```

## Vercel

Same idea in `middleware.ts`. Rewrite to `${pathname}.md` when the Accept header asks
for markdown, and set the Link header on everything else.

## Astro

Astro already has a middleware hook and a content collection whose entries are markdown
files, which makes it the most natural fit of any framework here. Untested by us: read it
as a sketch, not a recipe.

```ts
// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
export const onRequest = defineMiddleware(async ({ request, url }, next) => {
  const accept = request.headers.get("accept") || "";
  const wantsMd = url.pathname.endsWith(".md") ||
    (accept.includes("text/markdown") && !url.pathname.includes("."));
  if (wantsMd) {
    const slug = url.pathname.replace(/\.md$/, "").replace(/\/$/, "") || "/index";
    // On Cloudflare do not fetch your own origin (522, see the Workers file above):
    // use locals.runtime.env.ASSETS.fetch, or read getEntry(...).body from astro:content.
    const md = await fetch(new URL(`/_md${slug}.md`, url.origin)); // emitted at build
    if (md.ok) return new Response(md.body, { headers: {
      "content-type": "text/markdown; charset=utf-8", "vary": "Accept" } });
  }
  const res = await next();
  res.headers.set("link", `<${url.pathname.replace(/\/$/, "")}.md>; rel="alternate"; type="text/markdown"`);
  res.headers.append("vary", "Accept");
  return res;
});
```

At build time, copy every content collection entry's raw markdown to `public/_md/<slug>.md`
unchanged. The collection file is the source. The rendered page is derived from it. That
satisfies R1 without any second copy to maintain: the `_md` tree is regenerated on every
build and never edited.

## WordPress

WordPress stores HTML, not markdown, so a plugin can only serve a derived markdown, and
the honest name for that is a compatibility layer. The sketch covers the negotiation
half of R2. The `Link` header on the HTML response and the R3 manifest are yours to add.
It does not meet R1, because the post editor is the source and the markdown is an export
of it. Say so on the site rather than claim compliance. Untested sketch:

```php
// txtfirst.php, drop into wp-content/mu-plugins/
add_action('template_redirect', function () {
  $accept = $_SERVER['HTTP_ACCEPT'] ?? '';
  $wants  = str_contains($accept, 'text/markdown') || str_ends_with($_SERVER['REQUEST_URI'], '.md');
  if (!$wants || !is_singular()) return;
  $post = get_queried_object();
  header('Content-Type: text/markdown; charset=utf-8');
  header('Vary: Accept');
  echo "---\ntitle: " . $post->post_title . "\nupdated: " . $post->post_modified_gmt . "\n---\n\n";
  echo "# " . $post->post_title . "\n\n" . html_to_markdown(apply_filters('the_content', $post->post_content));
  exit;
});
add_action('wp_head', function () {
  if (is_singular()) echo '<link rel="alternate" type="text/markdown" href="' . rtrim(get_permalink(), '/') . '.md">';
});
```

`html_to_markdown` is any HTML to Markdown converter. The `.md` suffix needs a rewrite rule
so WordPress does not 404 it before the hook runs.

## Plain HTML, no framework

If your site is a folder of HTML files, the smallest compliant version is the one this
site ran on its first day: keep the markdown files next to the HTML, generate the HTML
from them with a script, and put the negotiation in whatever serves the folder (the nginx
and Apache blocks above). `build.py` on this site is a working example with no
dependencies.

## Check it, from anywhere

The checker is a single file with no dependencies. It makes at most seven GET requests
to one origin, identifies itself as `txtfirst-cli`, asks `robots.txt` first, writes
nothing and sends nothing anywhere.

```
curl -s https://txtfirst.com/cli/index.js > txtfirst.js

node txtfirst.js check yourdomain.com
node txtfirst.js check yourdomain.com --path /blog/a-post
node txtfirst.js check yourdomain.com --json      # for CI
```

It answers two questions in order. First, whether the origin answers a client that is
not pretending to be a browser, which most do not and which nothing else can fix.
Second, whether it meets the four requirements above. It exits 0 when a site is
conformant, 1 when it is not, 2 on bad input and 3 when it could not measure because
`robots.txt` disallows it, so it can gate a deploy without a site that refuses the
checker being read as a site that passed.

The source is at [/cli/index.js](/cli/index.js), which is the same file the command
downloads. Running it against `txtfirst.com` should return eight of eight requirement
checks and two of two access checks, and if it ever does not, this page is wrong rather
than your reading of it.

## A running system, not a sketch

Three news sites sharing one chassis (Cloudflare Pages Functions, KV, TypeScript) adopted
the layer on 6 September 2026. Each article body was already one markdown document in
KV, which is why the patch is small. The patch added the `.md` suffix, `Accept` negotiation,
`Link` and `<link>` advertising, a `/changes.json` feed and the manifest, and moved a
glossary tooltip's definition out of the article text into an attribute so a client that
strips markup no longer reads it mid sentence. Applied by a script with an assert on
every load-bearing edit, so a drifted copy fails loudly instead of being half patched:
[chassis_kv.py](/adopt/chassis_kv.py). Verified live by
[check_chasis.sh](/adopt/check_chasis.sh), fourteen checks per site.

## The build step

Any static site generator already does this, you are just changing which output you
consider canonical. If you want the smallest possible version, this site's build is a
single Python file with no dependencies that walks `content/`, writes `slug.md`
byte identical to the source and `slug.html` rendered from it, then emits
`robots.txt`, `llms.txt`, `sitemap.xml` and `/.well-known/txtfirst.json`.

```
python3 build.py          # build
python3 build.py --check  # self test the renderer
```

## Verify yourself

```
curl -sI https://example.com/page | grep -i '^link:'
curl -s -H "Accept: text/markdown" https://example.com/page | head -3
curl -s https://example.com/page.md | head -3
diff <(curl -s -H "Accept: text/markdown" https://example.com/page) \
     <(curl -s https://example.com/page.md) && echo "R2 ok"
curl -s https://example.com/.well-known/txtfirst.json | head -5
```

If the diff is empty and the JSON parses, you meet R2 and R3. R1 is a discipline, not
a header, and nobody can check it for you. R4 needs a human to look at your images
once.
