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,106 @@
1
+ /**
2
+ * file-scope.js — `sync --files <glob>` and `sync --retranslate <glob>`.
3
+ *
4
+ * Dogfood 2026-08-28, finding 3: the only way to translate a subset of
5
+ * files was hand-editing .champollion-content.lock — and deleting a lock
6
+ * entry did not even work, because the sync then ADOPTED the existing
7
+ * target as hand-translated instead of re-translating it.
8
+ *
9
+ * --files <glob> Only these content files are considered this run
10
+ * (the rest are untouched, not even scanned).
11
+ * --retranslate <glob> These content files are translated FRESH: the
12
+ * lock, the adoption rule and the Translation
13
+ * Memory are all bypassed for them. You named the
14
+ * file, so this is the one place a run overwrites a
15
+ * target it would otherwise keep — and pays for it.
16
+ *
17
+ * Patterns match the path the sync prints for a file: relative to the
18
+ * content directory for Hugo ("posts/hello.md"), and prefixed with the
19
+ * content folder for Docusaurus ("docs/intro.md", "blog/launch.md").
20
+ * A contentDir project ALSO matches the path from the project root
21
+ * ("newsletter/2026-10.md" for newsletter/2026-10.md): the repair hint a
22
+ * sync printed (`--redo files:2026-10.md --fresh`) failed with "No content
23
+ * file matches", and so did the root-relative path a person types next
24
+ * (Round 6, school persona).
25
+ * `*` stays within one folder, `**` crosses any number of folders. Both
26
+ * flags repeat. A pattern that matches no source file is an error, raised
27
+ * before anything is spent — a typo must not turn into "nothing happened".
28
+ */
29
+
30
+ import { compilePattern } from './no-translate.js';
31
+
32
+ /**
33
+ * @typedef {object} FileScope
34
+ * @property {(label: string) => boolean} includes - Is this file in the run?
35
+ * @property {(label: string) => boolean} retranslates - Translate it fresh?
36
+ * @property {() => void} assertAllMatched - Throw if a pattern matched nothing
37
+ * @property {boolean} limitsFiles - True when --files was given
38
+ * @property {string[]} retranslatePatterns
39
+ */
40
+
41
+ function toList(value) {
42
+ if (value == null) return [];
43
+ return (Array.isArray(value) ? value : [value])
44
+ .flatMap(v => String(v).split(','))
45
+ .map(v => v.trim().replace(/^\.\//, '').replace(/\\/g, '/'))
46
+ .filter(Boolean);
47
+ }
48
+
49
+ /**
50
+ * Build the run's file scope from CLI args. Returns null when neither flag
51
+ * was given (the common case costs nothing).
52
+ *
53
+ * @param {object} cliArgs
54
+ * @param {{ rootPrefix?: string }} [options] - rootPrefix: the content
55
+ * directory relative to the project root ("newsletter"); a pattern then
56
+ * also matches `<rootPrefix>/<label>`
57
+ * @returns {FileScope|null}
58
+ */
59
+ export function compileFileScope(cliArgs = {}, { rootPrefix = '' } = {}) {
60
+ const filesPatterns = toList(cliArgs.files);
61
+ const retranslatePatterns = toList(cliArgs.retranslate);
62
+ if (filesPatterns.length === 0 && retranslatePatterns.length === 0) return null;
63
+
64
+ const compile = (patterns) => patterns.map(p => ({ pattern: p, test: compilePattern(p, '/'), hits: 0 }));
65
+ const files = compile(filesPatterns);
66
+ const retranslate = compile(retranslatePatterns);
67
+
68
+ const rawPrefix = String(rootPrefix || '').replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/\/+$/, '');
69
+ const prefix = rawPrefix === '.' ? '' : rawPrefix;
70
+ const matchAny = (list, label) => {
71
+ const rel = label.replace(/\\/g, '/');
72
+ const forms = [rel.split('/')];
73
+ if (prefix) forms.push(`${prefix}/${rel}`.split('/'));
74
+ let hit = false;
75
+ for (const m of list) {
76
+ if (forms.some(segments => m.test(segments))) { m.hits++; hit = true; }
77
+ }
78
+ return hit;
79
+ };
80
+
81
+ return {
82
+ limitsFiles: files.length > 0,
83
+ retranslatePatterns,
84
+ retranslates: (label) => matchAny(retranslate, label),
85
+ // A --retranslate file is always in the run, even outside --files. Both
86
+ // lists are consulted every time: a short-circuit here left every
87
+ // --retranslate pattern (= `--redo files:<glob> --fresh`) with zero hits
88
+ // when no --files was given, and the run failed "No content file
89
+ // matches" for a file that exists (Round 6, school persona).
90
+ includes: (label) => {
91
+ const inRetranslate = matchAny(retranslate, label);
92
+ const inFiles = files.length === 0 || matchAny(files, label);
93
+ return inFiles || inRetranslate;
94
+ },
95
+ assertAllMatched() {
96
+ const dead = [...files, ...retranslate].filter(m => m.hits === 0).map(m => m.pattern);
97
+ if (dead.length > 0) {
98
+ const e = new Error(
99
+ `No content file matches ${dead.map(p => JSON.stringify(p)).join(', ')}. ` +
100
+ `Patterns match the paths sync prints, e.g. "docs/intro.md" or "posts/**"${prefix ? `, or the path from the project root ("${prefix}/…")` : ''}. Nothing was translated.`);
101
+ e.code = 'CHAMPOLLION_NO_FILE_MATCH';
102
+ throw e;
103
+ }
104
+ },
105
+ };
106
+ }
package/lib/flatten.js CHANGED
@@ -32,9 +32,57 @@ function flattenKeys(obj, prefix = '') {
32
32
  return keys;
33
33
  }
34
34
 
35
+ /** An i18next plural key: `<base>[_ordinal]_<category>` (lib/plurals.js). */
36
+ const PLURAL_KEY_RE = /^(.+?)(_ordinal)?_(zero|one|two|few|many|other)$/;
37
+ /** CLDR's order of the plural categories. */
38
+ const PLURAL_ORDER = ['zero', 'one', 'two', 'few', 'many', 'other'];
39
+
40
+ /**
41
+ * Put `key` into `obj`. An existing key keeps its place. A NEW plural form
42
+ * (`count_many`) goes beside its siblings (`count_one`, `count_other`, same
43
+ * base, cardinal or ordinal alike) in CLDR order — before the first sibling
44
+ * whose category comes after it, else after the last sibling — and nothing
45
+ * else in the object moves. Any other new key goes at the end, as before.
46
+ *
47
+ * WHY: `verify`'s repair for a missing `count_many` appended it after
48
+ * `count_other`, so French and Spanish files ordered their plural keys
49
+ * differently (Round 10, i18next persona). A fresh file is written in the
50
+ * expansion's CLDR order; a restored form now lands where it would have been.
51
+ *
52
+ * Works on a nested object's level (the leaf name) and on a flat map
53
+ * (`cart.count_many` — the base includes the dotted path).
54
+ *
55
+ * @param {object} obj
56
+ * @param {string} key
57
+ * @param {*} value
58
+ */
59
+ function assignInOrder(obj, key, value) {
60
+ if (Object.prototype.hasOwnProperty.call(obj, key)) { obj[key] = value; return; }
61
+ const m = PLURAL_KEY_RE.exec(key);
62
+ if (!m) { obj[key] = value; return; }
63
+ const rank = PLURAL_ORDER.indexOf(m[3]);
64
+ const keys = Object.keys(obj);
65
+ let at = -1; // insert before keys[at]
66
+ let lastSibling = -1;
67
+ for (let i = 0; i < keys.length; i++) {
68
+ const s = PLURAL_KEY_RE.exec(keys[i]);
69
+ if (!s || s[1] !== m[1] || !!s[2] !== !!m[2]) continue;
70
+ if (PLURAL_ORDER.indexOf(s[3]) > rank) { at = i; break; }
71
+ lastSibling = i;
72
+ }
73
+ if (at < 0 && lastSibling < 0) { obj[key] = value; return; }
74
+ const insertAt = at >= 0 ? at : lastSibling + 1;
75
+ if (insertAt >= keys.length) { obj[key] = value; return; }
76
+ const entries = Object.entries(obj);
77
+ entries.splice(insertAt, 0, [key, value]);
78
+ for (const k of keys) delete obj[k];
79
+ for (const [k, v] of entries) obj[k] = v;
80
+ }
81
+
35
82
  /**
36
83
  * Set a value in a nested object using a dot-notation path.
37
- * Creates intermediate objects as needed.
84
+ * Creates intermediate objects as needed. A new plural form is placed among
85
+ * its siblings in CLDR order (assignInOrder); other new keys go at the end.
38
86
  *
39
87
  * @param {object} obj - Target nested object
40
88
  * @param {string} dotPath - Dot-notation path like "pages.about.title"
@@ -49,7 +97,36 @@ function setNestedValue(obj, dotPath, value) {
49
97
  }
50
98
  current = current[parts[i]];
51
99
  }
52
- current[parts[parts.length - 1]] = value;
100
+ assignInOrder(current, parts[parts.length - 1], value);
101
+ }
102
+
103
+ /**
104
+ * Delete a dot-notation path from a nested object. Parents are left in
105
+ * place (they usually hold sibling keys); a parent left EMPTY by the delete
106
+ * is removed too, so no `{}` husk is written back to the locale file.
107
+ *
108
+ * @param {object} obj - Target nested object
109
+ * @param {string} dotPath - Dot-notation path like "cart.item_one"
110
+ * @returns {boolean} True when a value was removed
111
+ */
112
+ function deleteNestedValue(obj, dotPath) {
113
+ const parts = dotPath.split('.');
114
+ const chain = [obj];
115
+ let current = obj;
116
+ for (let i = 0; i < parts.length - 1; i++) {
117
+ const next = current[parts[i]];
118
+ if (typeof next !== 'object' || next === null || Array.isArray(next)) return false;
119
+ chain.push(next);
120
+ current = next;
121
+ }
122
+ const leaf = parts[parts.length - 1];
123
+ if (!Object.prototype.hasOwnProperty.call(current, leaf)) return false;
124
+ delete current[leaf];
125
+ for (let i = chain.length - 1; i > 0; i--) {
126
+ if (Object.keys(chain[i]).length > 0) break;
127
+ delete chain[i - 1][parts[i - 1]];
128
+ }
129
+ return true;
53
130
  }
54
131
 
55
- export { flattenKeys, setNestedValue };
132
+ export { flattenKeys, setNestedValue, deleteNestedValue, assignInOrder };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * flutter-locales.js — does Flutter's own UI text cover a target locale?
3
+ *
4
+ * `flutter gen-l10n` builds the app's messages from the ARB files sync
5
+ * writes, but Material and Cupertino widgets (date pickers, "Back",
6
+ * "Cancel", text direction) take their own text from flutter_localizations
7
+ * (GlobalMaterialLocalizations, GlobalCupertinoLocalizations,
8
+ * GlobalWidgetsLocalizations), which cover a fixed list of languages. An app
9
+ * whose supportedLocales include one outside that list — a private-use code
10
+ * such as `qaa`, most low-resource languages — fails at runtime ("No
11
+ * MaterialLocalizations found") unless it adds a fallback delegate (Round
12
+ * 11, hospital persona: neither init nor the Flutter docs said so).
13
+ *
14
+ * The list is Flutter's own, read from the Flutter SDK on this machine
15
+ * (FLUTTER_ROOT, or the `flutter` on PATH): one
16
+ * `packages/flutter_localizations/lib/src/l10n/material_<locale>.arb` file
17
+ * per covered locale. Nothing here guesses it: without an SDK, a private-use
18
+ * code is reported as uncovered (no list can hold one) and every other code
19
+ * is reported as unchecked, with where to read the rule.
20
+ */
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { isPrivateUseCode } from './registers.js';
25
+
26
+ /** Where the CLI's Flutter section explains the fallback delegate. */
27
+ export const FLUTTER_LOCALES_DOC = 'https://champollion.dev/docs/integrations/frameworks#flutter-locales-outside-flutters-own-list';
28
+
29
+ /** The directory of Flutter's material_<locale>.arb files, from an SDK root. */
30
+ const L10N_DIR = path.join('packages', 'flutter_localizations', 'lib', 'src', 'l10n');
31
+
32
+ /**
33
+ * The Flutter SDK root on this machine: FLUTTER_ROOT, else the directory
34
+ * above the `bin/` of the first `flutter` on PATH (symlinks resolved).
35
+ *
36
+ * @param {object} [env=process.env]
37
+ * @returns {string|null}
38
+ */
39
+ export function findFlutterSdk(env = process.env) {
40
+ const isSdk = (root) => !!root && fs.existsSync(path.join(root, L10N_DIR));
41
+ if (env.FLUTTER_ROOT && isSdk(env.FLUTTER_ROOT)) return env.FLUTTER_ROOT;
42
+ for (const dir of String(env.PATH || '').split(path.delimiter)) {
43
+ if (!dir) continue;
44
+ for (const name of process.platform === 'win32' ? ['flutter.bat', 'flutter'] : ['flutter']) {
45
+ const exe = path.join(dir, name);
46
+ let real;
47
+ try { real = fs.realpathSync(exe); } catch { continue; }
48
+ const root = path.dirname(path.dirname(real));
49
+ if (isSdk(root)) return root;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * The language codes Flutter's Material localizations cover, read from the
57
+ * SDK: `material_pt_BR.arb` → "pt". null when no SDK is found.
58
+ *
59
+ * @param {object} [env=process.env]
60
+ * @returns {{ languages: Set<string>, from: string }|null}
61
+ */
62
+ export function flutterMaterialLanguages(env = process.env) {
63
+ const root = findFlutterSdk(env);
64
+ if (!root) return null;
65
+ const dir = path.join(root, L10N_DIR);
66
+ let names;
67
+ try { names = fs.readdirSync(dir); } catch { return null; }
68
+ const languages = new Set();
69
+ for (const n of names) {
70
+ const m = /^material_([A-Za-z]{2,3})(?:_[A-Za-z0-9]+)*\.arb$/.exec(n);
71
+ if (m) languages.add(m[1].toLowerCase());
72
+ }
73
+ return languages.size > 0 ? { languages, from: dir } : null;
74
+ }
75
+
76
+ /** "pt_BR" / "pt-BR" → "pt". */
77
+ function languageOf(code) {
78
+ return String(code).split(/[-_]/)[0].toLowerCase();
79
+ }
80
+
81
+ /**
82
+ * Which ARB target locales Flutter's own widget text does not cover.
83
+ *
84
+ * @param {string[]} codes - Target locales as the ARB files name them
85
+ * @param {object} [env=process.env]
86
+ * @returns {{ uncovered: string[], unchecked: string[], from: string|null }}
87
+ * uncovered: outside Flutter's list (or private-use); unchecked: no SDK here to check against
88
+ */
89
+ export function flutterUncoveredLocales(codes, env = process.env) {
90
+ const list = flutterMaterialLanguages(env);
91
+ const uncovered = [];
92
+ const unchecked = [];
93
+ for (const code of codes) {
94
+ // A private-use code (qaa–qtz) is in no published list.
95
+ if (isPrivateUseCode(code)) uncovered.push(code);
96
+ else if (!list) unchecked.push(code);
97
+ else if (!list.languages.has(languageOf(code))) uncovered.push(code);
98
+ }
99
+ return { uncovered, unchecked, from: list ? list.from : null };
100
+ }
101
+
102
+ /**
103
+ * The line(s) init and sync print for ARB targets: what to add for a locale
104
+ * Flutter does not cover, or — with no SDK here — that it was not checked.
105
+ * Empty when every target is covered.
106
+ *
107
+ * @param {string[]} codes
108
+ * @param {object} [env=process.env]
109
+ * @returns {Array<{ level: 'warn'|'info', text: string }>}
110
+ */
111
+ export function flutterLocaleLines(codes, env = process.env) {
112
+ const { uncovered, unchecked, from } = flutterUncoveredLocales(codes, env);
113
+ const lines = [];
114
+ if (uncovered.length > 0) {
115
+ const basis = from ? `checked against ${from}` : 'a private-use code is in no list';
116
+ lines.push({ level: 'warn', text: `Flutter: its own Material/Cupertino widget text (GlobalMaterialLocalizations) does not cover ${uncovered.join(', ')} (${basis}). `
117
+ + `An app with ${uncovered.length > 1 ? 'these locales' : 'this locale'} in supportedLocales fails at runtime without a fallback delegate for it — add one: ${FLUTTER_LOCALES_DOC}` });
118
+ }
119
+ if (unchecked.length > 0) {
120
+ lines.push({ level: 'info', text: `Flutter: no Flutter SDK found here (FLUTTER_ROOT, or flutter on PATH), so ${unchecked.join(', ')} ${unchecked.length > 1 ? 'were' : 'was'} not checked against `
121
+ + `the languages Flutter's own widget text covers (GlobalMaterialLocalizations). A language outside that list needs a fallback delegate: ${FLUTTER_LOCALES_DOC}` });
122
+ }
123
+ return lines;
124
+ }