README 翻完,多语言入口却不出现——不起眼,但很影响使用:翻译任务生成了 README.zh-CN.md、README.ja.md,可主仓库的 README 里不会自动出现指向这些文件的链接,用户得自己翻目录才能找到对应语言版本。最初想用固定的链接文本硬编码进模板,结果仓库结构一换(比如 README 挪进 docs/ 目录、文件名变成小写)链接就失效。于是我给工具加了一个智能插入模块,让它在不同仓库结构下都能正确识别与插入。README 多语言链接([English](README.en.md) · [中文](README.zh.md))看似简单,但要让工具在不同仓库结构下都能正确识别与插入,需要做:识别 README 路径、解析现有语言标签、构造新链接、保持格式一致、跳过已存在条目。
一、目标
动手之前先把目标列清楚,避免后面实现时跑偏:
- 自动在 README 顶部/底部插入多语言导航;
- 不破坏原文档结构;
- 支持自定义顺序与格式;
- 与现有翻译任务兼容。
其中“不破坏原文档结构”这条我放在首位。翻译工具的定位是辅助,用户自己写的 README 才是主体,插链接时把别人的排版弄乱,再好的功能也没人敢用。所以后面所有实现都以“最小侵入”为前提,能只加一行就绝不重排整段。
二、链接格式
常见的链接形式有两种,先看它们各自的适用场景:
| 格式 | 用途 |
|---|---|
| 文字徽章 | 顶部用徽章卡片 |
| 文字列表 | 标题下方一行 |
本项目用 text 列表,兼容更多主题:
[English](README.en.md) · [中文](README.zh-CN.md) · [日本語](README.ja.md)
徽章方案视觉效果更好,但依赖 shields.io 这类外部服务,主题不匹配时样式会很突兀;文字列表不依赖任何外部服务,在 GitHub 和各代码托管平台上渲染一致,可访问性也更好。权衡下来选了 text 列表,后面所有解析、去重逻辑都围绕这种格式实现。
三、识别 README 路径
仓库里 README 的命名并不统一,识别要按优先级探测。候选路径按优先级:
README.md
README.zh.md
README.zh-CN.md
readme.md
docs/README.md
匹配规则:
- 大小写不敏感;
- 允许
docs/前缀; - 不区分点分隔与下划线分隔。
这里容易踩的坑是大小写:Linux 环境下 Readme.md 和 README.md 是两个文件,直接用字符串比较会漏掉,必须统一小写后再比。优先级从根目录的主 README 开始,命中即停,避免同时存在多个文件时选错基准。
四、解析现有语言标签
插入前先摸清 README 里已经有哪些链接,用正则把 Markdown 链接的 URL 全部收出来:
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
const existing = new Set<string>();
for (const m of content.matchAll(linkRegex)) existing.add(m[2]);
新生成的链接若 path 已存在则跳过。
解析这一步决定“智能”程度。如果只无脑追加,同一个语言链接会被插入两次,README 越改越乱。这里把已有链接的 URL 收进 Set,构造新链接时查重。用 Set 而不是数组,是因为语言多了之后判断是否存在的耗时是常数,不随链接数量增长。
五、构造新链接
针对每个目标语言,根据源 README 的位置推导目标文件路径:
function buildLink(lang: string, sourcePath: string) {
const dir = path.dirname(sourcePath);
const base = path.basename(sourcePath, ".md");
const target = path.join(dir, `${base}.${lang}.md`);
return { text: langDisplay[lang], path: target };
}
langDisplay 把 ISO code 映射为显示名,如 zh-CN → 中文。
构造链接的核心是让目标路径跟着源 README 走。源文件在根目录,目标就在根目录;源文件在 docs/ 下,目标也保持在 docs/ 下。path.dirname 和 path.basename 负责拆解路径,这样不管 README 放哪一层,链接都不会指错地方。
六、插入位置策略
插入位置同样可配置,三种策略对应不同的 README 风格:
| 策略 | 描述 |
|---|---|
| top-after-title | H1 后第一行 |
| top-insert-line | 文档最顶部 |
| bottom-append | 文末追加 |
默认 top-after-title,不破坏徽章区。插入前检查该行是否已经存在语言列表,避免重复。
三个策略怎么选:带徽章区的 README 用 top-after-title 更合适,插在 H1 之后、徽章之前,视觉上不抢焦点;没有徽章的简单 README 用 top-insert-line,一进页面就能看到语言切换;想完全不打扰正文就用 bottom-append。默认值选 top-after-title,是因为多数项目都有徽章区,这个位置兼顾了可见度和安全性。
七、避免破坏 Markdown 结构
插入位置再准,也得防着破坏原文档结构,所以立了几条硬规则:
- 不在代码块内插入;
- 不在 HTML 注释内插入;
- 不在引用块内插入;
- 检查前后空行,保证渲染清爽。
function isInCodeBlock(lines: string[], index: number) {
let inFence = false;
for (let i = 0; i < index; i++) {
if (/^```/.test(lines[i])) inFence = !inFence;
}
return inFence;
}
这条规则是从一次事故里来的。早期版本没做代码块判断,直接把链接行插进了一段 YAML 配置示例里,用户仓库的 CI 配置被改坏,发版直接失败。后来补上 isInCodeBlock 这类守卫函数,插入前逐行扫描,确认目标位置不在围栏、注释和引用块内,才允许写入。
八、与翻译任务协作
链接生成与翻译任务解耦:
- 翻译任务生成
README.zh-CN.md等文件; - README 链接插入作为独立步骤,根据任务结果批量更新主 README;
- 如果是首次翻译,没有目标语言文件,链接仍可先插入(指向待生成文件)。
解耦的意义在可测试性。翻译任务可能失败、可能中途取消,链接插入不该跟着一起翻车。把它设计成独立步骤后,翻译成功与否都不影响链接模块的确定性输出。首次翻译时目标文件还不存在,链接也照插不误,指向的文件后续由翻译任务补齐。
九、增量处理
仅当翻译任务新增了目标语言文件时才插入新链接:
- 比对新旧文件集合;
- 增量语言集合 → 生成新链接;
- 提交到 PR 描述中作为说明。
全量重写 README 会带来大量无意义 diff,评审的人要在一堆无关变更里找真正改了什么。增量处理只动新增语言的链接,diff 干净,PR 描述里还能自动列出本次新增的语言,省去手动写说明的步骤。
十、配置项
语言列表、插入位置、分隔符都收敛到配置里,代码只负责执行:
readme_links:
enabled: true
position: top-after-title
separator: " · "
languages:
- { code: en, label: English }
- { code: zh-CN, label: 中文 }
- { code: ja, label: 日本語 }
配置优先的好处是新增一种语言只改 YAML,不碰代码,也不影响其他模块的测试。separator 用 · 分隔不同语言,比竖线更不易与其他 Markdown 语法冲突,渲染出来也美观。
十一、常见边界
真实仓库里总有些边缘情况,逐一处理掉:
- README 名为
Readme.md:统一小写后比较; - 路径含空格:用 url 编码;
- 仓库使用 docs/ 目录:插入时保留目录;
- 多 README(如 monorepo):按 glob 列表逐一处理。
这些边界场景都是真实用户反馈回来的。路径含空格的情况最初没想到,链接生成后 Markdown 渲染正常,但点击跳转 404,url 编码后才修复。monorepo 里多个 README 各自处理,用 glob 列出候选再逐个走完整流程,避免只改根目录漏掉子包。
十二、回归测试
改动收敛后,用一组回归用例把容易破坏的行为固定住:
- 已有语言链接:不被覆盖;
- 含代码块的 README:不破坏代码块;
- 多种语言文件:链接按配置顺序输出;
- 链接大小写:大小写不敏感比较。
回归测试把前面所有坑固化下来。每次改代码前跑一遍,确保已有链接不被覆盖、代码块不被破坏。测试用例不多但都是真实场景,覆盖了路径大小写、代码块守卫、顺序输出三块容易回归的地方。
十三、给同类项目的建议
- 链接生成与翻译任务解耦,便于独立测试;
- 配置优先,代码只负责执行;
- 渲染前先 dry-run,让用户预览变更。
dry-run 是最后加的一条建议,也是相当实用的一条。插入前先生成一份 diff 预览,用户确认改动符合预期再落盘,避免工具自作主张。这三条合起来,链接插入从“会动的工具”变成“可预期的工具”,用户敢放心交给它处理仓库根目录的 README。
常见问题(FAQ)
Q1:链接放在 README 顶部还是底部?
顶部更易被发现,本项目默认顶部。
Q2:链接文本要不要本地化?
要,按 ISO code 映射显示名。
Q3:能否不修改源 README?
可以,改为生成单独的 README.index.md,但效果弱于原地插入。