champollion 0.3.4 → 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 (132) hide show
  1. package/README.md +41 -26
  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 +34 -0
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +286 -85
  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 +632 -125
  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 +15 -9
  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 +194 -35
  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 +6 -1
  100. package/lib/seal.mjs +4 -3
  101. package/lib/sealed-qualifier.mjs +1 -1
  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 +1 -1
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/cards-fallback.json +1 -1
  123. package/shared/curated-orthography-conventions.json +26 -8
  124. package/shared/gettext-plural-forms.json +45 -0
  125. package/shared/method-registry.json +2 -0
  126. package/shared/metric-registry.json +96 -18
  127. package/shared/schemas/champollion-plugin.schema.json +4 -0
  128. package/shared/schemas/corpora-card.schema.json +8 -2
  129. package/shared/schemas/method-index-record.schema.json +67 -0
  130. package/shared/schemas/method-registry.schema.json +4 -0
  131. package/shared/schemas/metric-registry.schema.json +55 -1
  132. package/shared/docent/corpus.json +0 -11739
package/lib/format.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Format adapter — reads and writes locale files in JSON, TOML, and YAML.
2
+ * Format adapter — reads and writes locale files in JSON, TOML, YAML,
3
+ * gettext PO/POT (lib/po.js) and Flutter ARB.
3
4
  *
4
5
  * WHY: Hugo uses TOML or YAML for i18n string files, not JSON.
5
6
  * Hugo's i18n/ structure looks like:
@@ -20,10 +21,20 @@
20
21
  */
21
22
 
22
23
  import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { readPO, writePO, emptyPO } from './po.js';
23
26
 
24
27
  // CLDR plural categories used by Hugo/go-i18n
25
28
  const PLURAL_FORMS = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
26
29
 
30
+ /**
31
+ * Key-value locale formats this module can READ and WRITE. The single list
32
+ * config validation (lib/config.js) and the layout reader
33
+ * (lib/locale-layout.js) check against — a format added to
34
+ * readLocaleFile/writeLocaleFile is added here, and nowhere else.
35
+ */
36
+ const LOCALE_FILE_FORMATS = Object.freeze(['json', 'toml', 'yaml', 'po', 'arb']);
37
+
27
38
  /**
28
39
  * Strip a leading UTF-8 BOM (U+FEFF) from a decoded string.
29
40
  *
@@ -48,23 +59,27 @@ function stripBOM(str) {
48
59
  * Detect the locale file format from a file path's extension.
49
60
  *
50
61
  * @param {string} filePath - Path to a locale file
51
- * @returns {'json'|'toml'|'yaml'} Detected format
62
+ * @returns {'json'|'toml'|'yaml'|'po'|'arb'} Detected format
52
63
  */
53
64
  function detectFormat(filePath) {
54
65
  if (filePath.endsWith('.toml')) return 'toml';
55
66
  if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml';
67
+ if (filePath.endsWith('.po') || filePath.endsWith('.pot')) return 'po';
68
+ if (filePath.endsWith('.arb')) return 'arb';
56
69
  return 'json';
57
70
  }
58
71
 
59
72
  /**
60
73
  * Get the file extension string for a format.
61
74
  *
62
- * @param {'json'|'toml'|'yaml'} format
75
+ * @param {'json'|'toml'|'yaml'|'po'|'arb'} format
63
76
  * @returns {string} File extension including the dot
64
77
  */
65
78
  function getExtension(format) {
66
79
  if (format === 'toml') return '.toml';
67
80
  if (format === 'yaml') return '.yaml';
81
+ if (format === 'po') return '.po';
82
+ if (format === 'arb') return '.arb';
68
83
  return '.json';
69
84
  }
70
85
 
@@ -72,21 +87,30 @@ function getExtension(format) {
72
87
  * Auto-detect the format used in a locales directory by scanning
73
88
  * for the most common file extension present.
74
89
  *
90
+ * Ties keep the historical preference: json, then toml, then yaml; the
91
+ * gettext (.po/.pot) and ARB counts only win outright.
92
+ *
75
93
  * @param {string} localesDir - Path to the locales directory
76
- * @returns {'json'|'toml'|'yaml'} Detected format, defaults to 'json'
94
+ * @returns {'json'|'toml'|'yaml'|'po'|'arb'} Detected format, defaults to 'json'
77
95
  */
78
96
  function detectFormatFromDir(localesDir) {
79
97
  if (!fs.existsSync(localesDir)) return 'json';
80
98
 
81
99
  const files = fs.readdirSync(localesDir);
82
- const counts = { json: 0, toml: 0, yaml: 0 };
100
+ const counts = { json: 0, toml: 0, yaml: 0, po: 0, arb: 0 };
83
101
 
84
102
  for (const file of files) {
85
103
  if (file.endsWith('.toml')) counts.toml++;
86
104
  else if (file.endsWith('.yaml') || file.endsWith('.yml')) counts.yaml++;
87
105
  else if (file.endsWith('.json')) counts.json++;
106
+ else if (file.endsWith('.po') || file.endsWith('.pot')) counts.po++;
107
+ else if (file.endsWith('.arb')) counts.arb++;
88
108
  }
89
109
 
110
+ const classic = Math.max(counts.json, counts.toml, counts.yaml);
111
+ if (counts.po > classic && counts.po >= counts.arb) return 'po';
112
+ if (counts.arb > classic && counts.arb > counts.po) return 'arb';
113
+
90
114
  // Return whichever format has the most files
91
115
  if (counts.toml > counts.json && counts.toml >= counts.yaml) return 'toml';
92
116
  if (counts.yaml > counts.json && counts.yaml >= counts.toml) return 'yaml';
@@ -104,11 +128,22 @@ function detectFormatFromDir(localesDir) {
104
128
  * { "key": "value" }. Plural keys flatten to { "key.one": "...",
105
129
  * "key.other": "..." }.
106
130
  *
131
+ * gettext (po): keys are msgids (`msgctxt\u0004msgid` with a context),
132
+ * plural entries are ICU plural messages — see lib/po.js. A SOURCE catalog
133
+ * (role 'source': a .pot or the source-language .po) yields msgid where
134
+ * msgstr is empty; a target yields only translated, non-fuzzy entries.
135
+ *
136
+ * ARB (Flutter): the translatable messages only — `@@locale` and every
137
+ * `@key` metadata object are never part of the map.
138
+ *
107
139
  * @param {string} filePath - Path to the locale file
108
- * @param {'json'|'toml'|'yaml'} format - File format
140
+ * @param {'json'|'toml'|'yaml'|'po'|'arb'} format - File format
141
+ * @param {{ role?: 'source'|'target', locale?: string|null }} [options] -
142
+ * gettext only: which side of the catalog is read, and its locale (for
143
+ * mapping msgstr[i] onto plural categories)
109
144
  * @returns {object} Flat key→value map
110
145
  */
111
- function readLocaleFile(filePath, format) {
146
+ function readLocaleFile(filePath, format, options = {}) {
112
147
  if (!fs.existsSync(filePath)) return {};
113
148
  // Strip a leading BOM before any parsing — a U+FEFF prefix otherwise
114
149
  // hard-crashes JSON.parse() and silently breaks the first key match in
@@ -118,6 +153,10 @@ function readLocaleFile(filePath, format) {
118
153
 
119
154
  if (format === 'toml') return parseTOMLToFlat(raw);
120
155
  if (format === 'yaml') return parseYAMLToFlat(raw);
156
+ if (format === 'po') {
157
+ return readPO(raw, { role: options.role || 'target', locale: options.locale || null, filePath }).flat;
158
+ }
159
+ if (format === 'arb') return arbMessages(parseARB(raw, filePath), filePath);
121
160
 
122
161
  // JSON: caller handles flattening. Wrap the raw V8 SyntaxError (which only
123
162
  // gives a byte offset, no filename or hint) in a file-named, friendlier
@@ -220,15 +259,43 @@ function findDuplicateJSONKeys(raw) {
220
259
  * For JSON, this writes the nested structure directly.
221
260
  * For TOML/YAML, this converts to the appropriate section format.
222
261
  *
262
+ * gettext and ARB files are DOCUMENTS, not maps: the writer rebuilds them
263
+ * around the flat map from a template — the source file named by
264
+ * `options.sourcePath` (its entry/key order, its metadata) — and the
265
+ * existing target (untouched entries kept byte for byte, translator
266
+ * comments, header). Without a sourcePath the existing file is its own
267
+ * template (xliff import, autofix).
268
+ *
223
269
  * @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)
270
+ * @param {object} data - Nested data object (for JSON) or flat map (for TOML/YAML/po/arb)
271
+ * @param {'json'|'toml'|'yaml'|'po'|'arb'} format - Target format
272
+ * @param {object} flatData - Flat key→value map (used for non-JSON reconstruction)
227
273
  * @param {'hugo'|'nested'|null} yamlStyle - YAML variant: 'hugo' for Hugo i18n, 'nested' for standard nested YAML
274
+ * @param {{ sourcePath?: string|null, locale?: string|null }} [options] -
275
+ * po/arb: the source (template) file and the locale being written
228
276
  */
229
- function writeLocaleFile(filePath, data, format, flatData, yamlStyle) {
277
+ function writeLocaleFile(filePath, data, format, flatData, yamlStyle, options = {}) {
230
278
  let content;
231
- if (format === 'toml') {
279
+ if (format === 'po' || format === 'arb') {
280
+ const readIfExists = (p) => (p && fs.existsSync(p) ? stripBOM(fs.readFileSync(p, 'utf-8')) : null);
281
+ const targetText = readIfExists(filePath);
282
+ const sourcePath = options.sourcePath && options.sourcePath !== filePath ? options.sourcePath : null;
283
+ const sourceText = readIfExists(sourcePath);
284
+ const flat = flatData || data;
285
+ if (format === 'po') {
286
+ content = writePO({
287
+ flat, sourceText, targetText: targetText && targetText.trim() ? targetText : null,
288
+ locale: options.locale || null, filePath, projectName: projectNameForHeader(),
289
+ });
290
+ } else {
291
+ content = serializeARB({
292
+ flat,
293
+ sourceDoc: sourceText && sourceText.trim() ? parseARB(sourceText, sourcePath) : null,
294
+ targetDoc: targetText && targetText.trim() ? parseARB(targetText, filePath) : null,
295
+ locale: options.locale || null,
296
+ });
297
+ }
298
+ } else if (format === 'toml') {
232
299
  content = flatToTOML(flatData || data);
233
300
  } else if (format === 'yaml') {
234
301
  // Route to the correct YAML serializer based on detected style.
@@ -796,6 +863,185 @@ function groupFlatKeys(flat) {
796
863
  return sections;
797
864
  }
798
865
 
866
+ // -----------------------------------------------------------------
867
+ // Flutter ARB
868
+ //
869
+ // {
870
+ // "@@locale": "en",
871
+ // "itemCount": "{count, plural, =0{No items} one{1 item} other{{count} items}}",
872
+ // "@itemCount": { "description": "…", "placeholders": { "count": { "type": "int" } } }
873
+ // }
874
+ //
875
+ // Only the messages are translatable. `@@locale` names the file's locale
876
+ // (gen-l10n refuses a file whose @@locale disagrees with its name), every
877
+ // `@key` object is code metadata — placeholder names and Dart types — and
878
+ // translating either breaks the build. So the reader never returns them,
879
+ // and the writer sets `@@locale` to the TARGET locale (Flutter's
880
+ // underscore form, as in the file name) and copies each message's metadata
881
+ // from the source (or keeps the target's, for a key the source has none
882
+ // for). Key order follows the source.
883
+ // -----------------------------------------------------------------
884
+
885
+ /**
886
+ * Parse an .arb file with a file-named error.
887
+ *
888
+ * @param {string} raw
889
+ * @param {string} filePath
890
+ * @returns {object}
891
+ */
892
+ function parseARB(raw, filePath) {
893
+ let doc;
894
+ try {
895
+ doc = JSON.parse(stripBOM(raw));
896
+ } catch (err) {
897
+ throw new Error(`Invalid ARB (JSON) in ${filePath}: ${err.message}`);
898
+ }
899
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
900
+ throw new Error(`Invalid ARB in ${filePath}: the top level must be a JSON object.`);
901
+ }
902
+ for (const dup of findDuplicateJSONKeys(raw)) {
903
+ console.warn(` [WARN] Duplicate ARB key "${dup}" in ${filePath} — later value wins, earlier value discarded.`);
904
+ }
905
+ return doc;
906
+ }
907
+
908
+ /** The translatable messages of an ARB document. */
909
+ function arbMessages(doc, filePath = '') {
910
+ const flat = {};
911
+ for (const [key, value] of Object.entries(doc)) {
912
+ if (key.startsWith('@')) continue;
913
+ if (typeof value === 'string') flat[key] = value;
914
+ else console.warn(` [WARN] ${filePath}: ARB message "${key}" is not a string — not translated.`);
915
+ }
916
+ return flat;
917
+ }
918
+
919
+ /** Flutter writes locales with underscores (app_pt_BR.arb, "@@locale": "pt_BR"). */
920
+ function flutterLocale(code) {
921
+ return String(code).replace(/-/g, '_');
922
+ }
923
+
924
+ /**
925
+ * Serialize a target ARB document.
926
+ *
927
+ * @param {object} options
928
+ * @param {object} options.flat - Messages (key → text)
929
+ * @param {object|null} options.sourceDoc - Parsed source ARB (template)
930
+ * @param {object|null} options.targetDoc - Parsed existing target ARB
931
+ * @param {string|null} options.locale - Target locale code
932
+ * @returns {string}
933
+ */
934
+ function serializeARB({ flat, sourceDoc, targetDoc, locale }) {
935
+ const out = {};
936
+ const has = (doc, key) => !!doc && Object.prototype.hasOwnProperty.call(doc, key);
937
+ const loc = locale ? flutterLocale(locale)
938
+ : (has(targetDoc, '@@locale') ? targetDoc['@@locale'] : (has(sourceDoc, '@@locale') ? sourceDoc['@@locale'] : null));
939
+ if (loc !== null && loc !== undefined) out['@@locale'] = loc;
940
+ const done = new Set(['@@locale']);
941
+
942
+ const emitMessage = (key) => {
943
+ if (done.has(key) || typeof flat[key] !== 'string') return;
944
+ out[key] = flat[key];
945
+ done.add(key);
946
+ const meta = `@${key}`;
947
+ if (has(sourceDoc, meta)) out[meta] = sourceDoc[meta];
948
+ else if (has(targetDoc, meta)) out[meta] = targetDoc[meta];
949
+ done.add(meta);
950
+ };
951
+ const emitGlobal = (key, fromDoc) => {
952
+ if (done.has(key)) return;
953
+ out[key] = has(targetDoc, key) ? targetDoc[key] : fromDoc[key];
954
+ done.add(key);
955
+ };
956
+
957
+ const template = sourceDoc || targetDoc || {};
958
+ for (const key of Object.keys(template)) {
959
+ if (key.startsWith('@@')) emitGlobal(key, template);
960
+ else if (!key.startsWith('@')) emitMessage(key);
961
+ }
962
+ // Messages the target has and the source does not (kept, with their
963
+ // metadata), then anything else the caller added.
964
+ for (const key of Object.keys(targetDoc || {})) {
965
+ if (key.startsWith('@@')) emitGlobal(key, targetDoc);
966
+ else if (!key.startsWith('@')) emitMessage(key);
967
+ }
968
+ for (const key of Object.keys(flat)) {
969
+ if (!key.startsWith('@')) emitMessage(key);
970
+ }
971
+ return JSON.stringify(out, null, 2) + '\n';
972
+ }
973
+
974
+ /**
975
+ * The content of a new, empty target file in a document format, or null
976
+ * for a format whose empty file is not special.
977
+ *
978
+ * @param {string} format
979
+ * @param {string} locale
980
+ * @param {{ sourcePath?: string|null }} [options] - po: the template the
981
+ * new catalog mirrors (its header fields are carried over)
982
+ * @returns {string|null}
983
+ */
984
+ function emptyDocumentContent(format, locale, { sourcePath = null } = {}) {
985
+ if (format === 'arb') return JSON.stringify({ '@@locale': flutterLocale(locale) }, null, 2) + '\n';
986
+ if (format === 'po') {
987
+ const sourceText = sourcePath && fs.existsSync(sourcePath) ? stripBOM(fs.readFileSync(sourcePath, 'utf-8')) : null;
988
+ return emptyPO(locale, { sourceText: sourceText && sourceText.trim() ? sourceText : null, projectName: projectNameForHeader() });
989
+ }
990
+ return null;
991
+ }
992
+
993
+ /**
994
+ * A new gettext catalog's Project-Id-Version when its template has only
995
+ * xgettext's "PACKAGE VERSION": the project folder's name, as msginit
996
+ * writes (lib/po.js newHeaderFields). Commands run from the project root.
997
+ */
998
+ function projectNameForHeader() {
999
+ return path.basename(process.cwd()) || null;
1000
+ }
1001
+
1002
+ /**
1003
+ * Per-key translator context a SOURCE file carries for the prompt: ARB
1004
+ * `@key.description`, gettext msgctxt and `#.` extracted comments. Other
1005
+ * formats carry none.
1006
+ *
1007
+ * @param {string} filePath
1008
+ * @param {string} format
1009
+ * @returns {object} key → description
1010
+ */
1011
+ function readLocaleContext(filePath, format) {
1012
+ if (!fs.existsSync(filePath)) return {};
1013
+ const raw = stripBOM(fs.readFileSync(filePath, 'utf-8'));
1014
+ if (!raw.trim()) return {};
1015
+ if (format === 'po') return readPO(raw, { role: 'source', filePath }).context;
1016
+ if (format === 'arb') {
1017
+ const doc = parseARB(raw, filePath);
1018
+ const out = {};
1019
+ for (const [key, value] of Object.entries(doc)) {
1020
+ if (key.startsWith('@') || typeof value !== 'string') continue;
1021
+ const meta = doc[`@${key}`];
1022
+ if (meta && typeof meta === 'object' && typeof meta.description === 'string' && meta.description.trim()) {
1023
+ out[key] = meta.description.trim();
1024
+ }
1025
+ }
1026
+ return out;
1027
+ }
1028
+ return {};
1029
+ }
1030
+
1031
+ /**
1032
+ * The `@@locale` an ARB file declares, or undefined when it has none.
1033
+ *
1034
+ * @param {string} filePath
1035
+ * @returns {string|undefined}
1036
+ */
1037
+ function readARBLocale(filePath) {
1038
+ if (!fs.existsSync(filePath)) return undefined;
1039
+ const raw = stripBOM(fs.readFileSync(filePath, 'utf-8'));
1040
+ if (!raw.trim()) return undefined;
1041
+ const doc = parseARB(raw, filePath);
1042
+ return Object.prototype.hasOwnProperty.call(doc, '@@locale') ? doc['@@locale'] : undefined;
1043
+ }
1044
+
799
1045
  // -----------------------------------------------------------------
800
1046
  // Docusaurus format helpers
801
1047
  //
@@ -931,6 +1177,7 @@ function injectDocusaurusMessages(sourceData, translatedFlat) {
931
1177
  }
932
1178
 
933
1179
  export {
1180
+ LOCALE_FILE_FORMATS,
934
1181
  detectFormat,
935
1182
  detectFormatFromDir,
936
1183
  getExtension,
@@ -950,5 +1197,12 @@ export {
950
1197
  extractDocusaurusMessages,
951
1198
  extractDocusaurusDescriptions,
952
1199
  injectDocusaurusMessages,
1200
+ parseARB,
1201
+ arbMessages,
1202
+ serializeARB,
1203
+ flutterLocale,
1204
+ emptyDocumentContent,
1205
+ readLocaleContext,
1206
+ readARBLocale,
953
1207
  };
954
1208
 
package/lib/hash.js CHANGED
@@ -15,15 +15,62 @@
15
15
  * content mutations. This hash layer closes that gap automatically.
16
16
  *
17
17
  * FILE FORMAT:
18
- * .champollion.lock is a JSON file mapping dot-notation keys to
19
- * their SHA-256 hash. It should be committed to version control so
20
- * that all developers and CI share the same baseline.
18
+ * .champollion.lock is JSON, committed to version control so that all
19
+ * developers and CI share the same baseline. Version 1 (every lock before
20
+ * 0.4.0, and still what a project with no per-locale record writes) maps
21
+ * each source key to the SHA-256 of its source text:
22
+ *
23
+ * { "nav.home": "a1b2c3...", "nav.about": "d4e5f6..." }
24
+ *
25
+ * Version 2 nests that map under "source" and adds what sync knows per
26
+ * target locale (lib/locale-state.js):
21
27
  *
22
28
  * {
23
- * "nav.home": "a1b2c3...",
24
- * "nav.about": "d4e5f6...",
25
- * ...
29
+ * "version": 2,
30
+ * "source": { "nav.home": "a1b2c3..." },
31
+ * "locales": {
32
+ * "fr": {
33
+ * "written": { "nav.home": "<12 hex of the source>:<12 hex of the value>" },
34
+ * "pending": { "nav.about": "--redo all --fresh-on-model-change" },
35
+ * "refused": { "nav.cta": { "source": "<12 hex>", "methods": ["llm|m|formal|"], "on": "2026-10-03" } },
36
+ * "forms": { "items_many": "many:<12 hex of the value>" },
37
+ * "gaps": { "files": { "source": "<12 hex>", "methods": ["local|stub-1|formal|"] } },
38
+ * "by": { "llm|google/gemini-3.5-flash|formal|": ["nav.home"] }
39
+ * }
40
+ * }
26
41
  * }
42
+ *
43
+ * written — the value sync left in the file for each key and the source
44
+ * text it translated: a value that no longer matches was edited
45
+ * by hand; a source that no longer matches means the translation
46
+ * is out of date.
47
+ * pending — keys a redo (--redo all, --redo keys:, a model switch) could not
48
+ * finish; the next plain sync asks for them once more.
49
+ * refused — keys whose current source text the quality gate refused from
50
+ * these methods; a plain sync does not send them to the same
51
+ * method again (it would bill the same answer).
52
+ * forms — borrowed i18next plural forms (French `items_many`, translated
53
+ * from the English `items_other` text) the model was asked for AS
54
+ * that form, with the fingerprint of the value its answer left.
55
+ * verify reads it to tell "the model wrote `_many` like `_other`"
56
+ * from "filled from `_other`" using committed files only. Older
57
+ * versions of the CLI ignore it.
58
+ * gaps — plural messages a setup (method key) answered without a form
59
+ * the language uses for ordinary counts, per current source text:
60
+ * a sync with another setup asks for them again, and one that
61
+ * already tried does not (lib/plural-gap-redo.js). Written only
62
+ * when there is one; older versions of the CLI ignore it.
63
+ * by — which method key (method|model|register|coaching) produced the
64
+ * value `written` records for each key — the model that answered,
65
+ * or the one whose cache entry served it. `status` names the
66
+ * model behind the files from it; a value with no `by` (written
67
+ * before 0.4.0) is attributed from the cache, or "model unknown"
68
+ * when several models cached the same text. Older versions of
69
+ * the CLI ignore it.
70
+ *
71
+ * readManifest() returns the source map for either version, so every
72
+ * reader of "which source changed" is unchanged; writeManifest() keeps the
73
+ * per-locale part of an existing lock unless it is given a new one.
27
74
  */
28
75
 
29
76
  import crypto from 'node:crypto';
@@ -97,35 +144,79 @@ function detectChangedKeys(sourceFlat, oldManifest) {
97
144
  * @returns {object} Hash manifest (key → hash), or {} if no lock file
98
145
  */
99
146
  function readManifest(cwd) {
147
+ return readLock(cwd).source;
148
+ }
149
+
150
+ /** Lock format written when there is per-locale state to record. */
151
+ const LOCK_VERSION = 2;
152
+
153
+ /**
154
+ * Split parsed lock JSON into its source map and per-locale state.
155
+ * Version 1 (a flat key → hash map) has no per-locale state.
156
+ *
157
+ * @param {*} data - Parsed .champollion.lock
158
+ * @returns {{ source: object, locales: object }}
159
+ */
160
+ function splitLock(data) {
161
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
162
+ throw new Error('not a JSON object');
163
+ }
164
+ // Version 2 is recognised by its shape: a v1 lock maps keys to hash
165
+ // STRINGS, so a numeric "version" next to an object "source" cannot be one.
166
+ if (typeof data.version === 'number' && data.source && typeof data.source === 'object' && !Array.isArray(data.source)) {
167
+ if (data.version > LOCK_VERSION) {
168
+ throw new Error(`written by a newer champollion (lock version ${data.version}; this version reads up to ${LOCK_VERSION}) — upgrade champollion`);
169
+ }
170
+ const locales = data.locales && typeof data.locales === 'object' && !Array.isArray(data.locales) ? data.locales : {};
171
+ return { source: { ...data.source }, locales };
172
+ }
173
+ return { source: { ...data }, locales: {} };
174
+ }
175
+
176
+ /**
177
+ * Read the whole lock: the source-hash map and the per-locale state
178
+ * (lib/locale-state.js). Fails loud on an unreadable file, like readManifest.
179
+ *
180
+ * @param {string} cwd - Project root directory
181
+ * @returns {{ source: object, locales: object, exists: boolean }}
182
+ */
183
+ function readLock(cwd) {
100
184
  const lockPath = path.join(cwd, LOCK_FILENAME);
101
185
 
102
186
  if (!fs.existsSync(lockPath)) {
103
- return {};
187
+ return { source: {}, locales: {}, exists: false };
104
188
  }
105
189
 
106
190
  try {
107
- return JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
191
+ return { ...splitLock(JSON.parse(fs.readFileSync(lockPath, 'utf-8'))), exists: true };
108
192
  } catch (err) {
109
193
  // FAIL LOUD. Returning {} means "no key has ever been synced", so the
110
194
  // very next sync treats every key as changed and re-translates the whole
111
195
  // project at full API cost. A `[WARN]` that says "it will be regenerated"
112
196
  // reads as routine housekeeping and gives no hint of that bill.
197
+ // Treating it as missing loses track of which translations are out of
198
+ // date: nothing counts as changed (detectChangedKeys needs an old hash),
199
+ // so every existing translation is kept even where its source moved.
200
+ // (An earlier message claimed the opposite — a full re-translation —
201
+ // which is not what the code does.)
113
202
  if (process.env.CHAMPOLLION_ALLOW_CACHE_RESET === '1') {
114
203
  console.error(
115
204
  `[WARN] ${LOCK_FILENAME} is unreadable (${err.message}). `
116
- + `CHAMPOLLION_ALLOW_CACHE_RESET=1 — treating this as a first run; `
117
- + `every key will be re-translated at full cost.`,
205
+ + `CHAMPOLLION_ALLOW_CACHE_RESET=1 — treating it as missing: missing keys are `
206
+ + `translated, but translations whose source changed are NOT detected this run. `
207
+ + `Use \`champollion sync --redo all\` to rebuild (cached text is free).`,
118
208
  );
119
- return {};
209
+ return { source: {}, locales: {}, exists: false };
120
210
  }
121
211
  const e = new Error(
122
212
  `Lock file is unreadable: ${err.message}\n\n`
123
213
  + ` ${lockPath}\n\n`
124
- + `Refusing to continue: treating this as a first run would mark every `
125
- + `key as changed and re-translate the whole project at full API cost.\n\n`
214
+ + `Refusing to continue: without it, sync cannot tell which existing `
215
+ + `translations are out of date, and would silently keep stale ones.\n\n`
126
216
  + ` • Restore the file from version control if you can, or\n`
127
- + ` • delete it and re-run to accept the re-translation cost, or\n`
128
- + ` • re-run with CHAMPOLLION_ALLOW_CACHE_RESET=1 to do that in place.`,
217
+ + ` • delete it and run \`champollion sync --redo all\` once (cached `
218
+ + `translations are served free; only text the cache has never seen is billed), or\n`
219
+ + ` • re-run with CHAMPOLLION_ALLOW_CACHE_RESET=1 to continue without it.`,
129
220
  );
130
221
  e.code = 'CHAMPOLLION_LOCK_UNREADABLE';
131
222
  throw e;
@@ -136,17 +227,49 @@ function readManifest(cwd) {
136
227
  * Write the hash manifest to disk.
137
228
  * Sorts keys alphabetically for stable, diff-friendly output.
138
229
  *
230
+ * The per-locale state (lib/locale-state.js) is written alongside when there
231
+ * is any: `locales` replaces it; omitted, the state already in the lock is
232
+ * kept (the Docusaurus path writes only source hashes). With no per-locale
233
+ * state the file stays in the version-1 flat form, byte for byte.
234
+ *
139
235
  * @param {string} cwd - Project root directory
140
236
  * @param {object} manifest - Hash manifest (key → hash)
237
+ * @param {object} [locales] - Per-locale state to record (see the header)
141
238
  */
142
- function writeManifest(cwd, manifest) {
239
+ function writeManifest(cwd, manifest, locales = undefined) {
143
240
  const lockPath = path.join(cwd, LOCK_FILENAME);
144
- // Sort keys for deterministic output — makes git diffs clean
145
- const sorted = {};
146
- for (const key of Object.keys(manifest).sort()) {
147
- sorted[key] = manifest[key];
241
+ let state = locales;
242
+ if (state === undefined) {
243
+ try {
244
+ state = fs.existsSync(lockPath) ? splitLock(JSON.parse(fs.readFileSync(lockPath, 'utf-8'))).locales : {};
245
+ } catch {
246
+ state = {};
247
+ }
248
+ }
249
+ const sorted = sortKeys(manifest);
250
+ const cleanLocales = {};
251
+ for (const code of Object.keys(state || {}).sort()) {
252
+ const entry = state[code];
253
+ if (!entry || typeof entry !== 'object') continue;
254
+ const out = {};
255
+ for (const part of ['written', 'pending', 'refused', 'forms', 'gaps', 'by']) {
256
+ if (entry[part] && typeof entry[part] === 'object' && Object.keys(entry[part]).length > 0) {
257
+ out[part] = sortKeys(entry[part]);
258
+ }
259
+ }
260
+ if (Object.keys(out).length > 0) cleanLocales[code] = out;
148
261
  }
149
- fs.writeFileSync(lockPath, JSON.stringify(sorted, null, 2) + '\n', 'utf-8');
262
+ const body = Object.keys(cleanLocales).length > 0
263
+ ? { version: LOCK_VERSION, source: sorted, locales: cleanLocales }
264
+ : sorted;
265
+ fs.writeFileSync(lockPath, JSON.stringify(body, null, 2) + '\n', 'utf-8');
266
+ }
267
+
268
+ /** A copy of an object with its own keys in sorted order. */
269
+ function sortKeys(obj) {
270
+ const sorted = {};
271
+ for (const key of Object.keys(obj || {}).sort()) sorted[key] = obj[key];
272
+ return sorted;
150
273
  }
151
274
 
152
275
  export {
@@ -154,6 +277,8 @@ export {
154
277
  buildHashManifest,
155
278
  detectChangedKeys,
156
279
  readManifest,
280
+ readLock,
157
281
  writeManifest,
158
282
  LOCK_FILENAME,
283
+ LOCK_VERSION,
159
284
  };