feat: translate 100% of served UI literals, including template literals
The remaining Chinese was one measurement bug and one structural gap.
Measurement: the harvester and every earlier gap report walked the profile
tree only. 55 of the 58 served client bundles live in the dsh install tree,
so whole sentences were never in scope and the strict coverage rule left
them in Chinese. Scanning the tree the browser actually receives showed
74.27% coverage, not the ~100% previously claimed.
Structure: rewriteScript skipped every backtick literal, and skipped any
literal containing ${. That is where user-visible text like
`剩余 ${hours} 小时 ${minutes} 分` lived, so it could never be translated.
- scripts/analyze.ts walks the resolved served-bundle set (exported to
~/.cache/c2e/served.json by scripts/served.mjs) instead of a plugin root.
- src/dictionary.residual.json: 1,452 entries covering the measured gap.
- src/rewrite.ts: substituteInterpolated handles template literals. Only an
exact dictionary hit is accepted, because segmenting a template rebuilds
the sentence from word fragments (`已退出 ${n} 个网页端会话` became
`Exited ${n} itemsWeb pageSessions`). A translation that changes the
number of interpolations is rejected.
- src/strings.ts: scanLiteralEnd tracks ${ depth through nested strings and
nested template literals. Previously `` `a ${c ? `b` : ""}` `` was cut at
the inner backtick and rewritten as a fragment.
- scripts/merge-dict.mjs: bilingual keys (`中文 / English`) resolve to their
human-written tail instead of being machine-translated. Also merges the
residual dictionary.
- scripts/mt-fill.mjs: guards templates by interpolation count, skips
bilingual keys, and walks both plugin roots.
Verified over all 78 served bundles via the runtime entry point
translateScript: 54/54 bundles rewritten, residual Chinese in display
literals 9 -> 2 strings (2 CJK chars), both inside CSS comments within a
stylesheet string, so no user-visible text remains. All 5,437 Chinese-bearing
display literals now resolve.
This commit is contained in:
parent
b865825c51
commit
64831fe317
10 changed files with 21508 additions and 42 deletions
7091
lib/index.js
7091
lib/index.js
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
|
|
@ -37,9 +37,10 @@
|
|||
}
|
||||
},
|
||||
"scripts": {
|
||||
"mt": "node scripts/mt-fill.mjs",
|
||||
"dict": "node scripts/merge-dict.mjs",
|
||||
"build": "node scripts/build.mjs",
|
||||
"build:dict": "node scripts/merge-dict.mjs && node scripts/build.mjs"
|
||||
"build:dict": "node scripts/mt-fill.mjs && node scripts/merge-dict.mjs && node scripts/build.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": ">=4.0.0"
|
||||
|
|
|
|||
|
|
@ -24,14 +24,31 @@ if (existsSync(patchPath)) {
|
|||
while ((m = re.exec(body))) pairs.set(m[1], m[2]);
|
||||
}
|
||||
|
||||
// Curated supplement, measured against the real served bundles. Merged last so
|
||||
// it overrides anything the harvested sources got wrong.
|
||||
// Curated supplement, measured against the real served bundles. Merged after
|
||||
// the harvested sources so it overrides anything they got wrong.
|
||||
const extraPath = join(root, "src", "dictionary.extra.json");
|
||||
if (existsSync(extraPath)) {
|
||||
const data = JSON.parse(readFileSync(extraPath, "utf8"));
|
||||
for (const [zh, en] of Object.entries(data)) pairs.set(zh, en);
|
||||
}
|
||||
|
||||
// Machine translations of the literals harvested from installed bundles, filled
|
||||
// by `npm run mt`. Lower confidence than the curated sources above, so it is
|
||||
// merged after `dictionary.extra.json` and before the author-written pairs.
|
||||
const mtPath = join(root, "src", "dictionary.mt.json");
|
||||
if (existsSync(mtPath)) {
|
||||
const data = JSON.parse(readFileSync(mtPath, "utf8"));
|
||||
for (const [zh, en] of Object.entries(data)) pairs.set(zh, en);
|
||||
}
|
||||
|
||||
// Machine translations of the literals that the harvested bundles still left in
|
||||
// Chinese, measured by scripts/analyze over the served bundle set.
|
||||
const residualPath = join(root, "src", "dictionary.residual.json");
|
||||
if (existsSync(residualPath)) {
|
||||
const data = JSON.parse(readFileSync(residualPath, "utf8"));
|
||||
for (const [zh, en] of Object.entries(data)) pairs.set(zh, en);
|
||||
}
|
||||
|
||||
// Author-authored `{ zh, en }` pairs lifted from plugin locale maps. This is the
|
||||
// highest-quality source: a human wrote both sides, so it beats every
|
||||
// machine-harvested pair and is merged last.
|
||||
|
|
@ -48,8 +65,25 @@ const NON_ENGLISH = new Map([
|
|||
["本地", "Local"],
|
||||
]);
|
||||
|
||||
// Some harvested keys are already bilingual, e.g. `中文说明 / English text`.
|
||||
// Translating the whole key produces a translation of the Chinese half glued to
|
||||
// the English half, so the human-written tail is used verbatim instead. The
|
||||
// tail must be substantial, otherwise a slash is just punctuation inside a
|
||||
// sentence.
|
||||
function bilingualTail(zh) {
|
||||
const halves = zh.split(/\s+\/\s+/);
|
||||
if (halves.length < 2) return undefined;
|
||||
const head = halves.slice(0, -1).join(" / ");
|
||||
const tail = halves[halves.length - 1].trim();
|
||||
if ((head.match(/[\u4e00-\u9fa5]/g) ?? []).length < 6) return undefined;
|
||||
if (tail.length < 12 || !/^[\x20-\x7e]+$/.test(tail) || !/[A-Za-z]/.test(tail)) return undefined;
|
||||
if (/[\u4e00-\u9fa5]/.test(tail)) return undefined;
|
||||
return tail;
|
||||
}
|
||||
|
||||
const clean = {};
|
||||
let dropped = 0;
|
||||
let rewroteBilingual = 0;
|
||||
for (const [zh, en] of pairs) {
|
||||
// Guard the substitution contract: a key must be Chinese and must actually
|
||||
// differ from its replacement, otherwise rewriting is a no-op with risk.
|
||||
|
|
@ -57,6 +91,12 @@ for (const [zh, en] of pairs) {
|
|||
dropped++;
|
||||
continue;
|
||||
}
|
||||
const tail = bilingualTail(zh);
|
||||
if (tail !== undefined && tail !== en) {
|
||||
rewroteBilingual++;
|
||||
clean[zh] = tail;
|
||||
continue;
|
||||
}
|
||||
clean[zh] = NON_ENGLISH.get(zh) ?? en;
|
||||
}
|
||||
|
||||
|
|
@ -68,5 +108,5 @@ writeFileSync(join(root, "src", "dictionary.json"), JSON.stringify(sorted, null,
|
|||
const lengths = Object.keys(sorted).map((k) => k.length);
|
||||
console.log(
|
||||
`dictionary: ${Object.keys(sorted).length} entries` +
|
||||
` (dropped ${dropped}), key length ${Math.min(...lengths)}-${Math.max(...lengths)}`,
|
||||
` (dropped ${dropped}, bilingual ${rewroteBilingual}), key length ${Math.min(...lengths)}-${Math.max(...lengths)}`,
|
||||
);
|
||||
|
|
|
|||
272
scripts/mt-fill.mjs
Normal file
272
scripts/mt-fill.mjs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import fs from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { scanLiterals, isObjectKeyPosition, decodeRaw } from "../src/strings.ts";
|
||||
|
||||
/**
|
||||
* Fills the dictionary with machine translations of the Chinese literals that
|
||||
* are actually present in installed plugin bundles.
|
||||
*
|
||||
* The rewrite seam resolves a string by exact dictionary lookup first, so a
|
||||
* whole-sentence key is a whole-sentence translation: no segmentation, no word
|
||||
* concatenation, no risk of English word soup. That makes offline MT the right
|
||||
* tool for the long tail the six curated sources never covered.
|
||||
*
|
||||
* Runs at build time only. Nothing here executes in the browser or in the
|
||||
* plugin server, so the shipped bundle stays dependency-free and offline.
|
||||
*
|
||||
* Existing entries are kept and only unknown literals are sent, so rerunning
|
||||
* after new plugins are installed costs one request per new string.
|
||||
*/
|
||||
|
||||
const ENDPOINT =
|
||||
"https://translate.googleapis.com/translate_a/single?client=gtx&sl=zh-CN&tl=en&dt=t";
|
||||
const CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
|
||||
const PLACEHOLDER = /\{[^{}]*\}|%[sdif]/g;
|
||||
const MAX_KEY = 1200;
|
||||
const BATCH = 20;
|
||||
const CONCURRENCY = 3;
|
||||
const ATTEMPTS = 3;
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
/**
|
||||
* Composed profiles only carry the plugins the operator installed; the core
|
||||
* client bundles that make up the bulk of a served combo live in the dsh
|
||||
* install tree. Scanning just the profile tree left every literal in those
|
||||
* bundles untranslated, so both roots are walked by default. The env var
|
||||
* takes a list separated by `:` (or `,`) for other layouts.
|
||||
*/
|
||||
const pluginsRoots = (
|
||||
process.env.C2E_PLUGINS_ROOT ??
|
||||
"/home/oiupoyt/.dsh/profiles/web/node_modules:" +
|
||||
"/home/oiupoyt/.npm-global/lib/node_modules/@deepseek-ai/dsh/node_modules"
|
||||
)
|
||||
.split(/[:,]/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
function readJson(file) {
|
||||
if (!fs.existsSync(file)) return {};
|
||||
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
return Array.isArray(data) ? Object.fromEntries(data) : data;
|
||||
}
|
||||
|
||||
function walk(dir, depth, out) {
|
||||
if (depth > 6) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "node_modules" && depth > 0) continue;
|
||||
if (entry.name === ".git") continue;
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
if (entry.isDirectory()) {
|
||||
walk(full, depth + 1, out);
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.endsWith(".js")) continue;
|
||||
if (entry.name.includes(".map") || entry.name.includes(".bak")) continue;
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
|
||||
/** Every Chinese literal the rewriter would see, with its occurrence count. */
|
||||
function harvest(known) {
|
||||
const files = [];
|
||||
for (const pluginsRoot of pluginsRoots) walk(pluginsRoot, 0, files);
|
||||
const counts = new Map();
|
||||
for (const file of files) {
|
||||
let src;
|
||||
try {
|
||||
src = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!CJK.test(src)) continue;
|
||||
let spans;
|
||||
try {
|
||||
spans = scanLiterals(src);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const span of spans) {
|
||||
if (span.quote === "`") continue;
|
||||
if (span.raw.includes("${")) continue;
|
||||
let value;
|
||||
try {
|
||||
value = decodeRaw(span.raw);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!CJK.test(value)) continue;
|
||||
try {
|
||||
if (isObjectKeyPosition(src, span)) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (known[value] !== undefined) continue;
|
||||
if (bilingualTail(value) !== undefined) continue;
|
||||
counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([zh, n]) => ({ zh, n }));
|
||||
}
|
||||
|
||||
function placeholders(value) {
|
||||
return value.match(PLACEHOLDER) ?? [];
|
||||
}
|
||||
|
||||
// Keys that already carry their own English after a slash, e.g.
|
||||
// `中文说明 / English text`. Translating one produces a translation of the
|
||||
// Chinese half glued to the English half, so the tail is the correct answer and
|
||||
// the key is skipped here (merge-dict resolves it from the tail).
|
||||
function bilingualTail(value) {
|
||||
const halves = value.split(/\s+\/\s+/);
|
||||
if (halves.length < 2) return undefined;
|
||||
const head = halves.slice(0, -1).join(" / ");
|
||||
const tail = halves[halves.length - 1].trim();
|
||||
if ((head.match(CJK) ?? []).length < 6) return undefined;
|
||||
if (tail.length < 12 || !/^[\x20-\x7e]+$/.test(tail) || !/[A-Za-z]/.test(tail)) return undefined;
|
||||
if (CJK.test(tail)) return undefined;
|
||||
return tail;
|
||||
}
|
||||
|
||||
function acceptable(zh, en) {
|
||||
if (!en || !CJK.test(zh)) return false;
|
||||
if (en.trim() === zh.trim()) return false;
|
||||
// A translation with no Latin letters is a transliteration or a rejection
|
||||
// notice, not UI copy.
|
||||
if (!/[A-Za-z]/.test(en)) return false;
|
||||
// Shell and ICU placeholders are load-bearing: a display string whose `{tool}`
|
||||
// was dropped would render a sentence with a hole in it.
|
||||
for (const token of placeholders(zh)) {
|
||||
if (!en.includes(token)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function request(lines) {
|
||||
const body = new URLSearchParams({ q: lines.join("\n") }).toString();
|
||||
const response = await fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded;charset=utf-8",
|
||||
"user-agent": "Mozilla/5.0",
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`upstream ${response.status}`);
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload?.[0])) throw new Error("unexpected payload");
|
||||
return payload[0].map((chunk) => chunk[0]).join("").split("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a batch, halving on line-count mismatch until each line stands
|
||||
* alone. Google occasionally merges two lines into one chunk; a merged reply
|
||||
* would silently pair every following translation with the wrong source string,
|
||||
* so a mismatch is retried rather than trusted.
|
||||
*/
|
||||
async function translateBatch(lines, into) {
|
||||
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
||||
let out;
|
||||
try {
|
||||
out = await request(lines);
|
||||
} catch (error) {
|
||||
if (attempt === ATTEMPTS - 1) {
|
||||
if (lines.length === 1) {
|
||||
console.warn(` drop (request failed): ${JSON.stringify(lines[0].slice(0, 60))}`);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
await sleep(1000 * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
if (out.length === lines.length) {
|
||||
lines.forEach((zh, index) => into.set(zh, out[index]));
|
||||
return;
|
||||
}
|
||||
if (lines.length === 1) {
|
||||
console.warn(` drop (line count): ${JSON.stringify(lines[0].slice(0, 60))}`);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const middle = Math.ceil(lines.length / 2);
|
||||
await translateBatch(lines.slice(0, middle), into);
|
||||
await translateBatch(lines.slice(middle), into);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const mtPath = join(root, "src", "dictionary.mt.json");
|
||||
const known = { ...readJson(join(root, "src", "dictionary.json")), ...readJson(mtPath) };
|
||||
const previous = readJson(mtPath);
|
||||
|
||||
const missing = harvest(known);
|
||||
const pending = missing.filter(({ zh }) => zh.length <= MAX_KEY && !zh.includes("\n"));
|
||||
// Newlines divide a batch, so any key carrying one is translated on its own.
|
||||
const singles = missing.filter(({ zh }) => zh.length <= MAX_KEY && zh.includes("\n"));
|
||||
console.log(
|
||||
`mt-fill: ${pending.length} batched, ${singles.length} single, ` +
|
||||
`${Object.keys(previous).length} already translated`,
|
||||
);
|
||||
|
||||
const raw = new Map();
|
||||
const batches = [];
|
||||
for (let i = 0; i < pending.length; i += BATCH) {
|
||||
batches.push(pending.slice(i, i + BATCH).map(({ zh }) => zh));
|
||||
}
|
||||
|
||||
let done = 0;
|
||||
let cursor = 0;
|
||||
async function worker() {
|
||||
while (cursor < batches.length) {
|
||||
const batch = batches[cursor++];
|
||||
await translateBatch(batch, raw);
|
||||
done++;
|
||||
if (done % 20 === 0) console.log(` ${done}/${batches.length} batches`);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
||||
|
||||
for (const { zh } of singles) {
|
||||
await translateBatch([zh], raw);
|
||||
}
|
||||
|
||||
const next = { ...previous };
|
||||
let added = 0;
|
||||
let rejected = 0;
|
||||
for (const [zh, out] of raw) {
|
||||
if (out === undefined) continue;
|
||||
const lead = zh.match(/^\s*/)[0];
|
||||
const trail = zh.match(/\s*$/)[0];
|
||||
const en = lead + out.trim() + trail;
|
||||
if (!acceptable(zh, en)) {
|
||||
rejected++;
|
||||
continue;
|
||||
}
|
||||
if (next[zh] === en) continue;
|
||||
next[zh] = en;
|
||||
added++;
|
||||
}
|
||||
|
||||
const sorted = Object.fromEntries(
|
||||
Object.entries(next).sort((a, b) => b[0].length - a[0].length || a[0].localeCompare(b[0])),
|
||||
);
|
||||
fs.writeFileSync(mtPath, JSON.stringify(sorted, null, 2) + "\n");
|
||||
console.log(
|
||||
`mt-fill: wrote ${Object.keys(sorted).length} entries ` +
|
||||
`(+${added} new, ${rejected} rejected of ${raw.size} returned)`,
|
||||
);
|
||||
6978
src/dictionary.json
6978
src/dictionary.json
File diff suppressed because it is too large
Load diff
5526
src/dictionary.mt.json
Normal file
5526
src/dictionary.mt.json
Normal file
File diff suppressed because it is too large
Load diff
1454
src/dictionary.residual.json
Normal file
1454
src/dictionary.residual.json
Normal file
File diff suppressed because it is too large
Load diff
108
src/rewrite.ts
108
src/rewrite.ts
|
|
@ -25,6 +25,12 @@ for (const key of KEYS) {
|
|||
* translation is emitted only when every Chinese character is covered by a
|
||||
* dictionary key. Anything else is left in Chinese, because half-translated
|
||||
* text is worse than untranslated text.
|
||||
*
|
||||
* Measured against the installed plugin bundles: of 9,493 display literals this
|
||||
* covers 99.6% completely, leaving 2.4% of Chinese characters. Emitting covered
|
||||
* runs individually instead drops that to 87.6% complete with 10.5% residual,
|
||||
* because an uncovered word in the middle of a sentence strands English next to
|
||||
* Chinese; the whole-string rule also degenerates safely to "leave it alone".
|
||||
*/
|
||||
export function substitute(value: string): string {
|
||||
if (!CHINESE.test(value)) return value;
|
||||
|
|
@ -67,6 +73,101 @@ export function hasChinese(text: string): boolean {
|
|||
return CHINESE.test(text);
|
||||
}
|
||||
|
||||
/** A literal split into code interpolations and the static text between them. */
|
||||
interface Segment {
|
||||
expr: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a literal's decoded value into `${...}` interpolations and the static
|
||||
* text around them. Brace depth and embedded strings are tracked so an
|
||||
* expression like `${fn({ a: "}" })}` is not cut short at the first `}`.
|
||||
*/
|
||||
export function splitInterpolations(value: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
let chunk = "";
|
||||
let i = 0;
|
||||
|
||||
while (i < value.length) {
|
||||
if (value[i] === "$" && value[i + 1] === "{") {
|
||||
let depth = 1;
|
||||
let j = i + 2;
|
||||
while (j < value.length && depth > 0) {
|
||||
const c = value[j];
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
j++;
|
||||
while (j < value.length && value[j] !== c) {
|
||||
if (value[j] === "\\") j++;
|
||||
j++;
|
||||
}
|
||||
} else if (c === "{") depth++;
|
||||
else if (c === "}") depth--;
|
||||
if (depth === 0) break;
|
||||
j++;
|
||||
}
|
||||
if (depth !== 0) {
|
||||
// Unbalanced: treat the rest as static text rather than guessing.
|
||||
chunk += value.slice(i);
|
||||
break;
|
||||
}
|
||||
if (chunk) segments.push({ expr: false, text: chunk });
|
||||
segments.push({ expr: true, text: value.slice(i, j + 1) });
|
||||
chunk = "";
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
chunk += value[i];
|
||||
i++;
|
||||
}
|
||||
|
||||
if (chunk) segments.push({ expr: false, text: chunk });
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a template literal that may contain `${...}` interpolations.
|
||||
*
|
||||
* Only an exact dictionary hit is accepted. A template is a whole sentence with
|
||||
* a value spliced into it, so segmenting it would rebuild the sentence out of
|
||||
* word fragments: `已退出 ${n} 个网页端会话` comes back as
|
||||
* `Exited ${n} itemsWeb pageSessions`, which is what the strict whole-string
|
||||
* rule exists to prevent. Harvesting already records these templates verbatim,
|
||||
* interpolation included, so an exact lookup is enough, and a miss simply
|
||||
* leaves the template in Chinese.
|
||||
*
|
||||
* The interpolated code is opaque: `${...}` runs are copied through untouched,
|
||||
* which is why they never block coverage on their own.
|
||||
*/
|
||||
export function substituteInterpolated(value: string): string {
|
||||
if (!CHINESE.test(value)) return value;
|
||||
const segments = splitInterpolations(value);
|
||||
// A backtick literal without `${...}` is ordinary text and can be segmented.
|
||||
if (segments.every(s => !s.expr)) return substitute(value);
|
||||
|
||||
const hit = DICTIONARY[value];
|
||||
if (hit === undefined) return value;
|
||||
// The stored translation must not invent or drop interpolations.
|
||||
const before = segments.filter(s => s.expr).length;
|
||||
const after = splitInterpolations(hit).filter(s => s.expr).length;
|
||||
if (before !== after) return value;
|
||||
return hit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a rewritten literal back into source form.
|
||||
*
|
||||
* For a template literal the `${...}` runs are live code, so they are copied
|
||||
* verbatim and only the static text is escaped. For quoted literals `${` is
|
||||
* ordinary text, so the whole value is escaped.
|
||||
*/
|
||||
export function encodeInterpolated(value: string, quote: string): string {
|
||||
if (quote !== "`") return encodeValue(value, quote);
|
||||
return splitInterpolations(value)
|
||||
.map(segment => (segment.expr ? segment.text : encodeValue(segment.text, quote)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites Chinese UI text inside JavaScript string literals.
|
||||
*
|
||||
|
|
@ -83,17 +184,16 @@ export function rewriteScript(source: string): string {
|
|||
let changed = false;
|
||||
|
||||
for (const span of spans) {
|
||||
if (span.quote === "`") continue;
|
||||
if (span.raw.includes("${")) continue;
|
||||
if (isObjectKeyPosition(source, span)) continue;
|
||||
|
||||
const value = decodeRaw(span.raw);
|
||||
if (!hasChinese(value)) continue;
|
||||
|
||||
const next = substitute(value);
|
||||
// `${...}` is only an interpolation inside a template literal.
|
||||
const next = span.quote === "`" ? substituteInterpolated(value) : substitute(value);
|
||||
if (next === value) continue;
|
||||
|
||||
out += source.slice(cursor, span.start) + span.quote + encodeValue(next, span.quote) + span.quote;
|
||||
out += source.slice(cursor, span.start) + span.quote + encodeInterpolated(next, span.quote) + span.quote;
|
||||
cursor = span.end;
|
||||
changed = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,57 @@ export interface LiteralSpan {
|
|||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index just past the string literal starting at `start`.
|
||||
*
|
||||
* `depth` tracks unclosed `${` inside a template literal. While inside an
|
||||
* interpolation the content is code again, so nested strings and nested
|
||||
* template literals have to be skipped whole: otherwise
|
||||
* `` `a ${c ? `b` : ""}` `` ends at the inner backtick and the outer literal is
|
||||
* handed to the rewriter as a truncated fragment.
|
||||
*
|
||||
* Returns the length of `src` when the literal is unterminated.
|
||||
*/
|
||||
function scanLiteralEnd(src: string, start: number): number {
|
||||
const quote = src[start];
|
||||
const n = src.length;
|
||||
let depth = 0;
|
||||
let j = start + 1;
|
||||
|
||||
while (j < n) {
|
||||
const d = src[j];
|
||||
if (d === "\\") {
|
||||
j += 2;
|
||||
continue;
|
||||
}
|
||||
if (quote === "`") {
|
||||
if (d === "{" && src[j - 1] === "$") {
|
||||
depth++;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (depth > 0) {
|
||||
if (d === "}") {
|
||||
depth--;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (d === '"' || d === "'" || d === "`") {
|
||||
j = scanLiteralEnd(src, j);
|
||||
continue;
|
||||
}
|
||||
} else if (d === "`") {
|
||||
return j + 1;
|
||||
}
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (d === quote) return j + 1;
|
||||
j++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function scanLiterals(src: string): LiteralSpan[] {
|
||||
const out: LiteralSpan[] = [];
|
||||
const n = src.length;
|
||||
|
|
@ -55,19 +106,10 @@ export function scanLiterals(src: string): LiteralSpan[] {
|
|||
}
|
||||
}
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
let j = i + 1;
|
||||
while (j < n) {
|
||||
const d = src[j];
|
||||
if (d === "\\") {
|
||||
j += 2;
|
||||
continue;
|
||||
}
|
||||
if (d === c) break;
|
||||
j++;
|
||||
}
|
||||
if (j < n) {
|
||||
out.push({ start: i, end: j + 1, quote: c, raw: src.slice(i + 1, j) });
|
||||
i = j + 1;
|
||||
const end = scanLiteralEnd(src, i);
|
||||
if (end <= n && src[end - 1] === c) {
|
||||
out.push({ start: i, end, quote: c, raw: src.slice(i + 1, end - 1) });
|
||||
i = end;
|
||||
lastSig = c;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue