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/format.js ADDED
@@ -0,0 +1,954 @@
1
+ /**
2
+ * Format adapter — reads and writes locale files in JSON, TOML, and YAML.
3
+ *
4
+ * WHY: Hugo uses TOML or YAML for i18n string files, not JSON.
5
+ * Hugo's i18n/ structure looks like:
6
+ *
7
+ * [home] # TOML section = translation key
8
+ * other = "Home" # 'other' = the default/plural form
9
+ *
10
+ * [items]
11
+ * one = "{{ .Count }} item"
12
+ * other = "{{ .Count }} items"
13
+ *
14
+ * This module converts between Hugo's format and our internal flat
15
+ * key→value map, so the diff/translate/hash engine stays format-agnostic.
16
+ *
17
+ * ZERO DEPENDENCIES: Hugo i18n files have a constrained, predictable
18
+ * structure. We don't need js-yaml or a full TOML parser — just
19
+ * targeted parsers for the subset Hugo actually uses.
20
+ */
21
+
22
+ import fs from 'node:fs';
23
+
24
+ // CLDR plural categories used by Hugo/go-i18n
25
+ const PLURAL_FORMS = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
26
+
27
+ /**
28
+ * Strip a leading UTF-8 BOM (U+FEFF) from a decoded string.
29
+ *
30
+ * WHY: Editors on Windows (and some export pipelines) prepend a BOM to
31
+ * locale files. JSON.parse() and our hand-rolled TOML/YAML scanners choke
32
+ * on the invisible leading character — JSON.parse throws "Unexpected token",
33
+ * and the YAML/TOML line scanners fail to match the first key. A BOM is a
34
+ * pure encoding artifact, never semantic content, so we drop it on read.
35
+ *
36
+ * @param {string} str - Decoded file content
37
+ * @returns {string} Content with any leading BOM removed
38
+ */
39
+ function stripBOM(str) {
40
+ return str.charCodeAt(0) === 0xFEFF ? str.slice(1) : str;
41
+ }
42
+
43
+ // -----------------------------------------------------------------
44
+ // Format detection
45
+ // -----------------------------------------------------------------
46
+
47
+ /**
48
+ * Detect the locale file format from a file path's extension.
49
+ *
50
+ * @param {string} filePath - Path to a locale file
51
+ * @returns {'json'|'toml'|'yaml'} Detected format
52
+ */
53
+ function detectFormat(filePath) {
54
+ if (filePath.endsWith('.toml')) return 'toml';
55
+ if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml';
56
+ return 'json';
57
+ }
58
+
59
+ /**
60
+ * Get the file extension string for a format.
61
+ *
62
+ * @param {'json'|'toml'|'yaml'} format
63
+ * @returns {string} File extension including the dot
64
+ */
65
+ function getExtension(format) {
66
+ if (format === 'toml') return '.toml';
67
+ if (format === 'yaml') return '.yaml';
68
+ return '.json';
69
+ }
70
+
71
+ /**
72
+ * Auto-detect the format used in a locales directory by scanning
73
+ * for the most common file extension present.
74
+ *
75
+ * @param {string} localesDir - Path to the locales directory
76
+ * @returns {'json'|'toml'|'yaml'} Detected format, defaults to 'json'
77
+ */
78
+ function detectFormatFromDir(localesDir) {
79
+ if (!fs.existsSync(localesDir)) return 'json';
80
+
81
+ const files = fs.readdirSync(localesDir);
82
+ const counts = { json: 0, toml: 0, yaml: 0 };
83
+
84
+ for (const file of files) {
85
+ if (file.endsWith('.toml')) counts.toml++;
86
+ else if (file.endsWith('.yaml') || file.endsWith('.yml')) counts.yaml++;
87
+ else if (file.endsWith('.json')) counts.json++;
88
+ }
89
+
90
+ // Return whichever format has the most files
91
+ if (counts.toml > counts.json && counts.toml >= counts.yaml) return 'toml';
92
+ if (counts.yaml > counts.json && counts.yaml >= counts.toml) return 'yaml';
93
+ return 'json';
94
+ }
95
+
96
+ // -----------------------------------------------------------------
97
+ // Read: format → flat key-value map
98
+ // -----------------------------------------------------------------
99
+
100
+ /**
101
+ * Read a locale file and return a flat key→value map.
102
+ *
103
+ * For TOML/YAML Hugo files, simple keys (only 'other') flatten to
104
+ * { "key": "value" }. Plural keys flatten to { "key.one": "...",
105
+ * "key.other": "..." }.
106
+ *
107
+ * @param {string} filePath - Path to the locale file
108
+ * @param {'json'|'toml'|'yaml'} format - File format
109
+ * @returns {object} Flat key→value map
110
+ */
111
+ function readLocaleFile(filePath, format) {
112
+ if (!fs.existsSync(filePath)) return {};
113
+ // Strip a leading BOM before any parsing — a U+FEFF prefix otherwise
114
+ // hard-crashes JSON.parse() and silently breaks the first key match in
115
+ // the TOML/YAML line scanners.
116
+ const raw = stripBOM(fs.readFileSync(filePath, 'utf-8'));
117
+ if (!raw.trim()) return {};
118
+
119
+ if (format === 'toml') return parseTOMLToFlat(raw);
120
+ if (format === 'yaml') return parseYAMLToFlat(raw);
121
+
122
+ // JSON: caller handles flattening. Wrap the raw V8 SyntaxError (which only
123
+ // gives a byte offset, no filename or hint) in a file-named, friendlier
124
+ // error so the user can actually find and fix the broken file.
125
+ let parsed;
126
+ try {
127
+ parsed = JSON.parse(raw);
128
+ } catch (err) {
129
+ const hint = /,\s*[}\]]/.test(raw)
130
+ ? ' (looks like a trailing comma — JSON does not allow them)'
131
+ : '';
132
+ throw new Error(`Invalid JSON in ${filePath}: ${err.message}${hint}`);
133
+ }
134
+ // Duplicate JSON keys silently last-win in JSON.parse — warn so the loss
135
+ // is visible (a copy-pasted key that quietly overwrote an earlier value).
136
+ for (const dup of findDuplicateJSONKeys(raw)) {
137
+ console.warn(` [WARN] Duplicate JSON key "${dup}" in ${filePath} — later value wins, earlier value discarded.`);
138
+ }
139
+ return parsed;
140
+ }
141
+
142
+ /**
143
+ * Find object keys that appear more than once within the same object in a
144
+ * JSON document. JSON.parse() silently keeps the last value, masking
145
+ * copy-paste mistakes; this scanner re-walks the raw text (string- and
146
+ * escape-aware) so we can warn instead.
147
+ *
148
+ * @param {string} raw - Raw JSON text
149
+ * @returns {string[]} Duplicate key names (deduplicated, in first-seen order)
150
+ */
151
+ function findDuplicateJSONKeys(raw) {
152
+ const duplicates = new Set();
153
+ // Stack of Sets — one per open object — tracking keys seen at that depth.
154
+ const objectStack = [];
155
+ let inString = false;
156
+ let escaped = false;
157
+ let stringStart = -1;
158
+ // `expectKey` is true when the next string token is an object key (i.e. we
159
+ // just opened an object or saw a comma inside an object).
160
+ let expectKey = false;
161
+
162
+ for (let i = 0; i < raw.length; i++) {
163
+ const ch = raw[i];
164
+
165
+ if (inString) {
166
+ if (escaped) { escaped = false; continue; }
167
+ if (ch === '\\') { escaped = true; continue; }
168
+ if (ch === '"') {
169
+ inString = false;
170
+ // If this string was a key, record it against the current object.
171
+ if (expectKey && objectStack.length > 0) {
172
+ const key = raw.slice(stringStart + 1, i);
173
+ const seen = objectStack[objectStack.length - 1];
174
+ if (seen.has(key)) duplicates.add(key);
175
+ else seen.add(key);
176
+ expectKey = false; // next is ':' then a value
177
+ }
178
+ }
179
+ continue;
180
+ }
181
+
182
+ switch (ch) {
183
+ case '"':
184
+ inString = true;
185
+ stringStart = i;
186
+ break;
187
+ case '{':
188
+ objectStack.push(new Set());
189
+ expectKey = true;
190
+ break;
191
+ case '}':
192
+ objectStack.pop();
193
+ expectKey = false;
194
+ break;
195
+ case '[':
196
+ // Array elements are never keys.
197
+ objectStack.push(null);
198
+ expectKey = false;
199
+ break;
200
+ case ']':
201
+ objectStack.pop();
202
+ expectKey = false;
203
+ break;
204
+ case ',':
205
+ // Inside an object, a comma means another key follows.
206
+ expectKey = objectStack.length > 0 &&
207
+ objectStack[objectStack.length - 1] !== null;
208
+ break;
209
+ default:
210
+ break;
211
+ }
212
+ }
213
+
214
+ return [...duplicates];
215
+ }
216
+
217
+ /**
218
+ * Write a nested data object to a locale file in the specified format.
219
+ *
220
+ * For JSON, this writes the nested structure directly.
221
+ * For TOML/YAML, this converts to the appropriate section format.
222
+ *
223
+ * @param {string} filePath - Output file path
224
+ * @param {object} data - Nested data object (for JSON) or flat map (for TOML/YAML)
225
+ * @param {'json'|'toml'|'yaml'} format - Target format
226
+ * @param {object} flatData - Flat key→value map (used for TOML/YAML reconstruction)
227
+ * @param {'hugo'|'nested'|null} yamlStyle - YAML variant: 'hugo' for Hugo i18n, 'nested' for standard nested YAML
228
+ */
229
+ function writeLocaleFile(filePath, data, format, flatData, yamlStyle) {
230
+ let content;
231
+ if (format === 'toml') {
232
+ content = flatToTOML(flatData || data);
233
+ } else if (format === 'yaml') {
234
+ // Route to the correct YAML serializer based on detected style.
235
+ // Hugo YAML uses CLDR plural sub-keys (other:, one:, etc.).
236
+ // Standard nested YAML uses arbitrary nesting (nav: { home: ... }).
237
+ const serializer = yamlStyle === 'nested' ? flatToNestedYAML : flatToYAML;
238
+ content = serializer(flatData || data);
239
+ } else {
240
+ content = JSON.stringify(data, null, 2) + '\n';
241
+ }
242
+ writeFileAtomic(filePath, content);
243
+ }
244
+
245
+ /**
246
+ * Write a file atomically: serialize to a sibling temp file, then rename over
247
+ * the target. rename(2) is atomic within a filesystem, so a crash or a partial
248
+ * write can never leave a half-written locale file on disk — readers see either
249
+ * the old content or the complete new content, never garbage.
250
+ *
251
+ * @param {string} filePath - Destination path
252
+ * @param {string} content - Full file content
253
+ */
254
+ function writeFileAtomic(filePath, content) {
255
+ // Co-locate the temp file in the same directory so the final rename stays on
256
+ // the same filesystem (cross-device rename is not atomic and would EXDEV).
257
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
258
+ try {
259
+ fs.writeFileSync(tmpPath, content, 'utf-8');
260
+ fs.renameSync(tmpPath, filePath);
261
+ } catch (err) {
262
+ // Clean up the temp file on failure so we don't litter the locales dir.
263
+ try { fs.unlinkSync(tmpPath); } catch { /* temp may not exist */ }
264
+ throw err;
265
+ }
266
+ }
267
+
268
+ // -----------------------------------------------------------------
269
+ // TOML parser (Hugo i18n subset)
270
+ // -----------------------------------------------------------------
271
+
272
+ /**
273
+ * Parse Hugo i18n TOML content into a flat key→value map.
274
+ *
275
+ * Handles:
276
+ * [section] → section header (translation key)
277
+ * other = "value" → simple string (flattens to { section: value })
278
+ * one = "singular" → plural form (flattens to { section.one: value })
279
+ * # comments → skipped
280
+ *
281
+ * @param {string} content - Raw TOML file content
282
+ * @returns {object} Flat key→value map
283
+ */
284
+ function parseTOMLToFlat(content) {
285
+ const sections = parseTOMLSections(content);
286
+ return sectionsToFlat(sections);
287
+ }
288
+
289
+ /**
290
+ * Parse TOML into an intermediate section map.
291
+ *
292
+ * @param {string} content - Raw TOML content
293
+ * @returns {Object<string, Object<string, string>>} Section map: { sectionName: { subKey: value } }
294
+ */
295
+ function parseTOMLSections(content) {
296
+ const sections = {};
297
+ let currentSection = null;
298
+
299
+ for (const line of content.split('\n')) {
300
+ const trimmed = line.trim();
301
+
302
+ // Skip empty lines and comments
303
+ if (!trimmed || trimmed.startsWith('#')) continue;
304
+
305
+ // Section header: [key_name]
306
+ const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
307
+ if (sectionMatch) {
308
+ currentSection = sectionMatch[1].trim();
309
+ if (!sections[currentSection]) sections[currentSection] = {};
310
+ continue;
311
+ }
312
+
313
+ // Key-value pair within a section
314
+ if (currentSection) {
315
+ const kv = parseTOMLKeyValue(trimmed);
316
+ if (kv) {
317
+ // Duplicate key within the same section silently last-wins, which can
318
+ // mask a copy-paste mistake. Warn so the loss is visible.
319
+ if (Object.prototype.hasOwnProperty.call(sections[currentSection], kv.key)) {
320
+ console.warn(
321
+ ` [WARN] Duplicate TOML key "${currentSection}.${kv.key}" — ` +
322
+ `later value wins, earlier value discarded.`
323
+ );
324
+ }
325
+ sections[currentSection][kv.key] = kv.value;
326
+ }
327
+ }
328
+ }
329
+
330
+ return sections;
331
+ }
332
+
333
+ /**
334
+ * Parse a single TOML key = "value" line.
335
+ * Handles double-quoted, single-quoted, and bare values.
336
+ *
337
+ * @param {string} line - Trimmed TOML line
338
+ * @returns {{ key: string, value: string }|null} Parsed key-value pair, or null if not a valid pair
339
+ */
340
+ function parseTOMLKeyValue(line) {
341
+ const eqIdx = line.indexOf('=');
342
+ if (eqIdx < 0) return null;
343
+
344
+ const key = line.slice(0, eqIdx).trim();
345
+ let value = line.slice(eqIdx + 1).trim();
346
+
347
+ // Double-quoted string
348
+ if (value.startsWith('"') && value.endsWith('"')) {
349
+ value = value.slice(1, -1)
350
+ .replace(/\\"/g, '"')
351
+ .replace(/\\\\/g, '\\')
352
+ .replace(/\\n/g, '\n')
353
+ .replace(/\\t/g, '\t');
354
+ return { key, value };
355
+ }
356
+
357
+ // Single-quoted string (literal, no escapes in TOML spec)
358
+ if (value.startsWith("'") && value.endsWith("'")) {
359
+ value = value.slice(1, -1);
360
+ return { key, value };
361
+ }
362
+
363
+ // Bare value (shouldn't happen in i18n files, but handle gracefully)
364
+ return { key, value };
365
+ }
366
+
367
+ /**
368
+ * Serialize a flat key→value map to Hugo i18n TOML format.
369
+ *
370
+ * Simple keys write as:
371
+ * [key]
372
+ * other = "value"
373
+ *
374
+ * Plural keys (key.one, key.other) group under one section:
375
+ * [key]
376
+ * one = "singular"
377
+ * other = "plural"
378
+ *
379
+ * @param {object} flat - Flat key→value map
380
+ * @returns {string} TOML content
381
+ */
382
+ function flatToTOML(flat) {
383
+ const grouped = groupFlatKeys(flat);
384
+ const lines = [];
385
+
386
+ for (const [section, values] of Object.entries(grouped)) {
387
+ lines.push(`[${section}]`);
388
+ for (const [subKey, value] of Object.entries(values)) {
389
+ const escaped = String(value)
390
+ .replace(/\\/g, '\\\\')
391
+ .replace(/"/g, '\\"')
392
+ .replace(/\n/g, '\\n')
393
+ .replace(/\t/g, '\\t');
394
+ lines.push(`${subKey} = "${escaped}"`);
395
+ }
396
+ lines.push('');
397
+ }
398
+
399
+ return lines.join('\n');
400
+ }
401
+
402
+ // -----------------------------------------------------------------
403
+ // YAML parser (Hugo i18n subset)
404
+ // -----------------------------------------------------------------
405
+
406
+ /**
407
+ * Parse Hugo i18n YAML content into a flat key→value map.
408
+ *
409
+ * Handles the standard Hugo format:
410
+ * key:
411
+ * other: "value"
412
+ * items:
413
+ * one: "singular"
414
+ * other: "plural"
415
+ *
416
+ * @param {string} content - Raw YAML file content
417
+ * @returns {object} Flat key→value map
418
+ */
419
+ function parseYAMLToFlat(content) {
420
+ const tree = parseYAMLNested(content);
421
+ return nestedTreeToFlat(tree);
422
+ }
423
+
424
+ /**
425
+ * Parse YAML into an arbitrarily-deep nested object using an indentation
426
+ * stack — the real fix for silently-dropped middle keys.
427
+ *
428
+ * The previous flat 2-level scanner (parseYAMLSections) could only see a
429
+ * top-level section and its immediate children; a third level like
430
+ * `nav:\n menu:\n home: ...` lost the `menu` container entirely, so the
431
+ * write side (flatToNestedYAML, which DOES support arbitrary depth) round-
432
+ * tripped to a corrupted file. This parser tracks indentation columns on a
433
+ * stack so any depth re-nests correctly.
434
+ *
435
+ * Only the subset Hugo/Docusaurus i18n actually uses is supported: mappings
436
+ * of scalar leaves. Block scalars / sequences are out of scope (and rare in
437
+ * i18n files); a non-mapping line is treated as a scalar leaf.
438
+ *
439
+ * @param {string} content - Raw YAML content
440
+ * @returns {object} Nested object tree
441
+ */
442
+ function parseYAMLNested(content) {
443
+ const root = {};
444
+ // Stack of { indent, node } frames. The top frame is the mapping that
445
+ // the next more-indented key belongs to.
446
+ const stack = [{ indent: -1, node: root }];
447
+
448
+ for (const rawLine of content.split('\n')) {
449
+ // Strip a trailing \r (Windows line endings) and skip blank/comment lines.
450
+ const line = rawLine.replace(/\r$/, '');
451
+ if (!line.trim() || line.trim().startsWith('#')) continue;
452
+
453
+ const indent = line.length - line.trimStart().length;
454
+ const trimmed = line.trim();
455
+
456
+ // Find the key boundary. We only handle `key:` and `key: value` here.
457
+ const colonIdx = trimmed.indexOf(':');
458
+ if (colonIdx < 0) continue; // not a mapping line — skip (out of subset)
459
+
460
+ const key = trimmed.slice(0, colonIdx).trim();
461
+ const valuePart = trimmed.slice(colonIdx + 1).trim();
462
+
463
+ // Pop frames until the top frame is shallower than this line's indent,
464
+ // so `node` is the correct parent mapping for this key.
465
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
466
+ stack.pop();
467
+ }
468
+ const parent = stack[stack.length - 1].node;
469
+
470
+ if (valuePart === '') {
471
+ // A container key: its children live on the following, more-indented
472
+ // lines. Create the child mapping and push it as the active frame.
473
+ const child = {};
474
+ parent[key] = child;
475
+ stack.push({ indent, node: child });
476
+ } else {
477
+ // A scalar leaf.
478
+ parent[key] = unquoteYAML(valuePart);
479
+ }
480
+ }
481
+
482
+ return root;
483
+ }
484
+
485
+ /**
486
+ * Flatten a nested YAML tree into the dotted-key map the rest of the
487
+ * pipeline expects, applying Hugo-aware collapse rules:
488
+ * - A mapping whose ONLY child is `other` collapses to the parent key
489
+ * (Hugo's "simple string" shape: `home:\n other: Home` → home).
490
+ * - Plural/sub-keys and arbitrary nesting flatten to dotted paths.
491
+ *
492
+ * @param {object} tree - Nested object from parseYAMLNested
493
+ * @param {string} [prefix] - Accumulated dotted prefix (internal)
494
+ * @param {object} [out] - Accumulator (internal)
495
+ * @returns {object} Flat key→value map
496
+ */
497
+ function nestedTreeToFlat(tree, prefix = '', out = {}) {
498
+ const keys = Object.keys(tree);
499
+
500
+ for (const key of keys) {
501
+ const value = tree[key];
502
+ const dotted = prefix ? `${prefix}.${key}` : key;
503
+
504
+ if (value !== null && typeof value === 'object') {
505
+ const childKeys = Object.keys(value);
506
+ // Hugo simple-string collapse: a lone `other:` child becomes the
507
+ // parent key itself (only at a Hugo plural section, i.e. leaf mapping).
508
+ if (childKeys.length === 1 && childKeys[0] === 'other' &&
509
+ typeof value.other !== 'object') {
510
+ out[dotted] = value.other;
511
+ } else {
512
+ nestedTreeToFlat(value, dotted, out);
513
+ }
514
+ } else {
515
+ out[dotted] = value;
516
+ }
517
+ }
518
+
519
+ return out;
520
+ }
521
+
522
+ /**
523
+ * Parse YAML into an intermediate section map.
524
+ *
525
+ * @param {string} content - Raw YAML content
526
+ * @returns {Object<string, Object<string, string>>} Section map: { sectionName: { subKey: value } }
527
+ */
528
+ function parseYAMLSections(content) {
529
+ const sections = {};
530
+ let currentSection = null;
531
+
532
+ for (const line of content.split('\n')) {
533
+ // Skip empty lines and comments
534
+ if (!line.trim() || line.trim().startsWith('#')) continue;
535
+
536
+ // Top-level key (no leading whitespace)
537
+ if (!line.startsWith(' ') && !line.startsWith('\t')) {
538
+ // Section with sub-keys: "key:" (nothing after colon, or only whitespace)
539
+ const sectionMatch = line.match(/^([^\s:]+):\s*$/);
540
+ if (sectionMatch) {
541
+ currentSection = sectionMatch[1];
542
+ sections[currentSection] = {};
543
+ continue;
544
+ }
545
+
546
+ // Flat key-value: "key: value"
547
+ const kvMatch = line.match(/^([^\s:]+):\s+(.+)$/);
548
+ if (kvMatch) {
549
+ const key = kvMatch[1];
550
+ const value = unquoteYAML(kvMatch[2]);
551
+ sections[key] = { other: value };
552
+ currentSection = null;
553
+ continue;
554
+ }
555
+ }
556
+
557
+ // Indented sub-key within a section
558
+ if (currentSection && (line.startsWith(' ') || line.startsWith('\t'))) {
559
+ const match = line.trim().match(/^([^\s:]+):\s+(.+)$/);
560
+ if (match) {
561
+ sections[currentSection][match[1]] = unquoteYAML(match[2]);
562
+ }
563
+ }
564
+ }
565
+
566
+ return sections;
567
+ }
568
+
569
+ /**
570
+ * Remove surrounding quotes from a YAML value.
571
+ *
572
+ * @param {string} value - Raw YAML value string
573
+ * @returns {string} Unquoted value
574
+ */
575
+ function unquoteYAML(value) {
576
+ const trimmed = value.trim();
577
+ if (
578
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
579
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
580
+ ) {
581
+ return trimmed.slice(1, -1);
582
+ }
583
+ return trimmed;
584
+ }
585
+
586
+ /**
587
+ * Serialize a flat key→value map to Hugo i18n YAML format.
588
+ *
589
+ * @param {object} flat - Flat key→value map
590
+ * @returns {string} YAML content
591
+ */
592
+ function flatToYAML(flat) {
593
+ const grouped = groupFlatKeys(flat);
594
+ const lines = [];
595
+
596
+ for (const [section, values] of Object.entries(grouped)) {
597
+ lines.push(`${section}:`);
598
+ for (const [subKey, value] of Object.entries(values)) {
599
+ lines.push(` ${subKey}: ${quoteYAMLValue(String(value))}`);
600
+ }
601
+ }
602
+
603
+ return lines.join('\n') + '\n';
604
+ }
605
+
606
+ /**
607
+ * Serialize a flat key→value map to standard nested YAML.
608
+ *
609
+ * Re-nests dot-separated keys into a tree structure:
610
+ * { "nav.home": "Home", "nav.about": "About" }
611
+ * Becomes:
612
+ * nav:
613
+ * home: Home
614
+ * about: About
615
+ *
616
+ * Does NOT add Hugo-specific "other:" wrapping.
617
+ * Handles arbitrarily deep nesting (a.b.c.d → 4 levels).
618
+ *
619
+ * @param {object} flat - Flat key→value map
620
+ * @returns {string} YAML content
621
+ */
622
+ function flatToNestedYAML(flat) {
623
+ // Build tree from flat keys
624
+ const tree = {};
625
+ for (const [key, value] of Object.entries(flat)) {
626
+ const parts = key.split('.');
627
+ let current = tree;
628
+ for (let i = 0; i < parts.length - 1; i++) {
629
+ if (!(parts[i] in current) || typeof current[parts[i]] !== 'object') {
630
+ current[parts[i]] = {};
631
+ }
632
+ current = current[parts[i]];
633
+ }
634
+ current[parts[parts.length - 1]] = value;
635
+ }
636
+
637
+ // Recursively serialize the tree to YAML with proper indentation
638
+ return serializeNestedYAML(tree, 0);
639
+ }
640
+
641
+ /**
642
+ * Recursively serialize a nested object tree to YAML.
643
+ *
644
+ * @param {object} obj - Nested object
645
+ * @param {number} indent - Current indentation level (in spaces)
646
+ * @returns {string} YAML content
647
+ */
648
+ function serializeNestedYAML(obj, indent) {
649
+ const lines = [];
650
+ const pad = ' '.repeat(indent);
651
+
652
+ for (const [key, value] of Object.entries(obj)) {
653
+ if (typeof value === 'object' && value !== null) {
654
+ lines.push(`${pad}${key}:`);
655
+ lines.push(serializeNestedYAML(value, indent + 2));
656
+ } else {
657
+ lines.push(`${pad}${key}: ${quoteYAMLValue(String(value))}`);
658
+ }
659
+ }
660
+
661
+ // Only add trailing newline at the top level
662
+ return indent === 0 ? lines.join('\n') + '\n' : lines.join('\n');
663
+ }
664
+
665
+ /**
666
+ * Quote a YAML value if it contains special characters.
667
+ *
668
+ * Shared by flatToYAML (Hugo) and flatToNestedYAML (standard) to ensure
669
+ * consistent quoting behavior across both serializers.
670
+ *
671
+ * @param {string} strValue - Value to potentially quote
672
+ * @returns {string} Quoted or bare value
673
+ */
674
+ function quoteYAMLValue(strValue) {
675
+ const needsQuotes = strValue.includes(':') || strValue.includes('#') ||
676
+ strValue.includes('{') || strValue.includes('}') ||
677
+ strValue.includes('[') || strValue.includes(']') ||
678
+ strValue.startsWith(' ') || strValue.endsWith(' ') ||
679
+ strValue.includes('"') || strValue.includes("'") ||
680
+ strValue === '' || strValue === 'true' || strValue === 'false' ||
681
+ strValue === 'null' || strValue === 'yes' || strValue === 'no';
682
+ return needsQuotes
683
+ ? `"${strValue.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
684
+ : strValue;
685
+ }
686
+
687
+ /**
688
+ * Detect whether a YAML file uses Hugo i18n format or standard nested format.
689
+ *
690
+ * Hugo i18n YAML uses CLDR plural sub-keys exclusively:
691
+ * home:
692
+ * other: "Home"
693
+ * items:
694
+ * one: "{{ .Count }} item"
695
+ * other: "{{ .Count }} items"
696
+ *
697
+ * Standard nested YAML uses arbitrary sub-keys:
698
+ * nav:
699
+ * home: Home
700
+ * about: About Us
701
+ *
702
+ * Detection: if ALL sub-keys across ALL sections are CLDR plural forms → 'hugo'.
703
+ * If any sub-key is not a plural form → 'nested'.
704
+ * Empty content defaults to 'nested'.
705
+ *
706
+ * @param {string} content - Raw YAML file content
707
+ * @returns {'hugo'|'nested'} Detected YAML style
708
+ */
709
+ function detectYAMLStyle(content) {
710
+ if (!content || !content.trim()) return 'nested';
711
+
712
+ // Use the full nested parser, not the old 2-level scanner: a deep file like
713
+ // `nav:\n menu:\n home: …` would otherwise leave the parser blind to the
714
+ // `menu` container and misclassify the file as 'hugo', re-serializing it
715
+ // through the wrong writer and corrupting it.
716
+ const tree = parseYAMLNested(content);
717
+ const sectionEntries = Object.entries(tree);
718
+
719
+ if (sectionEntries.length === 0) return 'nested';
720
+
721
+ // Hugo i18n is exactly one level deep, and every section's sub-keys are
722
+ // CLDR plural forms. Any non-plural sub-key, or any deeper nesting, means
723
+ // standard nested YAML.
724
+ for (const [, values] of sectionEntries) {
725
+ if (values === null || typeof values !== 'object') continue;
726
+ for (const [subKey, subVal] of Object.entries(values)) {
727
+ if (!PLURAL_FORMS.has(subKey)) return 'nested';
728
+ // A plural-named key that itself nests another mapping is not Hugo.
729
+ if (subVal !== null && typeof subVal === 'object') return 'nested';
730
+ }
731
+ }
732
+
733
+ return 'hugo';
734
+ }
735
+
736
+ // -----------------------------------------------------------------
737
+ // Shared utilities
738
+ // -----------------------------------------------------------------
739
+
740
+ /**
741
+ * Convert an intermediate section map to a flat key→value map.
742
+ *
743
+ * If a section has only 'other', flatten to { section: value }.
744
+ * If a section has plural forms, flatten to { section.one: ..., section.other: ... }.
745
+ *
746
+ * @param {object} sections - { sectionName: { subKey: value } }
747
+ * @returns {object} Flat key→value map
748
+ */
749
+ function sectionsToFlat(sections) {
750
+ const flat = {};
751
+ for (const [section, values] of Object.entries(sections)) {
752
+ const subKeys = Object.keys(values);
753
+ if (subKeys.length === 1 && subKeys[0] === 'other') {
754
+ // Simple string — just use the section name as the flat key
755
+ flat[section] = values.other;
756
+ } else {
757
+ // Plural forms or multiple sub-keys — preserve the structure
758
+ for (const [subKey, value] of Object.entries(values)) {
759
+ flat[`${section}.${subKey}`] = value;
760
+ }
761
+ }
762
+ }
763
+ return flat;
764
+ }
765
+
766
+ /**
767
+ * Group flat keys back into section → { subKey: value } structure
768
+ * for TOML/YAML serialization.
769
+ *
770
+ * Keys without plural suffixes get wrapped as { other: value }.
771
+ * Keys ending in a CLDR plural form (one, other, few, etc.)
772
+ * are grouped under their parent section.
773
+ *
774
+ * @param {object} flat - Flat key→value map
775
+ * @returns {object} Grouped sections: { section: { subKey: value } }
776
+ */
777
+ function groupFlatKeys(flat) {
778
+ const sections = {};
779
+
780
+ for (const [key, value] of Object.entries(flat)) {
781
+ const lastDot = key.lastIndexOf('.');
782
+ const possibleSection = lastDot > 0 ? key.substring(0, lastDot) : null;
783
+ const possibleSubKey = lastDot > 0 ? key.substring(lastDot + 1) : null;
784
+
785
+ if (possibleSubKey && PLURAL_FORMS.has(possibleSubKey)) {
786
+ // This is a plural form — group under the parent section
787
+ if (!sections[possibleSection]) sections[possibleSection] = {};
788
+ sections[possibleSection][possibleSubKey] = value;
789
+ } else {
790
+ // Simple string — wrap as { other: value }
791
+ if (!sections[key]) sections[key] = {};
792
+ sections[key].other = value;
793
+ }
794
+ }
795
+
796
+ return sections;
797
+ }
798
+
799
+ // -----------------------------------------------------------------
800
+ // Docusaurus format helpers
801
+ //
802
+ // Docusaurus i18n JSON files use a {message, description} wrapper:
803
+ //
804
+ // {
805
+ // "theme.blog.title": {
806
+ // "message": "Blog",
807
+ // "description": "The title of the blog page"
808
+ // }
809
+ // }
810
+ //
811
+ // These helpers convert between this format and the flat key→value
812
+ // strings that champollion's diff/translate pipeline expects.
813
+ //
814
+ // WHY separate functions (not modifying readLocaleFile/writeLocaleFile):
815
+ // The Docusaurus format is structurally different from nested JSON,
816
+ // TOML, or YAML. Routing it through readLocaleFile would require
817
+ // format-aware branching inside that function, which would risk
818
+ // breaking the existing JSON path. Keeping these as standalone
819
+ // helpers that the Docusaurus sync path calls directly is safer.
820
+ // -----------------------------------------------------------------
821
+
822
+ /**
823
+ * Check if a parsed JSON object uses Docusaurus's {message, description} format.
824
+ *
825
+ * Detection heuristic: sample the first few values and check if they
826
+ * are objects with a 'message' string field. Docusaurus code.json has
827
+ * 90+ keys all in this format, so even a small sample is definitive.
828
+ *
829
+ * @param {object} data - Parsed JSON object
830
+ * @returns {boolean} True if the data uses Docusaurus message format
831
+ */
832
+ function isDocusaurusJSON(data) {
833
+ if (!data || typeof data !== 'object') return false;
834
+
835
+ const values = Object.values(data);
836
+ if (values.length === 0) return false;
837
+
838
+ // Sample up to 5 values — if all are {message: string} objects,
839
+ // this is Docusaurus format. A single flat-string value disqualifies.
840
+ const sample = values.slice(0, 5);
841
+ return sample.every(
842
+ val => typeof val === 'object' && val !== null && typeof val.message === 'string'
843
+ );
844
+ }
845
+
846
+ /**
847
+ * Extract translatable message strings from a Docusaurus JSON file.
848
+ *
849
+ * Converts:
850
+ * { "key": { "message": "Hello", "description": "..." } }
851
+ * To:
852
+ * { "key": "Hello" }
853
+ *
854
+ * Keys whose values are plain strings (not wrapped in {message}) are
855
+ * passed through as-is, for forward compatibility with any Docusaurus
856
+ * files that mix formats.
857
+ *
858
+ * @param {object} data - Parsed Docusaurus JSON
859
+ * @returns {object} Flat key→message map
860
+ */
861
+ function extractDocusaurusMessages(data) {
862
+ const flat = {};
863
+ for (const [key, val] of Object.entries(data)) {
864
+ if (typeof val === 'object' && val !== null && 'message' in val) {
865
+ flat[key] = val.message;
866
+ } else if (typeof val === 'string') {
867
+ flat[key] = val;
868
+ }
869
+ // Skip non-string, non-object values (shouldn't exist in Docusaurus files)
870
+ }
871
+ return flat;
872
+ }
873
+
874
+ /**
875
+ * Extract description context from Docusaurus {message, description} JSON.
876
+ *
877
+ * Returns a flat key→description map for entries that have a non-empty
878
+ * description field. Keys without descriptions are omitted (not set to null).
879
+ *
880
+ * WHY: Docusaurus includes developer-written descriptions like
881
+ * "The title of the blog page" or "Button to submit a form"
882
+ * that explain the string's UI context. Feeding these to the LLM
883
+ * alongside the source text improves translation accuracy — the model
884
+ * knows whether "Post" means "submit" or "blog post".
885
+ *
886
+ * @param {object} data - Parsed Docusaurus JSON
887
+ * @returns {object} Flat key→description map (only keys with descriptions)
888
+ */
889
+ function extractDocusaurusDescriptions(data) {
890
+ const descriptions = {};
891
+ for (const [key, val] of Object.entries(data)) {
892
+ if (
893
+ typeof val === 'object' &&
894
+ val !== null &&
895
+ typeof val.description === 'string' &&
896
+ val.description.trim().length > 0
897
+ ) {
898
+ descriptions[key] = val.description;
899
+ }
900
+ }
901
+ return descriptions;
902
+ }
903
+
904
+ /**
905
+ * Inject translated messages back into the Docusaurus {message, description} format.
906
+ *
907
+ * Preserves the original 'description' field (and any other metadata Docusaurus
908
+ * might add in future versions) for each key. Only the 'message' field is replaced.
909
+ *
910
+ * Keys in translatedFlat that don't exist in sourceData are ignored (defense).
911
+ *
912
+ * @param {object} sourceData - Original parsed Docusaurus JSON (with descriptions)
913
+ * @param {object} translatedFlat - Flat key→translated message map
914
+ * @returns {object} Docusaurus JSON with translated messages, descriptions preserved
915
+ */
916
+ function injectDocusaurusMessages(sourceData, translatedFlat) {
917
+ const result = {};
918
+ for (const [key, val] of Object.entries(sourceData)) {
919
+ if (typeof val === 'object' && val !== null && 'message' in val) {
920
+ result[key] = {
921
+ ...val, // preserve description and any other metadata
922
+ message: key in translatedFlat ? translatedFlat[key] : val.message,
923
+ };
924
+ } else if (typeof val === 'string') {
925
+ result[key] = key in translatedFlat ? translatedFlat[key] : val;
926
+ } else {
927
+ result[key] = val;
928
+ }
929
+ }
930
+ return result;
931
+ }
932
+
933
+ export {
934
+ detectFormat,
935
+ detectFormatFromDir,
936
+ getExtension,
937
+ readLocaleFile,
938
+ writeLocaleFile,
939
+ parseTOMLToFlat,
940
+ parseYAMLToFlat,
941
+ flatToTOML,
942
+ flatToYAML,
943
+ flatToNestedYAML,
944
+ detectYAMLStyle,
945
+ quoteYAMLValue,
946
+ sectionsToFlat,
947
+ groupFlatKeys,
948
+ PLURAL_FORMS,
949
+ isDocusaurusJSON,
950
+ extractDocusaurusMessages,
951
+ extractDocusaurusDescriptions,
952
+ injectDocusaurusMessages,
953
+ };
954
+