champollion 0.3.3
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/LICENSE +133 -0
- package/README.md +387 -0
- package/bin/cli.js +278 -0
- package/index.js +135 -0
- package/lib/api-key.js +127 -0
- package/lib/autofix.js +432 -0
- package/lib/bridge/method_bridge.py +430 -0
- package/lib/card-source-resolution.mjs +284 -0
- package/lib/cards/cache.js +169 -0
- package/lib/cards/env.js +82 -0
- package/lib/cards/fetch-card-child.js +38 -0
- package/lib/cards/reader.js +435 -0
- package/lib/cards/refresh.js +111 -0
- package/lib/cards/remote.js +387 -0
- package/lib/cldf-export.mjs +540 -0
- package/lib/cldf-terms.mjs +62 -0
- package/lib/command-help.js +790 -0
- package/lib/commands/audit.js +49 -0
- package/lib/commands/card.js +454 -0
- package/lib/commands/doctor.js +559 -0
- package/lib/commands/fonts.js +489 -0
- package/lib/commands/help.js +91 -0
- package/lib/commands/init.js +1259 -0
- package/lib/commands/integrity.js +148 -0
- package/lib/commands/leaderboard.js +478 -0
- package/lib/commands/lint.js +30 -0
- package/lib/commands/models.js +177 -0
- package/lib/commands/plugin.js +103 -0
- package/lib/commands/provenance.js +45 -0
- package/lib/commands/recommend.js +75 -0
- package/lib/commands/register-corpus.js +678 -0
- package/lib/commands/repair-script.js +42 -0
- package/lib/commands/seal-corpus.js +355 -0
- package/lib/commands/seo.js +72 -0
- package/lib/commands/serve.js +147 -0
- package/lib/commands/status.js +265 -0
- package/lib/commands/submit.js +332 -0
- package/lib/commands/sync.js +89 -0
- package/lib/commands/tm.js +573 -0
- package/lib/commands/verify.js +39 -0
- package/lib/commands/watch.js +20 -0
- package/lib/commands/wrap.js +138 -0
- package/lib/commands/xliff.js +327 -0
- package/lib/commercial-eligibility.js +235 -0
- package/lib/concurrent.js +87 -0
- package/lib/config.js +523 -0
- package/lib/contamination-lane.js +76 -0
- package/lib/content-sync.js +731 -0
- package/lib/content.js +733 -0
- package/lib/corpus-registration.mjs +608 -0
- package/lib/cost-report.js +346 -0
- package/lib/diff.js +155 -0
- package/lib/docusaurus-sync.js +1256 -0
- package/lib/flatten.js +55 -0
- package/lib/format.js +954 -0
- package/lib/hash.js +159 -0
- package/lib/icu.js +473 -0
- package/lib/integrity.js +689 -0
- package/lib/license-gate.mjs +478 -0
- package/lib/license-identify.mjs +229 -0
- package/lib/lint.js +629 -0
- package/lib/method-manifest.js +60 -0
- package/lib/methods/anthropic.js +140 -0
- package/lib/methods/apertium.js +163 -0
- package/lib/methods/api.js +316 -0
- package/lib/methods/base.js +184 -0
- package/lib/methods/content-separator.js +45 -0
- package/lib/methods/deepl.js +426 -0
- package/lib/methods/direct-llm.js +586 -0
- package/lib/methods/external.js +332 -0
- package/lib/methods/fetch-with-retry.js +124 -0
- package/lib/methods/gemini.js +147 -0
- package/lib/methods/google-translate.js +402 -0
- package/lib/methods/http-utils.js +122 -0
- package/lib/methods/libretranslate.js +314 -0
- package/lib/methods/llm-coached.js +670 -0
- package/lib/methods/llm.js +592 -0
- package/lib/methods/local.js +76 -0
- package/lib/methods/microsoft-translator.js +331 -0
- package/lib/methods/openai.js +131 -0
- package/lib/methods/openrouter-client.js +327 -0
- package/lib/methods/openrouter-pricing.js +156 -0
- package/lib/methods/provider-env.js +115 -0
- package/lib/methods/provider-pricing.js +310 -0
- package/lib/methods/tilde.js +150 -0
- package/lib/methods/translated.js +229 -0
- package/lib/methods/translation-error.js +80 -0
- package/lib/models.js +258 -0
- package/lib/no-translate.js +233 -0
- package/lib/output.js +238 -0
- package/lib/pairs.js +547 -0
- package/lib/plugins.js +447 -0
- package/lib/provenance.js +323 -0
- package/lib/recommend.js +648 -0
- package/lib/registers.js +1185 -0
- package/lib/repair-script.js +266 -0
- package/lib/scripts.js +994 -0
- package/lib/seal.mjs +464 -0
- package/lib/sealed-qualifier.mjs +211 -0
- package/lib/security.js +59 -0
- package/lib/segment.js +369 -0
- package/lib/seo.js +275 -0
- package/lib/serve.js +854 -0
- package/lib/string-classify.js +85 -0
- package/lib/submit.mjs +344 -0
- package/lib/sync.js +969 -0
- package/lib/tags/bcp47.js +202 -0
- package/lib/tags/resolve.js +314 -0
- package/lib/terminology.js +111 -0
- package/lib/tm-seed.js +294 -0
- package/lib/tm.js +515 -0
- package/lib/translate-pair.js +197 -0
- package/lib/translate.js +203 -0
- package/lib/types.js +230 -0
- package/lib/validate.js +510 -0
- package/lib/verify.js +451 -0
- package/lib/watch.js +145 -0
- package/lib/xliff.js +184 -0
- package/package.json +93 -0
- package/shared/ATTRIBUTION.md +145 -0
- package/shared/CORPORA-CARDS.md +288 -0
- package/shared/DATA-SOVEREIGNTY.md +500 -0
- package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
- package/shared/card-lint-baseline.json +3189 -0
- package/shared/cards-fallback.json +1 -0
- package/shared/catalogue/card-config.json +6091 -0
- package/shared/catalogue/external-results.json +3888 -0
- package/shared/catalogue/gender-guidance.json +1038 -0
- package/shared/catalogue/method-coverage.json +1751 -0
- package/shared/catalogue/metric-coverage.json +170 -0
- package/shared/catalogue/metric-reliability.json +1 -0
- package/shared/catalogue/register-presets.json +3180 -0
- package/shared/catalogue/vitality-scales.json +55 -0
- package/shared/cldr-index.json +1115 -0
- package/shared/code-bridge.json +253 -0
- package/shared/corpora-cards-v1-reference.md +281 -0
- package/shared/curated-dictionary-flags.json +35 -0
- package/shared/curated-endonyms.json +35 -0
- package/shared/curated-fsts.json +51 -0
- package/shared/curated-orthography-conventions.json +26 -0
- package/shared/curated-sil-resources.json +374 -0
- package/shared/curated-tools.json +41 -0
- package/shared/docent/corpus.json +11333 -0
- package/shared/docent/faq.en.json +564 -0
- package/shared/docent/register-blocks.json +60 -0
- package/shared/docent/system-prompt.md +144 -0
- package/shared/domain-taxonomy.json +35 -0
- package/shared/explainers/glossary.json +2975 -0
- package/shared/explainers/tc-features.json +20112 -0
- package/shared/explainers/term-watchlist.json +147 -0
- package/shared/human-services.json +59 -0
- package/shared/license-corrections.json +261 -0
- package/shared/license-evidence.json +13452 -0
- package/shared/licenses.json +6781 -0
- package/shared/method-registry.json +236 -0
- package/shared/metric-registry.json +620 -0
- package/shared/model-aliases.json +7 -0
- package/shared/schemas/champollion-plugin.schema.json +206 -0
- package/shared/schemas/corpora-card.schema.json +957 -0
- package/shared/schemas/domain-taxonomy.schema.json +64 -0
- package/shared/schemas/external-results.schema.json +314 -0
- package/shared/schemas/human-services.schema.json +90 -0
- package/shared/schemas/language-card.schema.json +1308 -0
- package/shared/schemas/licenses.schema.json +155 -0
- package/shared/schemas/method-card.schema.json +412 -0
- package/shared/schemas/method-registry.schema.json +85 -0
- package/shared/schemas/metric-registry.schema.json +96 -0
- package/shared/schemas/metric-reliability.schema.json +178 -0
- package/shared/schemas/model-aliases.schema.json +27 -0
- package/shared/schemas/source-snapshot.schema.json +96 -0
package/lib/hash.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source content hash manifest — detects when English copy changes.
|
|
3
|
+
*
|
|
4
|
+
* HOW IT WORKS:
|
|
5
|
+
* After each successful sync, we store a SHA-256 hash of every source
|
|
6
|
+
* value in a lock file (.champollion.lock). On the next sync, we
|
|
7
|
+
* compare the current source values against the stored hashes. Any
|
|
8
|
+
* key whose hash differs means the English copy changed and all
|
|
9
|
+
* translations for that key are now stale.
|
|
10
|
+
*
|
|
11
|
+
* WHY:
|
|
12
|
+
* Without this, changing "Ship your product" to "Launch your product"
|
|
13
|
+
* in en.json leaves every target locale with the old translation.
|
|
14
|
+
* The diff engine only detects missing keys and [EN] fallbacks — not
|
|
15
|
+
* content mutations. This hash layer closes that gap automatically.
|
|
16
|
+
*
|
|
17
|
+
* FILE FORMAT:
|
|
18
|
+
* .champollion.lock is a JSON file mapping dot-notation keys to
|
|
19
|
+
* their SHA-256 hash. It should be committed to version control so
|
|
20
|
+
* that all developers and CI share the same baseline.
|
|
21
|
+
*
|
|
22
|
+
* {
|
|
23
|
+
* "nav.home": "a1b2c3...",
|
|
24
|
+
* "nav.about": "d4e5f6...",
|
|
25
|
+
* ...
|
|
26
|
+
* }
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import crypto from 'node:crypto';
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
|
|
33
|
+
const LOCK_FILENAME = '.champollion.lock';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compute a SHA-256 hash of a value.
|
|
37
|
+
* Non-string values are JSON-serialized before hashing to ensure
|
|
38
|
+
* deterministic comparison for arrays, numbers, booleans, etc.
|
|
39
|
+
*
|
|
40
|
+
* @param {*} value - The value to hash
|
|
41
|
+
* @returns {string} Hex-encoded SHA-256 hash
|
|
42
|
+
*/
|
|
43
|
+
function hashValue(value) {
|
|
44
|
+
const input = typeof value === 'string' ? value : JSON.stringify(value);
|
|
45
|
+
return crypto.createHash('sha256').update(input, 'utf-8').digest('hex');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build a hash manifest from a flattened source locale.
|
|
50
|
+
* Maps each key to the SHA-256 hash of its value.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} sourceFlat - Flattened source locale (key → value)
|
|
53
|
+
* @returns {object} Hash manifest (key → hash)
|
|
54
|
+
*/
|
|
55
|
+
function buildHashManifest(sourceFlat) {
|
|
56
|
+
const manifest = {};
|
|
57
|
+
for (const [key, value] of Object.entries(sourceFlat)) {
|
|
58
|
+
manifest[key] = hashValue(value);
|
|
59
|
+
}
|
|
60
|
+
return manifest;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Detect keys whose source content has changed since the last sync.
|
|
65
|
+
* Compares the current source values against a previously stored manifest.
|
|
66
|
+
*
|
|
67
|
+
* Returns only keys that:
|
|
68
|
+
* - Exist in BOTH the current source AND the previous manifest
|
|
69
|
+
* - Have a DIFFERENT hash (meaning the English copy changed)
|
|
70
|
+
*
|
|
71
|
+
* Keys that are new (not in the old manifest) are already caught by
|
|
72
|
+
* the "missing" detection in diffLocale. Keys that were removed are
|
|
73
|
+
* irrelevant — they won't be translated anyway.
|
|
74
|
+
*
|
|
75
|
+
* @param {object} sourceFlat - Current flattened source locale
|
|
76
|
+
* @param {object} oldManifest - Previously stored hash manifest
|
|
77
|
+
* @returns {string[]} Keys whose source content changed
|
|
78
|
+
*/
|
|
79
|
+
function detectChangedKeys(sourceFlat, oldManifest) {
|
|
80
|
+
const changed = [];
|
|
81
|
+
for (const [key, value] of Object.entries(sourceFlat)) {
|
|
82
|
+
const oldHash = oldManifest[key];
|
|
83
|
+
// Only flag keys that existed before AND have a different hash.
|
|
84
|
+
// New keys (not in oldManifest) are handled by diffLocale's "missing" logic.
|
|
85
|
+
if (oldHash && oldHash !== hashValue(value)) {
|
|
86
|
+
changed.push(key);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return changed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read the hash manifest from disk.
|
|
94
|
+
* Returns an empty object if the file doesn't exist (first run).
|
|
95
|
+
*
|
|
96
|
+
* @param {string} cwd - Project root directory
|
|
97
|
+
* @returns {object} Hash manifest (key → hash), or {} if no lock file
|
|
98
|
+
*/
|
|
99
|
+
function readManifest(cwd) {
|
|
100
|
+
const lockPath = path.join(cwd, LOCK_FILENAME);
|
|
101
|
+
|
|
102
|
+
if (!fs.existsSync(lockPath)) {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// FAIL LOUD. Returning {} means "no key has ever been synced", so the
|
|
110
|
+
// very next sync treats every key as changed and re-translates the whole
|
|
111
|
+
// project at full API cost. A `[WARN]` that says "it will be regenerated"
|
|
112
|
+
// reads as routine housekeeping and gives no hint of that bill.
|
|
113
|
+
if (process.env.CHAMPOLLION_ALLOW_CACHE_RESET === '1') {
|
|
114
|
+
console.error(
|
|
115
|
+
`[WARN] ${LOCK_FILENAME} is unreadable (${err.message}). `
|
|
116
|
+
+ `CHAMPOLLION_ALLOW_CACHE_RESET=1 — treating this as a first run; `
|
|
117
|
+
+ `every key will be re-translated at full cost.`,
|
|
118
|
+
);
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
const e = new Error(
|
|
122
|
+
`Lock file is unreadable: ${err.message}\n\n`
|
|
123
|
+
+ ` ${lockPath}\n\n`
|
|
124
|
+
+ `Refusing to continue: treating this as a first run would mark every `
|
|
125
|
+
+ `key as changed and re-translate the whole project at full API cost.\n\n`
|
|
126
|
+
+ ` • Restore the file from version control if you can, or\n`
|
|
127
|
+
+ ` • delete it and re-run to accept the re-translation cost, or\n`
|
|
128
|
+
+ ` • re-run with CHAMPOLLION_ALLOW_CACHE_RESET=1 to do that in place.`,
|
|
129
|
+
);
|
|
130
|
+
e.code = 'CHAMPOLLION_LOCK_UNREADABLE';
|
|
131
|
+
throw e;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Write the hash manifest to disk.
|
|
137
|
+
* Sorts keys alphabetically for stable, diff-friendly output.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} cwd - Project root directory
|
|
140
|
+
* @param {object} manifest - Hash manifest (key → hash)
|
|
141
|
+
*/
|
|
142
|
+
function writeManifest(cwd, manifest) {
|
|
143
|
+
const lockPath = path.join(cwd, LOCK_FILENAME);
|
|
144
|
+
// Sort keys for deterministic output — makes git diffs clean
|
|
145
|
+
const sorted = {};
|
|
146
|
+
for (const key of Object.keys(manifest).sort()) {
|
|
147
|
+
sorted[key] = manifest[key];
|
|
148
|
+
}
|
|
149
|
+
fs.writeFileSync(lockPath, JSON.stringify(sorted, null, 2) + '\n', 'utf-8');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export {
|
|
153
|
+
hashValue,
|
|
154
|
+
buildHashManifest,
|
|
155
|
+
detectChangedKeys,
|
|
156
|
+
readManifest,
|
|
157
|
+
writeManifest,
|
|
158
|
+
LOCK_FILENAME,
|
|
159
|
+
};
|
package/lib/icu.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICU MessageFormat parser — zero-dependency.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS:
|
|
5
|
+
* ICU MessageFormat strings like:
|
|
6
|
+
* "{count, plural, one {# document} other {# documents}}"
|
|
7
|
+
* "{gender, select, male {He} female {She} other {They}} liked this"
|
|
8
|
+
* contain structured syntax that LLMs routinely mangle. They:
|
|
9
|
+
* 1. Translate the keywords (plural → pluriel, select → sélectionner)
|
|
10
|
+
* 2. Reorder/drop plural categories
|
|
11
|
+
* 3. Break nesting by mismatching braces
|
|
12
|
+
* 4. Invent categories that don't exist in the target language
|
|
13
|
+
*
|
|
14
|
+
* This parser extracts only the translatable leaf text, shields the
|
|
15
|
+
* ICU syntax, sends just the text to the LLM, then reassembles the
|
|
16
|
+
* result with the original syntax intact.
|
|
17
|
+
*
|
|
18
|
+
* WHAT THIS HANDLES:
|
|
19
|
+
* - Simple arguments: "Hello, {name}!"
|
|
20
|
+
* - Plural: "{count, plural, one {# item} other {# items}}"
|
|
21
|
+
* - Select: "{gender, select, male {He} female {She} other {They}}"
|
|
22
|
+
* - Nested: "{count, plural, one {He has # cat} other {He has # cats}}"
|
|
23
|
+
* - Deeply nested select-in-plural and plural-in-select
|
|
24
|
+
* - Escaped braces ('' in ICU = literal quote)
|
|
25
|
+
*
|
|
26
|
+
* WHAT THIS DOES NOT HANDLE:
|
|
27
|
+
* - selectordinal (treated as plural — same structure)
|
|
28
|
+
* - Skeleton date/number formats ({0, date, ::yMMMd}) — passed through
|
|
29
|
+
* - The full ICU spec edge cases (we handle 99% of what i18n frameworks use)
|
|
30
|
+
*
|
|
31
|
+
* ARCHITECTURE:
|
|
32
|
+
* 1. isICUString(str) — fast heuristic check
|
|
33
|
+
* 2. parseICU(str) — recursive descent parser → AST
|
|
34
|
+
* 3. extractTranslatableSegments(ast) — leaf text with path addresses
|
|
35
|
+
* 4. reassembleICU(ast, translatedSegments) — rebuild from translations
|
|
36
|
+
* 5. getRequiredPluralCategories(locale) — from language card data
|
|
37
|
+
*
|
|
38
|
+
* ZERO DEPENDENCIES. Pure string processing.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { getLanguageCard } from './registers.js';
|
|
42
|
+
|
|
43
|
+
// -----------------------------------------------------------------
|
|
44
|
+
// Quick detection — does a string contain ICU MessageFormat syntax?
|
|
45
|
+
// -----------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fast heuristic to detect whether a string contains ICU MessageFormat syntax.
|
|
49
|
+
*
|
|
50
|
+
* Checks for patterns like:
|
|
51
|
+
* {name} — simple argument
|
|
52
|
+
* {count, plural, — plural/select/selectordinal
|
|
53
|
+
* {gender, select, — select
|
|
54
|
+
*
|
|
55
|
+
* Does NOT parse the string — just checks if it's worth parsing.
|
|
56
|
+
* False positives are acceptable (parser will handle them gracefully).
|
|
57
|
+
* False negatives are unacceptable (we'd mangle unparsed ICU strings).
|
|
58
|
+
*
|
|
59
|
+
* @param {string} str - String to check
|
|
60
|
+
* @returns {boolean} True if the string likely contains ICU MessageFormat
|
|
61
|
+
*/
|
|
62
|
+
function isICUString(str) {
|
|
63
|
+
if (typeof str !== 'string') return false;
|
|
64
|
+
|
|
65
|
+
// Must contain at least one brace pair
|
|
66
|
+
if (!str.includes('{') || !str.includes('}')) return false;
|
|
67
|
+
|
|
68
|
+
// Check for structured ICU patterns: {arg, type, ...}
|
|
69
|
+
// This catches plural, select, selectordinal
|
|
70
|
+
if (/\{\s*\w+\s*,\s*(?:plural|select|selectordinal)\s*,/i.test(str)) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check for simple arguments: {name}, {count}, {0}
|
|
75
|
+
// These are ICU if they have alphanumeric content between braces
|
|
76
|
+
if (/\{\s*\w+\s*\}/.test(str)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// -----------------------------------------------------------------
|
|
84
|
+
// AST node types
|
|
85
|
+
// -----------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @typedef {'text'|'argument'|'plural'|'select'} ICUNodeType
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @typedef {object} ICUNode
|
|
93
|
+
* @property {ICUNodeType} type - Node type
|
|
94
|
+
* @property {string} [value] - Text content (for 'text' nodes)
|
|
95
|
+
* @property {string} [name] - Argument name (for 'argument', 'plural', 'select')
|
|
96
|
+
* @property {string} [style] - Argument style/format (for 'argument' with format)
|
|
97
|
+
* @property {Object<string, ICUNode[]>} [options] - Category → child nodes (for 'plural', 'select')
|
|
98
|
+
* @property {number} [offset] - Offset value for plural (rare, but supported)
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
// -----------------------------------------------------------------
|
|
102
|
+
// Parser — recursive descent
|
|
103
|
+
// -----------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse an ICU MessageFormat string into an AST.
|
|
107
|
+
*
|
|
108
|
+
* The parser handles nested structures by recursively descending
|
|
109
|
+
* when it encounters a { character inside a plural/select option.
|
|
110
|
+
*
|
|
111
|
+
* BRACE BALANCING: The key challenge is matching braces correctly
|
|
112
|
+
* when options themselves contain { } for nested ICU or even for
|
|
113
|
+
* the # (number) placeholder in plural forms. We track brace depth
|
|
114
|
+
* and only close a category when we return to the original depth.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} str - ICU MessageFormat string
|
|
117
|
+
* @returns {ICUNode[]} AST (array of nodes at the top level)
|
|
118
|
+
*/
|
|
119
|
+
function parseICU(str) {
|
|
120
|
+
if (typeof str !== 'string') return [{ type: 'text', value: '' }];
|
|
121
|
+
|
|
122
|
+
const result = [];
|
|
123
|
+
let pos = 0;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parse a sequence of nodes until we hit the end of the string
|
|
127
|
+
* or a closing brace at our nesting level.
|
|
128
|
+
*/
|
|
129
|
+
function parseNodes(stopAtBrace = false) {
|
|
130
|
+
const nodes = [];
|
|
131
|
+
let textStart = pos;
|
|
132
|
+
|
|
133
|
+
while (pos < str.length) {
|
|
134
|
+
const ch = str[pos];
|
|
135
|
+
|
|
136
|
+
if (ch === '}' && stopAtBrace) {
|
|
137
|
+
// Flush any accumulated text
|
|
138
|
+
if (pos > textStart) {
|
|
139
|
+
nodes.push({ type: 'text', value: str.slice(textStart, pos) });
|
|
140
|
+
}
|
|
141
|
+
return nodes;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (ch === '{') {
|
|
145
|
+
// Flush text before this brace
|
|
146
|
+
if (pos > textStart) {
|
|
147
|
+
nodes.push({ type: 'text', value: str.slice(textStart, pos) });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
pos++; // skip {
|
|
151
|
+
const node = parseArgument();
|
|
152
|
+
nodes.push(node);
|
|
153
|
+
textStart = pos;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ICU escape: two single quotes = literal quote
|
|
158
|
+
if (ch === "'" && pos + 1 < str.length && str[pos + 1] === "'") {
|
|
159
|
+
pos += 2;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
pos++;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Flush remaining text
|
|
167
|
+
if (pos > textStart) {
|
|
168
|
+
nodes.push({ type: 'text', value: str.slice(textStart, pos) });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return nodes;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Parse the content inside { } — could be a simple argument,
|
|
176
|
+
* plural, select, or selectordinal.
|
|
177
|
+
*/
|
|
178
|
+
function parseArgument() {
|
|
179
|
+
skipWhitespace();
|
|
180
|
+
|
|
181
|
+
// Read the argument name (e.g., "count", "gender", "name")
|
|
182
|
+
const name = readIdentifier();
|
|
183
|
+
skipWhitespace();
|
|
184
|
+
|
|
185
|
+
// Simple argument: {name} — no comma, just close
|
|
186
|
+
if (pos >= str.length || str[pos] === '}') {
|
|
187
|
+
pos++; // skip }
|
|
188
|
+
return { type: 'argument', name };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Should be a comma for typed arguments
|
|
192
|
+
if (str[pos] !== ',') {
|
|
193
|
+
// Malformed — treat as simple argument, skip to closing brace
|
|
194
|
+
skipToClosingBrace();
|
|
195
|
+
return { type: 'argument', name };
|
|
196
|
+
}
|
|
197
|
+
pos++; // skip comma
|
|
198
|
+
skipWhitespace();
|
|
199
|
+
|
|
200
|
+
// Read the type keyword (plural, select, selectordinal, number, date, etc.)
|
|
201
|
+
const typeKeyword = readIdentifier().toLowerCase();
|
|
202
|
+
skipWhitespace();
|
|
203
|
+
|
|
204
|
+
// For plural/select/selectordinal, parse the options
|
|
205
|
+
if (typeKeyword === 'plural' || typeKeyword === 'select' || typeKeyword === 'selectordinal') {
|
|
206
|
+
if (str[pos] === ',') {
|
|
207
|
+
pos++; // skip comma
|
|
208
|
+
skipWhitespace();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const options = parseOptions();
|
|
212
|
+
|
|
213
|
+
// Skip closing }
|
|
214
|
+
if (pos < str.length && str[pos] === '}') {
|
|
215
|
+
pos++;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
type: typeKeyword === 'select' ? 'select' : 'plural',
|
|
220
|
+
name,
|
|
221
|
+
options,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// For other types (number, date, time) — skip to closing brace
|
|
226
|
+
// These are formatting-only, no translatable content inside
|
|
227
|
+
const startPos = pos;
|
|
228
|
+
skipToClosingBrace();
|
|
229
|
+
const style = str.slice(startPos, pos - 1).trim() || undefined;
|
|
230
|
+
return { type: 'argument', name, style };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Parse plural/select options: "=0 {no items} one {# item} other {# items}"
|
|
235
|
+
*/
|
|
236
|
+
function parseOptions() {
|
|
237
|
+
const options = {};
|
|
238
|
+
|
|
239
|
+
while (pos < str.length && str[pos] !== '}') {
|
|
240
|
+
skipWhitespace();
|
|
241
|
+
if (pos >= str.length || str[pos] === '}') break;
|
|
242
|
+
|
|
243
|
+
// Handle "offset:N" for plural
|
|
244
|
+
if (str.slice(pos, pos + 7) === 'offset:') {
|
|
245
|
+
pos += 7;
|
|
246
|
+
// Read the number
|
|
247
|
+
while (pos < str.length && /\d/.test(str[pos])) pos++;
|
|
248
|
+
skipWhitespace();
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Read category name: "one", "other", "=0", "male", "female", etc.
|
|
253
|
+
const category = readCategory();
|
|
254
|
+
skipWhitespace();
|
|
255
|
+
|
|
256
|
+
// Expect { to start the option body
|
|
257
|
+
if (pos < str.length && str[pos] === '{') {
|
|
258
|
+
pos++; // skip {
|
|
259
|
+
|
|
260
|
+
// Recursively parse the content inside this option
|
|
261
|
+
const children = parseNodes(true);
|
|
262
|
+
|
|
263
|
+
// pos should now be at }, skip it
|
|
264
|
+
if (pos < str.length && str[pos] === '}') {
|
|
265
|
+
pos++;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
options[category] = children;
|
|
269
|
+
} else {
|
|
270
|
+
// Malformed — skip to next category or end
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
skipWhitespace();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return options;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Read an identifier (alphanumeric + underscore).
|
|
282
|
+
*/
|
|
283
|
+
function readIdentifier() {
|
|
284
|
+
const start = pos;
|
|
285
|
+
while (pos < str.length && /[\w]/.test(str[pos])) {
|
|
286
|
+
pos++;
|
|
287
|
+
}
|
|
288
|
+
return str.slice(start, pos);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Read a category name (can include = prefix for exact match, e.g., "=0").
|
|
293
|
+
*/
|
|
294
|
+
function readCategory() {
|
|
295
|
+
const start = pos;
|
|
296
|
+
// Categories can be: one, other, =0, =1, male, female, etc.
|
|
297
|
+
if (str[pos] === '=') pos++;
|
|
298
|
+
while (pos < str.length && /[\w-]/.test(str[pos])) {
|
|
299
|
+
pos++;
|
|
300
|
+
}
|
|
301
|
+
return str.slice(start, pos);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Skip whitespace (spaces, tabs, newlines).
|
|
306
|
+
*/
|
|
307
|
+
function skipWhitespace() {
|
|
308
|
+
while (pos < str.length && /\s/.test(str[pos])) {
|
|
309
|
+
pos++;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Skip to the matching closing brace, handling nesting.
|
|
315
|
+
*/
|
|
316
|
+
function skipToClosingBrace() {
|
|
317
|
+
let depth = 1;
|
|
318
|
+
while (pos < str.length && depth > 0) {
|
|
319
|
+
if (str[pos] === '{') depth++;
|
|
320
|
+
else if (str[pos] === '}') depth--;
|
|
321
|
+
pos++;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return parseNodes(false);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// -----------------------------------------------------------------
|
|
329
|
+
// Segment extraction — pull out translatable leaf text
|
|
330
|
+
// -----------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Extract translatable text segments from an ICU AST.
|
|
334
|
+
*
|
|
335
|
+
* Returns an array of segments, each with:
|
|
336
|
+
* - text: the translatable string
|
|
337
|
+
* - path: a dot-separated path like "plural.one.0" or "select.male.1"
|
|
338
|
+
* used to put translations back in the right place
|
|
339
|
+
*
|
|
340
|
+
* WHAT'S TRANSLATABLE:
|
|
341
|
+
* - Text nodes inside plural/select options → yes
|
|
342
|
+
* - Top-level text nodes (before/after/between arguments) → yes
|
|
343
|
+
* - Argument names ({name}, {count}) → no (these are code references)
|
|
344
|
+
* - ICU keywords (plural, select, one, other) → no
|
|
345
|
+
* - The # symbol in plural options → no (it's a number placeholder)
|
|
346
|
+
*
|
|
347
|
+
* @param {ICUNode[]} ast - Parsed AST
|
|
348
|
+
* @param {string} [pathPrefix=''] - Path prefix for nested calls
|
|
349
|
+
* @returns {Array<{text: string, path: string}>}
|
|
350
|
+
*/
|
|
351
|
+
function extractTranslatableSegments(ast, pathPrefix = '') {
|
|
352
|
+
const segments = [];
|
|
353
|
+
|
|
354
|
+
for (let i = 0; i < ast.length; i++) {
|
|
355
|
+
const node = ast[i];
|
|
356
|
+
const nodePath = pathPrefix ? `${pathPrefix}.${i}` : `${i}`;
|
|
357
|
+
|
|
358
|
+
if (node.type === 'text') {
|
|
359
|
+
// Only include non-trivial text (not just whitespace or #)
|
|
360
|
+
const stripped = node.value.replace(/#/g, '').trim();
|
|
361
|
+
if (stripped.length > 0) {
|
|
362
|
+
segments.push({ text: node.value, path: nodePath });
|
|
363
|
+
}
|
|
364
|
+
} else if (node.type === 'plural' || node.type === 'select') {
|
|
365
|
+
// Recurse into each option
|
|
366
|
+
if (node.options) {
|
|
367
|
+
for (const [category, children] of Object.entries(node.options)) {
|
|
368
|
+
const catPath = `${nodePath}.${node.type}.${category}`;
|
|
369
|
+
const childSegments = extractTranslatableSegments(children, catPath);
|
|
370
|
+
segments.push(...childSegments);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// 'argument' nodes are not translatable — they're variable references
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return segments;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// -----------------------------------------------------------------
|
|
381
|
+
// Reassembly — put translated text back into ICU structure
|
|
382
|
+
// -----------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Reassemble an ICU string from an AST with translated segments.
|
|
386
|
+
*
|
|
387
|
+
* @param {ICUNode[]} ast - Original parsed AST
|
|
388
|
+
* @param {Map<string, string>} translatedMap - path → translated text
|
|
389
|
+
* @param {string} [pathPrefix=''] - Path prefix for nested calls
|
|
390
|
+
* @returns {string} Reassembled ICU MessageFormat string
|
|
391
|
+
*/
|
|
392
|
+
function reassembleICU(ast, translatedMap, pathPrefix = '') {
|
|
393
|
+
let result = '';
|
|
394
|
+
|
|
395
|
+
for (let i = 0; i < ast.length; i++) {
|
|
396
|
+
const node = ast[i];
|
|
397
|
+
const nodePath = pathPrefix ? `${pathPrefix}.${i}` : `${i}`;
|
|
398
|
+
|
|
399
|
+
if (node.type === 'text') {
|
|
400
|
+
// Use translated version if available, otherwise keep original
|
|
401
|
+
const translated = translatedMap.get(nodePath);
|
|
402
|
+
result += translated !== undefined ? translated : node.value;
|
|
403
|
+
} else if (node.type === 'argument') {
|
|
404
|
+
// Reconstruct: {name} or {name, style}
|
|
405
|
+
result += `{${node.name}`;
|
|
406
|
+
if (node.style) {
|
|
407
|
+
result += `, ${node.style}`;
|
|
408
|
+
}
|
|
409
|
+
result += '}';
|
|
410
|
+
} else if (node.type === 'plural' || node.type === 'select') {
|
|
411
|
+
const typeKeyword = node.type === 'select' ? 'select' : 'plural';
|
|
412
|
+
result += `{${node.name}, ${typeKeyword},`;
|
|
413
|
+
|
|
414
|
+
if (node.options) {
|
|
415
|
+
for (const [category, children] of Object.entries(node.options)) {
|
|
416
|
+
const catPath = `${nodePath}.${node.type}.${category}`;
|
|
417
|
+
const reassembled = reassembleICU(children, translatedMap, catPath);
|
|
418
|
+
result += ` ${category} {${reassembled}}`;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
result += '}';
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return result;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// -----------------------------------------------------------------
|
|
430
|
+
// Plural category resolution — from language card data
|
|
431
|
+
// -----------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Get the required CLDR plural categories for a locale.
|
|
435
|
+
*
|
|
436
|
+
* Reads from the language card's `rules.plurals.categories` field,
|
|
437
|
+
* which was populated during the v5 refactor from CLDR data.
|
|
438
|
+
*
|
|
439
|
+
* Falls back to ['other'] if no card exists or the card has no plural
|
|
440
|
+
* rules defined (e.g., conlangs). This is correct because every language
|
|
441
|
+
* has at least the 'other' category.
|
|
442
|
+
*
|
|
443
|
+
* @param {string} locale - Locale code (e.g., 'fr', 'ar', 'ja')
|
|
444
|
+
* @returns {string[]} Required plural categories (e.g., ['one', 'other'] for French)
|
|
445
|
+
*/
|
|
446
|
+
function getRequiredPluralCategories(locale) {
|
|
447
|
+
const card = getLanguageCard(locale);
|
|
448
|
+
// `pluralCategories` — CLDR's own cardinal categories, projected onto the
|
|
449
|
+
// card as a sourced fact. Previously this lived at rules.plurals.categories
|
|
450
|
+
// inside a config-shaped block, which is why it looked like something we had
|
|
451
|
+
// authored. It never was: the ten distinct sets are CLDR's, verbatim.
|
|
452
|
+
const categories = card?.pluralCategories;
|
|
453
|
+
|
|
454
|
+
// Cards with defined categories → use them
|
|
455
|
+
if (Array.isArray(categories) && categories.length > 0) {
|
|
456
|
+
return categories;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// No card or no plural rules → every language has at least 'other'
|
|
460
|
+
return ['other'];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// -----------------------------------------------------------------
|
|
464
|
+
// Exports
|
|
465
|
+
// -----------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
export {
|
|
468
|
+
isICUString,
|
|
469
|
+
parseICU,
|
|
470
|
+
extractTranslatableSegments,
|
|
471
|
+
reassembleICU,
|
|
472
|
+
getRequiredPluralCategories,
|
|
473
|
+
};
|