{"format":"letterpress-content-record","version":1,"site":{"name":"此间札记","language":"zh-CN","home":"https://xiaoxusop.github.io/letterpress/"},"id":"post:cjk-web-typography","kind":"post","slug":"cjk-web-typography","title":"中文网页排版，多数主题都做错了","summary":"行高、字重、行宽、中西文间距——这四个参数直接套西文的值，中文读者看到的是一份「能读但别扭」的页面。2026 年有三个原生 CSS 属性可以修好其中一半。","urls":{"html":"https://xiaoxusop.github.io/letterpress/cjk-web-typography/","markdown":"https://xiaoxusop.github.io/letterpress/cjk-web-typography.md"},"publishedAt":"2026-09-12T00:00:00.000Z","updatedAt":"2026-09-24T00:00:00.000Z","tags":["排版","css","中文"],"relations":{"outgoing":["wiki:cjk-typography","wiki:content-negotiation"],"backlinks":["wiki:cjk-typography"]},"provenance":{"sources":[{"sourceId":"css-values-4","revision":"WD-20240312","locator":"§5.1.1 长度单位 · ch"},{"sourceId":"css-values-4","revision":"WD-20240312","locator":"§5.1.1 长度单位 · ic"},{"sourceId":"css-fonts-4","revision":"WD-20260913","locator":"§2.2.2 Missing weights"},{"sourceId":"css-fonts-4","revision":"WD-20260913","locator":"§5 Font Matching Algorithm · 字重匹配"},{"sourceId":"css-fonts-4","revision":"WD-20260913","locator":"§2.8.1 Controlling synthesized bold"},{"sourceId":"apple-system-fonts","revision":"online-2026-09-24","locator":"System Fonts 表格 · PingFang SC 条目"}]},"content":{"mediaType":"text/markdown","bytes":13939,"sha256":"81327d682025e91c5affe8397280544c5f0e93be96907fdfa080e44d621df2db","text":"# 中文网页排版，多数主题都做错了\n\n日期：2026-09-12  \n标签：排版、css、中文\n\n> 行高、字重、行宽、中西文间距——这四个参数直接套西文的值，中文读者看到的是一份「能读但别扭」的页面。2026 年有三个原生 CSS 属性可以修好其中一半。\n\n来源：https://xiaoxusop.github.io/letterpress/cjk-web-typography/\n语言：zh-CN\n类型：文章\n\n## 被引用\n\n- [中文排版](https://xiaoxusop.github.io/letterpress/wiki/cjk-typography/)\n\n## 正文\n\n中文网页排版有个奇怪的现象：**大家都觉得别扭，但很少有人能指出哪里别扭。**\n\n原因多半是这四个参数被直接沿用了西文的值。它们单独看都不致命，\n叠在一起就是那种说不清的「不对劲」。\n\n## 一、行高\n\n西文正文的常用行高是 1.4 到 1.5。这个值搬到中文上会显得挤。\n\n因为汉字是**方块字**：笔画在字面内铺得很满，字与字之间没有西文那种\n由升降部带来的天然空隙。同样行高下，中文的行间留白视觉上更少。\n\n中文正文的行高应当在 **1.6 到 1.8** 之间。本站用的是 1.75。\n\n还有个细节：行高要用**无单位数值**（`line-height: 1.75`），不要用 `px` 或 `em`。\n无单位值是相对于元素自身字号计算的，继承时不会出乱子。\n\n## 二、字重\n\n这条最容易被忽略，但效果立竿见影。\n\n**很多中文字体只有两个真实字重：400 和 700。** 苹方有 Ultralight 到 Semibold，\n思源黑体有 300 到 900 的完整字重；微软雅黑主要就是 400 和 700。\n（Apple 官方系统字体清单里列有 `PingFang SC Semibold`，\n见 [System Fonts](https://developer.apple.com/fonts/system-fonts/)。）\n\n这里的通行说法是「写 600 会触发伪合成，所以只能写 500」。**这话只说对了一半。**\n\n伪合成的开关不是字重数值，而是 **`font-synthesis-weight`**。至于字重本身——\n写一个字体没有的字重时，规范要求的是**去找最接近的真实字重**，不是伪造一个：\n\n> When a weight is specified for which no face exists, a face with a\n> nearby weight is used.\n> —— [CSS Fonts Level 4 §2.2.2](https://www.w3.org/TR/2026/WD-css-fonts-4-20260913/#font-style-matching)\n\n**「最接近」具体朝哪边找，规范给了确定规则**（§5 字重匹配）：\n请求的字重**大于 500** 时，先按升序检查**不小于**目标的字重\n（600 → 700 → 800 → 900），都找不到再向下找。于是：\n\n- 苹方这类**确有 600** 的字体 → 请求 600 **直接命中 600**，不会跳到 700；\n- 微软雅黑这类**只有 400/700** 的字体 → 请求 600 先查 700，**落到 700**。\n\n两条路径**都不会伪造粗体**（前提是没开伪合成）：开着合成时它才会把 400\n算法加粗——笔画发虚、字形走样，而且每个浏览器合成得还不一样。\n\n本站的做法是把合成直接关掉：\n\n```css\nhtml {\n  font-synthesis-weight: none;\n}\n```\n\n于是标题可以写 `font-weight: 600`：拉丁部分拿到 Archivo 的真实 600，\n中文部分落到**自己字体里最接近 600 的真实字重**（苹方约 600、雅黑 700），\n两者视觉重量相当。\n（反过来，写 500 的话中文更容易掉到 **400**——比正文还轻，标题反而立不住。）\n\n更重要的是：**层级主要靠字号和颜色建立，不要靠字重硬拉。**\n一段 17px/400 的正文和一段 21px/600 的小标题，层级已经足够了。\n\n> **勘误（2026-09-22）**：这一节原先写的是「正确做法：标题字重写 500，\n> 让字体自己去挑最接近的真实字重」。**结论和建议都改掉了**：把伪合成归因到\n> 字重数值本身是错的——开关是 `font-synthesis-weight`；而在关掉合成的实现里，\n> 写 500 会让中文落到 400，比 600 更糟。\n> 依据见 [CSS Fonts Level 4 的字重匹配](https://www.w3.org/TR/2026/WD-css-fonts-4-20260913/#font-style-matching)\n> 与本站 `src/styles/base.css` 的 `font-synthesis-weight`。\n\n## 三、行宽\n\n这是四个里最麻烦的一个，因为中西文的最优值**在同一个单位下对不齐**。\n\n西文的理想行宽是 45 到 75 个字符，66 是经典值。而 `ch` 这个单位\n是为西文设计的——按规范，它等于**渲染所用字体**里「0」字形的宽度：\n\n> `ch` — Represents the typical advance measure of **European** alphanumeric\n> characters, and measured as the used advance measure of the \"0\" (ZERO,\n> U+0030) glyph **in the font used to render it**.\n> —— [CSS Values and Units Level 4](https://www.w3.org/TR/2024/WD-css-values-4-20240312/#ch)\n\n两个要点：它是**欧洲字符**的度量，而且**取决于字体**。所以 `ch` 换算不出一个\n跨字体稳定的汉字数——换个字体，「66ch 能排几个汉字」这个数就变了。\n\n粗估的话，一个汉字约等于两个 `ch`，66ch 大约排 **33 个汉字**。\n（顺带一提，这个数其实落在中文舒适的 30–40 字里——但它是粗估，且随字体浮动。）\n\n本站改用 `em`，图的是**可预测**：\n\n```css\n.prose {\n  max-width: 34em;\n}\n```\n\n`em` 等于元素的字号，这是规范保证的，且**不依赖字体**。\n但「一个汉字正好 1em 宽」**不是规范保证**——汉字的 advance 取决于具体字体，\n规范为此专门定义了一个 `ic` 单位来表示「字体中全角字形的典型 advance」，\n并规定在无法确定时**假设为 1em**：\n\n> The `ic` unit is … a typically exact measure (in the few fonts with proportional\n> fullwidth glyphs, an approximation) of a single fullwidth glyph's advance measure.\n> In the cases where it is impossible or impractical to determine the ideographic\n> advance measure, it must be assumed to be `1em`.\n> —— [CSS Values and Units Level 4 §5.1.1](https://www.w3.org/TR/2024/WD-css-values-4-20240312/#ic)\n\n**这一句本身就说明「容器宽度单位」和「实际字形宽度」不能混为一谈**——\n如果规范能保证两者相等，就不必再定义 `ic` 了。\n\n所以准确的说法是：34em 让内容栏宽度**稳定地跟随字号**；\n对常见全角汉字可**近似**理解为约 34 字，但**实际每行字数仍受字体、标点、\n字距和混排影响**。于是：\n\n- 中文：约 34 个字（近似，落在 30–40 的理想区间）✅\n- 西文：约 68 个字符（近似，落在 45–75 的理想区间）✅\n\n**一个值同时覆盖两种文字的舒适区，而且容器宽度不随字体变。** 这是\n`--measure: 34em` 的由来。**注意被保证的是「宽度稳定」，不是「字数确定」。**\n\n> **勘误（2026-09-22）**：这一节原先写的是「一个汉字大约等于两个 `ch`，\n> 所以按 66ch 排中文，每行会排出约 130 个汉字——远超出中文舒适的 30 到 40 字」。\n> **两处都错**：按它自己的前提应当除以 2 得 33，而不是乘以 2 得 130；\n> 而 33 本来就落在它自己说的舒适区间内，所以「远超」这个结论也不成立。\n>\n> 这句话还遮住了一个更关键的事实：**66ch ≈ 33em，与本题采用的 34em 宽度几乎相同**\n> （这一步依赖「0 字形约 0.5em」这个假设，是推算而非实测）。\n> 也就是说这个改动换来的是**容器宽度的可预测性**，不是行宽。\n> **结论（用 em）不变，论证方式改了。**\n>\n> 依据：[CSS Values and Units Level 4 §ch](https://www.w3.org/TR/2024/WD-css-values-4-20240312/#ch)、\n> [§ic](https://www.w3.org/TR/2024/WD-css-values-4-20240312/#ic)。\n>\n> **勘误（2026-09-24）**：上一条勘误之后，本节仍留着\n> 「`em` 等于字号、与字体无关。一个汉字约 1em 宽……**而且不随字体变**」，\n> 并据此把 `34em` 说成确定的字数换算。**前半句对，后半句越界了**：\n> 规范保证的是 `em` 等于**计算后的 font-size**，\n> 不保证**每个汉字的 advance 恒等于 1em**——所以「34em ≈ 34 字」是近似，\n> 而「**每行字数确定**」这句话是不成立的。详见上面引的 `ic` 那段。\n\n## 四、中西文间距与标点\n\n中文和英文、数字之间应该有一个小间隙：「共 500 条记录」比「共500条记录」好读。\n\n过去这件事要靠 [pangu.js](https://github.com/vinta/pangu.js) 之类的库在\n客户端跑一遍正则，找到边界然后插空格。它有一堆边缘情况（全角标点前后不加、\n`90°` 不加、`15%` 不加），而且要在 DOM 加载后重写文本。\n\n**2026 年浏览器原生做了这件事：**\n\n```css\nhtml {\n  text-autospace: normal;\n  text-spacing-trim: trim-start;\n}\n```\n\n- `text-autospace: normal` 自动在中西文之间插入约 1/4 个空格。2025 年进入 Baseline。\n- `text-spacing-trim: trim-start` 做中文标点的字距调整——行首的逗号句号\n  之类不再占满一格。Chrome / Edge 123+ 已默认开启。\n\n两个前提：**`<html lang>` 必须设置正确**，否则浏览器不会应用对应语言的规则；\n代码块里要关掉自动间距，因为代码里的空格是有意义的：\n\n```css\npre, code, kbd, samp { text-autospace: no-autospace; }\n```\n\n**只有 Chromium 系支持**：MDN 的浏览器兼容数据（2026-09-24 查）\n记 Chrome 自 123 起支持，**Safari 与 Firefox 均为「不支持」**——\n`caniuse` 上还没有这个特性（太新）。\n所以它是**渐进增强**——不支持的浏览器只是少了那一点调整，版式不会坏。\n\n> **本项目此前在这里写的是「全球覆盖约 72%」，现已删除。**\n> 那个数字**没有任何来源**（caniuse 抓不到、本项目也没实测过），\n> 而它写在一个「实测」味道很重的段落里。\n> **一个查不到来源的覆盖率，比不写更糟**——读者会以为它是量出来的。\n\n## 五、一条容易漏的：中文没有斜体\n\n中文字体不提供斜体字形。`font-style: italic` 对中文的结果是浏览器\n**合成倾斜**——把笔画整体拉斜，字形变形、笔画糊在一起。\n\n而 CSS 没法问「这段 `<em>` 里是中文还是西文」。所以务实的选择是：\n**统一把强调处理成加粗**，这也是中文排版的通行做法。\n\n```css\nem, i {\n  font-style: normal;\n  font-weight: 600;\n}\n```\n\n## 六、字体栈的顺序\n\n最后一条，顺序不能反：\n\n```css\nfont-family: 'Archivo', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;\n```\n\n**拉丁字体必须排在前面。** 如果中文字体排在前面，英文也会用中文字体渲染——\n而中文字体里的拉丁字母是等宽的（为了和汉字对齐），排版上很难看。\n\n顺序对了之后，英文用 Archivo 渲染，汉字自动落到苹方/雅黑，各取所长。\n\n## 那要不要自托管中文字体\n\n**默认不要。** 中文字体单文件 5–20 MB，哪怕做了子集化，一个博客站也不该\n为此付出首屏时间。而中文系统字体（苹方、微软雅黑、思源黑体）质量本身就很好——\n知乎、Medium 中文版这类大站的内文都用系统字体。\n\n实在需要自定义时，用 [cn-font-split](https://github.com/KonghaYao/cn-font-split)\n做子集化，并且务必用 `unicode-range` 限定覆盖面。\n\n## 小结\n\n| 参数 | 西文常用 | 中文应用 |\n|---|---|---|\n| 行高 | 1.4–1.5 | **1.6–1.8** |\n| 标题字重 | 600–700 | **600**（配 `font-synthesis-weight: none`） |\n| 行宽 | 66ch | **34em**（≈34 汉字 / ≈68 西文字符，**均为近似**） |\n| 中西文间距 | 手动加 | **`text-autospace`** |\n| 强调 | 斜体 | **加粗** |\n\n这些细节单独看都很小。但它们同时出现时，读者的感受就是「这个站读起来舒服」\n而说不出为什么——那正是排版该有的样子。\n\n相关条目：[中文排版](https://xiaoxusop.github.io/letterpress/wiki/cjk-typography/)、[内容协商](https://xiaoxusop.github.io/letterpress/wiki/content-negotiation/)。\n\n## 参考\n\n- [CSS Values and Units Level 4 §ch](https://www.w3.org/TR/2024/WD-css-values-4-20240312/#ch) —— `ch` 的定义\n  （W3C Working Draft, 12 March 2024）\n- [CSS Fonts Level 4 §2.2.2 Missing weights](https://www.w3.org/TR/2026/WD-css-fonts-4-20260913/#font-style-matching) —— 缺少字重时如何匹配\n- [CSS Fonts Level 4 §font-synthesis-weight](https://www.w3.org/TR/2026/WD-css-fonts-4-20260913/#font-synthesis-weight) —— 伪合成的开关\n  （W3C Working Draft, 13 September 2026）\n\n> **为什么链接带日期而不是 `/TR/css-values-4/`**：后者是\"最新版\"，\n> 会随规范修订移动——半年后回看，不知道当时引的是哪一版。\n> W3C 自己推荐的引用方式就是带日期的固定 URL。\n\n---\n\n**修订记录**\n\n- **2026-09-12** 发布。\n- **2026-09-22** 勘误：行宽一节把「一个汉字 ≈ 2 `ch`」算反了（应除以 2 得 33，\n  原写作乘以 2 得 130），连带「远超舒适区」的结论也不成立；字重一节把伪合成\n  归因到字重数值上，而开关其实是 `font-synthesis-weight`，且在关掉合成的实现里\n  写 500 会让中文落到 400。两处的**结论都保持不变**（用 `em`、标题用 600），\n  改的是论证与依据。同步修订了 [中文排版](https://xiaoxusop.github.io/letterpress/wiki/cjk-typography/)。\n- **2026-09-24** 勘误：上一轮只改了那句算错的算式，**却留下了更大的越界**——\n  「`em` 等于字号、与字体无关」被推广成「一个汉字正好 1em 宽、每行字数确定」。\n  规范保证的只是 `em` 等于**计算后的 font-size**；它为此专门定义了 `ic`\n  来表示全角字形的典型 advance，无法确定时假设 1em。\n  同时**苹方字重结论与 Apple 官方清单冲突**（清单里确有 `PingFang SC Semibold`），\n  且「请求 600 必落 700」是条件句：匹配算法对 >500 的请求**先向上找**，\n  有真 600 的字体直接命中 600。\n"}}
{"format":"letterpress-content-record","version":1,"site":{"name":"此间札记","language":"zh-CN","home":"https://xiaoxusop.github.io/letterpress/"},"id":"post:how-this-works","kind":"post","slug":"how-this-works","title":"这个博客是怎么搭的","summary":"改一个配置文件就能上线，文章放在一个目录里，知识层会自动长出链接图。这一篇解释目录结构、写作流程，以及怎么把它换成你自己的。","urls":{"html":"https://xiaoxusop.github.io/letterpress/how-this-works/","markdown":"https://xiaoxusop.github.io/letterpress/how-this-works.md"},"publishedAt":"2026-09-12T00:00:00.000Z","tags":["使用说明"],"relations":{"outgoing":["wiki:cjk-typography","wiki:content-negotiation","wiki:design-tokens","wiki:llm-wiki"],"backlinks":[]},"content":{"mediaType":"text/markdown","bytes":5738,"sha256":"982f1cbfc6a36e377d0a75d56d45d8c064f6e0f1e2cb2f65e185b2c0f74ce57f","text":"# 这个博客是怎么搭的\n\n日期：2026-09-12  \n标签：使用说明\n\n> 改一个配置文件就能上线，文章放在一个目录里，知识层会自动长出链接图。这一篇解释目录结构、写作流程，以及怎么把它换成你自己的。\n\n来源：https://xiaoxusop.github.io/letterpress/how-this-works/\n语言：zh-CN\n类型：文章\n\n## 正文\n\n这一篇是写给你的——如果你 clone 了这个项目，它会告诉你东西都在哪。\n\n## 你只需要改一个文件\n\n`src/config.ts`。里面的 `site`、`author`、`nav` 三项改完就能上线：\n\n```ts\nexport const site: SiteConfig = {\n  title: '你的站名',\n  tagline: '一句话说清这个站是什么。',\n  url: 'https://example.com',   // 部署前填，不留斜杠\n  lang: 'zh-CN',                // 别改错——浏览器的中文标点规则依赖它\n  author: { name: '你的名字', bio: '一句话介绍自己。' },\n  // …\n};\n```\n\n**其余全部有可用的默认值，而且默认值本身就是完整站点，不是占位符。**\n你现在看到的这些文章和知识库条目就是示例内容——它们是真的能读的，\n不是 `lorem ipsum`。删掉它们换成你自己的即可。\n\n## 东西都在哪\n\n```\nsrc/\n├── config.ts            你唯一必须改的文件\n├── content/\n│   ├── posts/           文章。按时间排\n│   └── wiki/            知识层。按主题互链\n├── layouts/             页面骨架\n├── components/          组件\n├── pages/               路由\n├── lib/                 纯逻辑（可测试，不依赖 Astro）\n│   ├── negotiate/       Accept 头解析——[[内容协商]] 的实现\n│   └── wiki/            链接图、体检、llms.txt 生成\n└── styles/\n    ├── tokens.css       设计令牌\n    ├── base.css         基线排版（含 [[中文排版]] 的那些修正）\n    └── site.css         布局与组件\n```\n\n## 两种内容，两种组织方式\n\n**文章**放在 `src/content/posts/`，按时间排列，是「流」。\n\n**知识库条目**放在 `src/content/wiki/`，按主题互链，是「网」。\n\n它们的区别不是格式，是**读者找东西的方式**：想看你最近写了什么就翻文章，\n想搞清某个概念就查知识层。两边的条目地位相同，可以互相引用。\n\n## 用 `[[方括号]]` 连起来\n\n在任何地方写 `[[目标]]`，就会生成一条链接：\n\n```markdown\n见 [[内容协商]] 一节。\n```\n\n支持三种写法：\n\n| 写法 | 效果 |\n|---|---|\n| `[[内容协商]]` | 链接，显示文本即目标名 |\n| `[[内容协商\\|那篇]]` | 自定义显示文本 |\n| `[[内容协商#为什么]]` | 指向页内某小节 |\n\n目标可以写 slug，也可以写标题——你大概不会记得每篇的 slug 是什么。\n解析时**大小写和空格都不敏感**。\n\n写错了不会静默失败：构建时会报一条 `broken-wikilink` 错误，\n并告诉你**哪个页面引用了什么**。\n\n## 构建时会做体检\n\n每次构建会跑一遍知识库体检，输出在控制台。分三级：\n\n- **错误**：断链、两个页面抢同一个 URL、文章占用 `/about/` 等系统路由、\n  引用了没登记的来源、复核状态与正文对不上。\n  **会让构建失败**（可在配置里关掉）\n- **警告**：孤儿页（没有任何页面指向它）、缺摘要、空正文、\n  同一关系在正文与 `related` 里各写了一次\n- **提示**：摘要过长之类的建议\n\n**这不是可选的装饰。** 一个由人（或 agent）持续维护的知识库，\n最容易的结局是慢慢烂掉——改了标题没改引用、删了页面留下断链。\n机械的检查必须能进 CI，否则「知识库」会退化成「一堆文件」。\n\n完整的规则表在 `AGENTS.md`——那份是**写给 agent 看的**，改规则时两边要一起改。\n\n## 给 AI 读者那一份\n\n这一站有两个出口，服务两类读者：\n\n**Markdown 孪生文件。** 每篇文章和知识页都有可直接访问的 `.md` URL。\n在支持内容协商的部署平台上，请求页面 URL 时带上 `Accept: text/markdown`\n也会得到 markdown；本站使用 GitHub Pages，因此应直接访问 `.md` 链接。\n原理和托管限制见 [内容协商](https://xiaoxusop.github.io/letterpress/wiki/content-negotiation/)。\n\n**目录与全文。** `/llms.txt` 是给 agent 看的目录（标题 + 一句话说明），\n`/llms-full.txt` 是全文内联版。每个页面还有 `.md` 孪生文件，\n比如 `/how-this-works.md`。\n\n需要说明的是：**`llms.txt` 的实际效果被高估了**——\nAhrefs 实测 13.7 万个域名里 97% 的 llms.txt 从未被请求过。\n本站生成它是因为零成本，但主要依靠显式 `.md` 链接、内容清单和干净的 HTML。\n\n## 部署\n\n静态产物，任何静态托管都能跑。但**内容协商需要一层边缘函数**——\n因为静态托管不解析请求头，它只会把文件原样吐出来。\n\n项目里带了三个平台的现成实现，**选你用的那个**：\n\n| 平台 | 边缘函数 | 平台配置 |\n|---|---|---|\n| Cloudflare Pages | `functions/[[path]].ts` | `public/_headers`（会被拷进产物） |\n| Netlify | `netlify/edge-functions/negotiate.ts` | 无额外配置 |\n| Vercel | `middleware.ts` | `vercel.json` |\n\n不想要这一层也没关系，站点照常工作，只是 agent 拿到的是 HTML 而不是 markdown。\n\n## 详细说明\n\n- 排版与设计决策：[中文排版](https://xiaoxusop.github.io/letterpress/wiki/cjk-typography/)、[设计令牌](https://xiaoxusop.github.io/letterpress/wiki/design-tokens/)\n- 知识层的设计取舍：[LLM 知识库](https://xiaoxusop.github.io/letterpress/wiki/llm-wiki/)\n"}}
{"format":"letterpress-content-record","version":1,"site":{"name":"此间札记","language":"zh-CN","home":"https://xiaoxusop.github.io/letterpress/"},"id":"post:markdown-for-agents","kind":"post","slug":"markdown-for-agents","title":"你的博客该不该给 AI 一份 markdown","summary":"内容协商是 HTTP 自带的能力，但博客圈几乎没人用。三个 agent 会主动要 markdown，四个不会——而现有工具都跳过了最难的那一步。","urls":{"html":"https://xiaoxusop.github.io/letterpress/markdown-for-agents/","markdown":"https://xiaoxusop.github.io/letterpress/markdown-for-agents.md"},"publishedAt":"2026-09-12T00:00:00.000Z","updatedAt":"2026-09-24T00:00:00.000Z","tags":["web","agent","http"],"relations":{"outgoing":["wiki:content-negotiation"],"backlinks":["wiki:content-negotiation"]},"provenance":{"sources":[{"sourceId":"rfc9110-accept","revision":"rfc9110","locator":"§12.5.1 Accept"},{"sourceId":"rfc7763-markdown","revision":"rfc7763","locator":"全文"},{"sourceId":"google-ai-features","revision":"online-2026-09-23","locator":"You don't need to create new machine readable files"},{"sourceId":"ahrefs-llmstxt-study","revision":"online-2026-06-15","locator":"Methodology / Top findings"},{"sourceId":"cloudflare-markdown-for-agents","revision":"online-2026-09-24","locator":"token 对比一节"}]},"content":{"mediaType":"text/markdown","bytes":10151,"sha256":"7b21a9b309cd4796b31172016ccfe5530bc6fb47667e8d99e415c6867872702d","text":"# 你的博客该不该给 AI 一份 markdown\n\n日期：2026-09-12  \n标签：web、agent、http\n\n> 内容协商是 HTTP 自带的能力，但博客圈几乎没人用。三个 agent 会主动要 markdown，四个不会——而现有工具都跳过了最难的那一步。\n\n来源：https://xiaoxusop.github.io/letterpress/markdown-for-agents/\n语言：zh-CN\n类型：文章\n\n## 被引用\n\n- [内容协商](https://xiaoxusop.github.io/letterpress/wiki/content-negotiation/)\n\n## 正文\n\n一个 AI agent 抓取你的网页时，看到的东西和你看到的不一样。它看到的是两万个\ntoken 的导航栏、页脚和 `<div>` 嵌套，而正文可能只占其中百分之几。\n\n这不是比喻。Cloudflare 实测自己的文档：同一页 HTML 要 16,180 token，\nmarkdown 版只要 3,150 token——**省 80%，内容一模一样**。\n\nHTTP 从 1.1 起就有解决这个问题的机制：内容协商。客户端在 `Accept` 头里说\n「我更想要 markdown，不行的话 HTML 也行」，服务端照办。\n\n问题是：**博客圈几乎没人用。**\n\n## 谁在要 markdown\n\n拿七个常见 agent 去请求 `httpbin.org/headers`，\n看它们各自发出的 `Accept` 头。结果是这样的：\n\n| Agent | 要 markdown 吗 | Accept 头 |\n|---|---|---|\n| Claude Code | 是 | `text/markdown, text/html, */*` |\n| Cursor | 是 | `text/markdown`, `text/html;q=0.9`, … |\n| OpenCode | 是 | `text/markdown;q=1.0`, `text/x-markdown;q=0.9`, … |\n| OpenAI Codex | 否 | `text/html`, `application/xhtml+xml`, … |\n| GitHub Copilot | 否 | `text/html`, `application/xhtml+xml`, … |\n| Gemini CLI | 否 | 通配符 |\n| Windsurf | 否 | 通配符 |\n\n七个里三个主动要。**而这七行数据本身就是一份测试用例**——见\n[内容协商](https://xiaoxusop.github.io/letterpress/wiki/content-negotiation/) 里关于怎么把它钉成回归测试的部分。\n\n## 一个安静失败的实现细节\n\nClaude Code 那一行值得单独说。\n\n它发的是 `text/markdown, text/html, */*`——**没有 q 值**。在 HTTP 里，\n缺省 q 值等于 1.0，于是 markdown 和 html 两个媒体范围并列。\n\n**这里要说清楚：RFC 并没有规定并列时怎么打破平局。**\n[RFC 9110 §12.5.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1)\n规定的是「媒体范围可以被更具体的范围覆盖，**更具体的优先**」——\n它按*具体程度*排序（`text/plain;format=flowed` → `text/plain` → `text/*` → `*/*`），\n而对 q 值相同的两个类型，规范只说它们是 *equally preferred*，没有给顺序。\n\n所以「靠前的优先」是**本项目的实现选择**，不是规范要求。\n\n但如果实现里写的是严格的 `>` 比较：\n\n```js\nif (markdown.q > html.q) return 'markdown';\nreturn 'html';\n```\n\n那么 `1.0 > 1.0` 为假，判定落到 HTML 上。**Claude Code 永远拿不到 markdown，\n而服务端不报错、不警告、日志里什么都没有。** 你只会觉得「这功能好像没用」。\n\n本项目的做法是**并列时比顺序**（如上所述，这是选择而非规范要求）：\n\n```js\nif (markdown.q !== html.q) return markdown.q > html.q;\nreturn markdown.index < html.index;\n```\n\n这类 bug 的可怕之处在于它**不会以错误的形式出现，只会以「没效果」的形式出现**。\n\n## 为什么现成的工具都不太行\n\nAstro 生态里已经有两个包在做这件事，但都停在半路：\n\n- **astro-markdown-for-agents**：在构建期生成 `.md` 文件，但协商**只在 dev server 里生效**。\n  它的 README 直说：「不包含托管平台运行时垫片；静态托管环境下的服务时协商\n  应由使用方自行实现。」也就是说**上生产就没了**。\n- **astro-markdown-export**：把源 `.md` 复制进产物目录，**完全不做协商**。\n  要访问 `/slug/index.md` 才拿得到，而且导出的是未渲染的源文件。\n\n两者的周下载量分别是 106 和 17。而主流 Astro 集成是三十万到两百万。\n**这不是「已经解决了只是没人知道」，是「有人碰过就放弃了」。**\n\n## 难在哪\n\n难的不是算法——算法上面已经写完了，四十行。\n\n难的是**静态托管不解析请求头**。静态站就是一堆文件，CDN 把文件原样吐出来，\n它不会因为你 `Accept` 写了什么就换一个文件给你。所以你需要一层边缘函数。\n\n这也是为什么 Cloudflare 的 Markdown for Agents 要 Pro 及以上套餐——\n它是在边缘替你做了转换。而 Vercel 的实现只在自己的平台内生效。\n**跑在免费静态托管上的博客，两头都够不着。**\n\n出路是：构建期生成 `.md` 孪生文件，再用一层几十行的边缘函数做选择。\n转换在构建期完成（零运行时成本），边缘只负责「给哪个」。\nCloudflare Pages Functions、Netlify Edge Functions、Vercel Middleware\n的免费额度都够用。\n\n## 两件必须做对的事\n\n**一、`Vary: Accept` 不能省。** 少了它，CDN 会把 markdown 版本缓存下来\n发给浏览器——用户打开博客看到一坨纯文本。这是个会让人当场关掉标签页的 bug，\n而它只在缓存命中时出现。\n\n**二、`*/*` 不算「想要 markdown」。** Gemini CLI 和 Windsurf 只发通配符，\n意思是「给什么都行」。如果把通配符也算作 markdown 偏好，这两家会被塞\nmarkdown——它们本来接受 HTML，你硬给别的格式，属于自找麻烦。\n\n## 顺带说一句 llms.txt\n\n你可能想问为什么不提 `llms.txt`。因为它的实际效果被严重高估了：\n\n- **Google 明说不需要。** Search Central 的 AI 功能文档里写着：\n\n  > You don't need to create new machine readable files, AI text files,\n  > or markup to appear in these features.\n\n  注意原文说的是「**不需要**」而不是「不支持」——不做也不会因此缺席，\n  但 Google 也没说它有害。\n  （[原文](https://developers.google.com/search/docs/appearance/ai-features)）\n- 多个独立实验都没测到可归因的效果。\n\n> **这组数字曾经被删掉过，2026-09-24 找回来了。** 这里原先写\n> 「Ahrefs 2026 年 5 月实测 137,210 个域名：97% 的 llms.txt 从未被请求过」。\n> 上一轮我按几种常见路径试 `ahrefs.com/blog/*` 全部 404、Ahrefs 自己的\n> sitemap 里没有收录博客，于是**把整组数字删了**，并写明「查不到就不该留着」。\n>\n> 那个删除是**对的推理、错的结论**：原文确实存在，地址是\n> <https://ahrefs.com/blog/llmstxt-study/>，此前只是没试中路径。\n> 2026-09-24 重新抓取原文逐字核对，数据全部对得上：\n>\n> - 样本：Ahrefs Web Analytics 中**2026 年 5 月**获得流量的全部 **137,210** 个域名\n>   （方法学章节原文：`all 137,210 domains ... that received traffic in May 2026`）；\n> - 其中 28% 发布了 `llms.txt`；\n> - 这些文件的 **97% 在 2026 年 5 月零请求**——`97% of those files received zero traffic in May 2026`；\n> - 有请求的那 3% 里，**96% 的请求来自 bots**。\n>\n> **发布日期与数据窗口是两件事**：原文发布于 **2026-06-15**，\n> 统计的是 **2026 年 5 月**。先前的写法把两者混成了「2026 年 5 月实测」。\n>\n> 留着一个可点开的来源，比留着一句「查不到」更有用；\n> 但**边界也要跟着一起写**：这是对 `llms.txt` 根文件的 HTTP 200 探测，\n> 不是对「AI 有没有读到内容」的观测——**被请求的 3% 里，77% 的 bots 并不是 AI 工具**。\n\n它**零成本、无下行风险**，而且确实有 agent 会抓（Claude Code 抓取 llms.txt\n的频率高于任何 AI 搜索机器人），所以本站在生成它。但把它当卖点是误导。\n\n真正的杠杆是**干净的内容协商**和**语义化的 HTML**——后者对静态站来说是白送的。\n\n## 参考文献\n\n- [RFC 9110 §12.5.1（Accept 头）](https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1)、\n  [RFC 7763（`text/markdown` 媒体类型）](https://www.rfc-editor.org/rfc/rfc7763.html)\n- [Cloudflare, *Markdown for Agents*](https://blog.cloudflare.com/markdown-for-agents/)\n  —— 正文里「16,180 → 3,150 token、省 80%」那组数字出自这里。\n  **测量条件是 Cloudflare 拿自己的文档做的，本项目没有复现它**；\n  它说明的是「干净 HTML 转 markdown 能省多少」，不是本站的节省率\n  （本站自己的数字在 [内容协商](https://xiaoxusop.github.io/letterpress/wiki/content-negotiation/) 末尾，且是**启发式估算**，那里附了真实词表的对照）\n- [Google Search Central, *AI features and your website*](https://developers.google.com/search/docs/appearance/ai-features)\n  —— 正文里「Google 明说不需要」那段引文出自这里\n- [Ahrefs, *We Analyzed 137K Sites: 97% of llms.txt Files Never Get Read*](https://ahrefs.com/blog/llmstxt-study/)，\n  2026-06-15 发布、数据窗口为 2026 年 5 月\n  —— 正文里 137,210 / 97% / 96% 那组数字出自这里。\n  **测量条件是 Ahrefs 拿自己的流量数据做的，本项目没有复现它**；\n  它观测的是「根文件有没有被请求」，**不是**「AI 有没有读到内容」——\n  被请求的那 3% 里 77% 的 bots 并不是 AI 工具\n- ~~Ahrefs 那组数字原先作为一条独立引用，2026-09-23 被移除。~~\n  **2026-09-24 找到了原始地址并已恢复**（见上一条）——\n  那一轮的删除是**对的推理、错的结论**：`ahrefs.com/blog/*` 几个常见路径全 404\n  只证明了「我按这几个路径没找到」，不等于「原文不存在」。\n  **一个找不到的路径不是不存在的证据。**\n\n> **勘误（2026-09-22 / 09-23 / 09-24）**：这几条原先都是纯文本、点不开，已核实的补了链接；\n> **09-23 那一轮把 Ahrefs 那条当作「核实不到」移除了，09-24 又找回来了**——\n> 因为原文一直存在，只是当时试的路径没命中。\n> 三轮下来值得记住的是：**「我没找到」和「它不存在」之间隔着一整条推理链**，\n> 跳过那条链就会把一次失败的检索固化成一条结论。\n"}}
{"format":"letterpress-content-record","version":1,"site":{"name":"此间札记","language":"zh-CN","home":"https://xiaoxusop.github.io/letterpress/"},"id":"post:reproducible-builds","kind":"post","slug":"reproducible-builds","title":"构建可复现比你想的难，而且它值得","summary":"同一份源码在两台机器上构建出不同的产物——这件事的代价不是洁癖，是你没法用 diff 判断\"这次改动干了什么\"。","urls":{"html":"https://xiaoxusop.github.io/letterpress/reproducible-builds/","markdown":"https://xiaoxusop.github.io/letterpress/reproducible-builds.md"},"publishedAt":"2025-11-20T00:00:00.000Z","updatedAt":"2026-09-19T00:00:00.000Z","tags":["构建","可复现","ci"],"relations":{"outgoing":["wiki:build-probe"],"backlinks":["wiki:build-probe"]},"content":{"mediaType":"text/markdown","bytes":5275,"sha256":"634f629fc24b489d7ba3f0baac9850ced04666f409b333c51249d2995ccfe83d","text":"# 构建可复现比你想的难，而且它值得\n\n日期：2025-11-20  \n标签：构建、可复现、ci\n\n> 同一份源码在两台机器上构建出不同的产物——这件事的代价不是洁癖，是你没法用 diff 判断\"这次改动干了什么\"。\n\n来源：https://xiaoxusop.github.io/letterpress/reproducible-builds/\n语言：zh-CN\n类型：文章\n\n## 被引用\n\n- [构建探针](https://xiaoxusop.github.io/letterpress/wiki/build-probe/)\n\n## 正文\n\n「同一份源码应该构建出同一个产物」听起来像句废话。\n\n但它在大多数项目里**并不成立**，而且失败方式全都极其安静：\n构建照常成功，产物照常能用，只是**每次都不一样**。\n\n代价在后面才显现：你没法用 diff 判断「这次改动到底改了什么」，\n因为产物里全是无关噪声。\n\n## 那些让构建不可复现的东西\n\n按我实际踩到的顺序：\n\n**一、时间戳。** 最容易想到，也最容易修。但它的变体很多：产物里嵌构建时间、\n日志文件带时间、`Date.now()` 漏进某个 ID 的计算。\n\n**二、文件系统的枚举顺序。** 目录里读文件不会保证顺序。你在本地按 A、B、C 遍历，\nCI 上可能是 C、A、B——于是拼接出的产物顺序不同。\n\n修法是**显式排序**：\n\n```js\nconst files = await readdir(dir);\nfiles.sort();   // 这一行不是可选的\n```\n\n**三、哈希的输入里混进了路径。** `哈希(绝对路径 + 内容)` 会让产物随 checkout 目录变化。\n看起来低级，但在「把目录结构也算进去」时很容易顺手写出来。\n\n**四、依赖的解析顺序。** 锁文件解决了一部分，但 `^1.2.3` 这类范围在锁文件被删后\n仍会解析到不同版本。\n\n**五、当前时区。** 这条最阴——见下文。\n\n## 时区那条：一次真实的 CI 失败\n\n我给站点加了「定时发布」：frontmatter 里写 `date`，到点之前不发布。\n\n实现很简单：比较文章日期与当前时刻。\n\n本地全绿。推上去，**CI 红了**。\n\n```\nUTC+8 的机器：此刻是 9 月 12 日 01:57\nCI（UTC）：  此刻是 9 月 11 日 17:57\n```\n\n`new Date('2026-09-12')` 在两种情况下都解析成 **UTC 午夜**，\n但「今天」取决于机器时区。于是**同一篇文章在两地判定相反**：\n本地认为该发布，CI 认为还没到点——而失败形态是「文章静默消失 →\n引用它的 `[[链接]]` 变成断链」。\n\n**根子不在比较逻辑写错了，在于「是否发布」依赖了构建时刻。**\n只要依赖构建时刻，就必然破坏两件事：\n\n1. **构建可复现**：9 月 11 日与 9 月 12 日构建出的产物不同\n2. **跨机器一致**：作者本地能看到、CI 看不到，或反过来\n\n我最后**把这个功能删掉了**。不是修不好，是代价算不过来：\n`draft: true` 已经覆盖了「还没写完」这个主要场景，\n而定时发布多出来的那点便利，换的是两个更重要的契约。\n\n**做不到的事不该留着。**\n\n## 怎么验\n\n「可复现」是个可以被证明的性质，不用靠信念。\n\n最直接的做法：**改变一个本不该影响内容的环境输入，再比哈希**。仓库已经把这条\n做成命令：\n\n```bash\nnpm run verify:reproducible\n```\n\n它会先扫描生产源码，禁止无参数 `new Date()` 与 `Date.now()` 直接读取构建时钟；\n再清空 `.astro` 与 `dist`，分别在 UTC 与 America/Los_Angeles 跑完整的\nAstro + Pagefind，逐文件比较 SHA-256。当前 93 个产物跨时区逐字节一致；\n任一文件不同都会列出路径并以非零状态退出。\n\n这里两层检查缺一不可：跨时区构建能抓出日期显示偏移，但抓不出一年才变化一次的\n页脚年份；源码时钟检查能在跨年之前就拦住后者。内容里的日期则统一按 UTC 日历显示，\n不会因为构建机位于负时区而退到前一天。\n\n更有价值的一种是**换一种无关输入再比**。比如换行符：\n\n```bash\n# CRLF 工作区构建一次，LF 工作区构建一次，比对产物\n```\n\n我在这个站上做过这个实验：**CRLF 与 LF 两种工作区，产物 sha256 完全一致**\n（87 个文件）。结论是换行符不影响产物——但这个结论**是测出来的，不是想出来的**。\n\n## 为什么值得\n\n有人会说这是洁癖。不是。\n\n**可复现是「用 diff 做判断」的前提。** 如果每次构建产物都在抖，\n那「这次提交改了什么」就只能靠读代码——而读代码是会漏的。\n\n它也是**缓存正确性**的前提。构建产物不稳定，内容寻址的缓存就永远不命中，\n或者更糟：命中了不该命中的。\n\n而代价通常很小。上面那些来源，每一个的修法都是几行：\n排序一下、固定一下、把时间从哈希输入里拿掉。\n\n**花的这几行，换来的是「diff 可信」。** 这笔账很好算。\n\n相关条目：[构建探针](https://xiaoxusop.github.io/letterpress/wiki/build-probe/)。\n\n## 参考\n\n- reproducible-builds.org（这个概念的系统性整理）\n- 本站的实测：`npm run verify:reproducible` 会在两个时区完整构建并逐文件比对，\n  同时禁止构建时钟泄漏；`.gitattributes` 统一换行符，内容层的排序全部显式化\n"}}
{"format":"letterpress-content-record","version":1,"site":{"name":"此间札记","language":"zh-CN","home":"https://xiaoxusop.github.io/letterpress/"},"id":"post:static-site-search","kind":"post","slug":"static-site-search","title":"静态站搜索的三种做法，以及它们各自真实的上限","summary":"前端过滤、构建期索引、外挂服务——三种方案的取舍不在功能多少，在「索引存在哪」这一件事上。","urls":{"html":"https://xiaoxusop.github.io/letterpress/static-site-search/","markdown":"https://xiaoxusop.github.io/letterpress/static-site-search.md"},"publishedAt":"2026-08-14T00:00:00.000Z","tags":["搜索","静态站","索引"],"relations":{"outgoing":["wiki:content-negotiation"],"backlinks":[]},"content":{"mediaType":"text/markdown","bytes":4830,"sha256":"ba6735e2ae16602acb58364c95a19f77bf4a57234b5334ff0ea1d5f92e439015","text":"# 静态站搜索的三种做法，以及它们各自真实的上限\n\n日期：2026-08-14  \n标签：搜索、静态站、索引\n\n> 前端过滤、构建期索引、外挂服务——三种方案的取舍不在功能多少，在「索引存在哪」这一件事上。\n\n来源：https://xiaoxusop.github.io/letterpress/static-site-search/\n语言：zh-CN\n类型：文章\n\n## 正文\n\n静态站没有后端，所以搜索必须换个地方放。只有三个位置：\n\n**放进浏览器**、**放进构建产物**、**放到另一台服务器上**。\n\n选哪种，取决于你愿意接受哪一种上限。\n\n## 一、放进浏览器：把内容全量打到前端\n\n最简单：构建时把全部文章导成一个 JSON，页面加载后在前端过滤。\n\n```js\nconst results = all.filter(p => p.title.includes(q) || p.body.includes(q));\n```\n\n**上限在「全量」两个字上。** 索引就是全部正文，文章一多，用户为了搜一个词要下载整站内容。\n\n50 篇中等长度的文章大约 1–2 MB。1000 篇就是几十 MB——搜索框还没用上，\n流量已经烧完了。\n\n它适合**几十篇以内**的站点。超过这个量级，你会开始想「能不能只加载摘要」，\n而那正是第二种方案。\n\n## 二、放进构建产物：只下载倒排索引\n\n构建时切词、建倒排索引，把索引作为静态资源发布。用户只下载索引，不下载正文；\n命中之后再按需取片段。\n\n这一类的代表是 [Pagefind](https://pagefind.app/) 和 [Fuse.js](https://www.fusejs.io/)：\n\n- **Fuse.js** 是纯前端模糊匹配，仍然需要你把待搜内容喂给它——所以它解决的是\n  「怎么匹配」，没解决「内容放哪」\n- **Pagefind** 是构建后跑的索引器，直接读你的 HTML 产物。它把索引切成小块，\n  搜索时只加载相关分片——**这才是真正的「索引比正文小」**\n\nPages/Netlify 这类托管上，Pagefind 是当前最省事的选择：一条命令，\n零后端，还能按语言分别处理。\n\n**上限在分词上。**\n\nPagefind 对中文不做词干提取——中文本来也没有词形变化，所以这不影响正确性。\n真正的影响是**召回**：它按字/词切分建索引，你搜「排版」能命中包含「排版」的段落，\n但搜「网页排版」不一定能命中只写了「排版」的地方。\n\n英文靠词干回退（running ↔ run）缓解这类问题，中文没有对应机制。\n**这是静态站搜索的固有限制，不是某个库的缺陷。**\n\n## 三、外挂服务：Algolia、Meilisearch 这类\n\n把索引放到一台真的服务器上，查询走网络。\n\n功能上限最高：分词器可换、同义词、拼写纠错、按字段加权、结果高亮、\n搜索分析——都能做。\n\n代价也很清楚：\n\n- **多了一个要运维的东西。** 静态站的全部好处就是「没有服务器」，\n  为了搜索把它加回来，这笔账要自己算\n- **多了一个会挂的东西。** 搜索服务挂了，站还在，但搜索框废了\n- **多了一个花钱的地方。** 免费额度通常按「记录数 × 查询次数」计\n\n**它适合「搜索是核心功能」的站点**——文档站、电商、知识库。\n对个人博客，通常是过度配置。\n\n## 怎么选\n\n| 文章数 | 建议 | 理由 |\n|---|---|---|\n| < 50 | 第一种就够 | 全量 JSON 也就几百 KB，省一套索引流程 |\n| 50 – 几千 | 第二种 | 索引与正文分离，零运维 |\n| 搜索本身是产品 | 第三种 | 只有它能在**分词**这一层做文章 |\n\n**判断的关键不是功能表，是「索引存在哪」**：在用户浏览器里、在构建产物里、\n还是在别人的服务器上。三种位置对应三种上限，没有例外。\n\n## 顺带一句：搜索需要 JavaScript，这是要认的\n\n前两种方案都跑在浏览器里，所以**没有 JS 就没有搜索**。\n\n这不是可以糊弄过去的——正确的做法是给一个 `<noscript>` 出口，\n让关掉 JS 的读者至少能走到归档页，而不是对着一个永远转圈的输入框。\n\n相关条目：[内容协商](https://xiaoxusop.github.io/letterpress/wiki/content-negotiation/)。\n\n## 参考\n\n- Pagefind 官方文档（索引分片与多语言处理）\n- Fuse.js 官方文档（模糊匹配算法与阈值）\n- 本文提到的中文召回限制来自本站在 2026-09 的实测：搜「排版」7 条命中，\n  搜「行高」6 条命中，而搜「网页排版」只有 4 条——少掉的那几条正是\n  正文里只写了「排版」、没写完整四个字的地方\n\n> 复现方式：`npm run build` 之后到 `/search/` 搜这三个词。\n> **`npm run dev` 里搜不到任何东西**——索引是构建期（Pagefind）生成的，\n> 开发服务器没有它。这个数字也会随内容增删而变化，上面记的是当时的快照。\n"}}
{"format":"letterpress-content-record","version":1,"site":{"name":"此间札记","language":"zh-CN","home":"https://xiaoxusop.github.io/letterpress/"},"id":"wiki:build-probe","kind":"wiki","wikiKind":"concept","slug":"build-probe","title":"构建探针","summary":"对「构建产物」本身做的断言——问的不是「代码写对没有」，而是「生成出来的东西对不对」。","urls":{"html":"https://xiaoxusop.github.io/letterpress/wiki/build-probe/","markdown":"https://xiaoxusop.github.io/letterpress/wiki/build-probe.md"},"tags":[],"relations":{"outgoing":["post:reproducible-builds","wiki:letterpress"],"backlinks":["post:reproducible-builds"]},"provenance":{"original":{"reason":"方法论与本站实践，没有可引用的外部条款"},"review":{"status":"reviewed","checkedAt":"2026-09-24T00:00:00.000Z","contentDigest":"5456e0ac44763538e2579fe16babcc1d8870af08678c5afe22509c5a86a3fc2a"}},"content":{"mediaType":"text/markdown","bytes":4059,"sha256":"5c7c388280630cfe88e84ed2896e0b2d817d3a5ebc7bbf62ae86947416bd9eb3","text":"# 构建探针\n\n> 对「构建产物」本身做的断言——问的不是「代码写对没有」，而是「生成出来的东西对不对」。\n\n来源：https://xiaoxusop.github.io/letterpress/wiki/build-probe/\n语言：zh-CN\n类型：知识库条目\n\n## 被引用\n\n- [构建可复现比你想的难，而且它值得](https://xiaoxusop.github.io/letterpress/reproducible-builds/)\n\n## 正文\n\n一类检查：不看源码，只看**构建出来的产物**。\n\n它和单元测试的分工很清楚：\n\n| | 问的问题 | 够不着的地方 |\n|---|---|---|\n| 单元测试 | 这个函数算得对吗 | 它有没有被接进管线 |\n| 构建探针 | 产物里真的是这样吗 | 为什么 |\n\n## 为什么需要它\n\n因为有一整类 bug，**单元测试永远抓不到**：\n\n- 配置写了但没接线（`@astrojs/mdx` 装了却没注册进 `integrations`）\n- 文件写好了但没人引用（`fonts.css` 从头到尾没被 import）\n- 处理器换了 API（老写法被接受但**不执行**，只发一条弃用警告）\n- 元素渲染了两遍（所有断言都问「存在吗」，没有一条问「出现几次」）\n\n它们的共同点是：**不报错、构建成功、测试全绿，只是没效果。**\n\n## 这个项目里的几条\n\n- **内容发布探针**：真的放一个 `.md` 与 `.mdx` 进去，构建，看能不能产出页面；\n  再放入草稿与占用 `/about/` 的文章，确认前者不会泄漏、后者会明确阻断构建。\n  **读配置不够**——配置可以是对的而接线是断的\n- **链接渲染契约**：正文里的 `[[方括号]]` 是不是真的变成了链接\n- **字体加载契约**：`@font-face` 有没有进产物、字体总量有没有失控\n- **区块唯一性**：同一区块在一页里不得出现两次\n- **子路径契约**：六类载体（属性 / 脚本内路径 / 绝对 URL / 纯文本产物 / 内容清单 / NDJSON 全量导出）的\n  前缀是否都正确\n\n## 一条纪律：新检查必须反向验证\n\n**把 bug 放回去，确认它真的报错、退出码为 1。**\n\n这不是形式主义。这个项目里出现过三次「检查本身是坏的」，\n而且**失效方向是相反的**：\n\n- **漏检**：一条检查因为 `readdir` 在别的作用域、抛异常被 catch 吞掉，\n  **扫了 0 个文件却报告「通过」**\n- **漏检**：一条检查只匹配 `href=\"/x\"` 形式的相对绝对路径，\n  产物里 `https://` 开头的 URL 全部**不进正则**——于是「前缀翻倍」\n  这个致命问题在它的注视下安然通过\n- **误报**：还是那条检查。它后来改成对整段 HTML 跑正则来找属性，\n  于是**正文里的代码示例也被当成链接**——就是上一段里 `href=\"/x\"`\n  那个写法（它此刻正作为一个反例出现在这里）。修法是把正则锚定到\n  标签上，只认真正的属性：`<[^>]*\\s(?:href|src)=\"…\"`\n\n**误报和漏检一样有害**：一条会红的检查，人第一反应是关掉它。\n对模板尤其致命——使用者写一篇提到 `href=\"/foo\"` 的文章，构建就失败，\n而失败原因和他的文章毫无关系。\n\n**一条从没失败过的检查不值得信任；\n一条会无缘无故变红的检查，同样不值得信任。**\n\n## 覆盖面要跟着「载体」走\n\n同一个地址可能出现在六类地方，而每类的写法不同：\n\n1. `href` / `src` **属性**中的相对绝对路径\n2. `<script>` 块里**拼出来的**路径（模板字符串拼的 `import()` 参数）\n3. `https://` 开头的**绝对 URL**（`og:image`、RSS 发现、JSON-LD）\n4. **纯文本产物**里的链接（`llms.txt`、`robots.txt`）\n5. **JSON 内容清单**里的结构化 URL\n6. **NDJSON 全量导出**里每条记录的站点地址与内容 URL\n\n**每漏一类，就漏掉一整类 bug。** 这些覆盖不是预先画出来的清单，\n而是每次遇到真实漏检后补进契约的。\n\n相关条目：[reproducible-builds](https://xiaoxusop.github.io/letterpress/reproducible-builds/)、[letterpress](https://xiaoxusop.github.io/letterpress/wiki/letterpress/)。\n"}}
{"format":"letterpress-content-record","version":1,"site":{"name":"此间札记","language":"zh-CN","home":"https://xiaoxusop.github.io/letterpress/"},"id":"wiki:cjk-typography","kind":"wiki","wikiKind":"concept","slug":"cjk-typography","title":"中文排版","summary":"行高、字重、行宽、中西文间距——四个参数直接套西文的值，中文读者看到的是「能读但别扭」的页面。","urls":{"html":"https://xiaoxusop.github.io/letterpress/wiki/cjk-typography/","markdown":"https://xiaoxusop.github.io/letterpress/wiki/cjk-typography.md"},"updatedAt":"2026-09-24T00:00:00.000Z","tags":[],"relations":{"outgoing":["post:cjk-web-typography","wiki:design-tokens"],"backlinks":["post:cjk-web-typography","post:how-this-works","wiki:design-tokens","wiki:letterpress"]},"provenance":{"sources":[{"sourceId":"css-values-4","revision":"WD-20240312","locator":"§5.1.1 长度单位 · ch"},{"sourceId":"css-values-4","revision":"WD-20240312","locator":"§5.1.1 长度单位 · ic"},{"sourceId":"css-fonts-4","revision":"WD-20260913","locator":"§2.2.2 Missing weights"},{"sourceId":"css-fonts-4","revision":"WD-20260913","locator":"§5 Font Matching Algorithm · 字重匹配"},{"sourceId":"apple-system-fonts","revision":"online-2026-09-24","locator":"System Fonts 表格 · PingFang SC 条目"}],"review":{"status":"reviewed","checkedAt":"2026-09-24T00:00:00.000Z","contentDigest":"222eb0aa7269f9ffa0bac97da9ec149e54b2f81211b3be0f810cb15e24b9134d"}},"content":{"mediaType":"text/markdown","bytes":5581,"sha256":"b08201aa15d527958d5584478bec492148271b1d95dcb2d50617846a6a6d2ed4","text":"# 中文排版\n\n> 行高、字重、行宽、中西文间距——四个参数直接套西文的值，中文读者看到的是「能读但别扭」的页面。\n\n来源：https://xiaoxusop.github.io/letterpress/wiki/cjk-typography/\n语言：zh-CN\n类型：知识库条目\n\n## 被引用\n\n- [中文网页排版，多数主题都做错了](https://xiaoxusop.github.io/letterpress/cjk-web-typography/)\n- [设计令牌](https://xiaoxusop.github.io/letterpress/wiki/design-tokens/)\n- [这个博客是怎么搭的](https://xiaoxusop.github.io/letterpress/how-this-works/)\n- [letterpress](https://xiaoxusop.github.io/letterpress/wiki/letterpress/)\n\n## 正文\n\n中文网页排版最常见的病因：**参数沿用了西文的值。**\n\n| 参数 | 西文常用 | 中文应用 | 为什么 |\n|---|---|---|---|\n| 行高 | 1.4–1.5 | **1.6–1.8** | 方块字笔画铺满字面，没有升降部带来的天然空隙 |\n| 标题字重 | 600–700 | **600** | 伪合成的开关是 `font-synthesis-weight`，不是字重数值；关掉后由字体挑真实字重——请求 >500 时**先向上找**（600→700→800），故有真 600 的字体直接命中 600 |\n| 行宽 | 66ch | **34em** | `ch` 按渲染字体的「0」字形算、随字体浮动；`em` 等于计算后的 font-size，**容器宽度**跨字体可预测 |\n| 中西文间距 | 手动加 | **`text-autospace`** | 2025 起浏览器原生支持，pangu.js 时代结束 |\n| 强调 | 斜体 | **加粗** | 中文无斜体字形，强制倾斜即笔画变形 |\n\n## `34em` 这个数字\n\n它大致同时覆盖两种文字的舒适区，因为：\n\n- 一个汉字 ≈ 1em 宽 → 34em 排**约** 34 个汉字（理想 30–40）\n- 一个西文字母平均 ≈ 0.5em 宽 → 34em 排**约** 68 个字符（理想 45–75）\n\n⚠️ **保证的是容器宽度，不是字数。** `em` 等于**计算后的 font-size**，\n规范保证且与字体无关；但**汉字 advance 是否恰好 1em，规范并不保证**——\n它为此定义了 `ic`（全角字形的典型 advance，无法确定时假设 1em，见\n[CSS Values 4 §ic](https://www.w3.org/TR/2024/WD-css-values-4-20240312/#ic)）。\n**如果两者相等就不必再定义 `ic`**——这正是那句话存在的理由。\n\n用 `ch` 连容器宽度都不稳：它按**渲染字体的「0」字形**计算\n（[CSS Values 4](https://www.w3.org/TR/2024/WD-css-values-4-20240312/#ch) 原文是\n\"the used advance measure of the \"0\" (ZERO, U+0030) glyph in the font used to render it\"），\n所以「66ch 排几个汉字」**随字体变**。粗估一个汉字约 2 `ch`，66ch ≈ 33 字——\n其实也不差，但它多了一层未实测的假设（「0 字形约 0.5em」），且容器宽度本身也随字体浮动。\n\n> **勘误（2026-09-22）**：这里原先写「`66ch` 排中文会变成约 **130 字**一行」。\n> 按它自己的前提（1 汉字 ≈ 2 `ch`）应当除以 2 得 33，而不是乘以 2。\n> 而且 33 落在舒适的 30–40 里——原句想说的「太宽」并不成立。\n> 顺带：66ch ≈ 33em，与本站的 34em **宽度几乎相同**，这个改动换的是容器宽度的可预测性而非行宽。\n>\n> **勘误（2026-09-24）**：上一条之后本页仍写着「**`em` 不是估算**」。\n> **这句话是错的，而且错得最彻底**——它把「容器宽度不随字体变」推广成了\n> 「字数可确定」。`em` 的定义里根本没有「汉字宽度」这一项；\n> 上面那段 `ic` 的原文就是规范自己承认这件事无法从 `em` 推出。已改写。\n\n## 2026 年的两个原生属性\n\n```css\nhtml { text-autospace: normal; text-spacing-trim: trim-start; }\npre, code, kbd, samp { text-autospace: no-autospace; }\n```\n\n- `text-autospace`：中西文之间自动插入约 1/4 空格。2025 进入 Baseline。\n- `text-spacing-trim`：中文标点字距调整，行首标点不再占满一格。\n  按 MDN 浏览器兼容数据（2026-09-24 查）自 **Chrome 123** 起支持，\n  **Safari 与 Firefox 均不支持**。\n\n**前提是 `<html lang>` 正确。** 语言标签不对，浏览器不应用对应语言的断行\n与标点规则。代码块里必须关掉——代码里的空格是有意义的。\n\nSafari / Firefox 尚不支持，属渐进增强，不支持时版式不坏。\n\n## 字重伪合成的识别\n\n**先看 `font-synthesis-weight` 是不是 `none`。** 是 `none` 的话浏览器不会合成，\n写 600 而字体没有 600 时它会去挑最近的真实字重（规范 §2.2.2：*a face with a\nnearby weight is used*）——本站就是这一档。\n\n若合成开着，表现才是笔画边缘发虚、与相邻字号比粗细不自然；\n在 DevTools 的 `Rendered Fonts` 里能看到实际用到的字体与字重。\n\n## 字体栈顺序\n\n```css\nfont-family: 'Archivo', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;\n```\n\n**拉丁字体必须在前。** 反了的话英文也会用中文字体渲染，\n而中文字体里的拉丁字母是等宽的（为与汉字对齐），排版很难看。\n\n配合 `unicode-range` 限定 webfont 只覆盖拉丁区，中文字符就不会触发\nwebfont 下载——**这不是回退，是分工。**\n\n## 相关\n\n- 完整论述与实测：[cjk-web-typography](https://xiaoxusop.github.io/letterpress/cjk-web-typography/)\n- 本站的具体取值：[设计令牌](https://xiaoxusop.github.io/letterpress/wiki/design-tokens/)\n\n## 参考\n\n- [CSS Values and Units Level 4 §ch](https://www.w3.org/TR/2024/WD-css-values-4-20240312/#ch)\n- [CSS Fonts Level 4 §2.2.2 Missing weights](https://www.w3.org/TR/2026/WD-css-fonts-4-20260913/#font-style-matching)\n"}}
{"format":"letterpress-content-record","version":1,"site":{"name":"此间札记","language":"zh-CN","home":"https://xiaoxusop.github.io/letterpress/"},"id":"wiki:content-negotiation","kind":"wiki","wikiKind":"concept","slug":"content-negotiation","title":"内容协商","summary":"用 HTTP 的 Accept 头决定返回 HTML 还是 markdown。算法只有四十行，难的是静态托管不解析请求头。","urls":{"html":"https://xiaoxusop.github.io/letterpress/wiki/content-negotiation/","markdown":"https://xiaoxusop.github.io/letterpress/wiki/content-negotiation.md"},"tags":[],"relations":{"outgoing":["post:markdown-for-agents","wiki:llm-wiki"],"backlinks":["post:cjk-web-typography","post:how-this-works","post:markdown-for-agents","post:static-site-search","wiki:letterpress","wiki:llm-wiki"]},"provenance":{"sources":[{"sourceId":"rfc9110-accept","revision":"rfc9110","locator":"§12.5.1 Accept"}],"review":{"status":"reviewed","checkedAt":"2026-09-23T00:00:00.000Z","contentDigest":"6104e8408171d1ccf8f2fc49b622c35bab4be29a39101ccb6bdc4a3c3b4dde0c"}},"content":{"mediaType":"text/markdown","bytes":3946,"sha256":"dde207eb2ab35d8c40dae6799b0cd33691894384fa0111e3a441f47d31830875","text":"# 内容协商\n\n> 用 HTTP 的 Accept 头决定返回 HTML 还是 markdown。算法只有四十行，难的是静态托管不解析请求头。\n\n来源：https://xiaoxusop.github.io/letterpress/wiki/content-negotiation/\n语言：zh-CN\n类型：知识库条目\n\n## 被引用\n\n- [中文网页排版，多数主题都做错了](https://xiaoxusop.github.io/letterpress/cjk-web-typography/)\n- [这个博客是怎么搭的](https://xiaoxusop.github.io/letterpress/how-this-works/)\n- [letterpress](https://xiaoxusop.github.io/letterpress/wiki/letterpress/)\n- [LLM 知识库](https://xiaoxusop.github.io/letterpress/wiki/llm-wiki/)\n- [你的博客该不该给 AI 一份 markdown](https://xiaoxusop.github.io/letterpress/markdown-for-agents/)\n- [静态站搜索的三种做法，以及它们各自真实的上限](https://xiaoxusop.github.io/letterpress/static-site-search/)\n\n## 正文\n\n同一个 URL，对人和对 agent 返回不同格式的同一份内容。\n\n标准依据是 RFC 9110 §12.5.1（Accept 头）与 RFC 7763（`text/markdown`）。\n**这是 HTTP 从 1.1 就有的能力，不是新发明。**\n\n## 判定算法\n\n```\nmarkdown = Accept 里显式声明的 text/markdown 或 text/x-markdown 中最优的\nhtml     = Accept 里 text/html、application/xhtml+xml 或通配符中最优的\n\n每个表示先按「具体类型 > 类型通配符 > 全局通配符」确定有效 q\n\n若 markdown 不存在          → 返回 HTML\n若 markdown 的 q 为 0       → 返回 HTML（q=0 是明确拒绝）\n若 html 不存在              → 返回 markdown\n若两者 q 不同               → 返回 q 大的那个\n若 q 相同                   → 返回在头里排得更靠前的那个\n```\n\n## 三个会把实现写错的地方\n\n**一、q 缺省为 1.0，平局比顺序。**\n\nClaude Code 发 `text/markdown, text/html, <通配符>`，**不写 q 值**。\n两者都是 q=1.0。用严格 `>` 比较会判定为「无偏好」从而返回 HTML——\n不报错、不警告、日志干净，只是永远不生效。\n\n**二、通配符不算「想要 markdown」。**\n\n只发通配符的客户端（Gemini CLI、Windsurf）意思是「给什么都行」。\n若把通配符算作 markdown 偏好，它们会被塞 markdown。\n所以 markdown 一侧**只认显式声明**。\n\n**三、`text/plain` 不是 markdown 请求。**\n\nOpenCode 会带 `text/plain;q=0.8`，那是它的降级选项。\n宁可漏给（客户端仍拿到可用的 HTML），不可错给。\n\n**四、具体类型先于通配符。**\n\n如果 HTML 被具体类型明确降权或拒绝，不能再用一个 q 更高的宽泛通配符把它放行。\nRFC 9110 要求先选匹配该表示的最具体媒体范围，再比较不同表示的 q。\n\n## 在静态托管上怎么实现\n\n静态托管只吐文件，不解析请求头。所以需要两层：\n\n1. **构建期**：为每个页面生成 `.md` 孪生文件（零运行时成本）\n2. **边缘函数**：读 `Accept`，决定回哪个文件\n\n项目里带了三个平台的实现：Cloudflare Pages Functions、Netlify Edge Functions、\nVercel Middleware。不用这一层站点照常工作，只是 agent 拿到 HTML。\n\n## 必须做的两件事\n\n- **`Vary: Accept`**——否则 CDN 会把 markdown 缓存下来发给浏览器。\n  这个 bug 只在缓存命中时出现，会让人以为「有时候网站会坏」。\n- **只在 markdown 是显式偏好时才返回**——见上面第二点。\n- **声明表示关系**——`Content-Location` 指向 `.md` 孪生文件，`Link` 同时声明\n  HTML canonical 与 markdown alternate，避免两种表示成为信息孤岛。\n\n## 效果数据\n\n| 来源 | 结果 |\n|---|---|\n| Cloudflare（自己的文档） | 80% token 减少 |\n| Vercel（自己的博客） | 500 KB → 2 KB，99.6% |\n\n## 回到本项目的代码\n\n`src/lib/negotiate/accept.ts`。真值表用七个 agent 的**真实 Accept 原文**做回归，\n固件在 `accept.test.ts` 里，注释写明了每一条的判定理由。\n"}}
{"format":"letterpress-content-record","version":1,"site":{"name":"此间札记","language":"zh-CN","home":"https://xiaoxusop.github.io/letterpress/"},"id":"wiki:design-tokens","kind":"wiki","wikiKind":"concept","slug":"design-tokens","title":"设计令牌","summary":"三层 token 结构，命名描述用途而非外观。换配色时只动原始值，换暗色模式时只动语义映射。","urls":{"html":"https://xiaoxusop.github.io/letterpress/wiki/design-tokens/","markdown":"https://xiaoxusop.github.io/letterpress/wiki/design-tokens.md"},"tags":[],"relations":{"outgoing":["wiki:cjk-typography","wiki:letterpress"],"backlinks":["post:how-this-works","wiki:cjk-typography","wiki:letterpress"]},"provenance":{"original":{"reason":"本站的设计选择与内部约定，外部规范没有对应条款"},"review":{"status":"reviewed","checkedAt":"2026-09-24T00:00:00.000Z","contentDigest":"422a50ea234de50cc6e0052e2d7cc817ef5c77f548f5e64e05e9d7dfcaf08edc"}},"content":{"mediaType":"text/markdown","bytes":2821,"sha256":"968531566511bd76efd4b7700406de88126c7275f59284dac8896ecef10125d8","text":"# 设计令牌\n\n> 三层 token 结构，命名描述用途而非外观。换配色时只动原始值，换暗色模式时只动语义映射。\n\n来源：https://xiaoxusop.github.io/letterpress/wiki/design-tokens/\n语言：zh-CN\n类型：知识库条目\n\n## 被引用\n\n- [中文排版](https://xiaoxusop.github.io/letterpress/wiki/cjk-typography/)\n- [这个博客是怎么搭的](https://xiaoxusop.github.io/letterpress/how-this-works/)\n- [letterpress](https://xiaoxusop.github.io/letterpress/wiki/letterpress/)\n\n## 正文\n\n`src/styles/tokens.css`。三层结构：\n\n```\nprimitive  → 原始值。组件永不直接引用\nsemantic   → 按用途引用 primitive。组件只引用这一层\ncomponent  → 只在某个组件内有意义的派生值\n```\n\n**为什么分三层**：要换配色时只动 primitive；要换暗色模式时只动 semantic 的映射。\n如果组件里直接写 `color: #002FA7`，上面两件事都得全库搜索替换，\n而且一定会漏掉几处，于是暗色模式下出现一块刺眼的亮蓝。\n\n命名描述**用途**而非外观：`--color-accent` 而不是 `--color-blue`。\n这样把强调色从克莱因蓝换成别的时，名字不用改。\n\n## 配色怎么定的\n\n**一个强调色，其余全是中性灰。**\n\n强调色取 International Klein Blue（`#002FA7`）——它是那个年代的产物，\n与国际主义风格同源，不是随手挑的「好看的蓝」。\n\n刻意避开的两个配色族：\n\n- **紫蓝渐变**——AI 生成页面的第一识别特征\n- **米色/奶油底 + 黄铜/陶土 + 浓咖啡近黑**——这个更隐蔽，\n  因为它看起来「有品味」。但它已经成了 2026 年可识别的默认值，\n  换成它只是把默认值换了个届，不是去 AI 味\n\n## 亮暗不是反转\n\n暗色模式**不是把亮色反过来**：反转会让层级关系也翻过来\n（原本的「抬升」变成「下沉」）。这里按**表面亮度表达层级**重新排：\n画布最暗（`#09090B`）、卡片略亮（`#131316`），靠亮度差而非阴影建立纵深。\n\n强调色在暗色下也不是同一个值：`#002FA7` 放到深色底上对比度只有约 2:1，\n读不了。暗色下用提亮版 `#6B91FF`。\n\n## 间距用 4 单位基数\n\n不是只用 8。因为 **8 的刻度缺少中间步**——想做「比 16 紧一点、但比 8 松」\n的节奏时没有可用值，于是只能写一次性数值，节奏就散了。\n\n## 圆角：全直角\n\n瑞士风格的一致性锁：**要么全用一套，要么不用。**\n混用（胶囊按钮 + 方卡片）是坏设计，除非有写下来的规则说明为什么。\n\n## 相关\n\n- 中文字号与行高的取值理由：[中文排版](https://xiaoxusop.github.io/letterpress/wiki/cjk-typography/)\n- 项目本身：[letterpress](https://xiaoxusop.github.io/letterpress/wiki/letterpress/)\n"}}
{"format":"letterpress-content-record","version":1,"site":{"name":"此间札记","language":"zh-CN","home":"https://xiaoxusop.github.io/letterpress/"},"id":"wiki:letterpress","kind":"wiki","wikiKind":"entity","slug":"letterpress","title":"letterpress","summary":"本站所属的开源项目。一个把中文排版做对、同时写给人和 AI 读的静态博客主题。","urls":{"html":"https://xiaoxusop.github.io/letterpress/wiki/letterpress/","markdown":"https://xiaoxusop.github.io/letterpress/wiki/letterpress.md"},"tags":[],"relations":{"outgoing":["wiki:cjk-typography","wiki:content-negotiation","wiki:design-tokens","wiki:llm-wiki"],"backlinks":["wiki:build-probe","wiki:design-tokens","wiki:llm-wiki"]},"provenance":{"original":{"reason":"本站自身的实现事实，外部无对应来源"},"review":{"status":"reviewed","checkedAt":"2026-09-24T00:00:00.000Z","contentDigest":"e72f97a238d046c186fa8f1fefadff7ab8cbe2f4f1bf3a8591c27e4b37df765d"}},"content":{"mediaType":"text/markdown","bytes":2698,"sha256":"cecace902c50949c996746e8ab6c3179cefe5d81605ca3d96d2897a7b31d9874","text":"# letterpress\n\n> 本站所属的开源项目。一个把中文排版做对、同时写给人和 AI 读的静态博客主题。\n\n来源：https://xiaoxusop.github.io/letterpress/wiki/letterpress/\n语言：zh-CN\n类型：知识库条目\n\n## 被引用\n\n- [构建探针](https://xiaoxusop.github.io/letterpress/wiki/build-probe/)\n- [设计令牌](https://xiaoxusop.github.io/letterpress/wiki/design-tokens/)\n- [LLM 知识库](https://xiaoxusop.github.io/letterpress/wiki/llm-wiki/)\n\n## 正文\n\n一个开源个人博客主题。Astro 7 + TypeScript，纯静态，零后端，MIT。\n\n## 它想解决的三件事\n\n**一、中文排版普遍做错。** 行高、字重、行宽直接套西文的值，\n[中文排版](https://xiaoxusop.github.io/letterpress/wiki/cjk-typography/) 里有完整的对照表。\n\n**二、对 AI agent 不友好。** 静态博客没法做 [内容协商](https://xiaoxusop.github.io/letterpress/wiki/content-negotiation/)，\n因为托管平台不解析请求头。项目带了三个平台的边缘函数实现。\n\n**三、知识会腐烂。** 加一层 [LLM 知识库](https://xiaoxusop.github.io/letterpress/wiki/llm-wiki/)，把文章从「流」变成「网」，\n并且把机械可判定的检查做成能进 CI 的 lint。\n\n同一原则也用于构建本身：`npm run verify:reproducible` 会禁止生产源码读取构建时钟，\n并在两个时区完整构建，逐文件比较 Astro 与 Pagefind 的全部产物，\n而不是靠「代码看起来确定」来判断。\n\n## 不做什么\n\n- **不做生成器**。生成器赛道拥挤且被托管平台生态锁死。\n- **不做数据库**。内容就是文件，躺在你的仓库里。\n- **不用 Tailwind**。瑞士风格需要精确的网格与字体控制，\n  手写 CSS + 自定义属性 + `@layer` 更直接，依赖面也更小。\n  一个博客模板要活十年，这是优势。\n- **不把 llms.txt 当卖点**。它的实际效果被严重高估，数据见 [内容协商](https://xiaoxusop.github.io/letterpress/wiki/content-negotiation/)。\n\n## 技术选择\n\n| 项 | 选择 | 理由 |\n|---|---|---|\n| 框架 | Astro 7 | 内容优先，默认零 JS，AI 爬虫不执行 JS |\n| 样式 | 手写 CSS + `@layer` | 精确控制，零依赖，见 [设计令牌](https://xiaoxusop.github.io/letterpress/wiki/design-tokens/) |\n| 字体 | 自托管拉丁子集 + 系统中文字体 | 中文字体 5–20 MB，不值得 |\n| 内容协商 | 构建期孪生 + 边缘函数 | 转换零运行时成本 |\n\n## 相关\n\n- 写作与使用：`how-this-works`\n- 排版细节：[中文排版](https://xiaoxusop.github.io/letterpress/wiki/cjk-typography/)、[设计令牌](https://xiaoxusop.github.io/letterpress/wiki/design-tokens/)\n"}}
{"format":"letterpress-content-record","version":1,"site":{"name":"此间札记","language":"zh-CN","home":"https://xiaoxusop.github.io/letterpress/"},"id":"wiki:llm-wiki","kind":"wiki","wikiKind":"concept","slug":"llm-wiki","title":"LLM 知识库","summary":"Karpathy 2026 年 4 月提出的模式——让 agent 主动读写并维护一层知识库，新素材进来时做的是「重构」而不是「追加」。","urls":{"html":"https://xiaoxusop.github.io/letterpress/wiki/llm-wiki/","markdown":"https://xiaoxusop.github.io/letterpress/wiki/llm-wiki.md"},"updatedAt":"2026-09-24T00:00:00.000Z","tags":[],"relations":{"outgoing":["wiki:content-negotiation","wiki:letterpress"],"backlinks":["post:how-this-works","wiki:content-negotiation","wiki:letterpress"]},"provenance":{"sources":[{"sourceId":"llm-wiki-gist","revision":"gist-2026-04","locator":"全文（三层结构与三个操作的出处）"},{"sourceId":"llm-wiki-gist","revision":"gist-2026-04-ingest","locator":"Ingest 段落（10–15 页那句的逐字出处）"}],"review":{"status":"reviewed","checkedAt":"2026-09-24T00:00:00.000Z","contentDigest":"f37e57db6c45490157be396281f507633ac5109d605ea7f155ad50bc5c53e6dc"}},"content":{"mediaType":"text/markdown","bytes":6726,"sha256":"398ee5b5f34b7626f8365bb78f5b08b0c4f76db60648ebcadf5efd38fdb88f42","text":"# LLM 知识库\n\n> Karpathy 2026 年 4 月提出的模式——让 agent 主动读写并维护一层知识库，新素材进来时做的是「重构」而不是「追加」。\n\n来源：https://xiaoxusop.github.io/letterpress/wiki/llm-wiki/\n语言：zh-CN\n类型：知识库条目\n\n## 被引用\n\n- [内容协商](https://xiaoxusop.github.io/letterpress/wiki/content-negotiation/)\n- [这个博客是怎么搭的](https://xiaoxusop.github.io/letterpress/how-this-works/)\n- [letterpress](https://xiaoxusop.github.io/letterpress/wiki/letterpress/)\n\n## 正文\n\n一份由 AI 持续读写的结构化知识库，通常就是一堆 markdown 文件。\n\n与**基础 RAG 用法**的区别：那种做法里新文档进来只是被**加进索引**，\n已经索引的内容不会被改写；而 LLM 知识库里新素材进来会触发一次**编译**——\n更新既有页面、建新页、加链接、标记矛盾。有人的说法是\n「摄取是一次重构，不是一次追加」。\n\n> **补一句分寸（2026-09-22）**：这里原先写的是「RAG 里新文档只会被索引，\n> **永不改变、永不连接**」。**「RAG 都这样」是错的**——比如\n> [GraphRAG](https://microsoft.github.io/graphrag/) 就会从资料里抽出实体与关系、\n> 组织成图并预计算摘要。所以本项目的取舍应当表述为\n> **「我们选择保存可编辑、可审阅的综合页面」**，而不是「所有 RAG 都不积累知识」。\n> 检索与知识整理可以共存，不是互斥的两条路。\n\n> **关于「一份来源可能改动 10 到 15 个页面」这个数**（勘误，2026-09-24）：\n> **它是有出处的**——Karpathy 的原始 gist 在 Ingest 段落里写着\n> `A single source might touch 10-15 wiki pages.` 本页此前写「没有出处」，\n> **那是错的**，现已逐字核对原文并补上链接。\n>\n> 该修的仍然要修：那句话是**作者描述自己工作流时的经验性例子**，\n> 不是普遍规律，所以**不能泛化、也不该当本项目的 KPI**。\n> 「有出处」与「可泛化」是两件事——**前者这次补上了，后者仍然不成立。**\n> 把页面改动数当指标会催生为了改而改。\n\n## 三层结构\n\n| 层 | 作用 | 本项目里对应 |\n|---|---|---|\n| 原始素材 | 原则上不可变的输入 | **尚无独立登记**。文章（`src/content/posts/`）可充当素材，但它们**会被修订**，引用时需钉具体版本 |\n| 知识层 | agent 维护的结构化页面 | `src/content/wiki/` |\n| schema | 告诉 agent 怎么维护 | `AGENTS.md` |\n\n## 三个操作\n\n- **摄取（Ingest）**：读入来源，写摘要页，更新索引与相关条目页\n- **查询（Query）**：从相关页面综合出答案，并给出处\n- **体检（Lint）**：检查矛盾、过时、孤儿页、断链\n\n## 已实现 / 计划实现\n\n| 能力 | 状态 |\n|---|---|\n| 知识层页面（`src/content/wiki/`）与互链 | **已实现** |\n| 机械检查（`src/lib/wiki/lint.ts`）进 CI | **已实现** |\n| 文章 → 知识页的人工综合 | **已实现**（本页就是） |\n| 来源登记与版本固定 | **已实现**（`knowledge/sources/`） |\n| 页面级的引用与复核字段 | **已实现**（`Doc.sources` / `Doc.review`） |\n| 摄取 / 影响分析的工作流脚本 | **已实现**（`scripts/wiki-impact.mjs`） |\n| 面向 agent 的检索与问题金标 | **已实现**（`knowledge/questions.md`） |\n| 语义级的矛盾检测（「这两页说法冲突」） | 计划中，尚未实现 |\n\n> 这张表是 2026-09-22 补的。此前本页通篇以「本项目怎么做」的口吻描述这套模式，\n> 而其中大半尚未落地——**读者无从分辨哪些是真的、哪些是打算做的**。\n>\n> **勘误（2026-09-23）**：这张表自己就出过这个毛病。上面三行原先写着\n> 「计划中，尚未实现」，而它们**在写下那句话的时候就已经落地了**——\n> 来源登记（`knowledge/sources/`）、引用与复核字段（`Doc.sources` / `Doc.review`）、\n> 影响分析脚本（`scripts/wiki-impact.mjs`）全都存在。\n> **没有任何检查会发现这件事**：lint 管的是断链、重复 slug、孤儿页，\n> 那些都是**页与页之间**的问题；而这一条是**页与仓库之间**的问题。\n>\n> 现在这张表的每一行都带一条 `verify:` 声明（见本页 frontmatter）：\n> 检查同时核对**那句话还在不在页面上**、以及**它对应的文件在不在**。\n> 只查文件是不够的——正文改了、声明没改的话，声明还在验一句没人说过的话。\n\n## 本项目的取舍：把 lint 做成确定性的\n\nKarpathy 那套的体检是「让 agent 定期看看」。这**不是工程方案**——\n它不可复现、不可回归、进不了 CI。\n\n所以本项目只把**机械可判定的那部分**做成代码（`src/lib/wiki/lint.ts`）：\n\n- 断链、重复 slug → 错误级，构建失败\n- 孤儿页、缺摘要、空正文 → 警告级\n- 摘要过长 → 提示级\n\n**语义级的检查仍然留给 agent**（「这两页说法矛盾」「这条结论已被推翻」），\n但地基是机械的。没有地基的话，agent 每次检查的结论都不一样，\n你没法判断「这次多了三条告警」是内容变了还是模型心情变了。\n\n## 与「数字花园」的关系\n\n数字花园（digital garden）强调**公开的、持续演化的笔记**，\n不按时间排列，靠链接组织。LLM 知识库是它的一个变体：\n区别在于**维护者从人换成了 agent**。\n\n两者面对同一个问题：**怎么防止腐烂**。花园派的做法是「定期打理」，\nLLM 知识库派的做法是「让 agent 定期重构」。本项目认为两者都不够，\n**机械检查必须先进 CI**。\n\n## 为什么博客适合这个模式\n\n博客天生是**流**——按时间排，读过就沉底。\n但知识是**网**：你写过三篇关于排版的文章，它们之间的共性不会自动浮现。\n\n加一层知识库，文章仍然是流（读者的入口），但知识层把散落各篇的东西\n连起来。读者（和人以外的读者）不必读完所有文章才能拼出全貌。\n\n## 参考\n\n- [Andrej Karpathy 的 `llm-wiki` gist](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)，2026-04\n  —— 本文的模式取自它；注意它是**抽象模式**，不是框架或 npm 包。\n  上面「10–15 个页面」那句即出自它的 Ingest 段落，原文为\n  `A single source might touch 10-15 wiki pages.`\n- DeepWiki（2025-05）、LangChain OpenWiki（2026-07）、微软 deep-wiki 插件\n- llmstxt.org 提案——注意其实际效果数据见 [内容协商](https://xiaoxusop.github.io/letterpress/wiki/content-negotiation/) 末尾\n"}}
