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.
Files changed (33) hide show
  1. package/.claude-plugin/plugin.json +14 -0
  2. package/CHANGELOG.md +249 -0
  3. package/PRIVACY.md +71 -0
  4. package/README.md +279 -44
  5. package/bin/claude-translator.mjs +11 -2
  6. package/bin/cli.test.mjs +20 -1
  7. package/glossary.example.json +23 -0
  8. package/i18n.config.example.json +7 -0
  9. package/package.json +9 -5
  10. package/scripts/audit-seo.mjs +6 -3
  11. package/scripts/build-locales.mjs +55 -6
  12. package/scripts/config.mjs +66 -0
  13. package/scripts/credit.mjs +12 -5
  14. package/scripts/extract.mjs +21 -6
  15. package/scripts/format-locale.mjs +290 -0
  16. package/scripts/format-locale.test.mjs +171 -0
  17. package/scripts/glossary.mjs +229 -0
  18. package/scripts/glossary.test.mjs +188 -0
  19. package/scripts/providers/openai.mjs +63 -3
  20. package/scripts/providers/providers.test.mjs +80 -0
  21. package/scripts/roles.mjs +142 -0
  22. package/scripts/roles.test.mjs +140 -0
  23. package/scripts/tqa-score.mjs +127 -0
  24. package/scripts/tqa-score.test.mjs +144 -0
  25. package/scripts/tqa.mjs +449 -0
  26. package/scripts/translate.mjs +104 -13
  27. package/scripts/verify.mjs +113 -5
  28. package/{SKILL.md → skills/translate-site/SKILL.md} +45 -13
  29. package/{references → skills/translate-site/references}/providers.md +16 -2
  30. package/{references → skills/translate-site/references}/quality-review.md +28 -0
  31. /package/{references → skills/translate-site/references}/adapting-generators.md +0 -0
  32. /package/{references → skills/translate-site/references}/failure-modes.md +0 -0
  33. /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 { BUILD_DIR as DIST, SEG_DIR, TM_DIR, BASE_URL as BASE, LOCALES as LANG_ROWS, BY_PATH, RTL, getPages } from './config.mjs';
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 = process.cwd();
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(translated, seg.tags ?? []);
302
- else if (seg.kind === 'jsonld') replacement = escJson(translated);
303
- else if (seg.kind.startsWith('attr:')) replacement = escAttr(translated);
304
- else replacement = escHtml(translated);
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(
@@ -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
  *
@@ -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
- * requires you to place the slot yourself, and is `rel="nofollow"` because turning it
23
- * on earns you something (see README). A compensated link that passes ranking signal
24
- * is exactly what Google asks you not to ship.
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
- export const VERSION = '1.3.0';
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.
@@ -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) hit.count++;
178
- else sources.set(h, { text, kind, count: 1, sample });
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(value, `attr:${attr.name}`, pageKey),
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
+ }