
1. 先把背景交代清楚fish-shell-docs-l10n 到底在做什么最近我把一个叫 fish-shell-docs-l10n 的项目从想法推进到了基本可用状态过程比想象中曲折也踩了不少坑。这篇文章不打算写什么宏大叙事就是把我在做 fish-shell 文档本地化时碰到的关键问题、选型思路和实操细节都摊开来讲一遍给之后想碰文档 l10n 的人当个参考。先说明 fish-shell 是什么。它是一个面向交互式终端环境的 Unix shell最大的特点是开箱即用语法高亮、智能补全、历史搜索、基于 web 的可视化配置这些功能默认就是开启的。很多开发者第一次体验 fish 之后留下的印象是原来终端还能这么好用。但紧接着问题就来了——当你输入man fish想深入学习的时候看到的是一大篇英文文档查某个命令的某个参数翻到对应的 manual 页依然是英文。对中文用户来说这个门槛比学习 shell 语法本身还要高。l10n 是 localization 的缩写visualization? 不localization 就是本地化。它和 translation 的区别在于本地化不只是把英文换成中文还要处理术语体系、标点习惯、命令示例的可用性、文档链接的完整性甚至文档里带出来的幽默语气。所以 fish-shell-docs-l10n 这个项目表面上是一个翻译工程实际是一个翻译工具链 内容工程 质量保障的组合。这个项目适合谁如果你是 fish 的重度用户想顺手回馈社区如果你对开源文档的 l10n 流程感兴趣想知道怎么把一个大型项目的 docs 变成可持续维护的多语言版本或者你单纯想找一个练习场景把 Sphinx、gettext、PO 文件、CI 这些技术串起来——这篇文章都值得看一下。我不准备讲太多翻译本身应该怎么措辞这种主观问题那部分留给翻译者自己发挥。我会把更多篇幅放在整个本地化项目是怎么设计出来的。2. 翻译前要对 fish 文档的结构做个体检做任何 l10n 项目第一件事不是拿词典开工而是搞清楚文档的源头在哪、构建流程是什么、哪些内容能翻、哪些内容不能碰。这一步做不仔细后面所有翻译成果都可能因为构建方式改变而变成一堆废文件。2.1 文档源文件到底存在哪里fish-shell 的官方仓库结构里有一个专门的doc_src目录这就是所有文档的源头。现代版本的 fish 已经从早期的 Doxygen 体系迁移到了 Sphinx reStructuredText 体系也就是说你在官网看到的 HTML 文档、安装后使用的 man page都是 Sphinx 从同一批 RST 源文件渲染出来的。具体来说doc_src下面有几类东西顶层的大块头文档比如fish.rst主手册、tutorial.rst入门教程、faq.rst常见问题一个cmds子目录里面每一个文件对应一个内置命令或函数的 man page比如ls.rst、string.rst、bind.rst一些支撑性文档比如语法说明、设计文档、交互模式说明等。这个结构对本地化非常友好因为每个 RST 文件基本对应一个独立的文档页面你可以按文件维度拆分翻译任务也能按文件维度统计翻译进度。对比一下那种几百个字符串堆在一个 PO 文件里的项目fish 的拆分粒度要舒服得多。但也要注意doc_src只是用户可见文档的部分不是全部。fish 在交互式终端里还有一些内置帮助文本、命令报错信息、提示文案这些存在于 C 和 Rust 源码的字符串里。如果项目目标是完整本地化那还要考虑这些代码内字符串怎么办。不过从我做的这个项目来看优先级最高的永远是 RST 文档因为它的覆盖面最广、用户接触最多、翻译起来也最不容易破坏代码逻辑。2.2 上游其实没有现成的中文渠道确定了源头之后下一个要回答的问题是fish-shell 上游有没有官方的本地化机制答案是有一些基建但没有形成稳定的中文渠道。开源项目常见的方案是接 Weblate 这类在线翻译平台让社区成员直接在网页上翻译PR 自动回到仓库。fish 的界面相关部分确实有一些多语言处理但文档的官方版本目前仍然以英文为主。这就带来一个现实选择要么做一份长期维护的 fork 分支要么把翻译文件作为补丁提交给上游等待维护者合并。我选择的是fork 独立构建路线。理由很简单上游合入 PR 的节奏不确定而且文档的翻译需要审校、需要构建验证、需要和版本发布同步这些流程放在自己的项目里才能完全控制。如果你也想做类似项目我建议在动手前就明确这一点你的成果是要成为官方文档的一部分还是作为一个社区维护的镜像两者的工作量差一个量级。做官方渠道要面对上游代码审查、术语分歧、翻译风格讨论做独立维护则要把构建、发布、域名、搜索这些配套事情都考虑进去。2.3 先把翻译范围清单列出来在对整个文档做全面翻译之前我先把翻译范围清单列了出来并且按优先级分了级。这个清单是我后面安排工作量、检查进度的主要依据。优先级文档文件说明P0tutorial.rst入门教程新用户接触最多的文档P0fish.rst主手册查询语法和核心概念必看P1cmds/*.rst各内置命令的 man page量大但每个文件独立P1faq.rst常见问题解决真实使用场景P2其余支撑文档设计说明、风格指南等翻译时效性要求不高这个表格看起来简单但它帮我避开了很多项目常见的陷阱。比如有人一上来就翻cmds下的命令文档觉得一个文件几百词翻一个是一个很有成就感。但实际用户刚接触 fish 时最先读的是 tutorial 和主手册这两个文件没翻之前先翻命令文档的体验是割裂的。先 P0 再 P1翻译的价值感会强很多。3. 用 sphinx-intl 把翻译流程搭成流水线翻译范围确定之后就要考虑工具链了。fish 文档既然是用 Sphinx 构建的本地化的首选方案就是sphinx-intl。这是一套围绕 gettext 体系设计的文档翻译工具核心思路是把 RST 文档里的可翻译文本抽成 POT 模板文件然后翻译成各个语言的 PO 文件最后在构建时把翻译合并回目标语言文档。这套体系的好处是翻译成果不依赖于原始文档的格式。就算上游改了 RST 段落、增删了句子你只需要重新提取 POT再用工具合并进已有 PO 文件就行了。旧翻译会被自动保留改动过的段落会被标记为需要复检不需要从头翻一遍。3.1 环境准备和最小化配置先建一个干净的 Python 虚拟环境安装 Sphinx 和 sphinx-intlpython3 -m venv .venv source .venv/bin/activate pip install sphinx sphinx-intl然后要在conf.py里加两行配置。第一行告诉 Sphinx 去哪里找翻译后的语言文件第二行关闭 gettext 的紧凑模式让每个 RST 页面生成独立的 POT 文件而不是把所有页面混在一起locale_dirs [locale/] gettext_compact Falsegettext_compact False这条值得单独说一下。默认情况下sphinx-build 的 gettext 构建器会把所有文档的文本合并成少数几个 POT 文件比如general.pot。对大型项目来说这会让 PO 文件变得巨大翻译者之间还容易产生文件冲突。关掉紧凑模式之后每个 RST 文件对应一个 POT翻译任务可以按文件拆给不同的人Git 合并冲突的概率也低很多。3.2 提取、初始化、翻译、构建四步走配置好之后流程就非常机械了。第一次跑的时候依次执行sphinx-build -b gettext doc_src _build/gettext sphinx-intl update -l zh_CN -p _build/gettext第一步用 Sphinx 的 gettext 构建器把doc_src下的所有 RST 文件转换成 POT 模板文件放到_build/gettext目录。第二步让 sphinx-intl 根据这些 POT 文件创建locale/zh_CN/LC_MESSAGES/下的 PO 文件结构。每个 RST 文件都会有一个对应的.po文件这个文件就是之后手动翻译或接入在线翻译平台的核心文件。PO 文件里每个条目的基本形态是这样的#: ../../doc_src/tutorial.rst:42 msgid What is fish? msgstr fish 是什么msgid是英文原文msgstr是中文译文。翻译工作本质上就是把这些msgstr填满并保证质量。翻译完成后构建有两个方向要试。一个是 HTML一个是 man pagesphinx-intl build -l zh_CN sphinx-build -b html -D languagezh_CN doc_src _build/html/zh_CN sphinx-build -b man -D languagezh_CN doc_src _build/man/zh_CN第一条命令把 PO 文件编译成二进制的 MO 文件Sphinx 在真正构建文档时会优先读取这些 MO 文件里的翻译。后面两条命令分别生成中文版的 HTML 网站和 man page。你可以直接检查_build/man/zh_CN目录下的文件用man命令查看本地化的手册或者直接打开 HTML 验证排版效果。我在第一次跑通这套流程时最大的感受是工具链其实并不复杂真正花时间的是后面那些翻完之后怎么保证质量的问题。3.3 接入 Weblate 还是用本地 PO 编辑器翻译 PO 文件有两种常见路径本地用 Poedit 这类编辑器或者接 Weblate 让人在浏览器里协作翻译。我最终选择了 Weblate原因是社区协作场景下浏览器工作流门槛最低。你不需要让翻译者先搞懂 gettext 和 PO 格式只要给他们一个链接告诉他们点开左侧未翻译的条目在右侧填中文就可以了。Weblate 会自动生成 PO 文件你再用 sphinx-intl 拉取回仓库。不过 Weblate 也有代价版本同步、术语表管理、审校流程都需要额外配置。如果只是三五个人协作本地 PO 编辑器的摩擦也没大到不能接受。核心判断标准是你到底想让多少人参与翻译。如果目标只是个人项目Poedit 足够如果想让整个中文社区来贡献Weblate 几乎是必须的。4. 翻译时最花精力的三场硬仗术语、文体和 RST 安全工具链搭好之后真正的工作才刚开始。翻译本身不是难点难的是你翻出来的东西既不能丢掉原文的技术准确性又不能违背中文的表达习惯还要保证文档结构和链接不出错。我把这段时间经历的主要问题归成三类每一类都是实战中反复碰到的。4.1 术语一致性fish 里有一批翻译陷阱fish-shell 虽然是出了名的易用但它的核心概念有不少是 fish 自己定义的直接逐字翻译很容易出错。我先把自己维护的术语表核心部分列出来供你参考英文原文我的译法说明universal variable通用变量刻意不叫全局变量因为它跟 shell 的 global variable 语义不同autosuggestion自动建议这是 fish 的核心交互特性保留建议的语义completion补全与自动补全比更简洁名词场景用命令补全abbreviation缩写fish 的缩写机制用户自定义的短命令prompt提示符避免翻译成命令行提示这种冗余说法binding键位绑定出现动词 bind 时译为绑定function函数在 fish 中直接对应内置的 function 机制不翻译但保持原词job作业进程管理上下文里译为作业pipeline管道保持传统翻译token词元语法解析上下文使用避免与令牌混淆universal variable是我特别想强调的例子。它是 fish 的一个独特概念变量的值会在所有 fish 会话之间共享还会写进配置文件。如果从字面理解去翻成通用变量新手读了会困惑这跟全局变量有什么区别把文档上下文展开解释之后读者才能真正理解。所以术语表不是简单的中英对照还要附上为什么这么翻的备注。这个备注后来直接变成了项目仓库里的glossary.rst翻译者开工之前必须读一遍。4.2 文体问题fish 官方的幽默感怎么处理fish 的文档在开源项目里是出了名的有人味。教程里会直接跟读者对话FAQ 里会有一些轻松的玩笑甚至某些命令的帮助文案也带点小幽默。翻译这类内容时最容易犯的错误是忠实到僵硬英文的梗直接搬进中文不仅不好笑还会让读者觉得莫名其妙。我的处理原则是意思优先幽默感和原文风格尽量保留但不强行造梗。比如某句话里放了一个双关语英文读者可能心领神会中文读者看到直译版本只会满头问号。这时候我就会在保留原意的基础上调整成中文里稍微带点轻松感的表达哪怕它跟原文的梗不完全一样。本地化本来就不该是逐字对应的机械运动。当然这里有一个前提你要跟读者明确哪些内容是翻译者的再创作哪些是严格的技术定义。技术定义、参数说明、行为描述这些内容必须字字斟酌容不得太多自由发挥叙事性段落、使用场景讲解、FAQ 里的闲聊部分可以适当放松。我在术语表里专门加了一条规则所有涉及命令行为、参数取值、配置文件字段的句子翻译必须保持和原文完全同等的约束不得为了通顺而改变语义。4.3 RST 语法和链接翻译最容易弄坏的东西这是最隐形也最致命的一类问题。RST 不是纯文本里面有大量标记语法翻译时稍不留神就会破坏文档结构。我在项目里碰到过几种典型情况。第一种是行内代码标记。RST 里用双反引号包裹代码片段比如fish表示代码字体。翻译时很多人会顺手把反引号内的内容也改成中文比如把命令名bind翻成绑定。这看起来似乎没错但反引号内的内容实际上是代码或命令名用户会在终端里原样输入翻译后会导致用户复制过去执行失败。所以我的规则是反引号内的命令名、参数名、文件路径一律保留英文原样中文翻译放在反引号外面。第二种是交叉引用语法。RST 里的:ref:、:doc:、:option:这些标记后面跟着的是目标地址或锚点名称这些名称是文档内部的身份标识翻译后就会断链。比如:ref:prompt-messages 可以翻译成提示符、主题与配色但prompt-messages这个锚点本身绝对不能动。遇到这种情况我会把可读文本翻译成中文引用标识原样保留。第三种是列表缩进和换行。RST 对缩进极度敏感一个列表项的续行一旦缩进错误整个列表的层级全乱甚至构建直接报错。PO 文件里原文的缩进是以空格形式保留在msgid里的翻译msgstr时必须保持同样的缩进模式。我在这上面翻过车一次翻译长句时为了排版好看把换行位置变了Sphinx 构建之后那个章节的列表结构错得一塌糊涂。从那之后凡是有列表、代码块、admonition 提示块的段落我都先用sphinx-build -W -n带警告构建检查一遍再合并。5. 验证与 CI让翻译在合并进仓库之前就被质检翻译是内容工作但内容工作也需要工程化验收。我之前见过不少翻译项目翻得很用心但构建一跑全是警告链接一查全是死链最后用户根本不会去看那份文档因为体验太差了。所以从项目一开始我就把验证和自动化放到了和翻译同等的地位。5.1 本地先过三道质检关卡每次翻译一批文件之后我都不会急着提交而是先跑三道检查。第一道是 PO 文件语法检查。用 gettext 自带的工具把 PO 编译成 MO同时输出统计信息msgfmt --check --statistics -o /dev/null locale/zh_CN/LC_MESSAGES/tutorial.po如果 PO 文件里存在格式非法、编码错误、复数形式不对的问题这一步会直接报错。--statistics会显示已翻译、模糊、未翻译各有多少条方便我掌握单文件的真实完成度。第二道是构建检查。用警告即错误的方式重新构建一次中文文档sphinx-build -W -n -b html -D languagezh_CN doc_src _build/html/zh_CN-W把所有警告当作错误任何一个文档结构问题都会导致构建失败-n是 nitpicky 模式会额外检查交叉引用的目标是否存在。这一步能抓出绝大多数 RST 结构问题。第三道是链接检查。Sphinx 自带的 linkcheck builder 可以检查文档中的所有外部链接是否可达sphinx-build -b linkcheck -D languagezh_CN doc_src _build/linkcheck链接检查对文档项目来说不是可选项。fish 文档里引用了一堆外部资源和官方 wiki这些链接一旦失效中文用户就比英文用户更容易卡住。linkcheck 的结果会列在_build/linkcheck/output.txt里我每次发版前都会扫一遍。5.2 把检查固化到 GitHub Actions本地检查跑得再勤也防不住别人在合并时绕过检查。所以我又加了一套 GitHub Actions 工作流每次有新 PR 进来自动跑一遍完整的构建验证流程。工作流的逻辑很直接name: locales-check on: [pull_request] jobs: build-zh-docs: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.12 - run: pip install -r requirements-docs.txt - run: sphinx-build -b gettext doc_src _build/gettext - run: sphinx-intl update -l zh_CN -p _build/gettext - run: sphinx-intl build -l zh_CN - run: sphinx-build -W -n -b man -D languagezh_CN doc_src _build/man/zh_CN这个流程的核心目的是防退化不是管质量。翻译质量只能靠人但工程质量完全可以靠机器。只要构建能通过、警告数为零、PO 文件结构正常我不会因为某个句子翻得不好而拦住合并那种问题留给后续审校来解决。把机器检查和人工审校分开项目的推进速度会快很多。5.3 跟上游版本同步的更新节奏文档翻译项目最怕的不是翻译量大而是上游更新太快每次同步都像重翻一遍。fish 的迭代节奏虽然不算疯狂但每个版本总会有新增命令、新增参数、文档结构调整。我现在的同步策略是固定在每个正式版本发布后的一到两周内做一轮同步。具体操作是用 sphinx-intl 的更新机制重新跑一遍提取流程然后把新 POT 合并进已有的 PO 文件sphinx-build -b gettext doc_src _build/gettext sphinx-intl update -l zh_CN -p _build/gettext这一步会把新增的文本条目加进 PO 文件把已经改动的原文标记为 fuzzy模糊原来的译文不会直接被删除。接下来我要做的就是分类处理新增的命令文档单独安排优先级fuzzy 条目逐个人工复检没有变化的内容自动保留。每次同步的 PR 我都不允许混入其他修改这样出了任何问题都能第一时间定位到是哪一轮同步引入的。6. 维护半年后我总结的实操经验项目做了半年多翻译进度推进了一大半但真正让我觉得有价值的不是翻了多少万字而是整个流程从混乱变得可预测。最后这部分我不想再讲流程了写几条只有实际做过的人才会注意到的经验。第一千万不要上来就铺开翻全部文件。我刚开始犯过这个错误一口气把cmds下二十多个文件建立了 PO 文件结果发现每轮上游更新这二十多个文件就要重新合并一遍而其中有些文件根本还没人翻纯属给自己增加维护负担。现在我只为已开工的文件建立追踪未开工的文档保持原样等要翻的时候再提取初始化。这样仓库里的焦虑感少了很多。第二翻译进度的可视化比想象中重要。我给项目加了一个简单的统计脚本每次构建后输出各文件的翻译覆盖率、模糊条数、未翻译条数。没有这个数据之前团队伙伴都凭感觉判断差不多翻完了有了数据之后这个文件还差 300 条没翻需要一周时间这种预估就变得可信了。统计脚本不复杂核心逻辑就是遍历所有 PO 文件调用msgattrib统计未翻译和 fuzzy 条目数输出成表格。第三翻译的产品意识要放在语言意识前面。这句话怎么理解就是说译文好不好不只是中文是否通顺还包括读者在终端里的实际体验是否顺畅。比如一个命令的 man page用户是带着具体需求来查的这时候如果译文把参数名、示例命令、选项说明混在一起用户反而更难看懂。所以我在审校时会刻意要求译者在每个命令文件的开头保留一段快速示例哪怕原文没有单独的摘要也要把最重要的用法突出出来。这是翻译之外的内容组织工作但对用户价值极大。第四PO 文件里的版本号注释非常有用。gettext 的 PO 文件支持在条目上方写注释我定了一条规矩任何基于猜测或在线工具初翻的译文必须在条目注释里标注待审校经过人工确认后再删除这个标记。这样一来后续审校的人一眼就能看出哪些条目还没被认真看过不会被一堆看似正常但实际质量存疑的译文误导。最后再说一个很多人会忽略的细节文档本地化项目的 README 本身也要写得好。你的翻译者、审校者、使用者都会先看 README。我在 README 里放了三样东西一是这个项目是什么、现在翻到什么程度、怎么参与二是术语表和翻译约定必须遵守的警告三是如何跑本地构建的完整命令。没有这三样项目贡献者的上手时间会成倍增加。如果你正在考虑给自己的开源项目做文档本地化我的建议是先从一个小文件试跑通全流程再逐步铺开。工具链选 sphinx-intl 还是其他方案没那么重要重要的是把提取、翻译、构建、验证、同步这五个环节的闭环先跑起来。闭环没建立之前翻译再多的文字都只是成本不是资产。