champollion 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +52 -37
  2. package/bin/cli.js +53 -5
  3. package/index.js +63 -2
  4. package/lib/api-key.js +17 -4
  5. package/lib/autofix.js +83 -36
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +51 -3
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +289 -88
  11. package/lib/commands/audit.js +10 -3
  12. package/lib/commands/card.js +583 -226
  13. package/lib/commands/doctor.js +54 -18
  14. package/lib/commands/help.js +37 -32
  15. package/lib/commands/init.js +1689 -87
  16. package/lib/commands/integrity.js +127 -40
  17. package/lib/commands/leaderboard.js +187 -67
  18. package/lib/commands/models.js +9 -2
  19. package/lib/commands/provenance.js +7 -2
  20. package/lib/commands/recommend.js +43 -14
  21. package/lib/commands/register-corpus.js +649 -130
  22. package/lib/commands/seal-corpus.js +1 -1
  23. package/lib/commands/status.js +564 -27
  24. package/lib/commands/submit.js +17 -12
  25. package/lib/commands/sync.js +31 -7
  26. package/lib/commands/tm.js +16 -10
  27. package/lib/commands/verify.js +27 -3
  28. package/lib/commands/wrap.js +63 -5
  29. package/lib/commands/xliff.js +135 -64
  30. package/lib/commercial-eligibility.js +1 -1
  31. package/lib/config.js +196 -14
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +270 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1127 -344
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +197 -38
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +726 -78
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +965 -253
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +964 -0
  44. package/lib/file-scope.js +106 -0
  45. package/lib/flatten.js +80 -3
  46. package/lib/flutter-locales.js +124 -0
  47. package/lib/format.js +266 -12
  48. package/lib/hash.js +146 -21
  49. package/lib/icu-structure.js +929 -0
  50. package/lib/integrity.js +223 -75
  51. package/lib/language-pair.js +157 -0
  52. package/lib/lint.js +78 -16
  53. package/lib/local-only-marks.js +106 -0
  54. package/lib/locale-layout.js +1103 -0
  55. package/lib/locale-state.js +571 -0
  56. package/lib/methods/anthropic.js +5 -0
  57. package/lib/methods/apertium.js +6 -3
  58. package/lib/methods/api.js +138 -25
  59. package/lib/methods/base.js +17 -0
  60. package/lib/methods/coaching-data.js +153 -0
  61. package/lib/methods/content-separator.js +43 -0
  62. package/lib/methods/deepl.js +1 -1
  63. package/lib/methods/direct-llm.js +252 -103
  64. package/lib/methods/external.js +146 -63
  65. package/lib/methods/gemini.js +1 -0
  66. package/lib/methods/google-translate.js +1 -0
  67. package/lib/methods/http-utils.js +41 -0
  68. package/lib/methods/libretranslate.js +7 -2
  69. package/lib/methods/llm-coached.js +68 -128
  70. package/lib/methods/llm.js +80 -31
  71. package/lib/methods/local.js +93 -10
  72. package/lib/methods/microsoft-translator.js +1 -2
  73. package/lib/methods/openai.js +4 -2
  74. package/lib/methods/openrouter-client.js +20 -19
  75. package/lib/methods/openrouter-pricing.js +150 -13
  76. package/lib/methods/prompt-methods.js +20 -0
  77. package/lib/methods/provider-pricing.js +42 -1
  78. package/lib/methods/request-capture.js +104 -0
  79. package/lib/methods/tilde.js +1 -1
  80. package/lib/methods/translated.js +1 -2
  81. package/lib/missing-key.js +93 -0
  82. package/lib/models.js +11 -0
  83. package/lib/name-rules.js +32 -0
  84. package/lib/named-keys.js +172 -0
  85. package/lib/no-translate.js +4 -3
  86. package/lib/output.js +160 -19
  87. package/lib/pairs.js +586 -30
  88. package/lib/placeholders.js +394 -0
  89. package/lib/plugins.js +8 -0
  90. package/lib/plural-gap-redo.js +109 -0
  91. package/lib/plurals.js +323 -0
  92. package/lib/po.js +1187 -0
  93. package/lib/public-catalogue.js +74 -0
  94. package/lib/recommend.js +527 -32
  95. package/lib/redo.js +95 -0
  96. package/lib/refusal-category.js +44 -0
  97. package/lib/registers.js +255 -11
  98. package/lib/repair-script.js +20 -13
  99. package/lib/scripts.js +193 -106
  100. package/lib/seal.mjs +6 -5
  101. package/lib/sealed-qualifier.mjs +2 -2
  102. package/lib/segment.js +2 -1
  103. package/lib/seo.js +19 -9
  104. package/lib/serve.js +43 -6
  105. package/lib/shared-output-seed.js +164 -0
  106. package/lib/source-contexts.js +39 -0
  107. package/lib/submit.mjs +57 -5
  108. package/lib/sync.js +2923 -474
  109. package/lib/terminology.js +13 -4
  110. package/lib/tm-evict.js +179 -0
  111. package/lib/tm-seed.js +5 -2
  112. package/lib/tm.js +818 -36
  113. package/lib/translate-pair.js +639 -34
  114. package/lib/translate.js +78 -5
  115. package/lib/types.js +22 -3
  116. package/lib/validate.js +880 -17
  117. package/lib/verify.js +1296 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +3 -2
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/DATA-SOVEREIGNTY.md +19 -20
  123. package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
  124. package/shared/cards-fallback.json +1 -1
  125. package/shared/catalogue/card-config.json +1 -1
  126. package/shared/curated-orthography-conventions.json +26 -8
  127. package/shared/docent/faq.en.json +14 -16
  128. package/shared/docent/system-prompt.md +17 -19
  129. package/shared/explainers/tc-features.json +15 -15
  130. package/shared/gettext-plural-forms.json +45 -0
  131. package/shared/human-services.json +1 -1
  132. package/shared/method-registry.json +2 -0
  133. package/shared/metric-registry.json +96 -18
  134. package/shared/schemas/champollion-plugin.schema.json +4 -0
  135. package/shared/schemas/corpora-card.schema.json +20 -10
  136. package/shared/schemas/human-services.schema.json +2 -2
  137. package/shared/schemas/language-card.schema.json +1 -1
  138. package/shared/schemas/method-card.schema.json +1 -1
  139. package/shared/schemas/method-index-record.schema.json +67 -0
  140. package/shared/schemas/method-registry.schema.json +4 -0
  141. package/shared/schemas/metric-registry.schema.json +55 -1
  142. package/shared/docent/corpus.json +0 -11333
@@ -0,0 +1,1103 @@
1
+ /**
2
+ * Locale layout — WHERE a project's key-value locale files live, for every
3
+ * locale, in one place.
4
+ *
5
+ * WHY THIS EXISTS: every command used to rebuild `<localesDir>/<code><ext>`
6
+ * by hand (sync, the cost estimate, verify, integrity, xliff, watch, lint,
7
+ * repair-script, wrap). That single shape is right for Hugo, vue-i18n and
8
+ * next-intl, and wrong for i18next — `public/locales/en/common.json`, one
9
+ * folder per language, one file per namespace — which is the most common
10
+ * layout in the React world. Users of those projects were told to write
11
+ * their own flatten/unflatten wrapper. This module is the one answer to
12
+ * "which files make up locale X?", so every caller agrees by construction.
13
+ *
14
+ * LAYOUTS
15
+ * flat <localesDir>/<code><ext> (today's behaviour; one file)
16
+ * dir <localesDir>/<code>/**\/<ns><ext> (one folder per locale; each
17
+ * file is a namespace, which
18
+ * may be a nested path such
19
+ * as "admin/users")
20
+ * pattern config `localesPattern` with {lang} and optional {ns}, e.g.
21
+ * public/locales/{lang}/{ns}.json
22
+ * lib/l10n/app_{lang}.arb
23
+ * locale/{lang}/LC_MESSAGES/{ns}.po
24
+ *
25
+ * AUTO-DETECTION (no localesPattern, no localesLayout): if
26
+ * `<localesDir>/<inputLocale>/` is a directory holding locale files the
27
+ * layout is `dir`; otherwise `flat`. When BOTH `<localesDir>/en.json` and a
28
+ * populated `<localesDir>/en/` exist the answer is ambiguous and we refuse
29
+ * to guess — silently picking one would translate the wrong files.
30
+ *
31
+ * FORMAT IS PASSED THROUGH, NEVER ASSUMED. Each file entry carries the format
32
+ * implied by its real extension (.yml stays .yml — the old getExtension()
33
+ * round trip turned a `.yml` source into a search for `.yaml`). A format the
34
+ * reader does not implement is refused loudly, never parsed as JSON.
35
+ *
36
+ * DOCUMENT FORMATS (.po, .arb). A gettext catalog or an ARB file is a
37
+ * document around the strings — headers, comments, plural forms, metadata.
38
+ * Every target file therefore knows its SOURCE file (`sourcePath`), and the
39
+ * writer rebuilds the target from the source's structure plus the target's
40
+ * own untouched parts (lib/po.js, lib/format.js serializeARB). Each file
41
+ * also knows its `role`: a gettext SOURCE reads msgid where msgstr is empty,
42
+ * a target reads only translated, non-fuzzy entries.
43
+ *
44
+ * GETTEXT TEMPLATES. When the source language has no .po of its own, the
45
+ * source is the template (.pot):
46
+ * flat the one .pot in localesDir (po/fr.po + po/messages.pot);
47
+ * pattern `<name>.pot` beside the source's would-be file, in the
48
+ * pattern's base folder or in the folder above it — <name>
49
+ * is the {ns} (one template per namespace) or the pattern's
50
+ * file name without {lang} ("messages.po" → messages.pot;
51
+ * a file name that is only {lang} takes the one .pot there);
52
+ * dir not searched — a folder-per-language project keeps its
53
+ * source catalog in <localesDir>/<source>/ (Django:
54
+ * `django-admin makemessages -l en`).
55
+ * Two candidate templates where one is expected is an error, never a guess.
56
+ *
57
+ * NAMESPACED KEYS. Wherever two files' keys share one space (the lock
58
+ * manifest, XLIFF unit ids, dry-run key lists, --force-keys) a key is
59
+ * written `<ns>::<key>`. Flat layouts (and a pattern without {ns}) have a
60
+ * single file and keep bare keys, so their lock files are byte-for-byte what
61
+ * they were. The Translation Memory is NOT namespaced: it is keyed by source
62
+ * TEXT, so an identical string in two files is translated once and reused
63
+ * for free (founder cost-first rule).
64
+ *
65
+ * Docusaurus keeps its own lane (lib/docusaurus-sync.js) — its JSON files
66
+ * carry {message, description} objects and a plugin directory structure —
67
+ * so this module refuses format 'docusaurus' rather than half-handling it.
68
+ */
69
+
70
+ import fs from 'node:fs';
71
+ import path from 'node:path';
72
+ import {
73
+ detectFormatFromDir, readLocaleFile, writeLocaleFile, detectYAMLStyle, LOCALE_FILE_FORMATS,
74
+ readLocaleContext, emptyDocumentContent,
75
+ } from './format.js';
76
+ import { flattenKeys } from './flatten.js';
77
+ import { isUnsafeKey } from './security.js';
78
+ import { findPluralGroups, expandPluralsForLocale } from './plurals.js';
79
+
80
+ /** Separator between namespace and key in every shared key space. */
81
+ const NS_SEPARATOR = '::';
82
+
83
+ /** Values accepted by the `localesLayout` config field (pattern = localesPattern). */
84
+ const LAYOUT_KINDS = ['flat', 'dir'];
85
+
86
+ /**
87
+ * Extension → format. Ordered: the first extension listed for a format is
88
+ * the one written when a project has no file to copy it from. A gettext
89
+ * template (.pot) is not a locale file — it is found separately, as a
90
+ * source (see "GETTEXT TEMPLATES" above).
91
+ */
92
+ const FORMAT_BY_EXT = Object.freeze({
93
+ '.json': 'json',
94
+ '.toml': 'toml',
95
+ '.yaml': 'yaml',
96
+ '.yml': 'yaml',
97
+ '.po': 'po',
98
+ '.arb': 'arb',
99
+ });
100
+
101
+ /** Directory names never walked when discovering locale files. */
102
+ const WALK_SKIP = new Set(['node_modules', '.git']);
103
+
104
+ /**
105
+ * Map a file extension (".yml") to its locale format ("yaml").
106
+ *
107
+ * @param {string} ext - Extension including the dot
108
+ * @returns {string|null} Format name, or null for an unrecognised extension
109
+ */
110
+ function formatForExtension(ext) {
111
+ return FORMAT_BY_EXT[String(ext).toLowerCase()] || null;
112
+ }
113
+
114
+ /**
115
+ * Every extension that carries a given format, preferred first.
116
+ *
117
+ * @param {string} format - 'json' | 'toml' | 'yaml' | 'po' | 'arb'
118
+ * @returns {string[]} Extensions including the dot (empty for unknown formats)
119
+ */
120
+ function extensionsForFormat(format) {
121
+ return Object.entries(FORMAT_BY_EXT).filter(([, f]) => f === format).map(([e]) => e);
122
+ }
123
+
124
+ /** Path → forward-slash form, so namespaces and patterns read the same on every OS. */
125
+ function toPosix(p) {
126
+ return p.split(path.sep).join('/');
127
+ }
128
+
129
+ function isDirectory(p) {
130
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
131
+ }
132
+
133
+ function isFile(p) {
134
+ try { return fs.statSync(p).isFile(); } catch { return false; }
135
+ }
136
+
137
+ /**
138
+ * Recursively list the files under `dir` that `accept` keeps — the ONE
139
+ * folder walk behind every per-locale folder in the CLI: this module's
140
+ * `dir` and `pattern` layouts, and the Docusaurus lane's i18n/<locale>/
141
+ * JSON discovery (lib/docusaurus-sync.js discoverDocusaurusJSONFiles).
142
+ *
143
+ * @param {string} dir - Absolute directory (missing → [])
144
+ * @param {(name: string) => boolean} accept - File-name filter
145
+ * @param {{ skipHidden?: boolean, maxDepth?: number }} [options] -
146
+ * skipHidden (default true) also skips node_modules/.git; maxDepth 1 =
147
+ * only `dir` itself
148
+ * @returns {string[]} Absolute paths, sorted
149
+ */
150
+ function walkFiles(dir, accept, { skipHidden = true, maxDepth = Infinity } = {}) {
151
+ const out = [];
152
+ function walk(d, depth) {
153
+ let entries;
154
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
155
+ for (const entry of entries) {
156
+ if (skipHidden && (entry.name.startsWith('.') || WALK_SKIP.has(entry.name))) continue;
157
+ const full = path.join(d, entry.name);
158
+ if (entry.isDirectory()) {
159
+ if (depth < maxDepth) walk(full, depth + 1);
160
+ } else if (entry.isFile() && accept(entry.name)) {
161
+ out.push(full);
162
+ }
163
+ }
164
+ }
165
+ walk(dir, 1);
166
+ return out.sort();
167
+ }
168
+
169
+ /**
170
+ * Locale files (any known locale extension) under `dir`.
171
+ *
172
+ * @param {string} dir - Absolute directory
173
+ * @param {number} [maxDepth=Infinity]
174
+ * @returns {string[]} Absolute paths, sorted
175
+ */
176
+ function walkLocaleFiles(dir, maxDepth = Infinity) {
177
+ return walkFiles(dir, name => !!formatForExtension(path.extname(name)), { maxDepth });
178
+ }
179
+
180
+ /**
181
+ * Throw a config error with the code resolveConfig() and the CLI map to a
182
+ * clean, unwrapped message.
183
+ */
184
+ function layoutError(message) {
185
+ const e = new Error(message);
186
+ e.code = 'CHAMPOLLION_CONFIG_INVALID';
187
+ return e;
188
+ }
189
+
190
+ // -----------------------------------------------------------------
191
+ // localesPattern
192
+ // -----------------------------------------------------------------
193
+
194
+ /**
195
+ * Compile a `localesPattern` ("public/locales/{lang}/{ns}.json") into the
196
+ * pieces discovery and rendering need.
197
+ *
198
+ * Rules (each violation fails loud — a pattern that silently matched
199
+ * nothing would make sync report "fully synced" on a project it never read):
200
+ * - {lang} is required; {ns} is optional and may appear at most once.
201
+ * - No other {placeholder}.
202
+ * - The file name must end in an extension that is not a placeholder.
203
+ * - {lang} may repeat (e.g. "{lang}/app_{lang}.arb"); every occurrence
204
+ * must name the same locale.
205
+ *
206
+ * @param {string} pattern - Absolute (or cwd-relative) pattern
207
+ * @returns {{ pattern: string, base: string, ext: string, hasNs: boolean,
208
+ * regex: RegExp, maxDepth: number, render: (lang: string, ns?: string) => string }}
209
+ */
210
+ function compileLocalesPattern(pattern) {
211
+ if (typeof pattern !== 'string' || pattern.trim() === '') {
212
+ throw layoutError('"localesPattern" must be a non-empty string such as "public/locales/{lang}/{ns}.json".');
213
+ }
214
+ const abs = path.resolve(pattern);
215
+ const posix = toPosix(abs);
216
+ const placeholders = [...posix.matchAll(/\{([^}]*)\}/g)].map(m => m[1]);
217
+ const unknown = placeholders.filter(p => p !== 'lang' && p !== 'ns');
218
+ if (unknown.length > 0) {
219
+ throw layoutError(
220
+ `"localesPattern" has unknown placeholder(s) ${unknown.map(u => `{${u}}`).join(', ')} — `
221
+ + 'only {lang} and {ns} are supported.');
222
+ }
223
+ if (!placeholders.includes('lang')) {
224
+ throw layoutError(`"localesPattern" must contain {lang} (got "${pattern}").`);
225
+ }
226
+ if (placeholders.filter(p => p === 'ns').length > 1) {
227
+ throw layoutError(`"localesPattern" may contain {ns} at most once (got "${pattern}").`);
228
+ }
229
+ const fileName = posix.slice(posix.lastIndexOf('/') + 1);
230
+ const ext = path.extname(fileName.replace(/\{[^}]*\}/g, 'X'));
231
+ if (!ext || /\{/.test(ext) || fileName.endsWith('}')) {
232
+ throw layoutError(
233
+ `"localesPattern" must end in a file extension, e.g. "{lang}/{ns}.json" (got "${pattern}").`);
234
+ }
235
+
236
+ // Static base directory: everything before the segment holding the first
237
+ // placeholder. Discovery walks only this tree.
238
+ const firstPh = posix.indexOf('{');
239
+ const base = posix.slice(0, posix.lastIndexOf('/', firstPh)) || '/';
240
+
241
+ // Build an anchored regex. {lang} is one path segment fragment (never a
242
+ // slash); {ns} may span directories ("admin/users").
243
+ let seenLang = false;
244
+ let src = '';
245
+ let last = 0;
246
+ for (const m of posix.matchAll(/\{(lang|ns)\}/g)) {
247
+ src += escapeRegex(posix.slice(last, m.index));
248
+ if (m[1] === 'lang') {
249
+ src += seenLang ? '\\k<lang>' : '(?<lang>[^/]+?)';
250
+ seenLang = true;
251
+ } else {
252
+ src += '(?<ns>.+?)';
253
+ }
254
+ last = m.index + m[0].length;
255
+ }
256
+ src += escapeRegex(posix.slice(last));
257
+ const regex = new RegExp(`^${src}$`);
258
+
259
+ const hasNs = placeholders.includes('ns');
260
+ // Without {ns} the depth below `base` is fixed by the pattern; with {ns}
261
+ // namespaces may nest arbitrarily deep.
262
+ const maxDepth = hasNs ? Infinity : posix.slice(base.length + 1).split('/').length;
263
+
264
+ const render = (lang, ns = '') => {
265
+ const out = posix.replace(/\{lang\}/g, lang).replace(/\{ns\}/g, ns);
266
+ return path.normalize(out.split('/').join(path.sep));
267
+ };
268
+
269
+ return { pattern, base: path.normalize(base), ext, hasNs, regex, maxDepth, render };
270
+ }
271
+
272
+ function escapeRegex(s) {
273
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
274
+ }
275
+
276
+ // -----------------------------------------------------------------
277
+ // Layout discovery
278
+ // -----------------------------------------------------------------
279
+
280
+ /**
281
+ * Resolve the format + extension of a flat project's source file.
282
+ *
283
+ * Explicit format: that format's extensions, preferring one that exists
284
+ * (so `format: "yaml"` finds `en.yml`). Auto: whichever `<inputLocale>.<ext>`
285
+ * actually exists; when several do, the directory's majority format
286
+ * (detectFormatFromDir — the pre-layout behaviour) breaks the tie; when none
287
+ * does, the majority format names the file the "source not found" error
288
+ * points at.
289
+ */
290
+ function resolveFlatSource(localesDir, inputLocale, explicitFormat) {
291
+ const exists = (ext) => isFile(path.join(localesDir, `${inputLocale}${ext}`));
292
+ if (explicitFormat) {
293
+ const exts = extensionsForFormat(explicitFormat);
294
+ if (exts.length === 0) {
295
+ throw layoutError(`Unknown locale format "${explicitFormat}".`);
296
+ }
297
+ return { format: explicitFormat, ext: exts.find(exists) || exts[0] };
298
+ }
299
+ const present = Object.keys(FORMAT_BY_EXT).filter(exists);
300
+ const families = [...new Set(present.map(formatForExtension))];
301
+ if (families.length === 1) {
302
+ return { format: families[0], ext: present[0] };
303
+ }
304
+ const majority = detectFormatFromDir(localesDir);
305
+ if (families.length === 0) {
306
+ return { format: majority, ext: extensionsForFormat(majority)[0] };
307
+ }
308
+ if (families.includes(majority)) {
309
+ return { format: majority, ext: present.find(e => formatForExtension(e) === majority) };
310
+ }
311
+ throw layoutError(
312
+ `Found more than one source locale file (${present.map(e => inputLocale + e).join(', ')}) in ${localesDir} — `
313
+ + 'set "format" in champollion.config.json to say which one is the source.');
314
+ }
315
+
316
+ /** gettext templates (*.pot) directly in `dir`, sorted. */
317
+ function findTemplates(dir) {
318
+ try {
319
+ return fs.readdirSync(dir)
320
+ .filter(n => n.endsWith('.pot') && isFile(path.join(dir, n)))
321
+ .sort()
322
+ .map(n => path.join(dir, n));
323
+ } catch {
324
+ return [];
325
+ }
326
+ }
327
+
328
+ function tooManyTemplates(dir, pots, inputLocale) {
329
+ return layoutError(
330
+ `Found several gettext templates in ${dir} (${pots.map(p => path.basename(p)).join(', ')}) and no `
331
+ + `${inputLocale} catalog — keep one template there, or create the source catalog `
332
+ + `(e.g. \`msginit --locale=${inputLocale} --no-translator\`), so the source is not a guess.`);
333
+ }
334
+
335
+ /** A LocaleFile for a template standing in for the source. */
336
+ function templateFile(pot, ns, inputLocale, base) {
337
+ return {
338
+ ns, code: inputLocale, path: pot, ext: '.pot', format: 'po',
339
+ rel: toPosix(path.relative(base, pot)), role: 'source', sourcePath: pot, template: true,
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Add role + sourcePath to a layout's file factory: a target file knows the
345
+ * source file it mirrors (the document formats are written from it).
346
+ */
347
+ function withSource(fileFor, sourceFiles, inputLocale) {
348
+ const byNs = new Map(sourceFiles.map(f => [f.ns, f.path]));
349
+ return (code, ns = '') => {
350
+ const f = fileFor(code, ns);
351
+ const role = code === inputLocale ? 'source' : 'target';
352
+ return { ...f, role, sourcePath: byNs.get(ns) ?? (role === 'source' ? f.path : null) };
353
+ };
354
+ }
355
+
356
+ /**
357
+ * Choose the format family for a set of discovered files: the explicit
358
+ * format when configured, else the most common one (ties → FORMAT_BY_EXT
359
+ * order, i.e. json first).
360
+ */
361
+ function chooseFamily(files, explicitFormat) {
362
+ if (explicitFormat) return explicitFormat;
363
+ const counts = new Map();
364
+ for (const f of files) {
365
+ const fam = formatForExtension(path.extname(f));
366
+ counts.set(fam, (counts.get(fam) || 0) + 1);
367
+ }
368
+ let best = null;
369
+ let bestCount = 0;
370
+ for (const fam of [...new Set(Object.values(FORMAT_BY_EXT))]) {
371
+ const c = counts.get(fam) || 0;
372
+ if (c > bestCount) { best = fam; bestCount = c; }
373
+ }
374
+ return best || 'json';
375
+ }
376
+
377
+ /**
378
+ * Describe where every locale's files live for this project.
379
+ *
380
+ * @param {object} config - Resolved config (inputLocale, localesDir, format,
381
+ * optional localesPattern / localesLayout). Raw objects work too: a
382
+ * missing format means 'auto'.
383
+ * @param {{ cwd?: string }} [options] - cwd only shapes the human-readable
384
+ * `display` path (relative to the project root)
385
+ * @returns {LocaleLayout}
386
+ *
387
+ * @typedef {object} LocaleFile
388
+ * @property {string} ns - Namespace ('' when the layout has one file per locale)
389
+ * @property {string} code - Locale code this file belongs to
390
+ * @property {string} path - Absolute path
391
+ * @property {string} ext - Real extension, e.g. '.yml'
392
+ * @property {string} format - 'json' | 'toml' | 'yaml' | 'po' | 'arb'
393
+ * @property {string} rel - Display path relative to the layout base ('fr.json', 'fr/common.json')
394
+ * @property {'source'|'target'} role - Which side of a translation this file is
395
+ * @property {string|null} sourcePath - The source file this one mirrors (the
396
+ * document formats are written from it); the file's own path for a source
397
+ * @property {boolean} [template] - A gettext template (.pot) standing in for
398
+ * the source language's catalog
399
+ *
400
+ * @typedef {object} LocaleLayout
401
+ * @property {'flat'|'dir'|'pattern'} kind
402
+ * @property {string} format - Format family of the source files
403
+ * @property {string} sourceLocale
404
+ * @property {string} baseDir - Directory every locale file lives under
405
+ * @property {boolean} namespaced - True when a locale may span several files
406
+ * @property {string} display - Human description, e.g. "public/locales/{lang}/{ns}.json"
407
+ * @property {LocaleFile[]} sourceFiles - The source locale's files (flat: the
408
+ * expected file, which may not exist; dir/pattern: files found on disk)
409
+ * @property {string[]} ignored - Source-side files skipped (other format family)
410
+ * @property {(code: string, ns?: string) => LocaleFile} fileFor
411
+ * @property {(code: string) => LocaleFile[]} filesFor - Mirror of sourceFiles for `code`
412
+ * @property {() => string[]} listLocales - Target locale codes present on disk
413
+ * (exact code match, source excluded, sorted)
414
+ */
415
+ function discoverLocaleLayout(config, { cwd = process.cwd() } = {}) {
416
+ const inputLocale = config.inputLocale || 'en';
417
+ const shown = (dir) => toPosix(path.relative(cwd, dir)) || '.';
418
+ const fmt = config.format && config.format !== 'auto' ? config.format : null;
419
+ if (fmt === 'docusaurus') {
420
+ throw new Error('Docusaurus projects use their own locale lane (i18n/<locale>/…{message} JSON) — the key-value layout does not apply.');
421
+ }
422
+ if (config.localesLayout != null && !LAYOUT_KINDS.includes(config.localesLayout)) {
423
+ throw layoutError(`"localesLayout" must be one of ${LAYOUT_KINDS.join(', ')} (got "${config.localesLayout}").`);
424
+ }
425
+
426
+ if (config.localesPattern) {
427
+ if (config.localesLayout) {
428
+ throw layoutError('Set either "localesPattern" or "localesLayout", not both — a pattern already fixes the layout.');
429
+ }
430
+ return patternLayout(config, inputLocale, fmt, shown);
431
+ }
432
+
433
+ const localesDir = path.resolve(config.localesDir || './locales');
434
+ const sourceDir = path.join(localesDir, inputLocale);
435
+ const dirCandidates = isDirectory(sourceDir) ? walkLocaleFiles(sourceDir) : [];
436
+ const flatPresent = Object.keys(FORMAT_BY_EXT)
437
+ .filter(ext => isFile(path.join(localesDir, `${inputLocale}${ext}`)));
438
+
439
+ let kind = config.localesLayout || null;
440
+ if (!kind) {
441
+ if (dirCandidates.length > 0 && flatPresent.length > 0) {
442
+ throw layoutError(
443
+ `Both ${path.join(localesDir, inputLocale + flatPresent[0])} and a folder of locale files `
444
+ + `${sourceDir}${path.sep} exist, so the layout is ambiguous. Set "localesLayout": "flat" `
445
+ + '(one file per locale) or "dir" (one folder per locale) in champollion.config.json.');
446
+ }
447
+ kind = dirCandidates.length > 0 ? 'dir' : 'flat';
448
+ }
449
+
450
+ return kind === 'dir'
451
+ ? dirLayout(localesDir, inputLocale, fmt, dirCandidates, shown)
452
+ : flatLayout(localesDir, inputLocale, fmt, shown);
453
+ }
454
+
455
+ function flatLayout(localesDir, inputLocale, fmt, shown) {
456
+ const { format, ext } = resolveFlatSource(localesDir, inputLocale, fmt);
457
+ const baseFileFor = (code) => ({
458
+ ns: '', code, path: path.join(localesDir, `${code}${ext}`), ext, format, rel: `${code}${ext}`,
459
+ });
460
+ let sourceFile = { ...baseFileFor(inputLocale), role: 'source' };
461
+ sourceFile.sourcePath = sourceFile.path;
462
+ // gettext: no <source>.po → the one template in the folder (GNU po/).
463
+ if (format === 'po' && !isFile(sourceFile.path)) {
464
+ const pots = findTemplates(localesDir);
465
+ if (pots.length > 1) throw tooManyTemplates(localesDir, pots, inputLocale);
466
+ if (pots.length === 1) sourceFile = templateFile(pots[0], '', inputLocale, localesDir);
467
+ }
468
+ const fileFor = withSource(baseFileFor, [sourceFile], inputLocale);
469
+ return {
470
+ kind: 'flat',
471
+ format,
472
+ sourceLocale: inputLocale,
473
+ baseDir: localesDir,
474
+ namespaced: false,
475
+ display: `${shown(localesDir)}/{lang}${ext}`,
476
+ sourceFiles: [sourceFile],
477
+ ignored: [],
478
+ fileFor,
479
+ filesFor: (code) => [fileFor(code)],
480
+ listLocales: () => {
481
+ if (!isDirectory(localesDir)) return [];
482
+ return fs.readdirSync(localesDir)
483
+ .filter(f => f.endsWith(ext) && isFile(path.join(localesDir, f)))
484
+ .map(f => f.slice(0, -ext.length))
485
+ .filter(code => code && code !== inputLocale)
486
+ .sort();
487
+ },
488
+ };
489
+ }
490
+
491
+ function dirLayout(localesDir, inputLocale, fmt, candidates, shown) {
492
+ const sourceDir = path.join(localesDir, inputLocale);
493
+ const all = candidates.length > 0 ? candidates : (isDirectory(sourceDir) ? walkLocaleFiles(sourceDir) : []);
494
+ const format = chooseFamily(all, fmt);
495
+ const exts = new Set(extensionsForFormat(format));
496
+ const chosen = all.filter(f => exts.has(path.extname(f).toLowerCase()));
497
+ const ignored = all.filter(f => !exts.has(path.extname(f).toLowerCase()))
498
+ .map(f => toPosix(path.relative(localesDir, f)));
499
+
500
+ const sourceFiles = chosen.map(f => {
501
+ const ext = path.extname(f);
502
+ const relInLocale = toPosix(path.relative(sourceDir, f));
503
+ const ns = relInLocale.slice(0, -ext.length);
504
+ return {
505
+ ns, code: inputLocale, path: f, ext, format, rel: `${inputLocale}/${relInLocale}`,
506
+ role: 'source', sourcePath: f,
507
+ };
508
+ }).sort((a, b) => a.ns.localeCompare(b.ns));
509
+ const extByNs = new Map(sourceFiles.map(f => [f.ns, f.ext]));
510
+ const defaultExt = extensionsForFormat(format)[0];
511
+
512
+ const fileFor = withSource((code, ns) => {
513
+ const ext = extByNs.get(ns) || defaultExt;
514
+ return {
515
+ ns, code, ext, format,
516
+ path: path.join(localesDir, code, ...`${ns}${ext}`.split('/')),
517
+ rel: `${code}/${ns}${ext}`,
518
+ };
519
+ }, sourceFiles, inputLocale);
520
+
521
+ return {
522
+ kind: 'dir',
523
+ format,
524
+ sourceLocale: inputLocale,
525
+ baseDir: localesDir,
526
+ namespaced: true,
527
+ display: `${shown(localesDir)}/{lang}/{ns}${defaultExt}`,
528
+ sourceFiles,
529
+ ignored,
530
+ fileFor,
531
+ filesFor: (code) => sourceFiles.map(f => fileFor(code, f.ns)),
532
+ // A locale is a sub-folder holding at least one file of this format, or
533
+ // an empty sub-folder (a project that created fr/ to ask for French).
534
+ // Folders holding only other files (utils/, components/) are not
535
+ // locales; stray files at the root are never locales.
536
+ listLocales: () => {
537
+ if (!isDirectory(localesDir)) return [];
538
+ return fs.readdirSync(localesDir, { withFileTypes: true })
539
+ .filter(e => e.isDirectory() && !e.name.startsWith('.') && !WALK_SKIP.has(e.name) && e.name !== inputLocale)
540
+ .filter(e => {
541
+ const d = path.join(localesDir, e.name);
542
+ const files = walkLocaleFiles(d);
543
+ if (files.some(f => exts.has(path.extname(f).toLowerCase()))) return true;
544
+ return fs.readdirSync(d).filter(n => !n.startsWith('.')).length === 0;
545
+ })
546
+ .map(e => e.name)
547
+ .sort();
548
+ },
549
+ };
550
+ }
551
+
552
+ function patternLayout(config, inputLocale, fmt, shown) {
553
+ const compiled = compileLocalesPattern(config.localesPattern);
554
+ const extFormat = formatForExtension(compiled.ext);
555
+ // An .arb read as plain JSON is exactly the reported Flutter breakage:
556
+ // "@@locale" and every placeholder type in the @key metadata get
557
+ // translated, and gen-l10n refuses to build. Never do it.
558
+ if (fmt === 'json' && extFormat === 'arb') {
559
+ throw layoutError(
560
+ '"format": "json" would translate the @@locale and @key metadata of .arb files, which breaks '
561
+ + '`flutter gen-l10n`. Remove "format" (ARB is detected from the extension) or set "format": "arb".');
562
+ }
563
+ // Explicit format wins; otherwise the extension decides, and an
564
+ // extension we cannot place fails loud instead of being parsed as JSON.
565
+ const format = fmt || extFormat;
566
+ if (!format) {
567
+ throw layoutError(
568
+ `Cannot tell the format of "${compiled.ext}" files from "localesPattern" — set "format" in champollion.config.json.`);
569
+ }
570
+
571
+ const matches = [];
572
+ if (isDirectory(compiled.base)) {
573
+ for (const f of walkLocaleFilesAnyExt(compiled.base, compiled.ext, compiled.maxDepth)) {
574
+ const m = compiled.regex.exec(toPosix(f));
575
+ if (m) matches.push({ path: f, lang: m.groups.lang, ns: compiled.hasNs ? m.groups.ns : '' });
576
+ }
577
+ }
578
+
579
+ const baseFileFor = (code, ns = '') => {
580
+ const p = compiled.render(code, ns);
581
+ return {
582
+ ns, code, path: p, ext: compiled.ext, format,
583
+ rel: toPosix(path.relative(compiled.base, p)),
584
+ };
585
+ };
586
+ const asSource = (f) => ({ ...f, role: 'source', sourcePath: f.path });
587
+
588
+ let sourceFiles;
589
+ if (compiled.hasNs) {
590
+ sourceFiles = matches.filter(m => m.lang === inputLocale)
591
+ .map(m => asSource(baseFileFor(inputLocale, m.ns)))
592
+ .sort((a, b) => a.ns.localeCompare(b.ns));
593
+ } else {
594
+ // One file per locale: the expected source file, existing or not, so
595
+ // the caller's "source not found" error names the exact path.
596
+ sourceFiles = [asSource(baseFileFor(inputLocale, ''))];
597
+ }
598
+
599
+ // gettext: no source-language catalog → its template (.pot).
600
+ if (format === 'po') {
601
+ const dirs = [...new Set([
602
+ compiled.hasNs ? null : path.dirname(sourceFiles[0].path),
603
+ compiled.base,
604
+ path.dirname(compiled.base),
605
+ ].filter(Boolean))];
606
+ if (compiled.hasNs && sourceFiles.length === 0) {
607
+ for (const d of dirs) {
608
+ const pots = findTemplates(d);
609
+ if (pots.length === 0) continue;
610
+ sourceFiles = pots.map(p => templateFile(p, path.basename(p, '.pot'), inputLocale, compiled.base));
611
+ break;
612
+ }
613
+ } else if (!compiled.hasNs && !isFile(sourceFiles[0].path)) {
614
+ const fileName = compiled.pattern.split(/[\\/]/).pop();
615
+ const name = fileName.slice(0, fileName.length - compiled.ext.length)
616
+ .replace(/[_\-.]?\{lang\}[_\-.]?/g, '');
617
+ for (const d of dirs) {
618
+ if (name) {
619
+ const p = path.join(d, `${name}.pot`);
620
+ if (isFile(p)) { sourceFiles = [templateFile(p, '', inputLocale, compiled.base)]; break; }
621
+ continue;
622
+ }
623
+ const pots = findTemplates(d);
624
+ if (pots.length > 1) throw tooManyTemplates(d, pots, inputLocale);
625
+ if (pots.length === 1) { sourceFiles = [templateFile(pots[0], '', inputLocale, compiled.base)]; break; }
626
+ }
627
+ }
628
+ }
629
+ const fileFor = withSource(baseFileFor, sourceFiles, inputLocale);
630
+
631
+ return {
632
+ kind: 'pattern',
633
+ format,
634
+ sourceLocale: inputLocale,
635
+ baseDir: compiled.base,
636
+ namespaced: compiled.hasNs,
637
+ display: shown(compiled.render('{lang}', '{ns}')),
638
+ sourceFiles,
639
+ ignored: [],
640
+ fileFor,
641
+ filesFor: (code) => sourceFiles.map(f => fileFor(code, f.ns)),
642
+ listLocales: () => [...new Set(matches.map(m => m.lang))]
643
+ .filter(code => code !== inputLocale)
644
+ .sort(),
645
+ };
646
+ }
647
+
648
+ /** Files under a pattern's base carrying the pattern's own extension (which may be unknown, e.g. ".strings"). */
649
+ function walkLocaleFilesAnyExt(dir, ext, maxDepth) {
650
+ return walkFiles(dir, name => name.endsWith(ext), { maxDepth });
651
+ }
652
+
653
+ /**
654
+ * Files making up one locale: the source's files for the input locale, the
655
+ * mirrored files for any other code.
656
+ *
657
+ * @param {object} config - Resolved config
658
+ * @param {{ code?: string, layout?: LocaleLayout }} [options]
659
+ * @returns {LocaleFile[]}
660
+ */
661
+ function resolveLocaleFiles(config, { code, layout } = {}) {
662
+ const l = layout || discoverLocaleLayout(config);
663
+ const target = code || l.sourceLocale;
664
+ return target === l.sourceLocale ? l.sourceFiles : l.filesFor(target);
665
+ }
666
+
667
+ /**
668
+ * Throw the "source not found" error every command shares, worded for the
669
+ * layout, when the source locale has no readable file.
670
+ *
671
+ * @param {LocaleLayout} layout
672
+ */
673
+ function assertSourceFiles(layout) {
674
+ if (layout.kind === 'flat' || (layout.kind === 'pattern' && !layout.namespaced)) {
675
+ const p = layout.sourceFiles[0].path;
676
+ if (!fs.existsSync(p)) {
677
+ const pot = layout.format === 'po' ? ' (nor a gettext template, .pot, where one is looked for)' : '';
678
+ throw new Error(`Source locale not found: ${p}${pot}`);
679
+ }
680
+ return;
681
+ }
682
+ if (layout.sourceFiles.length === 0) {
683
+ const where = layout.kind === 'dir'
684
+ ? path.join(layout.baseDir, layout.sourceLocale) + path.sep
685
+ : `${layout.display} with {lang} = ${layout.sourceLocale}`;
686
+ throw new Error(`Source locale not found: no ${layout.format} locale files in ${where}`);
687
+ }
688
+ }
689
+
690
+ // -----------------------------------------------------------------
691
+ // Read / write
692
+ // -----------------------------------------------------------------
693
+
694
+ /**
695
+ * Read one locale file into a flat key → value map (JSON flattened to
696
+ * dot-paths; TOML/YAML/po/arb are already flat). A missing file reads as {}.
697
+ *
698
+ * Formats the reader does not implement fail loud here — never parsed as
699
+ * JSON, which would report a confusing "Invalid JSON" for a .po file.
700
+ *
701
+ * @param {LocaleFile} file
702
+ * @returns {object} Flat map
703
+ */
704
+ function readLocaleFlat(file) {
705
+ assertReadableFormat(file);
706
+ const data = readLocaleFile(file.path, file.format, readOptions(file));
707
+ return file.format === 'json' ? flattenKeys(data) : { ...data };
708
+ }
709
+
710
+ /**
711
+ * Read one locale file in the shape sync edits: the nested object for
712
+ * JSON (so untouched structure round-trips), the flat map otherwise.
713
+ *
714
+ * @param {LocaleFile} file
715
+ * @returns {object}
716
+ */
717
+ function readLocaleData(file) {
718
+ assertReadableFormat(file);
719
+ return readLocaleFile(file.path, file.format, readOptions(file));
720
+ }
721
+
722
+ /** gettext reads differ by side: a source falls back to msgid. */
723
+ function readOptions(file) {
724
+ return { role: file.role || 'target', locale: file.code || null };
725
+ }
726
+
727
+ /**
728
+ * Write a locale file produced by readLocaleData()-shaped data, creating
729
+ * missing parent folders (a new locale in a dir layout has no folder yet).
730
+ * Document formats (po, arb) are rebuilt from the file's source.
731
+ *
732
+ * @param {LocaleFile} file
733
+ * @param {object} data - Nested object (JSON) or flat map (TOML/YAML/po/arb)
734
+ * @param {'hugo'|'nested'|null} [yamlStyle]
735
+ */
736
+ function writeLocaleData(file, data, yamlStyle = null) {
737
+ assertReadableFormat(file);
738
+ fs.mkdirSync(path.dirname(file.path), { recursive: true });
739
+ writeLocaleFile(file.path, data, file.format, file.format !== 'json' ? data : undefined, yamlStyle, {
740
+ sourcePath: file.sourcePath || null,
741
+ locale: file.code || null,
742
+ });
743
+ }
744
+
745
+ function assertReadableFormat(file) {
746
+ if (!LOCALE_FILE_FORMATS.includes(file.format)) {
747
+ throw new Error(
748
+ `${file.rel}: locale format "${file.format}" is not supported by this version of champollion `
749
+ + `(supported: ${LOCALE_FILE_FORMATS.join(', ')}).`);
750
+ }
751
+ }
752
+
753
+ /**
754
+ * The content of a brand-new, empty locale file of `format`, or null when
755
+ * this version cannot write that format.
756
+ *
757
+ * @param {string} format
758
+ * @param {string} [code] - The locale the file is for (po: the header's
759
+ * Language and Plural-Forms; arb: `@@locale`)
760
+ * @param {{ sourcePath?: string|null }} [options] - po: the template whose
761
+ * header fields (Project-Id-Version, POT-Creation-Date…) a new catalog carries
762
+ * @returns {string|null}
763
+ */
764
+ function emptyLocaleContent(format, code = null, { sourcePath = null } = {}) {
765
+ if (format === 'json') return '{}\n';
766
+ // An empty TOML/YAML file reads back as {} (readLocaleFile short-circuits
767
+ // blank files), and is valid in both languages.
768
+ if (format === 'toml' || format === 'yaml') return '';
769
+ if ((format === 'po' || format === 'arb') && code) return emptyDocumentContent(format, code, { sourcePath });
770
+ return null;
771
+ }
772
+
773
+ /**
774
+ * Create every missing target file for `codes`, mirroring the source's
775
+ * namespaces, as an empty file of the right format. Existing files are
776
+ * never touched. Used by `init --langs` and by sync for configured
777
+ * locales, so nobody has to hand-create an empty fr.json.
778
+ *
779
+ * @param {LocaleLayout} layout
780
+ * @param {string[]} codes - Target locale codes
781
+ * @returns {{ created: LocaleFile[], unsupported: LocaleFile[], refused: LocaleFile[] }}
782
+ * `unsupported`: files of a format this version cannot write; `refused`:
783
+ * paths a crafted code would put outside the layout's base directory.
784
+ */
785
+ function createMissingTargetFiles(layout, codes) {
786
+ const created = [];
787
+ const unsupported = [];
788
+ const refused = [];
789
+ for (const code of codes) {
790
+ if (code === layout.sourceLocale) continue;
791
+ for (const file of layout.filesFor(code)) {
792
+ if (fs.existsSync(file.path)) continue;
793
+ if (!isContained(file.path, layout.baseDir)) { refused.push(file); continue; }
794
+ const content = emptyLocaleContent(file.format, code, { sourcePath: file.sourcePath || null });
795
+ if (content === null) { unsupported.push(file); continue; }
796
+ fs.mkdirSync(path.dirname(file.path), { recursive: true });
797
+ fs.writeFileSync(file.path, content, 'utf-8');
798
+ created.push(file);
799
+ }
800
+ }
801
+ return { created, unsupported, refused };
802
+ }
803
+
804
+ function isContained(p, parent) {
805
+ const r = path.resolve(p);
806
+ const base = path.resolve(parent);
807
+ return r.startsWith(base + path.sep) || r === base;
808
+ }
809
+
810
+ // -----------------------------------------------------------------
811
+ // Source units — the source locale, one entry per file
812
+ // -----------------------------------------------------------------
813
+
814
+ /**
815
+ * Read every source file of the layout into a "unit": the per-file flat map
816
+ * sync diffs, translates and writes against. Fails loud when the source is
817
+ * missing (same message the flat path always gave).
818
+ *
819
+ * @param {LocaleLayout} layout
820
+ * @returns {SourceUnit[]}
821
+ *
822
+ * @typedef {object} SourceUnit
823
+ * @property {string} ns - Namespace ('' for single-file layouts)
824
+ * @property {LocaleFile} file - The source file
825
+ * @property {object} flat - Flat key → value map (unsafe keys removed)
826
+ * @property {'hugo'|'nested'|null} yamlStyle - YAML serialization style of the source
827
+ * @property {Map} pluralGroups - i18next plural groups (json only; empty otherwise)
828
+ * @property {object} context - key → translator note the source file carries
829
+ * (ARB `@key.description`, gettext msgctxt and `#.` comments); {} otherwise
830
+ */
831
+ function loadSourceUnits(layout) {
832
+ assertSourceFiles(layout);
833
+ return layout.sourceFiles.map((file) => {
834
+ const flat = readLocaleFlat(file);
835
+ // Defense-in-depth: drop keys that could cause prototype pollution.
836
+ for (const key of Object.keys(flat)) {
837
+ if (isUnsafeKey(key)) delete flat[key];
838
+ }
839
+ // YAML comes in two shapes (Hugo plural sub-keys vs standard nesting);
840
+ // the writer must reproduce the SOURCE's, so probe the raw text once.
841
+ const yamlStyle = file.format === 'yaml'
842
+ ? detectYAMLStyle(fs.readFileSync(file.path, 'utf-8'))
843
+ : null;
844
+ // i18next plural suffixes are a JSON convention; TOML/YAML (Hugo,
845
+ // go-i18n) carry plurals as sub-keys the format adapter already handles.
846
+ const pluralGroups = file.format === 'json'
847
+ ? findPluralGroups(flat, layout.sourceLocale)
848
+ : new Map();
849
+ const context = file.format === 'po' || file.format === 'arb'
850
+ ? readLocaleContext(file.path, file.format)
851
+ : {};
852
+ return { ns: file.ns, file, flat, yamlStyle, pluralGroups, context };
853
+ });
854
+ }
855
+
856
+ /**
857
+ * What a target locale is expected to contain for one source unit: the
858
+ * source map itself, or — when the file has i18next plural groups — the map
859
+ * with the TARGET's CLDR plural categories (lib/plurals.js).
860
+ *
861
+ * A source file that carries translator notes (ARB descriptions, gettext
862
+ * msgctxt / `#.` comments) returns an identity "expansion" holding only
863
+ * those notes as `descriptions` — the prompt context sync already threads
864
+ * from expansions — with no key mapping (origin {}), so lock keys,
865
+ * --force-keys and verify behave exactly as for a plain map.
866
+ *
867
+ * @param {SourceUnit} unit
868
+ * @param {string} sourceLocale
869
+ * @param {string} code - Target locale
870
+ * @returns {{ flat: object, expansion: ReturnType<typeof expandPluralsForLocale> }}
871
+ */
872
+ function expectedForTarget(unit, sourceLocale, code) {
873
+ if (!unit.pluralGroups || unit.pluralGroups.size === 0) {
874
+ if (unit.context && Object.keys(unit.context).length > 0) {
875
+ return {
876
+ flat: unit.flat,
877
+ expansion: {
878
+ flat: unit.flat, origin: {}, descriptions: unit.context, unused: [], groups: 0, unknownLocale: false,
879
+ },
880
+ };
881
+ }
882
+ return { flat: unit.flat, expansion: null };
883
+ }
884
+ const expansion = expandPluralsForLocale(unit.flat, sourceLocale, code, unit.pluralGroups);
885
+ return { flat: expansion.flat, expansion };
886
+ }
887
+
888
+ // -----------------------------------------------------------------
889
+ // Namespaced keys
890
+ // -----------------------------------------------------------------
891
+
892
+ /**
893
+ * The key a (namespace, key) pair has in shared key spaces (lock manifest,
894
+ * XLIFF ids, dry-run lists). Bare for single-file layouts — which is what
895
+ * keeps a flat project's lock file unchanged.
896
+ *
897
+ * @param {LocaleLayout|{namespaced: boolean}} layout
898
+ * @param {string} ns
899
+ * @param {string} key
900
+ * @returns {string}
901
+ */
902
+ function lockKey(layout, ns, key) {
903
+ return layout.namespaced ? `${ns}${NS_SEPARATOR}${key}` : key;
904
+ }
905
+
906
+ /**
907
+ * Split a shared-space key back into (namespace, key). For namespaced
908
+ * layouts the namespace is everything before the FIRST separator (a key
909
+ * may itself contain "::"; a namespace is a file path and cannot).
910
+ *
911
+ * @param {LocaleLayout|{namespaced: boolean}} layout
912
+ * @param {string} id
913
+ * @returns {{ ns: string, key: string }|null} null when a namespaced layout
914
+ * gets an id with no namespace
915
+ */
916
+ function splitLockKey(layout, id) {
917
+ if (!layout.namespaced) return { ns: '', key: id };
918
+ const i = id.indexOf(NS_SEPARATOR);
919
+ if (i < 0) return null;
920
+ return { ns: id.slice(0, i), key: id.slice(i + NS_SEPARATOR.length) };
921
+ }
922
+
923
+ /**
924
+ * A user-typed key with its gettext context separator restored: "␄"
925
+ * (U+2404) or the literal text `\x04` → U+0004.
926
+ *
927
+ * @param {string} key
928
+ * @returns {string}
929
+ */
930
+ function fromTypedContext(key) {
931
+ return key.replace(/\u2404/g, '\u0004').replace(/\\x04/g, '\u0004');
932
+ }
933
+
934
+ /**
935
+ * The subset of user-named keys (--force-keys) that applies to one
936
+ * namespace. In a namespaced layout "common::nav.home" names one file's
937
+ * key, and a bare "nav.home" names that key in every file that has it.
938
+ *
939
+ * @param {LocaleLayout|{namespaced: boolean}} layout
940
+ * @param {string[]} keys
941
+ * @param {string} ns
942
+ * @returns {string[]}
943
+ */
944
+ function keysForNamespace(layout, keys, ns) {
945
+ if (!keys || keys.length === 0) return keys || [];
946
+ // A gettext key with a context is `msgctxt\u0004msgid`. U+0004 cannot be
947
+ // typed, so two stand-ins are accepted: "␄" (U+2404 — how reports print
948
+ // it, so a printed key pastes back) and the literal four characters
949
+ // `\x04` (what a person can type: `--redo 'keys:verb\x04Open'`). Neither
950
+ // occurs in real msgids; the separator `::` and `\,` are unaffected.
951
+ keys = keys.map(k => (typeof k === 'string' ? fromTypedContext(k) : k));
952
+ if (!layout.namespaced) return keys;
953
+ const out = [];
954
+ for (const k of keys) {
955
+ const i = k.indexOf(NS_SEPARATOR);
956
+ if (i < 0) out.push(k);
957
+ else if (k.slice(0, i) === ns) out.push(k.slice(i + NS_SEPARATOR.length));
958
+ }
959
+ return out;
960
+ }
961
+
962
+ // -----------------------------------------------------------------
963
+ // Framework detection for document formats (used by `init`)
964
+ // -----------------------------------------------------------------
965
+
966
+ /** `key: value` lines of a flat YAML file (l10n.yaml is flat). */
967
+ function readFlatYAML(file) {
968
+ const out = {};
969
+ let text;
970
+ try { text = fs.readFileSync(file, 'utf-8'); } catch { return out; }
971
+ for (const line of text.split(/\r?\n/)) {
972
+ const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*?)\s*(?:#.*)?$/.exec(line);
973
+ if (m && m[2] !== '') out[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
974
+ }
975
+ return out;
976
+ }
977
+
978
+ /**
979
+ * A Flutter app's gen-l10n layout, or null when `cwd` is not a Flutter
980
+ * project. Reads l10n.yaml (`arb-dir`, `template-arb-file`) when present,
981
+ * else Flutter's defaults (lib/l10n, app_en.arb).
982
+ *
983
+ * The template file name gives both the pattern and the source locale the
984
+ * way gen-l10n reads it: the locale is what follows the first "_" that
985
+ * starts a locale CLDR knows ("app_en.arb" → app_{lang}.arb, en;
986
+ * "intl_pt_BR.arb" → intl_{lang}.arb, pt_BR).
987
+ *
988
+ * @param {string} cwd - Project root
989
+ * @returns {null | { framework: 'Flutter', format: 'arb', localesPattern: string,
990
+ * inputLocale: string, templateFile: string, templateExists: boolean,
991
+ * l10nYaml: boolean, targets: string[] }}
992
+ */
993
+ function detectFlutterL10n(cwd) {
994
+ const pubspec = path.join(cwd, 'pubspec.yaml');
995
+ if (!isFile(pubspec)) return null;
996
+ const text = fs.readFileSync(pubspec, 'utf-8');
997
+ if (!/^\s*flutter\s*:/m.test(text) && !/sdk:\s*flutter\b/.test(text)) return null;
998
+
999
+ const l10nPath = path.join(cwd, 'l10n.yaml');
1000
+ const l10n = isFile(l10nPath) ? readFlatYAML(l10nPath) : {};
1001
+ const arbDir = (l10n['arb-dir'] || 'lib/l10n').replace(/\/+$/, '');
1002
+ const template = l10n['template-arb-file'] || 'app_en.arb';
1003
+ const stem = template.replace(/\.arb$/, '');
1004
+
1005
+ let prefix = null;
1006
+ let inputLocale = null;
1007
+ for (let i = 0; i < stem.length; i++) {
1008
+ if (stem[i] !== '_') continue;
1009
+ const candidate = stem.slice(i + 1);
1010
+ const lang = candidate.split(/[_-]/)[0];
1011
+ if (/^[a-z]{2,3}$/.test(lang) && Intl.PluralRules.supportedLocalesOf([lang]).length > 0) {
1012
+ prefix = stem.slice(0, i + 1);
1013
+ inputLocale = candidate;
1014
+ break;
1015
+ }
1016
+ }
1017
+ if (!prefix) return null;
1018
+
1019
+ const localesPattern = `${toPosix(arbDir)}/${prefix}{lang}.arb`;
1020
+ const templatePath = path.join(cwd, ...toPosix(arbDir).split('/'), template);
1021
+ let targets = [];
1022
+ try {
1023
+ targets = discoverLocaleLayout({ inputLocale, localesPattern: path.join(cwd, localesPattern), format: 'arb' }, { cwd })
1024
+ .listLocales();
1025
+ } catch { /* an unreadable arb dir lists nothing */ }
1026
+ return {
1027
+ framework: 'Flutter', format: 'arb', localesPattern, inputLocale,
1028
+ templateFile: toPosix(path.relative(cwd, templatePath)), templateExists: isFile(templatePath),
1029
+ l10nYaml: isFile(l10nPath), targets,
1030
+ };
1031
+ }
1032
+
1033
+ /**
1034
+ * A gettext layout under `cwd`, or null. Probes the conventional shapes:
1035
+ * <dir>/<lang>/LC_MESSAGES/<domain>.po (Django locale/, Babel/Flask
1036
+ * translations/, Sphinx locales/)
1037
+ * po/<lang>.po + po/<domain>.pot (GNU)
1038
+ * and reports the config that describes it. The source is the source
1039
+ * language's catalog when present, else a template (see GETTEXT TEMPLATES).
1040
+ *
1041
+ * @param {string} cwd - Project root
1042
+ * @param {{ source?: string }} [options]
1043
+ * @returns {null | { framework: string, format: 'po', localesPattern?: string,
1044
+ * localesDir?: string, inputLocale: string, sourceFiles: string[], targets: string[] }}
1045
+ */
1046
+ function detectGettextLayout(cwd, { source = 'en' } = {}) {
1047
+ const framework = isFile(path.join(cwd, 'manage.py')) ? 'Django'
1048
+ : (isFile(path.join(cwd, 'babel.cfg')) ? 'Babel' : 'gettext');
1049
+ const describe = (config) => {
1050
+ try {
1051
+ const layout = discoverLocaleLayout({ inputLocale: source, format: 'po', ...config }, { cwd });
1052
+ const sourceFiles = layout.sourceFiles.filter(f => isFile(f.path)).map(f => toPosix(path.relative(cwd, f.path)));
1053
+ return { sourceFiles, targets: layout.listLocales() };
1054
+ } catch {
1055
+ return null;
1056
+ }
1057
+ };
1058
+
1059
+ for (const dir of ['locale', 'locales', 'translations', 'i18n', 'conf/locale']) {
1060
+ const abs = path.join(cwd, ...dir.split('/'));
1061
+ if (!isDirectory(abs)) continue;
1062
+ const hasCatalogs = fs.readdirSync(abs, { withFileTypes: true })
1063
+ .some(e => e.isDirectory() && walkFiles(path.join(abs, e.name, 'LC_MESSAGES'), n => n.endsWith('.po')).length > 0);
1064
+ if (!hasCatalogs) continue;
1065
+ const localesPattern = `${dir}/{lang}/LC_MESSAGES/{ns}.po`;
1066
+ const found = describe({ localesPattern: path.join(abs, '{lang}', 'LC_MESSAGES', '{ns}.po') });
1067
+ if (found) return { framework, format: 'po', localesPattern, inputLocale: source, ...found };
1068
+ }
1069
+
1070
+ const po = path.join(cwd, 'po');
1071
+ if (isDirectory(po) && fs.readdirSync(po).some(n => n.endsWith('.po') || n.endsWith('.pot'))) {
1072
+ const found = describe({ localesDir: po, localesLayout: 'flat' });
1073
+ if (found) return { framework, format: 'po', localesDir: './po', inputLocale: source, ...found };
1074
+ }
1075
+ return null;
1076
+ }
1077
+
1078
+ export {
1079
+ NS_SEPARATOR,
1080
+ LAYOUT_KINDS,
1081
+ FORMAT_BY_EXT,
1082
+ formatForExtension,
1083
+ extensionsForFormat,
1084
+ compileLocalesPattern,
1085
+ discoverLocaleLayout,
1086
+ resolveLocaleFiles,
1087
+ assertSourceFiles,
1088
+ readLocaleFlat,
1089
+ readLocaleData,
1090
+ writeLocaleData,
1091
+ emptyLocaleContent,
1092
+ createMissingTargetFiles,
1093
+ loadSourceUnits,
1094
+ expectedForTarget,
1095
+ lockKey,
1096
+ splitLockKey,
1097
+ keysForNamespace,
1098
+ fromTypedContext,
1099
+ walkFiles,
1100
+ walkLocaleFiles,
1101
+ detectFlutterL10n,
1102
+ detectGettextLayout,
1103
+ };