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.
Files changed (170) hide show
  1. package/LICENSE +133 -0
  2. package/README.md +387 -0
  3. package/bin/cli.js +278 -0
  4. package/index.js +135 -0
  5. package/lib/api-key.js +127 -0
  6. package/lib/autofix.js +432 -0
  7. package/lib/bridge/method_bridge.py +430 -0
  8. package/lib/card-source-resolution.mjs +284 -0
  9. package/lib/cards/cache.js +169 -0
  10. package/lib/cards/env.js +82 -0
  11. package/lib/cards/fetch-card-child.js +38 -0
  12. package/lib/cards/reader.js +435 -0
  13. package/lib/cards/refresh.js +111 -0
  14. package/lib/cards/remote.js +387 -0
  15. package/lib/cldf-export.mjs +540 -0
  16. package/lib/cldf-terms.mjs +62 -0
  17. package/lib/command-help.js +790 -0
  18. package/lib/commands/audit.js +49 -0
  19. package/lib/commands/card.js +454 -0
  20. package/lib/commands/doctor.js +559 -0
  21. package/lib/commands/fonts.js +489 -0
  22. package/lib/commands/help.js +91 -0
  23. package/lib/commands/init.js +1259 -0
  24. package/lib/commands/integrity.js +148 -0
  25. package/lib/commands/leaderboard.js +478 -0
  26. package/lib/commands/lint.js +30 -0
  27. package/lib/commands/models.js +177 -0
  28. package/lib/commands/plugin.js +103 -0
  29. package/lib/commands/provenance.js +45 -0
  30. package/lib/commands/recommend.js +75 -0
  31. package/lib/commands/register-corpus.js +678 -0
  32. package/lib/commands/repair-script.js +42 -0
  33. package/lib/commands/seal-corpus.js +355 -0
  34. package/lib/commands/seo.js +72 -0
  35. package/lib/commands/serve.js +147 -0
  36. package/lib/commands/status.js +265 -0
  37. package/lib/commands/submit.js +332 -0
  38. package/lib/commands/sync.js +89 -0
  39. package/lib/commands/tm.js +573 -0
  40. package/lib/commands/verify.js +39 -0
  41. package/lib/commands/watch.js +20 -0
  42. package/lib/commands/wrap.js +138 -0
  43. package/lib/commands/xliff.js +327 -0
  44. package/lib/commercial-eligibility.js +235 -0
  45. package/lib/concurrent.js +87 -0
  46. package/lib/config.js +523 -0
  47. package/lib/contamination-lane.js +76 -0
  48. package/lib/content-sync.js +731 -0
  49. package/lib/content.js +733 -0
  50. package/lib/corpus-registration.mjs +608 -0
  51. package/lib/cost-report.js +346 -0
  52. package/lib/diff.js +155 -0
  53. package/lib/docusaurus-sync.js +1256 -0
  54. package/lib/flatten.js +55 -0
  55. package/lib/format.js +954 -0
  56. package/lib/hash.js +159 -0
  57. package/lib/icu.js +473 -0
  58. package/lib/integrity.js +689 -0
  59. package/lib/license-gate.mjs +478 -0
  60. package/lib/license-identify.mjs +229 -0
  61. package/lib/lint.js +629 -0
  62. package/lib/method-manifest.js +60 -0
  63. package/lib/methods/anthropic.js +140 -0
  64. package/lib/methods/apertium.js +163 -0
  65. package/lib/methods/api.js +316 -0
  66. package/lib/methods/base.js +184 -0
  67. package/lib/methods/content-separator.js +45 -0
  68. package/lib/methods/deepl.js +426 -0
  69. package/lib/methods/direct-llm.js +586 -0
  70. package/lib/methods/external.js +332 -0
  71. package/lib/methods/fetch-with-retry.js +124 -0
  72. package/lib/methods/gemini.js +147 -0
  73. package/lib/methods/google-translate.js +402 -0
  74. package/lib/methods/http-utils.js +122 -0
  75. package/lib/methods/libretranslate.js +314 -0
  76. package/lib/methods/llm-coached.js +670 -0
  77. package/lib/methods/llm.js +592 -0
  78. package/lib/methods/local.js +76 -0
  79. package/lib/methods/microsoft-translator.js +331 -0
  80. package/lib/methods/openai.js +131 -0
  81. package/lib/methods/openrouter-client.js +327 -0
  82. package/lib/methods/openrouter-pricing.js +156 -0
  83. package/lib/methods/provider-env.js +115 -0
  84. package/lib/methods/provider-pricing.js +310 -0
  85. package/lib/methods/tilde.js +150 -0
  86. package/lib/methods/translated.js +229 -0
  87. package/lib/methods/translation-error.js +80 -0
  88. package/lib/models.js +258 -0
  89. package/lib/no-translate.js +233 -0
  90. package/lib/output.js +238 -0
  91. package/lib/pairs.js +547 -0
  92. package/lib/plugins.js +447 -0
  93. package/lib/provenance.js +323 -0
  94. package/lib/recommend.js +648 -0
  95. package/lib/registers.js +1185 -0
  96. package/lib/repair-script.js +266 -0
  97. package/lib/scripts.js +994 -0
  98. package/lib/seal.mjs +464 -0
  99. package/lib/sealed-qualifier.mjs +211 -0
  100. package/lib/security.js +59 -0
  101. package/lib/segment.js +369 -0
  102. package/lib/seo.js +275 -0
  103. package/lib/serve.js +854 -0
  104. package/lib/string-classify.js +85 -0
  105. package/lib/submit.mjs +344 -0
  106. package/lib/sync.js +969 -0
  107. package/lib/tags/bcp47.js +202 -0
  108. package/lib/tags/resolve.js +314 -0
  109. package/lib/terminology.js +111 -0
  110. package/lib/tm-seed.js +294 -0
  111. package/lib/tm.js +515 -0
  112. package/lib/translate-pair.js +197 -0
  113. package/lib/translate.js +203 -0
  114. package/lib/types.js +230 -0
  115. package/lib/validate.js +510 -0
  116. package/lib/verify.js +451 -0
  117. package/lib/watch.js +145 -0
  118. package/lib/xliff.js +184 -0
  119. package/package.json +93 -0
  120. package/shared/ATTRIBUTION.md +145 -0
  121. package/shared/CORPORA-CARDS.md +288 -0
  122. package/shared/DATA-SOVEREIGNTY.md +500 -0
  123. package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
  124. package/shared/card-lint-baseline.json +3189 -0
  125. package/shared/cards-fallback.json +1 -0
  126. package/shared/catalogue/card-config.json +6091 -0
  127. package/shared/catalogue/external-results.json +3888 -0
  128. package/shared/catalogue/gender-guidance.json +1038 -0
  129. package/shared/catalogue/method-coverage.json +1751 -0
  130. package/shared/catalogue/metric-coverage.json +170 -0
  131. package/shared/catalogue/metric-reliability.json +1 -0
  132. package/shared/catalogue/register-presets.json +3180 -0
  133. package/shared/catalogue/vitality-scales.json +55 -0
  134. package/shared/cldr-index.json +1115 -0
  135. package/shared/code-bridge.json +253 -0
  136. package/shared/corpora-cards-v1-reference.md +281 -0
  137. package/shared/curated-dictionary-flags.json +35 -0
  138. package/shared/curated-endonyms.json +35 -0
  139. package/shared/curated-fsts.json +51 -0
  140. package/shared/curated-orthography-conventions.json +26 -0
  141. package/shared/curated-sil-resources.json +374 -0
  142. package/shared/curated-tools.json +41 -0
  143. package/shared/docent/corpus.json +11333 -0
  144. package/shared/docent/faq.en.json +564 -0
  145. package/shared/docent/register-blocks.json +60 -0
  146. package/shared/docent/system-prompt.md +144 -0
  147. package/shared/domain-taxonomy.json +35 -0
  148. package/shared/explainers/glossary.json +2975 -0
  149. package/shared/explainers/tc-features.json +20112 -0
  150. package/shared/explainers/term-watchlist.json +147 -0
  151. package/shared/human-services.json +59 -0
  152. package/shared/license-corrections.json +261 -0
  153. package/shared/license-evidence.json +13452 -0
  154. package/shared/licenses.json +6781 -0
  155. package/shared/method-registry.json +236 -0
  156. package/shared/metric-registry.json +620 -0
  157. package/shared/model-aliases.json +7 -0
  158. package/shared/schemas/champollion-plugin.schema.json +206 -0
  159. package/shared/schemas/corpora-card.schema.json +957 -0
  160. package/shared/schemas/domain-taxonomy.schema.json +64 -0
  161. package/shared/schemas/external-results.schema.json +314 -0
  162. package/shared/schemas/human-services.schema.json +90 -0
  163. package/shared/schemas/language-card.schema.json +1308 -0
  164. package/shared/schemas/licenses.schema.json +155 -0
  165. package/shared/schemas/method-card.schema.json +412 -0
  166. package/shared/schemas/method-registry.schema.json +85 -0
  167. package/shared/schemas/metric-registry.schema.json +96 -0
  168. package/shared/schemas/metric-reliability.schema.json +178 -0
  169. package/shared/schemas/model-aliases.schema.json +27 -0
  170. 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
+ };