From 2213fe7def47916a6dbc23b85981a4776cac08d5 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 17:40:53 +0800 Subject: [PATCH] feedback5 W5S: tParts() for a sentence with markup inside (the translation split at its placeholders, vnode params kept); tools/i18n.mjs extracts its msgids; docs/I18N.md --- docs/I18N.md | 10 ++++++++-- shared/i18n.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ test/i18n.test.js | 22 +++++++++++++++++++++- tools/i18n.mjs | 8 ++++---- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/docs/I18N.md b/docs/I18N.md index 9c6f3efc..1045d862 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -24,11 +24,16 @@ t('还剩 {n} 秒', { n }); // params: {name}; arrays fill {0}, t('同盟密钥为 {ROOM_CODE_LEN} 位字母或数字', { ROOM_CODE_LEN }); tc('toggle', '关闭'); // context: the key 'toggle::关闭' ("Off"), else '关闭' ("Close") const TABLE = { a: N_('独立模拟') }; // module level: N_ only marks it; call t(TABLE.a) where it is shown +html`${tParts('第 {r} 回合 · 最终攻势', { r: html`${r}` })}`; // markup inside a sentence ``` - **Placeholders** are the same in the msgid and the translation: `{name}`, positional `{0}`, and one plural form for English, `{n|one|other}` (`'{n} {n|round|rounds}'`). A value that is an array renders as a list (`、` / `, `). A missing value leaves the placeholder as it is (official templates such as `{0}博士…` pass through untouched). +- **Markup inside a sentence** (a number in ``, a ``): `tParts(msgid, params)` returns the translation split at + its placeholders, with a param that is markup (a vnode, or an array of vnodes) kept as it is — the translator gets the + whole sentence and its word order, the Chinese pieces are the text around the markup exactly as before. Do not split + such a sentence into fragments (`第` … `回合`). - **Game-data names** inside a message are marked `dn(name)` (`{ dn: '…' }`) and translated through the localized data (`tName(name)` does the same for a bare name). **Player names are never translated** — pass them as plain strings. - **Data texts are not msgids**: `getChess(id).name`, `getMode(id).desc`, `data.get('config').tips` … already come in the @@ -59,8 +64,9 @@ present. Use the official EN terms of the game (the EN client's own wording, e.g ### Tools: `node tools/i18n.mjs` -- `extract [paths] [--list]` — per file: the msgids in `t()` / `tc()` / `N_()` / `msg()`, and the Chinese literals still - shown untranslated; the total says how many of those already have a translation (ready for the codemod). +- `extract [paths] [--list]` — per file: the msgids in `t()` / `tc()` / `tParts()` / `N_()` / `msg()`, and the Chinese + literals still shown untranslated; the total says how many of those already have a translation (ready for the + codemod). - `check [paths] [--strict] [--stale]` — msgids the code uses that `public/i18n/en.json` lacks (`--strict` exits 1; `--stale` lists entries no code uses yet). - `codemod [--write]` — wraps a file's Chinese literals: html`` text and attribute values → `${t('…')}`, strings → diff --git a/shared/i18n.js b/shared/i18n.js index cca75be9..e7195634 100644 --- a/shared/i18n.js +++ b/shared/i18n.js @@ -225,6 +225,50 @@ export function tc(context, msgid, params) { return t(id, params); } +/** A tParts() param kept as it is: an object that is not a `{ dn }` name (a vnode), or an array holding one. */ +const isMarkup = (v) => !!v && typeof v === 'object' && !own(v, 'dn') && (!Array.isArray(v) || v.some((x) => isMarkup(x))); + +/** + * A message as pieces for a renderer, for a sentence with markup inside (「第 14 回合」): the translation split at + * its placeholders; a param that is markup (a vnode, or an array holding vnodes) is kept as it is, the others render as + * in t() ({ dn } names, lists, {n|one|other}). Adjacent text joins, empty text is dropped, so the Chinese pieces are the + * text around the markup exactly as written before: + * html`${tParts('第 {r} 回合 · 最终攻势', { r: html`${n}` })}` + * @param {unknown} msgid + * @param {Record | unknown[] | null} [params] + * @returns {unknown[]} strings and the markup params, in the order of the translation + */ +export function tParts(msgid, params) { + const id = msgid == null ? '' : String(msgid); + let text = id; + if (current !== DEFAULT_LANG) { + const tr = catalogs.get(current)?.get(id); + if (tr) text = tr; + } + /** @type {unknown[]} */ + const out = []; + const push = (s) => { + if (s === '') return; + if (typeof out[out.length - 1] === 'string') out[out.length - 1] += s; + else out.push(s); + }; + let last = 0; + if (params && typeof params === 'object') { + for (const m of text.matchAll(PLACEHOLDER)) { + const [whole, key, one, other] = m; + if (!own(params, key)) continue; + push(text.slice(last, m.index)); + const v = /** @type {any} */ (params)[key]; + if (one !== undefined) push(Number(v) === 1 ? one : other); + else if (isMarkup(v)) out.push(v); + else push(renderValue(v, current, nameResolver)); + last = m.index + whole.length; + } + } + push(text.slice(last)); + return out; +} + /** * Mark a string as a msgid without translating it (module-level tables: translate with t() where it is shown). * @template {string} S diff --git a/test/i18n.test.js b/test/i18n.test.js index 79324417..dedbde18 100644 --- a/test/i18n.test.js +++ b/test/i18n.test.js @@ -8,7 +8,7 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { - t, tc, N_, setLang, getLang, onLangChange, addMessages, setMessages, hasMessage, normalizeLang, format, setNameResolver, + t, tc, tParts, N_, setLang, getLang, onLangChange, addMessages, setMessages, hasMessage, normalizeLang, format, setNameResolver, tName, dn, msg, renderMessage, wireMessage, translateWire, DEFAULT_LANG, } from '../shared/i18n.js'; import { templateMsgid, paramName, quote, codemodSource, scanSource } from '../tools/i18n.mjs'; @@ -56,6 +56,22 @@ test('tc(): a context key wins, else the plain msgid; Chinese shows the msgid', assert.equal(N_('标记'), '标记', 'N_ only marks a msgid'); }); +test('tParts(): a sentence with markup inside — the translation split at its placeholders, object params kept as they are', () => { + const b = { type: 'b', props: { children: 14 } }; // a vnode stand-in + addMessages('en', { '第 {r} 回合 · 最终攻势': 'Final Assault in round {r}', '{n} 名{who}已调整': '{n} {n|operator|operators} of {who} adjusted' }); + assert.deepEqual(tParts('第 {r} 回合 · 最终攻势', { r: b }), ['第 ', b, ' 回合 · 最终攻势'], 'zh: the text around the markup as written'); + assert.deepEqual(tParts('只有文字'), ['只有文字']); + assert.deepEqual(tParts('{a}{b}', { a: '甲', b: 2 }), ['甲2'], 'plain values join the text around them'); + assert.deepEqual(tParts('{r}', { r: b }), [b], 'empty text is dropped'); + assert.deepEqual(tParts('留着 {x}', {}), ['留着 {x}'], 'a missing value keeps the placeholder'); + assert.deepEqual(tParts('禁用{names}盟约', { names: [b, b] }), ['禁用', [b, b], '盟约'], 'an array of vnodes is markup too'); + assert.deepEqual(tParts('{names}', { names: ['甲', dn('乙')] }), ['甲、乙'], 'an array of texts is a list'); + setLang('en'); + assert.deepEqual(tParts('第 {r} 回合 · 最终攻势', { r: b }), ['Final Assault in round ', b], 'the English word order'); + setNameResolver((name) => ({ 阿米娅: 'Amiya' })[name] || name); + assert.deepEqual(tParts('{n} 名{who}已调整', { n: 1, who: dn('阿米娅') }), ['1 operator of Amiya adjusted'], 'plurals and data names as in t()'); +}); + test('data names: { dn } params and tName go through the resolver, only outside Chinese; player names never do', () => { setNameResolver((name) => ({ 琳琅诗怀雅: 'Swire the Elegant Wit', 阿米娅: 'Amiya' })[name] || name); addMessages('en', { '{names}只能部署在召唤者攻击范围内,已退回整备区': '{names} were returned to the Bench', '{name}博士中途退出了模拟': 'Dr. {name} left the simulation' }); @@ -160,4 +176,8 @@ test('tools/i18n.mjs: msgids of template literals name their params; the codemod assert.match(out.src, />\$\{t\('你好'\)\} \$\{name \|\| t\('博士'\)\} \$\{t\('世界'\)\}/); assert.match(out.src, /<\/b> \$\{t\('回合'\)\}<\/p>/); assert.equal(out.manual.length, 1); + // tParts() is a msgid call: its msgid is extracted, the text inside is not a literal left to wrap + const scanned = await scanSource("const v = html`${tParts('第 {r} 回合', { r: html`${n}` })}`;", 'x.js'); + assert.deepEqual(scanned.msgids.map((m) => [m.msgid, m.via]), [['第 {r} 回合', 'tParts']]); + assert.deepEqual(scanned.literals.filter((l) => !l.reason), []); }); diff --git a/tools/i18n.mjs b/tools/i18n.mjs index efcd7227..b14ab8f0 100644 --- a/tools/i18n.mjs +++ b/tools/i18n.mjs @@ -2,7 +2,7 @@ // tools/i18n.mjs — UI string tooling for the gettext-style i18n (shared/i18n.js, public/i18n/.json; docs/I18N.md). // // node tools/i18n.mjs extract [paths…] [--list] [--json] -// per file: the msgids passed to t() / tc() / N_() / msg(), and the Chinese literals still shown untranslated +// per file: the msgids passed to t() / tc() / tParts() / N_() / msg(), and the Chinese literals still shown untranslated // (string literals, template literals, html`` text and attribute values outside those calls; comments, // console.* and Error messages are ignored). --list prints each literal with its line. // node tools/i18n.mjs check [paths…] [--lang en] [--strict] [--stale] @@ -34,14 +34,14 @@ const HAN = /[\u3400-\u9fff\uf900-\ufaff]/; const DEFAULT_ROOTS = ['public/js', 'shared', 'server']; const SKIP_DIRS = new Set(['node_modules', 'vendor', 'assets', 'fonts', 'dev']); /** Calls whose first argument is a msgid (tc: the second, keyed `context::msgid`). */ -const MSGID_CALLS = new Set(['t', 'N_', 'msg']); +const MSGID_CALLS = new Set(['t', 'tParts', 'N_', 'msg']); /** * Server messaging methods (m.toast, ctx.toast, this.tickerText …, called on an object): a Chinese string literal they * get is sent as text and translated by the client as a msgid (main.js translateWire). */ const SERVER_TEXT_CALLS = new Set(['toast', 'tickerText', 'ticker']); /** Calls whose arguments are never UI text to wrap. */ -const SKIP_CALLS = new Set(['t', 'tc', 'N_', 'msg', 'tName', 'dn', 'format', 'renderMessage', 'require', 'import']); +const SKIP_CALLS = new Set(['t', 'tc', 'tParts', 'N_', 'msg', 'tName', 'dn', 'format', 'renderMessage', 'require', 'import']); /** String methods: a literal argument is data, not display text. */ const STRING_METHODS = new Set(['includes', 'startsWith', 'endsWith', 'indexOf', 'lastIndexOf', 'replace', 'replaceAll', 'split', 'match', 'matchAll', 'test', 'search', 'has', 'get', 'set', 'delete', 'localeCompare', 'padStart', 'padEnd', 'join', 'add']); @@ -560,7 +560,7 @@ async function cmdExtract(flags) { } const cat = readCatalog(flags.lang || 'en'); const ready = rows.reduce((n, r) => n + r.literals.filter((l) => typeof cat[l.msgid] === 'string' && cat[l.msgid]).length, 0); - console.log(`total: ${tCount} msgids in t() / tc() / N_() / msg(); ${litCount} Chinese literals not wrapped, in ${rows.filter((r) => r.untranslated).length} files` + console.log(`total: ${tCount} msgids in t() / tc() / tParts() / N_() / msg(); ${litCount} Chinese literals not wrapped, in ${rows.filter((r) => r.untranslated).length} files` + ` (${ready} of them already translated in public/i18n/${flags.lang || 'en'}.json, ready for the codemod)`); }