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)`);
}