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.
- package/README.md +52 -37
- package/bin/cli.js +53 -5
- package/index.js +63 -2
- package/lib/api-key.js +17 -4
- package/lib/autofix.js +83 -36
- package/lib/bridge/method_bridge.py +15 -3
- package/lib/cards/reader.js +51 -3
- package/lib/cards/remote.js +15 -0
- package/lib/cards/search-names.js +178 -0
- package/lib/command-help.js +289 -88
- package/lib/commands/audit.js +10 -3
- package/lib/commands/card.js +583 -226
- package/lib/commands/doctor.js +54 -18
- package/lib/commands/help.js +37 -32
- package/lib/commands/init.js +1689 -87
- package/lib/commands/integrity.js +127 -40
- package/lib/commands/leaderboard.js +187 -67
- package/lib/commands/models.js +9 -2
- package/lib/commands/provenance.js +7 -2
- package/lib/commands/recommend.js +43 -14
- package/lib/commands/register-corpus.js +649 -130
- package/lib/commands/seal-corpus.js +1 -1
- package/lib/commands/status.js +564 -27
- package/lib/commands/submit.js +17 -12
- package/lib/commands/sync.js +31 -7
- package/lib/commands/tm.js +16 -10
- package/lib/commands/verify.js +27 -3
- package/lib/commands/wrap.js +63 -5
- package/lib/commands/xliff.js +135 -64
- package/lib/commercial-eligibility.js +1 -1
- package/lib/config.js +196 -14
- package/lib/content-estimate.js +96 -0
- package/lib/content-refusals.js +270 -0
- package/lib/content-review.js +372 -0
- package/lib/content-sync.js +1127 -344
- package/lib/content.js +94 -7
- package/lib/corpus-registration.mjs +197 -38
- package/lib/cost-label.js +29 -0
- package/lib/cost-report.js +726 -78
- package/lib/diff.js +38 -4
- package/lib/docusaurus-sync.js +965 -253
- package/lib/edit-distance.js +31 -0
- package/lib/fallback.js +964 -0
- package/lib/file-scope.js +106 -0
- package/lib/flatten.js +80 -3
- package/lib/flutter-locales.js +124 -0
- package/lib/format.js +266 -12
- package/lib/hash.js +146 -21
- package/lib/icu-structure.js +929 -0
- package/lib/integrity.js +223 -75
- package/lib/language-pair.js +157 -0
- package/lib/lint.js +78 -16
- package/lib/local-only-marks.js +106 -0
- package/lib/locale-layout.js +1103 -0
- package/lib/locale-state.js +571 -0
- package/lib/methods/anthropic.js +5 -0
- package/lib/methods/apertium.js +6 -3
- package/lib/methods/api.js +138 -25
- package/lib/methods/base.js +17 -0
- package/lib/methods/coaching-data.js +153 -0
- package/lib/methods/content-separator.js +43 -0
- package/lib/methods/deepl.js +1 -1
- package/lib/methods/direct-llm.js +252 -103
- package/lib/methods/external.js +146 -63
- package/lib/methods/gemini.js +1 -0
- package/lib/methods/google-translate.js +1 -0
- package/lib/methods/http-utils.js +41 -0
- package/lib/methods/libretranslate.js +7 -2
- package/lib/methods/llm-coached.js +68 -128
- package/lib/methods/llm.js +80 -31
- package/lib/methods/local.js +93 -10
- package/lib/methods/microsoft-translator.js +1 -2
- package/lib/methods/openai.js +4 -2
- package/lib/methods/openrouter-client.js +20 -19
- package/lib/methods/openrouter-pricing.js +150 -13
- package/lib/methods/prompt-methods.js +20 -0
- package/lib/methods/provider-pricing.js +42 -1
- package/lib/methods/request-capture.js +104 -0
- package/lib/methods/tilde.js +1 -1
- package/lib/methods/translated.js +1 -2
- package/lib/missing-key.js +93 -0
- package/lib/models.js +11 -0
- package/lib/name-rules.js +32 -0
- package/lib/named-keys.js +172 -0
- package/lib/no-translate.js +4 -3
- package/lib/output.js +160 -19
- package/lib/pairs.js +586 -30
- package/lib/placeholders.js +394 -0
- package/lib/plugins.js +8 -0
- package/lib/plural-gap-redo.js +109 -0
- package/lib/plurals.js +323 -0
- package/lib/po.js +1187 -0
- package/lib/public-catalogue.js +74 -0
- package/lib/recommend.js +527 -32
- package/lib/redo.js +95 -0
- package/lib/refusal-category.js +44 -0
- package/lib/registers.js +255 -11
- package/lib/repair-script.js +20 -13
- package/lib/scripts.js +193 -106
- package/lib/seal.mjs +6 -5
- package/lib/sealed-qualifier.mjs +2 -2
- package/lib/segment.js +2 -1
- package/lib/seo.js +19 -9
- package/lib/serve.js +43 -6
- package/lib/shared-output-seed.js +164 -0
- package/lib/source-contexts.js +39 -0
- package/lib/submit.mjs +57 -5
- package/lib/sync.js +2923 -474
- package/lib/terminology.js +13 -4
- package/lib/tm-evict.js +179 -0
- package/lib/tm-seed.js +5 -2
- package/lib/tm.js +818 -36
- package/lib/translate-pair.js +639 -34
- package/lib/translate.js +78 -5
- package/lib/types.js +22 -3
- package/lib/validate.js +880 -17
- package/lib/verify.js +1296 -104
- package/lib/watch.js +32 -13
- package/lib/xliff.js +44 -3
- package/package.json +3 -2
- package/shared/CORPORA-CARDS.md +2 -0
- package/shared/DATA-SOVEREIGNTY.md +19 -20
- package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
- package/shared/cards-fallback.json +1 -1
- package/shared/catalogue/card-config.json +1 -1
- package/shared/curated-orthography-conventions.json +26 -8
- package/shared/docent/faq.en.json +14 -16
- package/shared/docent/system-prompt.md +17 -19
- package/shared/explainers/tc-features.json +15 -15
- package/shared/gettext-plural-forms.json +45 -0
- package/shared/human-services.json +1 -1
- package/shared/method-registry.json +2 -0
- package/shared/metric-registry.json +96 -18
- package/shared/schemas/champollion-plugin.schema.json +4 -0
- package/shared/schemas/corpora-card.schema.json +20 -10
- package/shared/schemas/human-services.schema.json +2 -2
- package/shared/schemas/language-card.schema.json +1 -1
- package/shared/schemas/method-card.schema.json +1 -1
- package/shared/schemas/method-index-record.schema.json +67 -0
- package/shared/schemas/method-registry.schema.json +4 -0
- package/shared/schemas/metric-registry.schema.json +55 -1
- package/shared/docent/corpus.json +0 -11333
package/lib/format.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Format adapter — reads and writes locale files in JSON, TOML,
|
|
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
|
|
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 === '
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
* "
|
|
24
|
-
* "nav.
|
|
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
|
|
117
|
-
+ `
|
|
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:
|
|
125
|
-
+ `
|
|
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
|
|
128
|
-
+ `
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
};
|