第24章 - 测试、国际化与贡献流程
前两章分别讲了“写插件”和“改源码”,这一章讲怎么把改动合进主干:本地要跑哪些门禁、测试怎么写、文案怎么翻译、文档与生成物怎么保持一致,以及 CI 红了先看哪里。GeoLibre 的门禁设计成一个反复出现的模式——棘轮(ratchet):指标只能变好,不能变坏。
说明:本章基于 GeoLibre 3.4 的
package.json、docs/contributing.md、docs/maintenance.md、docs/i18n.md、playwright.config.ts与仓库现状(tests/下 858 个*.test.ts、e2e/下 56 个*.spec.ts)。
24.1 贡献流程
24.1.1 四种参与方式
| 方式 | 做法 |
|---|---|
| 报 bug / 提需求 | 开 issue,写清复现步骤、期望与实际,并注明操作系统、Web 还是桌面构建 |
| 改文档 | 改 docs/(就是文档站本体) |
| 修 bug / 做功能 | 改应用或某个 package |
| 写插件 | 不需要 fork 主仓库,按第21章的 Plugin API 走 |
计划做大改动时先开 issue 对齐方案,再投入 PR。
24.1.2 一个 PR 的生命周期
- 从
main切分支:git switch -c feat/short-description。永远不要直接提交到main。 - 改动保持聚焦,跟随周围代码的风格,不要顺手引入新模式。
- 跑质量检查(24.3),确认通过。
- 用 Conventional Commits 风格提交:
feat:、fix:、docs:、refactor:、chore:。 - 推分支、对
main开 PR;模板会问“改了什么/为什么”“怎么测的”“关联哪个 issue”。 - 等评审。自动评审可能留行内评论:要么改,要么说明为什么不适用。
24.1.3 格式化与提交钩子
.pre-commit-config.yaml 按语言分工,提交即自动改写文件:
| 工具 | 覆盖范围 | 配置 |
|---|---|---|
| ruff | .py、.pyi、.ipynb(format + lint --fix) |
ruff.toml,行宽 100,规则 F/I/W/E |
| oxfmt | JS/TS/JSON/CSS/YAML/TOML | .oxfmtrc.json,2 空格、双引号、分号、宽 100 |
| ESLint | 应用与包的 .tsx 等 |
见 24.3;--max-warnings 受棘轮约束 |
| 其它 | check-yaml/check-json/check-toml、EOF、行尾空白、mixed-line-ending --fix=lf |
.gitattributes 统一 LF |
strip-notebook-outputs |
所有被跟踪的 .ipynb |
用 nbstripout 就地重写,仓库里不允许带输出的 notebook |
npm-build |
整个应用 | 本地钩子,跑一次完整生产构建,慢 |
两条实践建议:
- 用
pre-commit run --files <你改的每个文件>,不要--all-files(会重排整个仓库、动到你没碰的文件)。 - 已经构建过就加
SKIP=npm-build pre-commit run --files ...跳过构建钩子。
pre-commit.ci 在每个 PR(含 fork)上跑同一批钩子并自动提交 style: auto-format 修复;它跳过 npm-build 与 eslint(需要完整工具链)。它不在 push 到 main 时运行,所以自动格式化不会绕过评审。
注意:
strip-notebook-outputs会对所有跟踪的 notebook 生效,包括你正在迭代的那份。提交任何文件都可能抹掉本地 notebook 的输出——重新跑一次,或留一份未跟踪的副本。
24.2 环境与依赖
24.2.1 前置条件
| 依赖 | 版本/说明 |
|---|---|
| Node.js | 22+;.nvmrc 锁主版本,nvm use 直接生效 |
| Rust | 桌面构建需要(rustup);rust-toolchain.toml 选 CI 用的 stable 通道 |
| Linux | webkit2gtk 与 libayatana-appindicator(Tauri 前置) |
| Python | 3.10+,给 pre-commit、backend 与其测试用 |
| 包管理器 | npm(仓库跟踪 package-lock.json) |
24.2.2 装起来与跑起来
git clone https://github.com/YOUR_GITHUB_USERNAME/GeoLibre.git
cd GeoLibre
npm install # 根目录一次装好所有 workspace
pre-commit install # 装提交钩子
pip install -e "backend/geolibre_server[test]" # 不装,矢量/栅格/SQL/ML 测试会静默跳过
npm run dev # 浏览器开发服务器 → http://localhost:5173
npm run tauri:dev # 桌面应用(文件对话框、本地 MBTiles/栅格必需)
24.2.3 仓库布局(找文件用)
| 路径 | 内容 |
|---|---|
apps/geolibre-desktop |
Tauri + React 应用(壳、组合、Tauri I/O) |
packages/core |
领域类型、Zustand store、工程格式 |
packages/map |
MapLibre 集成与图层同步(含另三种引擎适配) |
packages/ui |
Tailwind + shadcn 原语 |
packages/plugins |
Plugin API 与内置插件 |
packages/processing |
客户端算法注册表 |
packages/embed |
类型化 iframe 客户端 |
packages/collab-core |
协作协议共享层 |
workers/ |
viewer、collab、collab-node、tiles、ai-proxy |
backend/geolibre_server |
可选 FastAPI sidecar |
backend/geolibre_server_api |
参考用的多用户项目/身份 API |
python/ |
Jupyter widget 与 MCP 服务器 |
e2e/ |
Playwright 端到端测试 |
tests/ |
前端单元与组件测试 |
docs/ |
文档站(Zensical,读 mkdocs.yml) |
24.3 质量门全览
24.3.1 三个入口
| 命令 | 覆盖 | 需要什么 |
|---|---|---|
npm run ci:web |
lint、共享依赖、i18n catalog、gallery、插件文档、typecheck:fast、typecheck:tests、前端单元测试 |
只要 Node |
npm run ci:frontend |
i18n 检查 → gallery → 插件文档 → 构建 → 前端覆盖率 → 未测模块 → worker 检查 | 只要 Node(但会完整构建) |
npm run ci |
lint + check:shared-deps + ci:frontend + typecheck:tests + ci:backend + check:rust |
完整工具链(Node + Python + Rust) |
只改文档或前端时,npm run ci:web 是快速门;要合并前跑 npm run ci(CI 跑的就是它)。
24.3.2 门禁清单
| 步骤 | 命令 | 管什么 |
|---|---|---|
| Lint | npm run lint |
ESLint 覆盖 apps/、packages/、workers/、tests/,--max-warnings 242 |
| 共享依赖 | npm run check:shared-deps |
应用与 packages/plugins 同时声明的依赖必须范围一致 |
| i18n 工具文案 | npm run i18n:tools:check |
en.json 里的处理工具串与注册表一致(npm run i18n:tools 重生成) |
| Gallery | npm run gallery:check |
docs/gallery.md 与 docs/demos.md 摘要匹配 scripts/demo-gallery.json |
| 插件文档 | npm run plugins:docs:check |
docs/user-guide/plugins.md 的内置插件表匹配注册表 |
| 构建 | npm run build |
tsc -b + Vite 构建(含启动包预算门禁) |
| 前端测试+覆盖率 | npm run test:frontend:coverage |
tests/ 全量 + 覆盖率地板 |
| 未测模块 | npm run check:untested-modules |
500 行以上且无测试加载的源文件,基线棘轮 |
| Worker | npm run test:worker |
五个 worker 的类型检查 + collab-node 测试 |
| 测试类型 | npm run typecheck:tests |
tsc 检查 tests/,错误数棘轮(当前 --max-errors 0) |
| 后端 | npm run test:backend:coverage |
sidecar 的 pytest + 覆盖率地板 55% |
| Docker | npm run test:docker |
容器部署与会话策略脚本的 pytest |
| Rust | npm run check:rust |
Tauri crate 的 cargo check |
24.3.3 CI 的作业与分流
.github/workflows/ci.yml 不是一个串行大管道,而是并行作业:lint(“Lint and type check”)、checks(“Build and test”)、e2e(“E2E core (Playwright)”)、backend、containers、rust,以及两个集成作业 sidecar-mssql、identity-integration;最前面的 changes 作业持有路径过滤器。
| 规则 | 说明 |
|---|---|
| PR 必跑 | lint 与 “Build and test” 始终运行 |
| 按路径触发 | backend、Rust、containers、E2E、数据库与 OIDC 集成只在改动相关路径时运行 |
| 唯一必过检查 | 汇总作业 CI passed:任一作业失败它就失败,分支保护只要求它 |
| 新作业 | 必须加进 ci-passed 的 needs 列表,否则它的失败不会阻塞合并 |
| 慢速 e2e | 打 full-e2e 标签可在 PR 上跑 nightly 的功能规格 |
| 预览冒烟 | 每个 PR 的 Cloudflare 预览部署后有一次冒烟:/demo/ 必须完成地图加载、无 console 错误、自身主机无 4xx/5xx |
提示:给
npm run ci加一步时,同时把它加进ci:frontend或ci:backend之一——CI 才拿得到。
24.3.4 只跑你关心的那一段
不必每次都跑全量。按改动范围挑:
| 改动 | 最小验证 |
|---|---|
| 只改文案/组件 | npm run typecheck:fast + node --import tsx --test tests/<name>.test.ts |
| 动了处理工具注册表 | npm run i18n:tools + 对应的 tests/*-tools.test.ts |
| 动了内置插件注册 | npm run plugins:docs + npm run test:frontend |
动了 workers/ |
npm run test:worker |
| 动了 sidecar | npm run test:backend(先装 test extra) |
| 动了 Tauri crate | npm run check:rust |
| 想要 PR 前的完整信号 | npm run ci(或前端改动用 npm run ci:web) |
24.4 前端测试
24.4.1 运行方式
前端测试跑在 node --test 上,用 tsx 加载 TS(只剥类型、不做类型检查):
npm run test:frontend # tests/*.test.ts 全量
npm run test:frontend:coverage # 附带覆盖率(有地板)
node --import tsx --test tests/<name>.test.ts # 单个文件
tsx 不检查类型,所以“测试照跑、照样通过”不代表测试里的假对象还与真实接口一致——这正是 typecheck:tests 存在的理由(见 24.8.3)。
24.4.2 组件测试的六条规矩
React 面板也跑在同一个 runner 里,对着一份 happy-dom + Testing Library,背后没有地图。样板看 tests/component-*.test.ts:
import { fireEvent, render, screen, useAppStore } from "./helpers/dom";
import { createElement } from "react";
const { LayerPanel } = await import("../apps/geolibre-desktop/src/components/panels/LayerPanel");
- 必须动态
import():helper 注册的 loader 钩子只对“注册之后加载”的模块生效,静态 import 在钩子之前就执行了。 - 状态用
useAppStore.setState(...)铺;断言看用户可见文本或 store 结果。 mapControllerRef传{ current: null }——需要真地图才能测的面板,说明逻辑该挪到lib/里当单元测试。render之后写 store 要用act(...)包住,否则断言时还没重渲染。fetch默认 reject;要响应就用mockFetch(...)。虚拟化列表(属性表)要调stubLayout(),否则每行 0px 高、渲染不出行。- 断言普通值(
textContent、.checked、.value、store 状态),不要断言 DOM 节点:assert.equal比较节点失败时 Node 会格式化整棵 happy-dom 树,看起来像卡死;数行数用queryAll…().length。
helper 还会安装应用自己的 i18next(用 en.json),所以查询用的是真实英文串;每个用例结束后它会卸载组件并重置应用/设置 store、localStorage、fetch 与 stubLayout。
24.4.3 写测试的取舍
| 场景 | 建议 |
|---|---|
| 大模块第一次有测试 | 测叶子模块(或从中抽一个),别直接 import 大文件——它会把整片依赖拉进覆盖率分母(见 24.8.1) |
| 需要图层记录 | 用 tests/helpers/layer-fixtures.ts,部分 LayerStyle 用 DEFAULT_LAYER_STYLE 展开 |
| 需要伪造大接口 | 在小的类型化 helper 里经 unknown 转一次,别在每个调用点 as |
| 需要测 worker 源码 | 主项目 exclude,并加进某个 worker 的 tsconfig 列表(脚本会检查漏加) |
24.5 后端测试
24.5.1 前置与运行
pip install -e "backend/geolibre_server[test]" # 关键:装上可选引擎,否则大量用例自我跳过
npm run test:backend # pytest backend/geolibre_server/tests
npm run test:backend:coverage # 加覆盖率,地板 55%
python -m pytest backend/geolibre_server/tests/test_x.py::test_y # 单个用例
警告:没装
testextra 时,矢量/栅格/SQL/ML 测试会静默跳过——CI 变绿但内容是空的。这是后端最常见的“假绿”。
24.5.2 覆盖率与依赖
后端覆盖率跑 --cov=geolibre_server --cov-fail-under=55;npm run ci 调的是 :coverage 变体,所以需要 pytest-cov(在 backend 的 dev extra 里)。
24.5.3 容器与集成测试
| 测试 | 命令 | 说明 |
|---|---|---|
| 容器脚本 | npm run test:docker |
docker/tests:部署策略与 sidecar 策略脚本 |
| SQL Server | CI 作业 sidecar-mssql |
sidecar 的 mssql 路由集成 |
| 身份 | CI 作业 identity-integration |
PostgreSQL + OIDC 的 backend/geolibre_server_api 集成 |
24.6 端到端测试(Playwright)
24.6.1 两个项目,一次跑完全部
npm run test:e2e 对构建产物跑(先 npm run build,再用 vite preview 起服务),headless Chromium 驱动。首次需 npx playwright install chromium。
| 项目 | 命令 | 覆盖 | 何时跑 |
|---|---|---|---|
core |
npm run test:e2e:core |
应用能启动并渲染地图,加上共享 UI 面:图层面板、属性表、对话框、拖放、主题、RTL、无障碍、PWA 外壳、工程保存与重开、插件深链 | 每次 push,以及改动 Web 构建所读路径的 PR(CI 作业 E2E core (Playwright)) |
core-engines |
由 test:e2e:core 一并触发 |
每个备用引擎一次冒烟:切到 Cesium、再切 ArcGIS、加 GeoJSON、Identify 一个要素 | 同上;在 core 之后、单 worker 跑 |
features |
npm run test:e2e:features |
按功能集成:Mapbox/Cesium、STAC、导出、故事地图、场景图、插件安装 | nightly 与按需(e2e-full.yml,4 分片),或打 full-e2e 标签的 PR |
两个项目对 e2e/ 做划分(不是重复):所以直接 npm run test:e2e 仍然每个 spec 只跑一次。另有独立配置:npm run test:e2e:sso(企业 SSO,需要活的 API 与 Keycloak)与 npm run test:e2e:preview(对着已部署站点)。
24.6.2 playwright.config.ts 的关键参数
| 参数 | 值 | 原因 |
|---|---|---|
testDir |
./e2e |
— |
workers |
CI 3 / 本地 1 | 软件渲染下加速是次线性的;10 worker 时争用开始让串行能过的用例失败 |
retries |
CI 1 / 本地 0 | — |
timeout / expect.timeout |
60 s / 15 s | — |
| Chromium 参数 | --use-gl=angle --use-angle=swiftshader |
headless CI 没有 GPU,必须软件渲染才能初始化地图 |
storageState |
预置 uiProfile.onboarded: true |
否则首次启动的 UI profile 向导会在每个新上下文里弹窗拦截点击 |
trace / screenshot |
on-first-retry / only-on-failure |
失败产物随报告上传 |
webServer.command |
npm run build && npm run preview -w geolibre-desktop -- --port <PORT> --strictPort |
对着生产输出测 |
webServer.env |
CESIUM_TOKEN/VITE_CESIUM_TOKEN 置空 |
否则有 token 的机器会构建出另一个应用,cesium-globe.spec.ts 就分不清有/无 token 的路径 |
reuseExistingServer |
本地 true、CI false |
本地复用已监听的服务;注意复用会跳过上面的 env,所以那条断言写成“检查无 token 提示”而不是信任配置 |
E2E_PORT |
默认 4173 | 第二个 checkout/并行运行时换端口 |
提示:本地跑
core-engines时用--no-deps单独起(否则会先等core跑完);改--workers=N可覆盖默认值。
24.6.3 加一个 spec 的规则
- 只有“坏了会影响每个用户”的 spec 才进
core:名单在playwright.config.ts的CORE_SPECS(smoke、theme、a11y、keyboard-only、layer-panel、drop-overlay、error-handling、attribute-status、rtl、pwa、deployment-policy、layer-groups、plugin-deep-link),不在名单里的自动归features。 - 拆分是墙钟时间的取舍,不是价值判断:全量串行约 36 分钟,feature 规格占约 27 分钟,曾是 CI 唯一的瓶颈路径。
- 碰到 nightly 覆盖的区域时,给 PR 打
full-e2e标签,让那批规格在合并前跑一次。 - 无障碍断言放在
e2e/a11y.ts与a11y*.spec.ts;拖放/错误处理等共享步骤抽到e2e/helpers.ts,spec 里别再复制一遍。
24.6.4 失败产物与本地复现
| 配置 | 产物 |
|---|---|
trace: "on-first-retry" |
首次重试时录制 trace,可在报告里逐步回放(含 DOM 快照与网络) |
screenshot: "only-on-failure" |
失败时截图;video: "off",不产视频 |
| reporter | CI 用 list + html(open: "never");本地只有 list |
CI 的两个 e2e 作业都会在失败时上传 Playwright 报告 artifact;本地复现时优先按 spec 名跑单个文件,再用 --project 限定项目。core-engines 依赖 core,想单独跑要加 --no-deps。所有断言都对着构建产物,所以先确认本地构建是最新的——webServer 命令会重新构建并起 vite preview。
24.7 Worker 的类型检查与测试
npm run test:worker 是一个复合命令,按顺序做六件事:typecheck -w geolibre-viewer-worker、collab-worker、collab-node,跑 collab-node 的测试,再 typecheck -w geolibre-tiles-worker 与 ai-proxy-worker。
为什么 worker 要单独检查:Vite 把每个 new Worker(new URL(...)) 打成独立构建,worker 里 import 的东西会在主线程之外再发一份;Workers 运行时类型还重新声明了 DOM 全局,不能和主程序共用一个 program。历史上 h5wasm(libhdf5,约 4.8 MB)就被发过两份,现在主构建产出显式 chunk、worker 按 URL import,且构建在出现非唯一 hdf5_hl-*.js 时失败。
24.8 四种棘轮与一个预算
24.8.1 覆盖率地板
| 套件 | 地板 |
|---|---|
| 前端 | 78% 行 / 78% 分支 / 63% 函数 |
| 后端 | 55%(--cov-fail-under) |
地板比当前数字低几分,作为棘轮:退步就红,涨上去后要把地板抬起来锁住收益。前端报告只统计被测试 import 过的文件——所以“大模块的第一个测试”会读起来像覆盖率退步:模块本身和它 import 的一切同时进入分母。真实案例:一个测试 import 了 usePlugins.ts,把整个内置插件注册表(39 个文件)拖进分母,函数覆盖率从 72.90% 掉到 60.36%。正确的解法是测叶子模块(lib/plugin-layer-queries.ts、geo-editor-geometry.ts 就是这么抽出来的),而不是下调地板。
test:frontend:coverage 走 scripts/coverage-check.mjs 而不是直接 node --test:Node 仍然强制三个地板,wrapper 只做一件事——当且仅当所有测试通过、且只有行覆盖率不达标时重测一次。行覆盖率在 CI 上不确定(同源码两次运行报过 81.82% 与 76.47%,444 个文件里 114 个行覆盖不同,而分支/函数零差异);分支/函数不达标或任何测试失败都立即失败、不重试。
24.8.2 未测模块基线
npm run check:untested-modules(scripts/check-untested-modules.mjs)读 test:frontend:coverage 写出的 coverage/frontend.lcov,与 apps/*/src、packages/*/src 下所有源文件比对。声明文件、index.* barrel、语言 catalog、生成物(带 AUTO-GENERATED/@generated/DO NOT EDIT 头,以及 Whitebox 菜单 catalog)不算。
| 规则 | 说明 |
|---|---|
| 500 行门槛 | 每个无测试的 500 行以上源文件必须列在 scripts/untested-modules-baseline.json(带行数) |
| 新文件 | 出现新的 500 行+ 无测试文件 → 失败;要么加测试,要么抽出叶子模块再测 |
| 增长 | 已在基线里的文件再长 50 行且仍无测试 → 失败 |
| 基线只缩不扩 | 文件有测试/降到 500 行以下/被删时,用 -- --prune 在同一个 PR 里移除;--write-baseline 只用于首次 bootstrap |
它跑在 ci:frontend 的覆盖率步骤之后;ci:web 跳过它(因为不测覆盖率)。命令还会不阻塞地打印后端没有 test_<module>.py 的模块,当作“去查一下”的提示,而不是未测代码计数。
24.8.3 测试类型棘轮
npm run typecheck:tests(scripts/check-test-typecheck.mjs)对 tests/ 跑三个 tsc --noEmit 项目,错误数超过 --max-errors 就失败——当前是 0。
| 项目 | 检查什么 |
|---|---|
tests/tsconfig.json |
全部测试,DOM lib + Node/React/Vite 类型 + 应用与包的 ambient .d.ts;allowJs 让测试能 import scripts/*.mjs |
tests/tsconfig.workers.json |
import workers/tiles 或 workers/viewer 的测试(@cloudflare/workers-types) |
tests/tsconfig.ai-proxy.json |
import workers/ai-proxy 的测试(该 worker 生成的 worker-configuration.d.ts) |
只有 tests/ 里的错误计数:测试 import 的产品源码由它自己 workspace 的 tsconfig 管,报在测试设置下的错误只打印、不计数。新增一个 import worker 源码的测试,要同时进主项目的 exclude 与某个 worker 项目,脚本会检查“被 exclude 却不在任何 worker 项目里”的情况。
修法:对着真实类型造 fake(DEFAULT_LAYER_STYLE 展开、tests/helpers/layer-fixtures.ts、大接口的故意残缺假对象经 unknown 在小 helper 里转一次)。不要抬上限;修好了反而要把上限降到新的总数。
24.8.4 ESLint 告警棘轮
npm run lint 传 --max-warnings 242(写在根 package.json 里),所以告警数只能降。告警来源:react-hooks/exhaustive-deps、@typescript-eslint/no-explicit-any、类型感知的 @typescript-eslint/no-floating-promises、local/no-physical-tailwind(RTL 规则)、以及应用与包 .tsx 上的两条 eslint-plugin-jsx-a11y 规则。
| 情形 | 做法 |
|---|---|
| PR 新增一个告警 | 修掉。故意 fire-and-forget 的加 void 并确保自己处理 rejection;必须保持物理方向的加 // eslint-disable-next-line local/no-physical-tailwind -- <理由> |
| PR 修掉告警 | 同一个 PR 里把上限降到 ESLint 打印的新总数 |
| 开启新规则 | 这是上限唯一允许上调的时机:按新规则在当前代码上的计数精确上调,并在 PR 里说明 |
24.8.5 启动包预算
npm run build 在入口静态 import 的 JS(index.html 在壳挂载前 modulepreload 的那些)超过预算时失败,或在其中发现 Cesium 时失败。检查逻辑是 vite.config.ts 的 bootBundleBudgetPlugin,预算在 apps/geolibre-desktop/boot-budget.json:
| 预算 | 上限 | 设定时实测 |
|---|---|---|
rawBytes |
3 MiB(minified) | 约 2.2 MB |
gzipBytes |
900 KiB(gzip 9) | 约 680 kB |
大多数“超预算”来自 chunk 分组变化,把整包懒加载依赖(Cesium、DuckDB、某个 maplibre-gl-*)拖上了启动路径。修法是改 CODE_SPLITTING_GROUPS/manualChunks,不要抬预算;npm run bundle:report 会在构建后写出每个 JS/WASM 资源的原始与 gzip 体积、启动集合与各顶层目录体积,CI 会把它作为 bundle-report artifact 上传。
24.9 国际化(i18n)体系
24.9.1 目录、加载与回退
apps/geolibre-desktop/src/i18n/
index.ts # i18next 初始化、catalog 自动发现、语言检测
languages.ts # 友好语言名、解析与方向辅助
i18next.d.ts # 用 en.json 给 t() 的键做类型
locales/ # en.json 为源,另有 20 个语言(zh/es/fr/de/pt/it/nl/ja/ko/ru/tr/id/hi/th/vi/az/ka/ar/he/fa)
| 机制 | 说明 |
|---|---|
| 源与类型 | en.json 是唯一源;i18next.d.ts 用它类型化 t(),缺键/拼错是编译错误 |
| 自动发现 | index.ts 用 import.meta.glob 收 locales/*.json——加文件即多一门语言,无需改代码 |
| 打包策略 | 英语随应用 chunk;其余每个 catalog 是独立 chunk i18n-locale-<code>,切到该语言才加载(i18nReady 在首渲染前 await 初始语言) |
| 回退 | fallbackLng: "en";非英文 catalog 允许落后于 en.json,缺的键运行时回落 |
| 离线 | Service Worker 把这些 chunk 排除在 app-shell 预缓存外,首次使用后 CacheFirst 缓存 |
非英文 catalog 是机器辅助翻译起步的,欢迎母语者提 PR 修正。
24.9.2 语言检测顺序
?locale=或?lang=查询参数(给嵌入用,与?theme=/?maponly一致)。- 桌面设置里持久化的
language。 - 浏览器偏好语言(
navigator.languages)。 - 默认
en。
只有带 catalog 的语言会被采纳,否则落到下一条。应用内选择器(Settings → Language)即时切换并记录选择。例子:https://geolibre.app/?maponly&locale=es。
24.9.3 在组件里用
import { useTranslation } from "react-i18next";
function Example() {
const { t } = useTranslation();
return <button aria-label={t("common.cancel")}>{t("common.save")}</button>;
}
| 需求 | 写法 |
|---|---|
| 插值 | t("settings.env.removeAria", { name }),catalog 值 "Remove {{name}}" |
| 复数 | 定义 key_one/key_other,调用 t("...", { count }) |
| 富文本(链接、加粗) | <Trans components={{ tokenLink: <a href="…" /> }} /> |
| 模块级常量 | 存 catalog key 而不是英文串,渲染处 t(item.labelKey)(见 SettingsDialog.tsx 的 SECTION_ITEMS、toolbar/constants.ts) |
不要翻译:开发者日志、URL、store ID、文件格式名/扩展名、持久化数据值。
24.9.4 加一门语言
- 复制
locales/en.json为locales/<code>.json并翻译。目标译全;漏掉的运行时回落英文,catalog 测试会报告而不是失败。产品名、格式名、单位按该语言的惯例保留英文。 - 复数按该语言的 CLDR 类别:中/日/韩/印尼/泰只留
_other;俄语加_few/_many;阿拉伯语有六种形式。catalog 测试按“复数归一化后的键”比较,所以形式多寡不影响通过。 - 若代码不在
LANGUAGE_NAMES(languages.ts)里,加一条友好名(可选,否则显示原始 code)。 - 完成:语言出现在 Settings → Language,也支持
?locale=<code>。
24.9.5 目录完整性测试的规则
tests/i18n-catalogs.test.ts(属于 npm run test:frontend)对每个 catalog 检查:
| 规则 | 结果 |
|---|---|
| 没有多余键 | en.json 里不存在的键 → 失败(拼错或残留) |
| 占位符一致 | 每条译文保留英文的 {{placeholders}} 与 <tag> 标记 |
| 缺键 | 只报告不强制;复数键按基名比较 |
所以新增一个 t() 键(或内置插件通过 app.translate 解析的 plugin.<id>.* 键)只需要加进 en.json;其它语言在发版前用 geolibre-i18n 审计补齐。补译文时不要重排 catalog(多份已与 en.json 顺序漂移,重排的 diff 会把真实改动埋掉)。
24.9.6 从右到左(RTL)
阿拉伯语(ar)、希伯来语(he)、波斯语(fa)是 RTL catalog,布局方向已经接好,新增 RTL 语言是加法:
| 机制 | 说明 |
|---|---|
| 方向注册表 | languages.ts 的 RTL_LANGUAGES 列基语言(ar、he、fa、ur);languageDirection(code) 与 resolveLanguage 同规则解析 |
| 文档镜像 | index.ts 在 init 之前订阅 languageChanged(所以初始语言也触发),设置 document.documentElement.lang 与 .dir |
| Radix 方向 | App.tsx 用 DirectionProvider 包壳,菜单/子菜单朝阅读方向展开,方向键、滑块、tabs 跟随镜像 |
| 逻辑工具类 | 用 ms-/me-、ps-/pe-、text-start/text-end、border-s/border-e、start-/end-,不用 ml-/left-;同一个边不要混用物理与逻辑类(tailwind-merge 把它们当不同组,两个都会生效) |
| 地图外壳固定 ltr | index.css 把 .maplibregl-control-container 钉成 direction: ltr;.maplibregl-map 其余部分仍可镜像,所以 portal 进地图容器的应用覆盖物(如故事地图演示器)跟随 RTL |
| 文本整形 | 弹窗里的阿拉伯文经 Unicode bidi 正常整形;地图底图标签靠 MapLibre 内建 bidi/shaping(6.11+),没有注册旧版 RTL 插件(它会错误连接库尔德语字母);导出的故事地图 HTML 钉的是更老的 CDN maplibre-gl,仍会加载该插件 |
对称居中(left-1/2 + -translate-x-1/2)、贴地图的覆盖层、JS 计算的像素偏移故意保持物理方向。
24.9.7 让格式化跟随语言
数字、日期、坐标格式化继续走运行时 locale 的 Intl API(new Intl.NumberFormat(undefined, …) / Intl.DateTimeFormat),不要硬编码格式。
24.9.8 处理工具元数据
工具名、描述、分组标签、参数标签/帮助、下拉项标签住在没有 i18n 访问权的处理注册表里。处理对话框(Vector / Network / Spatial statistics / Raster / Batch、Whitebox 工具箱、模型构建器面板)通过 apps/geolibre-desktop/src/lib/processing-tool-i18n.ts 解析 processing.toolMeta.<catalog>.<toolId>…,失败则回退注册表自己的字符串。
| 要点 | 说明 |
|---|---|
| 键里为什么要 catalog | 工具 id 会跨注册表重名(reproject 既是矢量工具也是栅格工具) |
| 分组 | 共用 processing.toolGroup.<slug>,因为同名分组出现在多个注册表 |
| 生成物 | 四个小型的随包注册表由 npm run i18n:tools 生成 en.json 基线,CI 检查漂移;只重写 processing.toolMeta 与 processing.toolGroup,不动其它语言 |
| Whitebox | 数百条描述不进任何随包 catalog;英文由运行时提供,可选译文是版本化语言包(opengeos/geolibre-language-packs),从 Settings → Language 下载或导入 JSON,校验后合并、存进独立 IndexedDB |
| 语言包根 | v1 只接受 processing.toolMeta.whitebox、processing.whitebox.categories、menuTool、menuSubcategory;官方包在 https://languages.geolibre.app/v1/whitebox/<locale>.json,自托管可用 VITE_LANGUAGE_PACK_BASE_URL 指镜像,本地导入离线可用 |
| 模型构建器 | groupModelTools 按原始标签分组、标题本身没有 provider,且 WASM catalog 的 terrain(小写)会 slug 到栅格注册表的 Terrain——所以用 translateModelToolGroup(只在组内至少一个工具来自矢量注册表时翻译),不要直接用 translateToolGroup |
24.9.9 插件文案与错误
| 面 | 机制 |
|---|---|
| 插件显示名 | 解析 toolbar.plugin.<pluginId>;所有展示它的面(Plugins 菜单、命令面板、Settings → Interface、Manage Plugins)都走 lib/plugin-display-name.ts,避免一个菜单是译文、下一个是英文 |
| 插件自己的 UI | 原生 DOM,靠 getLocale/onLocaleChange/translate/registerTranslations(见第21章 21.8.4);内置插件把串放 bundled catalog 下的 plugin.<pluginId>.*,用 createPluginTranslator 解析 |
| 非 UI 模块抛错 | 用 @geolibre/plugins 的 LocalizedError:构造时通过宿主注册的 translator 自译(setLocalizedErrorTranslator 在 hooks/usePlugins.ts 里注册),英文留在 error.englishMessage;纯 GeoLibre 撰写的片段用 localizedMessage(key, english, params),服务端返回的原文保持原样 |
| 校验 | tests/localized-error.test.ts 扫描 packages/plugins/src 里每个调用,键缺失或英文不一致就失败 |
| 注册表派生键 | Deck.gl Layer 对话框用 deckgl-viz/registry.ts 的 id 派生键(addData.deckViz.kinds.<kind>.label|description 等),tests/deck-viz-i18n.test.ts 在 en.json 与注册表不一致时失败 |
24.9.10 已知限制
包了第三方 MapLibre 控件的插件(例如 Esri Wayback 的 maplibre-gl-esri-wayback,文案如 “Only versions with local changes”)由外部库自己渲染,不经过 react-i18next,所以无论选哪种语言都是英文。要译只能上游改(或 fork),在这里的 catalog 里做不到。
24.9.11 新增文案的自查清单
- 键只加进
en.json(源),值写成完整英文句子;组件里用t(),不要拼字符串。 - 保留占位符与标签:
{{name}}、<tokenLink>…</tokenLink>的写法在译文里必须原样出现,否则 catalog 测试失败。 - 需要复数就定义
_one/_other(其它语言按自己的 CLDR 形式),调用时传{ count }。 - 插件相关键用
plugin.<pluginId>.前缀;处理工具相关键用processing.toolMeta.<catalog>.<toolId>,并跑npm run i18n:tools。 - 不重排 catalog 顺序——多份语言已与
en.json漂移,重排的 diff 会把真实改动埋掉。 - 新组件的布局用逻辑工具类;数字/日期用
Intl,不要手拼格式。 - 跑
npm run test:frontend -- tests/i18n-catalogs.test.ts(或全量前端测试)确认没有多余键与占位符差异。
24.10 文档与生成物
24.10.1 文档站
站点用 Zensical(MkDocs Material 团队的后继工具)构建,直接读现有的 mkdocs.yml:
python -m pip install -r requirements-docs.txt
zensical serve # → http://localhost:8000
| 规则 | 说明 |
|---|---|
| 新页面 | 必须加进 mkdocs.yml 的 nav |
| 严格构建 | CI 跑 zensical build --strict:坏链接、或没进导航的页面会让构建失败 |
| 站内链接 | 用相对路径(如 architecture.md) |
| 站外文件 | 用完整 GitHub URL;唯一例外是 geolibre-assets 里的资产,用它的发布 URL |
24.10.2 图片、演示与样例数据
不要把截图、GIF、视频、样例数据提交进主仓库:二进制永远删不干净,会让所有人的 clone 和 CI 永久变大。放到 opengeos/geolibre-assets(纯静态主机,任意文件类型),推到它的 main 后由 GitHub Pages 发布:
| 仓库路径 | 发布 URL |
|---|---|
images/my-panel.webp |
https://assets.geolibre.app/images/my-panel.webp |
demos/my-demo.gif |
https://assets.geolibre.app/demos/my-demo.gif |
data/sample.parquet |
https://assets.geolibre.app/data/sample.parquet |
约定:images/ 放静图、demos/ 放动图与录屏、data/ 放样例数据、styles//fonts/ 放地图资源;静图优先 WebP/AVIF,数据优先 PMTiles/GeoParquet;单文件低于 100 MB(GitHub 硬上限);发布路径事实上永久,改名/删除会打断所有已引用它的页面;资产带宽松 CORS,应用可跨域取。样例数据集另有 data.geolibre.app 托管,那些 URL 继续可用、无需改写。仓库里唯一该存在的图片是 docs/assets/ 下的站点装饰(目前只有 logo/favicon),不要往里加东西。
24.10.3 生成物与跨文件同步
| 生成物 | 生成命令 | 检查命令 | 源 |
|---|---|---|---|
docs/gallery.md 与 docs/demos.md 的展示摘要 |
npm run gallery |
npm run gallery:check |
scripts/demo-gallery.json |
docs/user-guide/plugins.md 的内置插件表 |
npm run plugins:docs |
npm run plugins:docs:check |
usePlugins.ts 的 BUILT_IN_PLUGINS、各插件定义的 id/name、en.json 的 toolbar.plugin、PluginsMenu.tsx 的菜单位置、scripts/plugin-reference.json 里手写的一行描述 |
en.json 的处理工具元数据 |
npm run i18n:tools |
npm run i18n:tools:check |
packages/processing 的四个小型注册表 |
skills/geolibre/(面向使用者的 agent skill) |
手工维护 | python/tests/test_agent_skill.py |
MCP 工具面、basemap/color-ramp/legend catalog、工程 schema、嵌入参数 |
几条容易踩空的规则:
- 插件文档生成器在“注册了但没有描述”时直接失败;
tests/plugin-reference-docs.test.ts也守着同一份表。 - 手改生成区(
<!-- plugin-reference:start/end -->之间)会被下次生成覆盖,改了也白改。 skills/geolibre/无法机械校验的只有散文;尺寸上限、行为限制这类“说明性事实”必须同 PR 手改,否则会把外部 agent 带到已经不存在的路上——而且没有任何失败提示。
24.10.4 文档 PR 自查
| 检查项 | 说明 |
|---|---|
新页面进 nav |
否则 zensical build --strict 失败 |
| 站内链接 | 相对路径,指向 docs/ 内真实存在的 .md |
| 图片 | 用 assets.geolibre.app 的发布 URL,不要把二进制提交进仓库 |
| 生成区 | <!-- plugin-reference:start/end --> 之类区块不要手改,改源后重生成 |
| 代码块 | 标注语言,命令可直接复制 |
24.11 发布与维护要点
24.11.1 依赖更新与安全审计
| 机制 | 说明 |
|---|---|
| Dependabot | .github/dependabot.yml 按周分组开 PR:npm、pip(backend + python/)、cargo、Actions |
npm run audit:ci |
scripts/audit-check.mjs 包 npm audit --omit=dev,仍然对每个 high/critical 失败,除了 ALLOWLIST 里列出的;跑在 CI 的 “Lint and type check” 作业 |
| pip-audit | 对后端解析后的环境跑,不阻塞,在 “Backend tests” 作业 |
| 上游内部实现被手抄的部分 | 升级 maplibre-gl、任一 maplibre-gl-*、geolibre-wasm、@tauri-apps/plugin-http 前先读 docs/maintenance.md 并跑前端测试套件;漂移是静默的 |
24.11.2 发布包与凭据扫描
| 事项 | 说明 |
|---|---|
| npm 发布 | GitHub Release 触发 publish-packages.yml,发布 @geolibre/core、@geolibre/map、@geolibre/embed |
| 入口字段 | 仓库里指向 TS 源码(Vite/tsc/tsx 直接吃),tarball 指向 dist,由 publishConfig + scripts/prepare-npm-package.mjs 在上传前抬升 |
| sidecar 锁 | backend/geolibre_server/uv.lock 必须提交;CI 有 uv lock --check 步骤防止漂移 |
| 凭据扫描 | scripts/scan-credentials.mjs 校验构建输出(build-embed.mjs 暂存 wheel 前、hatch_build.py 打包任何 wheel/sdist 前都跑);规则与 scripts/credential-patterns.json 共用,JS 与 Python 扫描器不会漂移 |
| 白名单哈希 | allowedValueHashes 只放“公开但命中规则”的值(目前是 CesiumJS 内置的默认 Ion token);先解码确认再加哈希,Cesium 升级会让它变化,这是预期 |
| 构建环境变量 | BUILD_ENV_KEYS 是能进 bundle 的 VITE_ 白名单;CREDENTIAL_ENV_KEYS 在可再分发构建里置空 |
24.11.3 棘轮怎么维护
| 棘轮 | 上涨/下降的时机 |
|---|---|
| 覆盖率地板 | 覆盖率明显高于地板时上调,锁住收益 |
| lint 告警上限 | 修掉告警时在同一个 PR 里下调;只有开新规则才允许上调 |
| 测试类型错误数 | 修好后下调;当前为 0 |
| 未测模块基线 | 文件有测试/变短/被删时用 -- --prune 移除;永远不手工新增 |
| 启动包预算 | 只有确认“新代码确实该在启动路径上”才调,并在 PR 里说明 |
24.11.4 版本号从哪来
| 位置 | 版本 |
|---|---|
根 package.json |
应用版本(本教程基于 3.4.0) |
python/src/geolibre/__init__.py |
Python 包的动态版本源(hatch_build.py 从这里取) |
CITATION.cff |
引用信息与 DOI(10.5281/zenodo.20785400) |
改版本时注意:Python 包版本不写在 pyproject.toml 里,而在 __init__.py;发版工作流靠它给 wheel 打版本号。
24.12 CI 失败排查清单
| 症状 | 先看 | 处理 |
|---|---|---|
| “启动包超预算” | bundle-report artifact 里最大的启动 chunk |
改 CODE_SPLITTING_GROUPS/manualChunks,别抬预算 |
| 覆盖率不达标 | 本地 npm run test:frontend:coverage |
若是“新测试把大模块带进分母”,改成测叶子模块 |
| 覆盖率只报行、且重试后过了 | coverage-check.mjs 的日志 |
已知的行覆盖率不确定性;分支/函数与测试失败不重试 |
| 未测模块棘轮 | check:untested-modules 的输出 |
加测试,或 -- --prune 清掉已解决的基线条目 |
ESLint 超过 --max-warnings |
本地 npm run lint |
修告警;修完把上限降到新总数 |
typecheck:tests 失败 |
node scripts/check-test-typecheck.mjs --max-errors 0 --verbose |
按真实类型修 fake,不要抬上限 |
i18n:tools:check / gallery:check / plugins:docs:check |
对应生成命令 | 跑生成器并提交产物;只改源不改产物必红 |
check:shared-deps |
应用与 packages/plugins 的 package.json |
两侧依赖范围对齐 |
| E2E 超时 | playwright-report artifact(含 trace/截图) |
SwiftShader 是 CPU 瓶颈;确认 worker 数,必要时打 full-e2e 重跑 |
| 后端“绿但空” | pytest 输出里的 skip 数 | 装 backend/geolibre_server[test] extra |
cargo check 失败 |
本地 npm run check:rust |
依赖 Rust 版本或 Tauri 配置 |
| “CI passed” 红了但各作业都绿 | ci.yml 的 ci-passed 作业 |
新作业没进 needs 列表 |
24.13 本章小结
- 分支 → 聚焦改动 → 跑门禁 → Conventional Commits → PR;格式化交给 pre-commit 钩子,笔记本输出永不被提交。
- 三个入口:
ci:web(只要 Node)、ci:frontend(构建 + 前端 + worker)、ci(全量含后端与 Rust);CI 把它们拆成并行作业,合并只看 CI passed。 - 前端测试是
node --test+ tsx(不查类型);组件测试用 happy-dom + Dynamic import +useAppStore.setState,mapControllerRef传{ current: null }。 - 后端测试必须先装
testextra,否则大量用例静默跳过;覆盖率为 55%。 - e2e 用 Playwright,分
core/core-engines/features三个项目划分而非重复;CORE_SPECS决定谁能进每提交门禁。 - 四种棘轮(覆盖率、未测模块基线、lint 告警、测试类型错误)+ 一个启动包预算:指标只能变好,修复收益要在同一个 PR 里锁住。
- i18n 的源是
en.json,键被类型化;其它语言懒加载、允许落后、运行时回落;RTL 靠方向注册表 + 逻辑工具类;插件与处理工具各有自己的桥。 - 生成物(gallery、插件表、
i18n:tools、agent skill)必须与源同 PR 更新,否则 CI 或测试会红;文档站--strict会拦坏链接。
24.14 实践练习
- 跑一遍全门禁:在干净 clone 上依次跑
npm run ci:web、npm run test:e2e:core、npm run ci,记录每一步耗时与失败点;对每一步说明它在防什么回归。 - 写一个组件测试:给图层面板加一个“只统计可见图层数”的小组件,用
tests/helpers/dom.ts写测试,覆盖空状态、有图层、切换可见性三种情况;断言只用普通值。 - 制造并修掉一次覆盖率退步:写一个 import 大模块的测试,观察前端覆盖率数字变化;再改成测叶子模块(或抽出叶子模块),使地板重新通过。
- 加一门语言:复制
en.json为pt-BR.json,翻译 20 个常用键(含一个复数键、一个带{{name}}的键),跑tests/i18n-catalogs.test.ts;再验证?locale=pt-BR生效、切换语言后插件面板标题跟随。 - 修一个 RTL 问题:挑一个用了物理方向类的组件,换成逻辑工具类,跑
npm run lint确认没有新增local/no-physical-tailwind告警,并在 RTL 语言下目视检查。 - 加一个 e2e spec:为新功能写一个 spec,判断它该进
core还是features并说明理由;本地分别用--project=core与--project=features跑一次。
24.15 延伸阅读
- 第21章 - 插件系统与插件开发:插件侧 i18n、助手工具与
plugins:docs的来龙去脉。 - 第23章 - 源码架构与二次开发:这些门禁保护的目录结构与数据流。
- 第25章 - 性能优化与故障排查:启动包预算、诊断面板与运行时性能的排查方法。
- 第22章 - 服务端、自托管与部署运维:容器测试与部署策略校验。