
Metabase i18n 国际化管线全解析从源码提取、Crowdin 同步到运行时产物与翻译违规报告【免费下载链接】metabaseThe easy-to-use open source Business Intelligence and Embedded Analytics tool that lets everyone work with data :bar_chart:项目地址: https://gitcode.com/GitHub_Trending/me/metabaseMetabase 的前后端跨 Clojure 与 TypeScript 两大技术栈其国际化i18n由 bin/i18n/README.md 描述的一条「提取 → Crowdin 同步 → 产物生成」三段式流水线支撑先从.clj/.cljc/.ts/.tsx源码中扫描翻译调用点生成 gettext 模板locales/metabase.po翻译者在 Crowdin 上补全各语种的msgstr后构建阶段再把这些.po文件加工成后端与前端运行时真正消费的翻译资源并用一份自动化违规报告兜底质量问题。读完本文你将掌握这条管线的每个环节、其背后的 Clojure 实现位置主要在 bin/build/src/i18n/以及当翻译出现invalid-message-format、skipped-arg-index、arg-count-mismatch三类问题时应该如何定位与修复。一、管线总览三个阶段、四种产物Metabase 的翻译数据流可以概括为下面的 Mermaid 图出自 bin/i18n/README.md三个阶段各自承担的任务是Extraction提取扫描 Clojure 与 TypeScript 源码中的trs/tru/ ttagt标记调用点生成 gettext 源模板到locales/metabase.po。其中 ttag 是前端使用的翻译运行时库在 package.json 中以ttag: 1.7.21及配套的babel-plugin-ttag、eslint-plugin-ttag引入。Crowdin sync同步翻译者译者/社区在 Crowdin 每周定时运行cron: 30 5 * * 2UTC 周二早上调用 Crowdin GitHub Action 下载翻译并自动打开一个 PR。Artifact generation运行时产物生成读回已翻译的各语种locales/*.po先应用自动修复autofix pass修正翻译者可以无歧义修复的错误再做校验扫描最终输出后端产物resources/i18n/*.edn由metabase.util.i18n.impl/translate通过java.text.MessageFormat消费前端产物resources/frontend_client/app/locales/*.json由 ttag 在浏览器中消费以及target/i18n-violations.csv违规报告。bin/i18n/目录下正是编排这些环节的三个脚本update-translation-template提取并合并模板、build-translation-resources生成产物、merge-translations分支间合并翻译。二、Extraction如何把源码中的翻译字符串变成.po模板2.1 后端 pot 文件的手动构建在项目根目录执行clojure -X:build:build/i18n❯ clojure -X:build:build/i18n Created pot file at cli.pot Found 1393 forms for translations Grouped into 1313 distinct pot entries输出含义共找到 1393 处翻译调用点forms由于 pot 文件中每条字符串只能有一个条目grouped by message归并成 1313 个不同的 pot 条目。实际上这条命令背后是 deps.edn 中的:build/i18nalias其:exec-fn是i18n.create-artifacts/create-all-artifacts!产物生成入口见后文。而模板提取则由 update-translation-template 脚本统一完成它会分别构建三份 pot 文件再用 gettext 的msgcat合并成单一产物# 1) 前端 pot —— 用 babel 的 BABEL_ENVextract 模式跑一遍 ttag 插件 BABEL_ENVextract ./node_modules/.bin/babel --quiet -x .ts,.tsx -o /dev/null {enterprise/,}frontend/src # 随后把 ttag 的 ${ 0 } 风格占位符替换成 xgettext 的 {0} 风格 sed -i.bak -E s/\$\{ *([0-9]) *\}/{1}/g $POT_FRONTEND_NAME # 2) 后端 pot —— 调用 i18n.enumerate/enumerate clojure -X:build i18n.enumerate/enumerate :filename \$POT_BACKEND_NAME\ # 3) 自动仪表盘 pot clojure -M:generate-automagic-dashboards-pot # 4) 合并三份 pot msgcat --add-locationfile -F $POT_FRONTEND_NAME $POT_BACKEND_NAME $POT_AUTODASH_NAME $POT_NAME合并后的模板即locales/metabase.po仓库中真实存在例如其中的条目形如#: enterprise/frontend/src/metabase-enterprise/whitelabel/lib/loading-message.ts #: frontend/src/metabase/plugins/oss/core.ts msgid Doing science... msgstr 2.2 源码扫描的原理Grasp 自定义 spec后端提取不依赖 clang 之类的通用工具而是使用 Grasp。翻译相关的 var 集合定义在translation-vars中#{metabase.util.i18n/trs metabase.util.i18n/tru metabase.util.i18n/deferred-trs metabase.util.i18n/deferred-tru metabase.util.i18n/trsn metabase.util.i18n/trun metabase.util.i18n/deferred-trsn metabase.util.i18n/deferred-trun}匹配用 spec::translate则要求一个非 vector 的 form首符号能解析为上述 var且带有参数(s/def ::translate (s/and (complement vector?) (s/cat :translate-symbol (fn [x] (and (symbol? x) (translation-vars (g/resolve-symbol x)))) :args (s/ any?))))这些宏定义于 src/metabase/util/i18n.clj例如tru/trs分别翻译为「用户 locale」与「站点 locale」字符串deferred-tru/deferred-trs生成UserLocalizedString/SiteLocalizedString记录把翻译动作推迟到str展开时适用于编译期执行的代码trun/trsn及 deferred 版本处理复数的翻译如(trun {0} table {0} tables n)。enumerate 的扫描把调用点的文件名和行列号作为 metadata 一并返回通过 REPL 可以直观看到enumerate (def single-file (str u/project-root-directory /src/metabase/util.clj)) #i18n.enumerate/single-file enumerate (map (juxt meta identity) (g/grasp single-file ::translate)) ([{:line 81, :column 13, :uri file:/.../src/metabase/util.clj} (trs Maximum memory available to JVM: {0} (format-bytes (.maxMemory (Runtime/getRuntime))))] [{:line 313, :column 33, :uri file:/.../src/metabase/util.clj} (tru Timed out after {0} (format-milliseconds timeout-ms))])2.3 从 form 到 pot 条目拿到 form 后form-messages负责抽取字符串字面量既支持直接的字符串也支持(str Foo {0} Bar)这种字符串拼接多个字符串字面量会被连接成一个完整 message复数宏则从第三个参数取message-pl。随后group-results-by-string以 message 为 key 做归并把同一字符串的所有源文件引用#: path:line注释折叠进一个条目——因为 pot 文件中每条字符串只能出现一次。最终processed-catalog用 jgettext 的Catalog/Message写回 pot。create-pot-file!是整个流程的便捷入口返回统计信息enumerate (create-pot-file! single-file pot.pot) Created pot file at pot.pot {:valid-usages 3, :entry-count 4, :bad-forms ()}生成的 pot 内容如下注意#:源引用、msgid/msgstr结构以及java.text.MessageFormat风格的{0}占位符# Copyright (C) 2022 Metabase docsmetabase.com # This file is distributed under the same license as the Metabase package #, fuzzy msgid msgstr Project-Id-Version: 1.0\n Report-Msgid-Bugs-To: docsmetabase.com\n POT-Creation-Date: 2022-07-22 14:03-0500\n MIME-Version: 1.0\n Content-Type: text/plain; charsetUTF-8\n Content-Transfer-Encoding: 8bit\n #: /.../src/metabase/sync/analyze/fingerprint/fingerprinters.clj msgid Error generating fingerprint for {0} msgstr #: metabase/util.clj:81 msgid Maximum memory available to JVM: {0} msgstr 2.4 Overrides处理宏遮蔽导致扫不到的调用点这里有一个值得注意的坑指定单个文件src/metabase/util.clj扫描结果却出现了来自fingerprinters.clj的条目。原因在于那条trs位于一个会展开为defrecord的宏内部宏展开时的 quote 改变了 form 的结构——(trs foobar) 实际是(clojure.core/seq (clojure.core/concat ...))形态的嵌套 seq无法匹配自定义 spec参见 Grasp 的 issue #28。对策是维护一个手工 override 列表overrides定义于 enumerate.clj把这类 grasp 找不到 的形式显式补充进去;; 找不到 fingerprinters 里的用法 —— 宏展开成 defmethodquoting 改变了 seq 形态导致 spec 不匹配 [{:file /src/metabase/analyze/fingerprint/fingerprinters.clj :message Error generating fingerprint for {0}}]这些 override 会在group-results-by-string阶段与扫描结果合并(concat overrides)确保不会漏掉任何需要翻译的字符串。2.5 源码侧占位符校验值得顺带一提的是翻译字符串的占位符一致性不仅在构建期被校验在宏展开期也有防线metabase.util.i18n/validate-number-of-args见 src/metabase/util/i18n.clj会在编译 Clojure 源码时就检查(deferred-)trs/tru的格式串里{N}占位符数量与实参数量是否一致例如「missing some {} placeholders」或「expects %d args, got %d」这类断言。纯校验函数集中在 src/metabase/util/i18n/validation.clj且刻意不依赖 jgettext 等构建期依赖因此可以被「宏展开期检查、构建期.po扫描器、产物回归测试」三层共享复用。复数宏trsn/trun还额外要求只允许单个{0}占位符用于数量 n。三、Artifact generation把翻译变成后端与前端各自消费的产物3.1 一键运行整条产物流水线翻译者在 Crowdin 上补全locales/locale.po之后在项目根目录执行❯ ./bin/i18n/build-translation-resources该脚本本质就是对clojure -X:build:build/i18n的封装见 bin/i18n/build-translation-resources最终调用i18n.create-artifacts/create-all-artifacts!bin/build/src/i18n/create_artifacts.clj。每个 locale 的处理步骤create-artifacts-for-locale!解析.poi18n.common/po-contents用 jgettext 的PoParser把文件解析为{:headers {...} :messages [...]}其中每条 message 含:id、:id-plural、:str、:str-plural、:fuzzy?、:plural?、:source-references、:context等字段。解析函数位于 bin/build/src/i18n/common.clj。自动修复i18n.autofix/autofix-po-contents先修正可无歧义修复的译者错误详见下文。校验扫描i18n.validation/invalid-messages-in-po对修复后的内容产出违规 map 序列详见第四、五节。推导丢弃集合并写产物用i18n.validation/drop-from-build?决定哪些 msgid 要被剔除见下节 drop policy再把修复后的 po-contents 连同丢弃集合交给前后端 writer被丢弃的 msgid 运行时回退为英文。聚合报告所有 locale 完成后把全部违规聚合写入target/i18n-violations.csv与target/i18n-violations.edn。create-all-artifacts!还会先调用i18n.pseudo-locale/generate-pseudo-locale-po!生成一个en-ZZ伪语言环境见第七节并生成resources/locales.clj声明可用 locale 列表的自动生成文件标注「DO NOT EDIT」。3.2 源码所在地产物生成管线的「单一事实来源」位于bin/build/src/i18n/文件职责autofix.clj校验前修复如撇号转义validation.clj扫描器 drop policycreate_artifacts.clj编排器 报告写入create_artifacts/backend.clj后端.edn写入器create_artifacts/frontend.clj前端.json写入器common.clj.po解析器与共享工具backend-message?、po-contents、locales3.3 后端.edn产物与运行时消费后端 writer 只保留backend-message?为真的消息——判定规则是 message 的任一:source-references按空格和冒号拆分后以.clj或.cljc结尾即字符串来自 Clojure 源码。随后剔除属于 drop-msgids 的条目把:headers与:messages以 UTF-8 写入resources/i18n/locale.edn。复数条目以向量形式存储{msgid [单数 复数]}结构。运行时消费逻辑在 src/metabase/util/i18n/impl.cljtranslate先按用户 locale / 站点 locale 读取对应的.edn资源io/resource i18n/locale.edn用get-in translations [:messages format-string]查表查不到时先尝试 fallback locale如pt_PT→pt_BR见fallback-locale最终回退为英文原串最终用java.text.MessageFormat做占位符插值整体包在 try/catch 中一旦翻译串本身格式非法如未转义撇号会记录错误并回退英文——这正是构建期要预筛invalid-message-format的根本原因。3.4 前端.json产物与 ttag前端 writerfrontend.clj则相反frontend-message?要求 source-references 匹配frontend|cljs|cljc。它的输出是 ttag 运行时能直接消费的 JSON关键点有两处占位符风格转换xgettext/MessageFormat 的{0}被替换成 ttag 的${ 0 }风格-ttag-reference。以 msgctxt 为顶层 key 组织ttag 按(context, msgid)二元组查表因此顶层 map 以消息的msgctxt为 key无 context 的归到。这样即便两条消息共享同一msgid只要 context 不同就能各自保有自己的翻译——例如Year既作为ngettext(Year, Years, n)的复数形式存在、又作为c(Date granularity option).tYear 的粒度选项出现若仅按msgid归并后写入的条目会覆盖前者导致翻译丢失。文件命名上把 locale 中的-替换为_如pt-BR→pt_BR.json写入resources/frontend_client/app/locales/。前端源码侧则通过import { t } from ttag使用模板标签例如 UserPasswordForm.tsx 中的tCurrent password。3.5 Autofix pass先修再验保证「修复与产物一致」autofix 在每个 locale 上、在校验之前对解析出的 po-contents 运行一次。它默默纠正「无歧义可修复」的译者错误使本会失败的翻译能够正确发布。由于校验器和产物 writer 都消费修复后的内容三者对「最终上线的到底是什么」保持一致。当前唯一自动修复且仅针对后端字符串——前端msgstr原样通过因为 ttag 的转义规则不同撇号转义与单词相邻的单个撇号如加泰罗尼亚语daquí、英语Its会被翻倍成这样java.text.MessageFormat会将其视为字面字符而非转义界定符。已经成对的撇号或紧挨{/}的撇号即刻意构造的 MessageFormat 转义如{0}则原样通过。实现的正则autofix.clj会避开非单词字符包括{/}邻接的撇号(def ^:private apostrophe-regex #(?![^a-zA-Z0-9\s\u00C0-\u017F])(?![^a-zA-Z0-9\s\u00C0-\u017F]))正因为 autofix 在扫描器之前执行仅犯撇号拼写错误的翻译永远不会出现在违规报告里。如果你在报告中看到daquí风格的条目说明 autofix 够不到它大概率是非后端上下文需要直接去 Crowdin 修复。扩展 autofix 层的原则文档中明确约定只有当修复 (a) 确定无歧义、(b) 语义中立渲染输出不变、(c) 覆盖常见译者错误时才应加入i18n.autofix/autofix-po-contents其余情况应让其在违规报告中浮现。3.6 Drop policy什么才值得从产物中剔除drop-from-build?只对「运行时会抛异常、无论如何都会触发英文回退」的违规返回true——即:invalid-message-format。这是因为MessageFormat构造器对非法 pattern 会抛IllegalArgumentException运行时必然落入 try/catch 的英文回退分支既然如此构建期直接剔除反而能保证.edn/.json产物干净。反之:skipped-arg-index与:arg-count-mismatch虽然渲染不完美但不会抛异常因此保留在产物中——宁可让用户看到「大部分本地化 个别字面{N}」的文本也不整体回退成纯英文。用msgids-to-drop汇总时若某复数消息的任一形态命中可丢弃违规则整条消息被剔除。四、Violations report构建产出的翻译体检表每次产物生成都会输出一份违规报告两种形态并存target/i18n-violations.csv——每条违规一行符合 RFC 4180可用任意表格软件打开这是 CI 中作为i18n-violationsartifact 上传的文件见 .github/workflows/translation-update.ymlpath: target/i18n-violations.*保留 30 天。target/i18n-violations.edn——同一批数据以 Clojure map 向量形式存在供程序化消费。自动生成的 translation-update PR 还会在摘要评论中给出按类型统计的条数以及 artifact 下载直链workflow 里用awk解析 CSV 汇总各类型计数。4.1 CSV 的列定义ColumnMeaninglocale发现违规的 locale如fr、de、pt-BR、ar-SAtypes该行的违规类型集合以连接如skipped-arg-indexarg-count-mismatch见后文droppedtrue表示该翻译被排除在.edn/.json产物之外运行时会回退英文false表示虽有问题仍保留在产物中运行时会以可见问题渲染backendtrue表示 msgid 源自 Clojure 源码.clj/.cljcfalse表示源自前端源码.ts/.tsx/.js/.jsx。这决定应用哪套格式系统规则plural_index非复数串为空复数串为受影响形态在msgstr[N]数组中的下标0、1、…msgid英文单数源串——翻译者在 Crowdin 里看到的 keymsgid_plural英文复数形式如果有。非复数为空msgstr未通过校验的译串——翻译者实际产出的文本error仅invalid-message-format行有值——java.text.MessageFormat的异常消息如Unmatched braces in the pattern.其余类型为空expected_argsarg-count-mismatch行可接受的占位符数量由msgid与msgid_plural推导逗号分隔如0,1表示单数 0 个、复数 1 个占位符actual_argsarg-count-mismatch行msgstr中实际用到的不同占位符下标个数source_refs空格分隔的path:line指针指向该 msgid 在源码中的使用位置这些列在create_artifacts.clj的violations-csv-columns与violation-row中定义写入前全部用(fnil str )把 nil 归一为空字符串。4.2 三类违规类型详解invalid-message-format译文根本无法作为 MessageFormat 解析msgstr不是合法的java.text.MessageFormatpattern——构造器会抛IllegalArgumentException。典型诱因花括号不配对{0或缺少正确转义的{{...}}使用{login}这类命名占位符——Java MessageFormat 只认数字下标。这类行恒为droppedtrue。因为运行时MessageFormat构造器就会抛异常、触发 try/catch 英文回退构建期提前剔除可保证.edn产物干净无雷。译者行动去 Crowdin 修复msgstr通常意味着对齐英文字符串的转义约定字面双写、花括号配对等。skipped-arg-index占位符下标不连续译文的{N}占位符引用了不连续下标如只出现{0}和{2}而没有{1}或同一下标被多次使用造成缺口。运行时缺失下标会字面渲染{2}作为四个字符{2}原样出现在输出里。这类行droppedfalse——翻译保留在产物中用户看到的是「绝大部分本地化文本 个别{N}瑕疵」而非整体英文回退。译者行动让占位符连续——{0}、{1}、{2}、……逐一排列。底层判定逻辑src/metabase/util/i18n/validation.clj 的skipped-arg-index?正是比对MessageFormat.getFormats与getFormatsByArgumentIndex的数量是否一致。arg-count-mismatch占位符数量与源串不匹配译文的占位符数量匹配不上英文源串的任一可接受数量。expected_args是「可接受数量」集合含单数与复数的数量若有复数actual_args是译文实际数量。运行时缺失占位符会字面渲染{0}原样出现或多出的调用方参数被静默丢弃。这类行同样droppedfalse。常见成因是翻译者自行加了个{0}例如为了本地复数化而英文源没有或者漏掉了英文要求的{0}。译者行动与英文源的占位符数量对齐。若源串复数形态占位符数不同也要匹配复数形态。注意来自原文档的特别提示罗曼语族的撇号错误如加泰罗尼亚语daquí在 MessageFormat 转义语义下「吞掉」结尾的{0}本会触发该违规——autofix pass 会在上游处理因此通常不应出现在报告中但这些错误仍然存在于 Crowdin 中。4.3 三类违规的构建期/运行时行为对照Violation typeBuild-time actionRuntime behaviorinvalid-message-formatDropped from artifactEnglish fallbackskipped-arg-indexKept in artifactLocalized text with literal{N}for missing indicesarg-count-mismatchKept in artifactLocalized text with literal{N}for missing placeholders, or extra args silently dropped4.4 如何高效使用这份报告按locale排序看每种语言的问题——译者常存在系统性毛病如加泰罗尼亚语译者反复忘记双写撇号。**筛选droppedtrue**看哪些条目在静默回退英文——这是对 locale 用户最可见的体验回退。**筛选typesinvalid-message-format**优先处理 Crowdin bug——这类修复优先级永远最高。**利用source_refs**判断违规影响哪个 UI、是高流量字符串还是边角用例。每一行都足以在 Crowdin 中定位对应字符串msgid是源 keylocale告诉你要打开哪种语言。五、前端扫描与检查的差异为什么前后端规则不同前端字面量来自 ttagt标签 /ngettext复数其占位符风格在 pot 里被统一成{0}但运行时由 ttag 解析且会再次转回${ 0 }。因此校验时前后端规则不同后端字符串全部走java.text.MessageFormat三连检格式合法性、跳号、数量匹配前端字符串只用与格式系统无关的检查跳号、数量匹配借助基于正则的辅助函数regex-arg-count、regex-skipped-arg-index?避免MessageFormat对撇号的误解析见validation.clj的check-violations(if backend? validation/message-format-arg-count validation/regex-arg-count)。同时为后端字符串做三类检查时会复用一次性的MessageFormat解析结果只有格式合法才继续测跳号与数量匹配format-error存在时跳过后续检查。所有命中类型会一并收集、不做短路因此单行可能出现多个 type如skipped-arg-indexarg-count-mismatch同时触发。六、日常维护工具merge-translations 与 CI 每周同步6.1 分支间合并翻译merge-translations 用于把指定分支的翻译合并进当前分支。典型场景把翻译 PR 反向移植backport到发布分支后该 PR 可能删掉了当前分支仍需要的翻译运行该脚本可以把发布分支的翻译合并回 backport 分支。冲突时默认以当前分支的翻译为准如需给另一分支更高优先级则加-o参数./bin/i18n/merge-translations branch-name [-o]注意需在仓库根目录运行因为脚本假设.po文件位于locales/。6.2 CI 的每周自动化.github/workflows/translation-update.ymlname: Synchronize Translations每周二 05:30 UTC 通过schedule触发也支持workflow_dispatch手动触发。流程要点安装 gettext通过 Crowdin GitHub Action读取 locales/crowdin.ymldownload翻译并排除一批如el、et、es-MX等未完成 locale依次运行./bin/i18n/update-translation-template更新模板并提交、./bin/i18n/build-translation-resources重建产物并提交if: always()上传target/i18n-violations.*作为i18n-violationsartifact运行 Crowdinupload同步新源字符串最后通过 GitHub CLI 创建 PR。七、进阶补充en-ZZ伪语言环境与测试产物构建前还会生成en-ZZ伪语言环境见 bin/build/src/i18n/pseudo_locale.clj。它把locales/metabase.po里的每条英文字符串转换为[zz] {English}产出locales/en-ZZ.po再走正常构建管线。E2E 测试可把 UI 切到en-ZZ并断言可预测的[zz] ...前缀而不依赖 Crowdin 随时可能更新的真实语言字符串。该文件被.gitignore忽略、每次构建重新生成Crowdin 亦不感知它不在crowdin.yml中。后端通过src/metabase/util/i18n/impl.clj的available-locale?把en_ZZ加入可设置 locale 白名单extra-available-locales前端语言选择器仅在设置MB_ENABLE_TEST_LOCALEStrue时才显示测试伪语言环境test-only-locales、show-test-locales?其显示名被覆盖为更清晰的 English (ZZ)。八、实战清单把一条坏翻译从发现到修复串起来结合上述内容一次完整的「翻译体检与修复」工作流建议如下本地执行./bin/i18n/build-translation-resources重建全部产物并生成最新报告打开target/i18n-violations.csv按locale排序看系统性译员问题先处理droppedtrue且typesinvalid-message-format的行——它们已在静默回退英文是体验影响最大的问题结合error列的MessageFormat异常消息如Unmatched braces in the pattern.判断病因用source_refs定位受影响源码如 src/metabase/util.clj 中的tru/trs调用评估该字符串是否高流量到 Crowdin 中按msgidlocale定位并修复注意后端串撇号须双写、花括号须配对、占位符下标须连续且数量与英文源一致若嫌流程繁琐可退而求其次直接用create-pot-file!在 REPL 中对单个文件做小范围抽取验证参考 enumerate.clj 末尾的comment示例。最后再次强调核心机制校验发生在 autofix 之后、产物写入之前autofix 纠正「能无歧义修好的」drop policy 剔除「运行时会抛异常的」二者共同保证进入resources/i18n/*.edn与resources/frontend_client/app/locales/*.json的每一条翻译都至少「不会让运行时崩溃、不会整段回退英文」。理解了这条流水线与三类违规的取舍逻辑你就能既当好翻译质量守门人也能在需要时安全地扩展 bin/build/src/i18n/ 下的构建工具链。【免费下载链接】metabaseThe easy-to-use open source Business Intelligence and Embedded Analytics tool that lets everyone work with data :bar_chart:项目地址: https://gitcode.com/GitHub_Trending/me/metabase创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考