claude-translator 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +14 -0
- package/CHANGELOG.md +249 -0
- package/PRIVACY.md +71 -0
- package/README.md +279 -44
- package/bin/claude-translator.mjs +11 -2
- package/bin/cli.test.mjs +20 -1
- package/glossary.example.json +23 -0
- package/i18n.config.example.json +7 -0
- package/package.json +9 -5
- package/scripts/audit-seo.mjs +6 -3
- package/scripts/build-locales.mjs +55 -6
- package/scripts/config.mjs +66 -0
- package/scripts/credit.mjs +12 -5
- package/scripts/extract.mjs +21 -6
- package/scripts/format-locale.mjs +290 -0
- package/scripts/format-locale.test.mjs +171 -0
- package/scripts/glossary.mjs +229 -0
- package/scripts/glossary.test.mjs +188 -0
- package/scripts/providers/openai.mjs +63 -3
- package/scripts/providers/providers.test.mjs +80 -0
- package/scripts/roles.mjs +142 -0
- package/scripts/roles.test.mjs +140 -0
- package/scripts/tqa-score.mjs +127 -0
- package/scripts/tqa-score.test.mjs +144 -0
- package/scripts/tqa.mjs +449 -0
- package/scripts/translate.mjs +104 -13
- package/scripts/verify.mjs +113 -5
- package/{SKILL.md → skills/translate-site/SKILL.md} +45 -13
- package/{references → skills/translate-site/references}/providers.md +16 -2
- package/{references → skills/translate-site/references}/quality-review.md +28 -0
- /package/{references → skills/translate-site/references}/adapting-generators.md +0 -0
- /package/{references → skills/translate-site/references}/failure-modes.md +0 -0
- /package/{references → skills/translate-site/references}/throughput-and-cost.md +0 -0
|
@@ -28,10 +28,14 @@ import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, rmSync
|
|
|
28
28
|
import { join, dirname } from 'path';
|
|
29
29
|
import { fileURLToPath } from 'url';
|
|
30
30
|
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
BUILD_DIR as DIST, SEG_DIR, TM_DIR, BASE_URL as BASE, LOCALES as LANG_ROWS,
|
|
33
|
+
BY_PATH, RTL, getPages, ROOT_DIR, I18N_DIR, LOCALE_FORMAT,
|
|
34
|
+
} from './config.mjs';
|
|
35
|
+
import { formatText, intlLocale } from './format-locale.mjs';
|
|
32
36
|
import { applyPageMarkers, applyVisibleLink, markerBytes } from './credit.mjs';
|
|
33
37
|
|
|
34
|
-
const ROOT =
|
|
38
|
+
const ROOT = ROOT_DIR;
|
|
35
39
|
|
|
36
40
|
const args = Object.fromEntries(
|
|
37
41
|
process.argv
|
|
@@ -270,6 +274,15 @@ console.log(
|
|
|
270
274
|
|
|
271
275
|
let builtPages = 0;
|
|
272
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Every monetary amount seen, across every locale, written to i18n/locale-format.json.
|
|
279
|
+
*
|
|
280
|
+
* This is the deliberate answer to "does it convert currency?" — no, and here is the
|
|
281
|
+
* list so you can decide per market. A price is a commercial commitment; a build script
|
|
282
|
+
* is the wrong thing to be making one on your behalf at a rate it cannot check.
|
|
283
|
+
*/
|
|
284
|
+
const moneyFindings = [];
|
|
285
|
+
|
|
273
286
|
for (const lang of LANGS) {
|
|
274
287
|
const tmFile = join(TM_DIR, `${lang}.json`);
|
|
275
288
|
if (!existsSync(tmFile)) {
|
|
@@ -282,6 +295,8 @@ for (const lang of LANGS) {
|
|
|
282
295
|
rmSync(join(DIST, lang), { recursive: true, force: true });
|
|
283
296
|
|
|
284
297
|
const report = { pages: 0, replaced: 0, untranslated: 0, misses: new Set() };
|
|
298
|
+
const INTL_TAG = intlLocale(BY_PATH[lang]);
|
|
299
|
+
let formatCount = 0;
|
|
285
300
|
|
|
286
301
|
for (const target of targets) {
|
|
287
302
|
const { slug, file, segments } = target;
|
|
@@ -297,11 +312,25 @@ for (const lang of LANGS) {
|
|
|
297
312
|
report.untranslated++;
|
|
298
313
|
continue;
|
|
299
314
|
}
|
|
315
|
+
// Locale conventions are applied HERE, not in the model: rule 4 of the translation
|
|
316
|
+
// prompt tells the model to leave numbers alone precisely so this step can rewrite
|
|
317
|
+
// their presentation deterministically. Values are never changed — see
|
|
318
|
+
// format-locale.mjs for why currency conversion is permanently out of scope.
|
|
319
|
+
let localized = translated;
|
|
320
|
+
if (LOCALE_FORMAT.enabled) {
|
|
321
|
+
const formatted = formatText(translated, INTL_TAG, LOCALE_FORMAT);
|
|
322
|
+
localized = formatted.text;
|
|
323
|
+
formatCount += formatted.changes.length;
|
|
324
|
+
for (const m of formatted.money) {
|
|
325
|
+
moneyFindings.push({ page: slug || '(home)', currency: m.currency, value: m.value, wrote: m.wrote });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
300
329
|
let replacement;
|
|
301
|
-
if (seg.kind === 'block') replacement = renderBlock(
|
|
302
|
-
else if (seg.kind === 'jsonld') replacement = escJson(
|
|
303
|
-
else if (seg.kind.startsWith('attr:')) replacement = escAttr(
|
|
304
|
-
else replacement = escHtml(
|
|
330
|
+
if (seg.kind === 'block') replacement = renderBlock(localized, seg.tags ?? []);
|
|
331
|
+
else if (seg.kind === 'jsonld') replacement = escJson(localized);
|
|
332
|
+
else if (seg.kind.startsWith('attr:')) replacement = escAttr(localized);
|
|
333
|
+
else replacement = escHtml(localized);
|
|
305
334
|
|
|
306
335
|
html = html.slice(0, seg.start) + replacement + html.slice(seg.end);
|
|
307
336
|
report.replaced++;
|
|
@@ -319,6 +348,9 @@ for (const lang of LANGS) {
|
|
|
319
348
|
`${lang}: ${report.pages} pages, ${report.replaced.toLocaleString()} segments replaced, ` +
|
|
320
349
|
`${report.untranslated.toLocaleString()} left in English`
|
|
321
350
|
);
|
|
351
|
+
if (LOCALE_FORMAT.enabled && formatCount) {
|
|
352
|
+
console.log(` ${formatCount.toLocaleString()} number/currency/percent forms rewritten for ${INTL_TAG}`);
|
|
353
|
+
}
|
|
322
354
|
if (report.misses.size) {
|
|
323
355
|
console.log(` ⚠ identity rules that matched nothing: ${[...report.misses].join(', ')}`);
|
|
324
356
|
}
|
|
@@ -327,6 +359,23 @@ for (const lang of LANGS) {
|
|
|
327
359
|
|
|
328
360
|
// Disclosed at the point of action, not buried in a README: this is what the pages
|
|
329
361
|
// now carry, and which key removes it.
|
|
362
|
+
if (LOCALE_FORMAT.enabled) {
|
|
363
|
+
const byCurrency = {};
|
|
364
|
+
for (const m of moneyFindings) byCurrency[m.currency] = (byCurrency[m.currency] ?? 0) + 1;
|
|
365
|
+
writeFileSync(
|
|
366
|
+
join(I18N_DIR, 'locale-format.json'),
|
|
367
|
+
JSON.stringify({ counts: byCurrency, total: moneyFindings.length, findings: moneyFindings }, null, 2)
|
|
368
|
+
);
|
|
369
|
+
if (moneyFindings.length) {
|
|
370
|
+
const summary = Object.entries(byCurrency).map(([c, n]) => `${n} ${c}`).join(', ');
|
|
371
|
+
console.log(
|
|
372
|
+
`\n\u2139 ${moneyFindings.length.toLocaleString()} monetary amount(s) reformatted, never converted (${summary}).` +
|
|
373
|
+
`\n Values are unchanged in every locale. Review i18n/locale-format.json to decide` +
|
|
374
|
+
`\n whether any market needs a different price — this pipeline will not guess one.`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
330
379
|
const attrBytes = markerBytes(BY_PATH[LANGS[0]]);
|
|
331
380
|
if (attrBytes) {
|
|
332
381
|
console.log(
|
package/scripts/config.mjs
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
9
9
|
import { join, resolve } from 'path';
|
|
10
10
|
|
|
11
|
+
import { loadGlossary } from './glossary.mjs';
|
|
12
|
+
|
|
11
13
|
const ROOT = process.env.I18N_ROOT ? resolve(process.env.I18N_ROOT) : process.cwd();
|
|
12
14
|
const CONFIG_PATH = process.env.I18N_CONFIG ? resolve(process.env.I18N_CONFIG) : join(ROOT, 'i18n.config.json');
|
|
13
15
|
|
|
@@ -121,6 +123,70 @@ export const DNT = {
|
|
|
121
123
|
formats: raw.doNotTranslate?.formats ?? ['PDF', 'DOCX', 'XLSX', 'PPTX', 'CSV', 'TXT', 'JSON', 'HTML', 'XML'],
|
|
122
124
|
};
|
|
123
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Glossary — the term base. Array inline, or a path to a JSON file holding one.
|
|
128
|
+
*
|
|
129
|
+
* `doNotTranslate` is folded in as case-sensitive "keep" rules, so every existing config
|
|
130
|
+
* gains word-boundary matching and, for the first time, a check that its brands actually
|
|
131
|
+
* survived translation. Before 2.0 the brand list was matched against the whole trimmed
|
|
132
|
+
* unit and otherwise existed only as a sentence in the prompt; nothing verified the
|
|
133
|
+
* outcome. An explicit glossary entry for the same term wins, so a config can override
|
|
134
|
+
* the inherited default without deleting it from doNotTranslate.
|
|
135
|
+
*/
|
|
136
|
+
export const GLOSSARY = (() => {
|
|
137
|
+
const { terms, problems } = loadGlossary(raw.glossary, ROOT, resolve);
|
|
138
|
+
|
|
139
|
+
const named = new Set(terms.map((t) => t.source));
|
|
140
|
+
const inherited = loadGlossary(
|
|
141
|
+
[...DNT.brands, ...DNT.formats]
|
|
142
|
+
.filter((sourceText) => typeof sourceText === 'string' && sourceText.trim() && !named.has(sourceText))
|
|
143
|
+
.map((sourceText) => ({ source: sourceText, rule: 'keep' })),
|
|
144
|
+
ROOT,
|
|
145
|
+
resolve
|
|
146
|
+
).terms;
|
|
147
|
+
|
|
148
|
+
const all = [...terms, ...inherited].sort((a, b) => b.source.length - a.source.length);
|
|
149
|
+
if (problems.length) {
|
|
150
|
+
for (const p of problems.slice(0, 10)) console.warn(` glossary: ${p}`);
|
|
151
|
+
if (problems.length > 10) console.warn(` glossary: ${problems.length - 10} more`);
|
|
152
|
+
}
|
|
153
|
+
return all;
|
|
154
|
+
})();
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Locale conventions applied to translated text at build time.
|
|
158
|
+
*
|
|
159
|
+
* FORMATTING ONLY. `currency: "format"` rewrites how an amount is written — symbol
|
|
160
|
+
* placement, separators, spacing — and never what it is worth. There is no "convert"
|
|
161
|
+
* value and there is not going to be one: converting a price at a rate baked into a
|
|
162
|
+
* build is how a translation tool starts publishing wrong offers. Amounts are reported
|
|
163
|
+
* to i18n/locale-format.json instead, for a human to price per market.
|
|
164
|
+
*
|
|
165
|
+
* `units` is reserved and unimplemented. It is accepted so a config written now keeps
|
|
166
|
+
* parsing when unit conversion lands, and it does nothing today.
|
|
167
|
+
*/
|
|
168
|
+
export const LOCALE_FORMAT = (() => {
|
|
169
|
+
const raw_ = raw.localeFormat ?? {};
|
|
170
|
+
const cfg = {
|
|
171
|
+
numbers: raw_.numbers ?? true,
|
|
172
|
+
percent: raw_.percent ?? true,
|
|
173
|
+
currency: raw_.currency === 'off' ? 'off' : 'format',
|
|
174
|
+
units: 'off',
|
|
175
|
+
};
|
|
176
|
+
if (raw_.currency && raw_.currency !== 'off' && raw_.currency !== 'format') {
|
|
177
|
+
console.error(
|
|
178
|
+
`config.localeFormat.currency must be "format" or "off" (got "${raw_.currency}"). ` +
|
|
179
|
+
'Currency conversion is deliberately not supported.'
|
|
180
|
+
);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
if (raw_.units && raw_.units !== 'off') {
|
|
184
|
+
console.warn(' localeFormat.units is reserved and not implemented yet — ignored');
|
|
185
|
+
}
|
|
186
|
+
cfg.enabled = cfg.numbers || cfg.percent || cfg.currency === 'format';
|
|
187
|
+
return cfg;
|
|
188
|
+
})();
|
|
189
|
+
|
|
124
190
|
/**
|
|
125
191
|
* Model and provider.
|
|
126
192
|
*
|
package/scripts/credit.mjs
CHANGED
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
* mechanism WordPress, Hugo and Astro use, and it is how this project shows up in
|
|
19
19
|
* technology-adoption surveys.
|
|
20
20
|
*
|
|
21
|
-
* A *visible* credit is available too — `credit.visibleLink` — and it is opt-in,
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* is
|
|
21
|
+
* A *visible* credit is available too — `credit.visibleLink` — and it is opt-in, requires
|
|
22
|
+
* you to place the slot yourself, and earns you nothing: it exists for people who want to
|
|
23
|
+
* credit their tools. It is still `rel="nofollow"`, because a link a build script adds to
|
|
24
|
+
* every page of a site is sitewide-by-tooling rather than editorial, which is the shape
|
|
25
|
+
* Google's link-scheme guidance is aimed at.
|
|
25
26
|
*
|
|
26
27
|
* ── Hints ────────────────────────────────────────────────────────────────────
|
|
27
28
|
* The scripts print a short note when they detect something this pipeline genuinely
|
|
@@ -32,7 +33,13 @@
|
|
|
32
33
|
|
|
33
34
|
import { CREDIT } from './config.mjs';
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Kept as a literal on purpose. `init` vendors these scripts into the user's own project,
|
|
38
|
+
* where the nearest package.json is THEIR application's — reading the version from disk
|
|
39
|
+
* would stamp their app's version into our generator tag. CI asserts this string matches
|
|
40
|
+
* package.json in this repo, which is the only place the two can be compared.
|
|
41
|
+
*/
|
|
42
|
+
export const VERSION = '2.0.0';
|
|
36
43
|
|
|
37
44
|
/**
|
|
38
45
|
* The product name written into every localized page's generator tag.
|
package/scripts/extract.mjs
CHANGED
|
@@ -171,11 +171,19 @@ const hashOf = (s) => createHash('sha1').update(s).digest('hex').slice(0, 16);
|
|
|
171
171
|
const sources = new Map();
|
|
172
172
|
let totalSegments = 0;
|
|
173
173
|
|
|
174
|
-
function record(text, kind, sample) {
|
|
174
|
+
function record(text, kind, sample, el = null) {
|
|
175
175
|
const h = hashOf(text);
|
|
176
176
|
const hit = sources.get(h);
|
|
177
|
-
if (hit)
|
|
178
|
-
|
|
177
|
+
if (hit) {
|
|
178
|
+
hit.count++;
|
|
179
|
+
// The SAME string can appear in a <button> on one page and a <p> on another. The hash
|
|
180
|
+
// is over the text alone, so both share one unit and one translation. Keeping the
|
|
181
|
+
// first element seen would hand the model a confident wrong answer on the other, so a
|
|
182
|
+
// conflict clears the hint instead and the unit is translated as ordinary prose.
|
|
183
|
+
if (hit.el !== el) hit.el = null;
|
|
184
|
+
} else {
|
|
185
|
+
sources.set(h, { text, kind, count: 1, sample, el });
|
|
186
|
+
}
|
|
179
187
|
return h;
|
|
180
188
|
}
|
|
181
189
|
|
|
@@ -285,7 +293,14 @@ function collectAttrs(node, html, segments, pageKey, deep = false) {
|
|
|
285
293
|
segments.push({
|
|
286
294
|
start: aLoc.startOffset + idx,
|
|
287
295
|
end: aLoc.startOffset + idx + attr.value.length,
|
|
288
|
-
hash: record(
|
|
296
|
+
hash: record(
|
|
297
|
+
value,
|
|
298
|
+
`attr:${attr.name}`,
|
|
299
|
+
pageKey,
|
|
300
|
+
// kind flattens every meta tag to "attr:content", but a description behaves
|
|
301
|
+
// nothing like an og:title, so the key travels in el instead.
|
|
302
|
+
node.tagName === 'meta' ? `meta:${attrMap.name || attrMap.property}` : node.tagName
|
|
303
|
+
),
|
|
289
304
|
kind: `attr:${attr.name}`,
|
|
290
305
|
tags: [],
|
|
291
306
|
});
|
|
@@ -394,7 +409,7 @@ function collect(node, html, segments, pageKey) {
|
|
|
394
409
|
segments.push({
|
|
395
410
|
start: innerStart + lead,
|
|
396
411
|
end: innerEnd - trail,
|
|
397
|
-
hash: record(trimmed, 'block', pageKey),
|
|
412
|
+
hash: record(trimmed, 'block', pageKey, tag),
|
|
398
413
|
kind: 'block',
|
|
399
414
|
tags: unit.tags.map((t) => [t.open, t.close]),
|
|
400
415
|
});
|
|
@@ -420,7 +435,7 @@ function collect(node, html, segments, pageKey) {
|
|
|
420
435
|
segments.push({
|
|
421
436
|
start: cLoc.startOffset + lead,
|
|
422
437
|
end: cLoc.startOffset + lead + trimmed.length,
|
|
423
|
-
hash: record(decodeEntities(trimmed), 'text', pageKey),
|
|
438
|
+
hash: record(decodeEntities(trimmed), 'text', pageKey, tag),
|
|
424
439
|
kind: 'text',
|
|
425
440
|
tags: [],
|
|
426
441
|
});
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale conventions — number, percent and currency FORMATTING.
|
|
3
|
+
*
|
|
4
|
+
* ── What this does and, more importantly, does not do ────────────────────────
|
|
5
|
+
* It changes how a quantity is WRITTEN. It never changes what the quantity IS.
|
|
6
|
+
*
|
|
7
|
+
* 1,234.56 -> 1.234,56 (de) formatting yes
|
|
8
|
+
* $5 -> 5 $ (fr) formatting yes
|
|
9
|
+
* $5 -> 4,60 € (fr) conversion NEVER
|
|
10
|
+
*
|
|
11
|
+
* Currency conversion is deliberately absent and will stay absent. A price is a
|
|
12
|
+
* commercial commitment; converting one silently, at a rate that goes stale the day it
|
|
13
|
+
* is written, turns a translation tool into a source of mispriced offers. What the
|
|
14
|
+
* pipeline does instead is REPORT every page where a monetary amount appears, so the
|
|
15
|
+
* site owner can decide per market. That report is i18n/locale-format.json.
|
|
16
|
+
*
|
|
17
|
+
* Unit conversion (in->cm, F->C) is likewise not implemented. The config key is reserved
|
|
18
|
+
* so a config written today does not break when it lands.
|
|
19
|
+
*
|
|
20
|
+
* ── Why this is deterministic and not a prompt rule ──────────────────────────
|
|
21
|
+
* translate.mjs rule 4 tells the model to leave numbers ALONE, and that stays. Models
|
|
22
|
+
* are unreliable at separator conventions and there is no way to verify a per-locale
|
|
23
|
+
* separator choice cheaply. Intl is exact, free, and already in Node. So the model
|
|
24
|
+
* preserves the number and this module re-writes its presentation afterwards, at splice
|
|
25
|
+
* time, where the result can be diffed against the source.
|
|
26
|
+
*
|
|
27
|
+
* ── Why the matching is narrow ───────────────────────────────────────────────
|
|
28
|
+
* Pulling numbers out of prose with a regex is the dangerous part of this file. A
|
|
29
|
+
* greedy pattern will happily "fix" a version number, a time, an IP address or a phone
|
|
30
|
+
* number into nonsense. So a run only touches a number when it is unambiguously a
|
|
31
|
+
* quantity: attached to a currency symbol or code, followed by a percent sign, or
|
|
32
|
+
* written with digit-grouping separators. Everything else is left exactly as it is,
|
|
33
|
+
* which is the correct default for anything this module cannot positively identify.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Currency symbols and ISO codes worth recognising. Symbol first (longest first, so "CA$"
|
|
38
|
+
* beats "$"), then codes. Anything not listed is simply not treated as currency, which
|
|
39
|
+
* means it is left alone — the safe direction.
|
|
40
|
+
*/
|
|
41
|
+
const CURRENCY_SYMBOLS = [
|
|
42
|
+
['CA$', 'CAD'], ['A$', 'AUD'], ['NZ$', 'NZD'], ['HK$', 'HKD'], ['R$', 'BRL'],
|
|
43
|
+
['US$', 'USD'], ['$', 'USD'], ['€', 'EUR'], ['£', 'GBP'], ['¥', 'JPY'],
|
|
44
|
+
['₹', 'INR'], ['₽', 'RUB'], ['₩', 'KRW'], ['₺', 'TRY'], ['₴', 'UAH'],
|
|
45
|
+
['zł', 'PLN'], ['Kč', 'CZK'], ['R', 'ZAR'],
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** Characters that may appear inside a written number: digits, separators, thin spaces. */
|
|
49
|
+
const SEP = '., ';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Contexts in which a run of digits is NOT a quantity we may reformat.
|
|
53
|
+
*
|
|
54
|
+
* Every entry here is a real false positive that a naive pattern produces:
|
|
55
|
+
* 1.2.3 semantic version "Node 20.5.1"
|
|
56
|
+
* 10:30 time "opens at 10:30"
|
|
57
|
+
* 192.168.1.1 IPv4
|
|
58
|
+
* 2026-08-29 ISO date
|
|
59
|
+
* +1-800-555 phone number
|
|
60
|
+
* 1/2 fraction or date part
|
|
61
|
+
* v2.0 version with a prefix
|
|
62
|
+
*/
|
|
63
|
+
const UNSAFE_NEIGHBOUR_BEFORE = /[:\-\/\d]$/;
|
|
64
|
+
const UNSAFE_NEIGHBOUR_AFTER = /^[:\-\/\d]/;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The full extent of a numeric run: digits plus any separator that could be part of the
|
|
68
|
+
* same written number. Matching the WHOLE run matters even when we decide not to touch
|
|
69
|
+
* it, because the scanner must then skip past all of it — re-entering the middle of
|
|
70
|
+
* "192.168.1.1" is precisely how it once produced "1.921.681,1".
|
|
71
|
+
*/
|
|
72
|
+
const NUM_RUN = /^\d+(?:[.,\u00a0\u202f ]\d+)*/;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Is this run a well-formed written number, and if so what is its value?
|
|
76
|
+
*
|
|
77
|
+
* Accepts exactly three shapes, and nothing else:
|
|
78
|
+
* 1234 plain integer
|
|
79
|
+
* 1,234,567 grouped — ONE consistent separator, groups of exactly 3
|
|
80
|
+
* 1,234.56 grouped with a decimal part, using the OTHER separator
|
|
81
|
+
* 1234.56 plain decimal
|
|
82
|
+
*
|
|
83
|
+
* Rejecting everything else is what protects version numbers ("1.2.3" — a group of one
|
|
84
|
+
* digit), IP addresses ("192.168.1.1" — same), and dotted identifiers. A rejected run is
|
|
85
|
+
* emitted verbatim.
|
|
86
|
+
*/
|
|
87
|
+
function parseWritten(raw) {
|
|
88
|
+
if (/^\d+$/.test(raw)) {
|
|
89
|
+
const value = Number(raw);
|
|
90
|
+
return Number.isFinite(value) ? { value, decimals: 0, grouped: false } : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const GROUPED = /^(\d{1,3})((?:[.,\u00a0\u202f ]\d{3})+)(?:([.,])(\d+))?$/;
|
|
94
|
+
const m = GROUPED.exec(raw);
|
|
95
|
+
if (m) {
|
|
96
|
+
const [, head, groups, decSep, decDigits] = m;
|
|
97
|
+
const seps = new Set([...groups.matchAll(/[.,\u00a0\u202f ]/g)].map((x) => x[0]));
|
|
98
|
+
// "1,234.567,89" mixes separators — not a number anyone wrote on purpose.
|
|
99
|
+
if (seps.size !== 1) return null;
|
|
100
|
+
const groupSep = [...seps][0];
|
|
101
|
+
// The decimal mark must differ from the group mark, or "1.234.567" is ambiguous.
|
|
102
|
+
if (decSep && decSep === groupSep) return null;
|
|
103
|
+
const digits = head + groups.replace(/[.,\u00a0\u202f ]/g, '');
|
|
104
|
+
const value = Number(decDigits ? `${digits}.${decDigits}` : digits);
|
|
105
|
+
return Number.isFinite(value) ? { value, decimals: decDigits?.length ?? 0, grouped: true } : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Plain decimal: one separator, 1-3 digits after it. Two decimal places is money;
|
|
109
|
+
// three would be a group, which the branch above already handled.
|
|
110
|
+
const DECIMAL = /^(\d+)([.,])(\d{1,3})$/;
|
|
111
|
+
const d = DECIMAL.exec(raw);
|
|
112
|
+
if (d) {
|
|
113
|
+
const value = Number(`${d[1]}.${d[3]}`);
|
|
114
|
+
return Number.isFinite(value) ? { value, decimals: d[3].length, grouped: false } : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Format one amount for a locale, holding the currency constant.
|
|
122
|
+
* Returns null if Intl refuses the locale or currency, so the caller leaves the text alone.
|
|
123
|
+
*/
|
|
124
|
+
function formatCurrency(value, decimals, currency, locale) {
|
|
125
|
+
try {
|
|
126
|
+
// When the source wrote decimals, keep exactly that many — "$1,234.5" must not gain a
|
|
127
|
+
// digit it did not have. When it wrote none, defer to Intl, which knows that USD
|
|
128
|
+
// shows two and JPY shows none. Forcing 0 produced "1.500 $" for "1,500 USD".
|
|
129
|
+
const opts = decimals > 0
|
|
130
|
+
? { style: 'currency', currency, minimumFractionDigits: decimals, maximumFractionDigits: decimals }
|
|
131
|
+
: { style: 'currency', currency };
|
|
132
|
+
return new Intl.NumberFormat(locale, opts).format(value);
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatNumber(value, decimals, locale) {
|
|
139
|
+
try {
|
|
140
|
+
return new Intl.NumberFormat(locale, {
|
|
141
|
+
minimumFractionDigits: decimals,
|
|
142
|
+
maximumFractionDigits: decimals,
|
|
143
|
+
}).format(value);
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function formatPercent(value, decimals, locale) {
|
|
150
|
+
try {
|
|
151
|
+
// Intl's percent style multiplies by 100, so divide first to keep the printed value.
|
|
152
|
+
return new Intl.NumberFormat(locale, {
|
|
153
|
+
style: 'percent',
|
|
154
|
+
minimumFractionDigits: decimals,
|
|
155
|
+
maximumFractionDigits: decimals,
|
|
156
|
+
}).format(value / 100);
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Rewrite the locale conventions in one translated string.
|
|
164
|
+
*
|
|
165
|
+
* @returns { text, changes, money } — `money` is the amounts seen, for the review report.
|
|
166
|
+
*/
|
|
167
|
+
export function formatText(text, locale, options = {}) {
|
|
168
|
+
const opts = {
|
|
169
|
+
numbers: true,
|
|
170
|
+
percent: true,
|
|
171
|
+
currency: 'format',
|
|
172
|
+
...options,
|
|
173
|
+
};
|
|
174
|
+
if (typeof text !== 'string' || !text) return { text, changes: [], money: [] };
|
|
175
|
+
|
|
176
|
+
const changes = [];
|
|
177
|
+
const money = [];
|
|
178
|
+
let out = '';
|
|
179
|
+
let i = 0;
|
|
180
|
+
|
|
181
|
+
const symbols = CURRENCY_SYMBOLS.slice().sort((a, b) => b[0].length - a[0].length);
|
|
182
|
+
|
|
183
|
+
while (i < text.length) {
|
|
184
|
+
// A placeholder is markup, never prose. Skip it whole so no pattern can reach inside.
|
|
185
|
+
const ph = /^<\/?\d+\/?>/.exec(text.slice(i));
|
|
186
|
+
if (ph) {
|
|
187
|
+
out += ph[0];
|
|
188
|
+
i += ph[0].length;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let matched = false;
|
|
193
|
+
|
|
194
|
+
// ── Currency: symbol before the number ("$1,234.50", "€10") ──────────────
|
|
195
|
+
if (opts.currency === 'format') {
|
|
196
|
+
for (const [sym, code] of symbols) {
|
|
197
|
+
if (!text.startsWith(sym, i)) continue;
|
|
198
|
+
const rest = text.slice(i + sym.length);
|
|
199
|
+
const run = NUM_RUN.exec(rest.replace(/^[\u00a0\u202f ]/, ''));
|
|
200
|
+
if (!run) continue;
|
|
201
|
+
const lead = rest.length - rest.replace(/^[\u00a0\u202f ]/, '').length;
|
|
202
|
+
const raw = run[0];
|
|
203
|
+
const after = rest.slice(lead + raw.length);
|
|
204
|
+
// A dotted or dashed continuation means this was never a price.
|
|
205
|
+
if (UNSAFE_NEIGHBOUR_AFTER.test(after)) break;
|
|
206
|
+
const parsed = parseWritten(raw);
|
|
207
|
+
if (!parsed) break;
|
|
208
|
+
|
|
209
|
+
const formatted = formatCurrency(parsed.value, parsed.decimals, code, locale);
|
|
210
|
+
const original = sym + rest.slice(0, lead) + raw;
|
|
211
|
+
money.push({ currency: code, value: parsed.value, wrote: formatted ?? original });
|
|
212
|
+
if (formatted && formatted !== original) {
|
|
213
|
+
changes.push({ from: original, to: formatted, kind: 'currency' });
|
|
214
|
+
out += formatted;
|
|
215
|
+
} else {
|
|
216
|
+
out += original;
|
|
217
|
+
}
|
|
218
|
+
i += sym.length + lead + raw.length;
|
|
219
|
+
matched = true;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (matched) continue;
|
|
224
|
+
|
|
225
|
+
// ── A numeric run, possibly followed by % or a currency code ─────────────
|
|
226
|
+
const runMatch = NUM_RUN.exec(text.slice(i));
|
|
227
|
+
if (runMatch) {
|
|
228
|
+
const raw = runMatch[0];
|
|
229
|
+
const before = out.slice(-1);
|
|
230
|
+
const rest = text.slice(i + raw.length);
|
|
231
|
+
|
|
232
|
+
const unsafe = UNSAFE_NEIGHBOUR_BEFORE.test(before) || UNSAFE_NEIGHBOUR_AFTER.test(rest);
|
|
233
|
+
const pct = /^[\u00a0\u202f ]?%/.exec(rest);
|
|
234
|
+
const codeAfter = /^[\u00a0\u202f ]?([A-Z]{3})\b/.exec(rest);
|
|
235
|
+
const knownCode = codeAfter && symbols.some(([, c]) => c === codeAfter[1]);
|
|
236
|
+
const parsed = unsafe ? null : parseWritten(raw);
|
|
237
|
+
|
|
238
|
+
if (parsed) {
|
|
239
|
+
if (pct && opts.percent) {
|
|
240
|
+
const formatted = formatPercent(parsed.value, parsed.decimals, locale);
|
|
241
|
+
if (formatted && formatted !== raw + pct[0]) {
|
|
242
|
+
changes.push({ from: raw + pct[0], to: formatted, kind: 'percent' });
|
|
243
|
+
out += formatted;
|
|
244
|
+
i += raw.length + pct[0].length;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
} else if (knownCode && opts.currency === 'format') {
|
|
248
|
+
const formatted = formatCurrency(parsed.value, parsed.decimals, codeAfter[1], locale);
|
|
249
|
+
const original = raw + codeAfter[0];
|
|
250
|
+
money.push({ currency: codeAfter[1], value: parsed.value, wrote: formatted ?? original });
|
|
251
|
+
if (formatted && formatted !== original) {
|
|
252
|
+
changes.push({ from: original, to: formatted, kind: 'currency' });
|
|
253
|
+
out += formatted;
|
|
254
|
+
i += raw.length + codeAfter[0].length;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
} else if (opts.numbers && parsed.grouped) {
|
|
258
|
+
// Only grouped numbers. A bare "2026" or "50" has no separator convention to
|
|
259
|
+
// get wrong, and rewriting it risks mangling a year or a count.
|
|
260
|
+
const formatted = formatNumber(parsed.value, parsed.decimals, locale);
|
|
261
|
+
if (formatted && formatted !== raw) {
|
|
262
|
+
changes.push({ from: raw, to: formatted, kind: 'number' });
|
|
263
|
+
out += formatted;
|
|
264
|
+
i += raw.length;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Emit the WHOLE run untouched and step past all of it. Advancing by less would
|
|
271
|
+
// let the scanner re-enter the middle of something it just declined to format.
|
|
272
|
+
out += raw;
|
|
273
|
+
i += raw.length;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
out += text[i];
|
|
278
|
+
i += 1;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { text: out, changes, money };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The BCP-47 tag to hand Intl for a locale row. `hreflang` is already the right shape
|
|
286
|
+
* ("pt-BR", "zh-Hant"); pathCode is a URL segment ("pt-br") and is not.
|
|
287
|
+
*/
|
|
288
|
+
export function intlLocale(row) {
|
|
289
|
+
return row?.hreflang ?? row?.pathCode ?? 'en';
|
|
290
|
+
}
|