i18n: generate zh-Hant from zh-CN

zh-Hant is the zh-CN text in Traditional characters, but contributors
still translated it by hand from `--missing zh-Hant`, and a model asked
for Traditional Chinese writes Taiwan vocabulary (影片, 伺服器, 載入).

`npm run i18n -- --zh-hant` now rewrites zh-Hant.json from zh-CN.json
with OpenCC s2tw (opencc-js 1.4.2, cn -> tw, characters only), then the
fixed list of characters it picks wrongly. The check that `npm test`
runs fails when zh-Hant differs from that conversion, naming the key,
and `--missing zh-Hant` points to the command instead of printing a
template.

The conversion reproduces every one of the 3346 entries on main. 25
keys were out of zh-CN order and move; no value changes.

The converter loads on first use, so a copy of the script without
node_modules (stale-index.test.mjs) still extracts messages.
This commit is contained in:
German Khokhlov committed 2026-11-13 18:46:27 +04:00
1 parent 42540ad85e
commit 5d28d614e0
9 files changed
+230 -38

No files matched your search

+56
View File
@@ -3,13 +3,16 @@ import assert from 'node:assert/strict';
import {
checkCatalog,
checkZhHant,
extractMessages,
findUnmarked,
markedTemplate,
readCatalog,
shippedLocales,
toZhHant,
unmarkedMarkup,
unmarkedScript,
zhHantCatalog,
} from '../../scripts/i18n.mjs';
import { LOCALES } from './locale.js';
@@ -36,6 +39,59 @@ for (const locale of shippedLocales()) {
});
}
test('zh-Hant is zh-CN converted to Traditional characters (npm run i18n -- --zh-hant)', () => {
assert.deepEqual(
checkZhHant(readCatalog('zh-CN'), readCatalog('zh-Hant')),
[],
);
});
test('toZhHant converts characters, not vocabulary, and fixes what s2tw picks wrongly', () => {
// s2twp would write 影片, 伺服器 and 載入; zh-Hant keeps the zh-CN words.
assert.equal(toZhHant('视频'), '視頻');
assert.equal(toZhHant('服务器'), '服務器');
assert.equal(toZhHant('加载'), '加載');
assert.equal(toZhHant('50 年历史'), '50 年歷史');
assert.equal(toZhHant('发布于 {date}'), '發布於 {date}');
assert.equal(toZhHant('1 周/秒'), '1 週/秒');
});
test('checkZhHant names each entry that is not the conversion of zh-CN', () => {
const zhCN = {
Video: '视频',
Server: '服务器',
'{count} ships': { other: '{count} 艘船' },
};
const generated = zhHantCatalog(zhCN);
assert.deepEqual(generated, {
Video: '視頻',
Server: '服務器',
'{count} ships': { other: '{count} 艘船' },
});
assert.deepEqual(checkZhHant(zhCN, generated), []);
const fix =
'zh-Hant is generated: edit zh-CN, then run npm run i18n -- --zh-hant';
const { Server, ...withoutServer } = generated;
assert.deepEqual(
checkZhHant(zhCN, {
...withoutServer,
Video: '影片',
'{count} ships': { other: '{count} 艘船隻' },
Extra: '多餘',
}),
[
`"Video" is "影片", not zh-CN converted ("視頻"). ${fix}`,
`"Server" is in zh-CN but not here. ${fix}`,
`"{count} ships" is {"other":"{count} 艘船隻"}, not zh-CN converted ({"other":"{count} 艘船"}). ${fix}`,
`"Extra" is not in zh-CN. ${fix}`,
],
);
assert.deepEqual(checkZhHant(zhCN, { Server, ...withoutServer }), [
'entries leave the zh-CN order at "Video": run npm run i18n -- --zh-hant',
]);
});
test('every UI string is marked for translation or kept on purpose (npm run i18n)', () => {
assert.deepEqual(
findUnmarked().map(({ where, text }) => `${where} ${text}`),
+25 -25
View File
@@ -612,6 +612,7 @@
"Cyclone advisories unavailable": "氣旋公告不可用",
"Cyclone data unavailable": "氣旋數據不可用",
"Potential tropical cyclone": "潛在熱帶氣旋",
"Post-tropical cyclone": "後熱帶氣旋",
"Hurricane": "颶風",
"Tropical storm": "熱帶風暴",
"Tropical depression": "熱帶低壓",
@@ -707,11 +708,15 @@
"Merge onto {road}": "併入 {road}",
"maneuver|Merge": "併道",
"Take the ramp on the {direction} onto {road}": "{direction}駛入匝道,進入 {road}",
"Take the ramp {direction} onto {road}": "{direction}駛入匝道,進入 {road}",
"Take the ramp on the {direction}": "{direction}駛入匝道",
"Take the ramp {direction}": "{direction}駛入匝道",
"Take the ramp onto {road}": "駛入匝道,進入 {road}",
"Take the ramp": "駛入匝道",
"Take the exit on the {direction} onto {road}": "從出口{direction}駛出,進入 {road}",
"Take the exit {direction} onto {road}": "從出口{direction}駛出,進入 {road}",
"Take the exit on the {direction}": "從出口{direction}駛出",
"Take the exit {direction}": "從出口{direction}駛出",
"Take the exit onto {road}": "從出口駛出,進入 {road}",
"Take the exit": "從出口駛出",
"At the roundabout, take the {exit} exit onto {road}": "在環島從{exit}出口駛出,進入 {road}",
@@ -1599,6 +1604,9 @@
"LIVE SIGNALS": "實時信號",
"CCTV layer unavailable": "CCTV 圖層不可用",
"Link copied!": "鏈接已複製!",
"Link shared": "鏈接已分享",
"This share link can't be opened": "無法打開此分享鏈接",
"Shared vessel is unavailable": "分享的船舶不可用",
"Copy failed": "複製失敗",
"Return map to straight-down view": "將地圖恢復為垂直俯視",
"Tilt map to oblique view": "將地圖傾斜至斜視角",
@@ -1952,6 +1960,8 @@
"ORBIT": "環繞",
"Location not found": "未找到位置",
"Search failed": "搜索失敗",
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
"Press Enter to try again.": "按 Enter 重試。",
"Detailed outline unavailable": "無法獲取詳細輪廓",
"Fly to a POI first": "請先飛往一個 POI",
"Reset to full globe view": "重置為完整地球視圖",
@@ -2977,16 +2987,6 @@
"Search": "搜索",
"More": "更多",
"Panels": "面板",
"Post-tropical cyclone": "後熱帶氣旋",
"Take the ramp {direction} onto {road}": "{direction}駛入匝道,進入 {road}",
"Take the ramp {direction}": "{direction}駛入匝道",
"Take the exit {direction} onto {road}": "從出口{direction}駛出,進入 {road}",
"Take the exit {direction}": "從出口{direction}駛出",
"Link shared": "鏈接已分享",
"This share link can't be opened": "無法打開此分享鏈接",
"Shared vessel is unavailable": "分享的船舶不可用",
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
"Press Enter to try again.": "按 Enter 重試。",
"Recent speech": "最近語音",
"Voice Settings": "語音設置",
"Preview": "預覽",
@@ -3025,8 +3025,19 @@
"other": "{count} 個地標"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。",
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
"route proxy error": "路線代理出錯",
"fire confidence filter|All": "全部",
"fire confidence filter|Nominal+": "中+",
"fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有火點探測——許多強度最高的火點被評為低置信度",
"Nominal and high confidence detections": "中、高置信度的火點探測",
"High confidence detections only": "僅高置信度的火點探測",
"Fire intensity (FRP)": "火勢強度(FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "置信度",
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
"fire intensity filter|All": "全部",
"Detection confidence": "探測置信度",
"{shown} of {total} shown": "顯示 {shown} / {total}",
"{lower} to {upper}": "{lower} 至 {upper}",
"Up to {upper}": "最高 {upper}",
"From {lower}": "最低 {lower}",
@@ -3039,6 +3050,8 @@
"Prohibited": "禁區",
"Restricted": "限制區",
"Prohibited & Restricted Airspace": "禁區與限制區空域",
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
"route proxy error": "路線代理出錯",
"AUTHORING": "創作",
"Create and edit scenes": "創建和編輯場景",
"SCENE": "場景",
@@ -3064,19 +3077,6 @@
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
"Left panels": "左側面板",
"Right panels": "右側面板",
"fire confidence filter|All": "全部",
"fire confidence filter|Nominal+": "中+",
"fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有火點探測——許多強度最高的火點被評為低置信度",
"Nominal and high confidence detections": "中、高置信度的火點探測",
"High confidence detections only": "僅高置信度的火點探測",
"Fire intensity (FRP)": "火勢強度(FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "置信度",
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
"fire intensity filter|All": "全部",
"Detection confidence": "探測置信度",
"{shown} of {total} shown": "顯示 {shown} / {total}",
"UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往已繪製的形狀",
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",