通过Hugo构建时输出.md文件副本,配合Cloudflare Pages的小工具实现按请求头Accept:text/markdown自动切换返回格式,全程在免费套餐内完成,无需Workers AI计费。
AI agents、LLM 爬虫和 CLI 工具不需要你的 HTML。它们需要的是内容——标题、列表和链接,而不需要导航栏、主题切换按钮以及围绕在周围的 40 KB markup。礼貌的做法是内容协商:当客户端发送 Accept: text/markdown 时返回 Markdown;否则按正常方式提供 HTML。
问题在于:内容协商依赖请求头,而静态托管无法做到这一点。Cloudflare Pages 的 _headers 文件只能按路径设置响应头——它无法查看客户端请求了什么。因此多年来的答案一直是"使用真实的服务器"。
Cloudflare 也提供了一条路径:Workers AI 上的 toMarkdown 转换工具,可以按需将文档转换为 Markdown。这是一个不错的工具,但适用场景不同——它在请求时转换任意文件(图片转换可能涉及 Workers AI 计费),而且意味着要添加 Workers AI 绑定作为依赖。对于你自己的站点页面,不需要这个。
你根本不需要那些。这篇文章展示如何在 Hugo + Cloudflare Pages 上实现它,完全使用免费套餐,只需:
.md 副本,以及$ curl -sD- -H 'Accept: text/markdown' https://sumeetnaik.com/blog/
HTTP/2 200
content-type: text/markdown; charset=utf-8
vary: accept
x-markdown-tokens: 32
# Blog
- [Serve Markdown to AI Agents ...](/blog/markdown-for-ai-agents-hugo-cloudflare-pages/) — 2026-07-14
...
浏览器访问同一 URL 得到的仍是 HTML,原封不动。
两个部分,且它们相互独立:
index.html 旁边生成 index.md。静态文件,像其他内容一样在边缘缓存。Accept,如果客户端需要 Markdown,则提供预构建的 .md。由于 Markdown 是预渲染的,函数几乎不做任何工作——只是选择返回哪个文件。这让它保持快速,且完全在免费套餐的限制内。
Hugo 的输出格式允许一个页面渲染为多个文件。定义一个 MARKDOWN 格式,并在站点配置(hugo.toml)中为每种页面类型启用它:
[outputs]
home = ["HTML", "RSS", "MARKDOWN"]
section = ["HTML", "MARKDOWN"]
page = ["HTML", "MARKDOWN"]
[outputFormats.MARKDOWN]
mediaType = "text/markdown" # -> ".md" 后缀
baseName = "index" # -> <path>/index.md,位于 index.html 旁边
isPlainText = true # 不要通过 HTML 转义器
notAlternative = true # 使其不参与 <link rel=alternate> 发现
baseName = "index" 是关键:它将 index.md 放在每个 index.html 旁边,所以 /blog/my-post/ 会有一个同级的 /blog/my-post/index.md。之后映射起来很方便。
输出格式需要模板。Hugo 按名称 + 格式选择模板,所以以 .markdown.md 结尾的模板只会渲染 MARKDOWN 格式,不会与你的 HTML 布局冲突。你需要三个通用模板。
单页 — layouts/_default/single.markdown.md。.RawContent 是页面原始的 Markdown 源码,不含 front matter——正是 agent 需要的:
# {{ .Title }}
{{ with .Date }}{{ if not .IsZero }}
_{{ .Format "January 2, 2006" }}_
{{ end }}{{ end }}
{{ .RawContent }}
section 列表 — layouts/_default/list.markdown.md:
# {{ .Title }}
{{ with .RawContent }}
{{ . }}{{ end }}
{{ range .Pages }}- [{{ .Title }}]({{ .RelPermalink }}){{ with .Date }}{{ if not .IsZero }} — {{ .Format "2006-01-02" }}{{ end }}{{ end }}
{{ end }}
首页 — layouts/index.markdown.md。首页通常需要定制化,所以从站点 params / menus 构建,而不是 .RawContent:
# {{ .Site.Params.name }}
{{ .Site.Params.intro }}
## Pages
{{ range .Site.Menus.main }}
- [{{ .Name }}]({{ .URL }}){{ end }}
运行 hugo,你会看到 index.md 文件在 public/ 中出现。通用情况就完成了。
几乎每个 Hugo 站点都会遇到这个问题。许多"页面"将内容存储在 front matter 中,而不是 Markdown 正文——比如一个带有 groups: 列表的 /uses 页面、一个带有 jobs: 的 /resume 页面、一个带有 sections: 的 /now 页面。对于这些,.RawContent 是空的,通用模板只会给你一个孤零零的 # Title,下面什么都没有。
解决方法是每个自定义布局写一个 Markdown 模板,镜像 HTML 模板所做的——只是输出 Markdown 而不是 <div>。如果你的 resume 页面从 HTML 布局中的 jobs 数组渲染,就添加 layouts/_default/resume.markdown.md:
# {{ .Title }}
{{ .Params.sub }}
## Experience
{{ range .Params.jobs }}
### {{ .title }}
_{{ .dateRange }}_ — {{ .meta }}
{{ range .points }}- {{ . }}
{{ end }}{{ end }}
uses 页面同理(定界符、嵌套 range)或 now 页面。无论你的 HTML 布局读取什么结构化数据,你的 Markdown 布局读取相同的字段。Hugo 自动解析 <layout>.markdown.md,用于任何 front matter 中设置了 layout: "resume"(等等)的页面。
经验法则:对于你拥有的每个自定义 HTML 布局,检查内容是在正文还是 front matter 中。正文内容 → 通用 single.markdown.md 可以覆盖。Front matter 内容 → 写一个对应的 *.markdown.md。
现在是协商部分。在仓库根目录放置一个文件 functions/_middleware.js——Cloudflare Pages 自动检测 functions/ 目录,无需配置、无需 wrangler.toml、无依赖。_middleware.js 在每个请求上运行。
export async function onRequest(context) {
const { request, next } = context;
const accepts = request.headers.get("Accept") || "";
// Require markdown to be explicitly acceptable — a bare `*/*` keeps HTML.
if (!/text\/markdown/i.test(accepts)) {
return next();
}
const url = new URL(request.url);
const path = url.pathname;
let mdPath;
if (path.endsWith("/")) {
mdPath = path + "index.md";
} else if (!/\.[a-z0-9]+$/i.test(path)) {
mdPath = path + "/index.md"; // extensionless page URL
} else {
return next(); // real file (css, rss.xml, ...) — leave it
}
const mdUrl = new URL(url);
mdUrl.pathname = mdPath;
// Fetch the static .md that Hugo built for this page.
const res = await next(new Request(mdUrl, request));
// If there's no .md for this path, fall back to HTML. A missing asset can be a
// 404 (production) OR the root index.html served with status 200 (wrangler
// dev), so reject anything that isn't actually markdown — don't trust status.
const type = res.headers.get("content-type") || "";
if (!res.ok || /html/i.test(type)) {
return next();
}
const body = await res.text();
const headers = new Headers(res.headers);
headers.set("Content-Type", "text/markdown; charset=utf-8");
headers.set("Vary", "Accept");
headers.delete("Content-Length"); // let the runtime recompute
headers.set("x-markdown-tokens", String(Math.ceil(body.length / 4)));
return new Response(body, { status: 200, headers });
}
有几件事值得指出:
context.next(request) 是关键技巧。不传参数调用它,管道继续处理当前 URL;用另一个 URL 的 Request 调用它,则改为获取那个静态资产。这就是我们如何在没有第二次网络跳转的情况下获取 index.md。它不会重新进入中间件,所以不会有无限循环。
我们要求 text/markdown 是字面意义的。浏览器发送 Accept: text/html,...,*/*;q=0.8,不匹配,所以浏览器永不受影响。Agents 明确选择加入。
x-markdown-tokens 是一个礼貌响应头。边缘没有分词器,所以 字符数 / 4 是英文的标准粗略估算——对于客户端决定是否获取足够用了。
当 .md 不存在时期望得到干净的 404。但 wrangler pages dev 将根 index.html 作为后备提供,状态为 200——所以简单的 if (!res.ok) 检查会通过,然后你愉快地用 text/markdown content-type 包装 HTML。我在测试一个假 URL 时就遇到了这个。检查获取的资源的 content-type 不是 HTML 可以同时修复开发和生产环境。不要单独信任状态码。
一个头信息保持 CDN 的诚实。添加 Vary: Accept,这样缓存永远不会向浏览器提供 Markdown 变体(反之亦然)。函数已在 Markdown 响应上设置了它;通过 static/_headers 为 HTML 端全局添加它:
/*
Vary: Accept
构建后,用函数连接上运行 Cloudflare 自己的开发服务器:
hugo
npx wrangler pages dev public --port 8788
然后测试两条路径:
# Markdown for agents
curl -sD- -H 'Accept: text/markdown' http://localhost:8788/blog/ | head
# HTML for everyone else — unchanged
curl -sD- http://localhost:8788/blog/ | head
# Real files are left alone
curl -sD- -H 'Accept: text/markdown' http://localhost:8788/rss.xml | head
检查:第一个请求 Content-Type 是 text/markdown,有 x-markdown-tokens 头和 Markdown 正文;第二个是纯 HTML;第三个你的 feed 原封不动。不存在的 URL 应该回退到 HTML,而不是 Markdown 标记的 404。
这里没有任何涉及付费功能:
三个移动部分——一个输出格式、一组 *.markdown.md 模板和约 30 行中间件——你站点的每个页面现在都能向任何请求它的东西提供 Markdown,而人类继续获得完整体验。如果你想让你的站点对机器更易读,可以结合 agents.txt 和 Link: rel="describedby" 头一起使用,效果很好。
如果你要将这个适配到自己的主题,有两件事我会记住:为每个 front matter 驱动的布局写一个 Markdown 模板,并根据获取的 content-type 而不是状态码来保护。一切都是机械的。