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
This commit is contained in:
1 parent
e2bb090f44
commit
2213fe7def
4 files changed
+77
-7
No files matched your search
+8
-2
@@ -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`<span>${tParts('第 {r} 回合 · 最终攻势', { r: html`<b class="num">${r}</b>` })}</span>`; // 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 `<b>`, a `<kbd>`): `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 <files> [--write]` — wraps a file's Chinese literals: html`` text and attribute values → `${t('…')}`, strings →
|
||||
|
||||
@@ -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 (「第 <b>14</b> 回合」): 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`<span>${tParts('第 {r} 回合 · 最终攻势', { r: html`<b class="num">${n}</b>` })}</span>`
|
||||
* @param {unknown} msgid
|
||||
* @param {Record<string, unknown> | 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
|
||||
|
||||
+21
-1
@@ -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\('世界'\)\}<b>/);
|
||||
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`<span>${tParts('第 {r} 回合', { r: html`<b>${n}</b>` })}</span>`;", 'x.js');
|
||||
assert.deepEqual(scanned.msgids.map((m) => [m.msgid, m.via]), [['第 {r} 回合', 'tParts']]);
|
||||
assert.deepEqual(scanned.literals.filter((l) => !l.reason), []);
|
||||
});
|
||||
+4
-4
@@ -2,7 +2,7 @@
|
||||
// tools/i18n.mjs — UI string tooling for the gettext-style i18n (shared/i18n.js, public/i18n/<lang>.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)`);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user