champollion 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +133 -0
  2. package/README.md +387 -0
  3. package/bin/cli.js +278 -0
  4. package/index.js +135 -0
  5. package/lib/api-key.js +127 -0
  6. package/lib/autofix.js +432 -0
  7. package/lib/bridge/method_bridge.py +430 -0
  8. package/lib/card-source-resolution.mjs +284 -0
  9. package/lib/cards/cache.js +169 -0
  10. package/lib/cards/env.js +82 -0
  11. package/lib/cards/fetch-card-child.js +38 -0
  12. package/lib/cards/reader.js +435 -0
  13. package/lib/cards/refresh.js +111 -0
  14. package/lib/cards/remote.js +387 -0
  15. package/lib/cldf-export.mjs +540 -0
  16. package/lib/cldf-terms.mjs +62 -0
  17. package/lib/command-help.js +790 -0
  18. package/lib/commands/audit.js +49 -0
  19. package/lib/commands/card.js +454 -0
  20. package/lib/commands/doctor.js +559 -0
  21. package/lib/commands/fonts.js +489 -0
  22. package/lib/commands/help.js +91 -0
  23. package/lib/commands/init.js +1259 -0
  24. package/lib/commands/integrity.js +148 -0
  25. package/lib/commands/leaderboard.js +478 -0
  26. package/lib/commands/lint.js +30 -0
  27. package/lib/commands/models.js +177 -0
  28. package/lib/commands/plugin.js +103 -0
  29. package/lib/commands/provenance.js +45 -0
  30. package/lib/commands/recommend.js +75 -0
  31. package/lib/commands/register-corpus.js +678 -0
  32. package/lib/commands/repair-script.js +42 -0
  33. package/lib/commands/seal-corpus.js +355 -0
  34. package/lib/commands/seo.js +72 -0
  35. package/lib/commands/serve.js +147 -0
  36. package/lib/commands/status.js +265 -0
  37. package/lib/commands/submit.js +332 -0
  38. package/lib/commands/sync.js +89 -0
  39. package/lib/commands/tm.js +573 -0
  40. package/lib/commands/verify.js +39 -0
  41. package/lib/commands/watch.js +20 -0
  42. package/lib/commands/wrap.js +138 -0
  43. package/lib/commands/xliff.js +327 -0
  44. package/lib/commercial-eligibility.js +235 -0
  45. package/lib/concurrent.js +87 -0
  46. package/lib/config.js +523 -0
  47. package/lib/contamination-lane.js +76 -0
  48. package/lib/content-sync.js +731 -0
  49. package/lib/content.js +733 -0
  50. package/lib/corpus-registration.mjs +608 -0
  51. package/lib/cost-report.js +346 -0
  52. package/lib/diff.js +155 -0
  53. package/lib/docusaurus-sync.js +1256 -0
  54. package/lib/flatten.js +55 -0
  55. package/lib/format.js +954 -0
  56. package/lib/hash.js +159 -0
  57. package/lib/icu.js +473 -0
  58. package/lib/integrity.js +689 -0
  59. package/lib/license-gate.mjs +478 -0
  60. package/lib/license-identify.mjs +229 -0
  61. package/lib/lint.js +629 -0
  62. package/lib/method-manifest.js +60 -0
  63. package/lib/methods/anthropic.js +140 -0
  64. package/lib/methods/apertium.js +163 -0
  65. package/lib/methods/api.js +316 -0
  66. package/lib/methods/base.js +184 -0
  67. package/lib/methods/content-separator.js +45 -0
  68. package/lib/methods/deepl.js +426 -0
  69. package/lib/methods/direct-llm.js +586 -0
  70. package/lib/methods/external.js +332 -0
  71. package/lib/methods/fetch-with-retry.js +124 -0
  72. package/lib/methods/gemini.js +147 -0
  73. package/lib/methods/google-translate.js +402 -0
  74. package/lib/methods/http-utils.js +122 -0
  75. package/lib/methods/libretranslate.js +314 -0
  76. package/lib/methods/llm-coached.js +670 -0
  77. package/lib/methods/llm.js +592 -0
  78. package/lib/methods/local.js +76 -0
  79. package/lib/methods/microsoft-translator.js +331 -0
  80. package/lib/methods/openai.js +131 -0
  81. package/lib/methods/openrouter-client.js +327 -0
  82. package/lib/methods/openrouter-pricing.js +156 -0
  83. package/lib/methods/provider-env.js +115 -0
  84. package/lib/methods/provider-pricing.js +310 -0
  85. package/lib/methods/tilde.js +150 -0
  86. package/lib/methods/translated.js +229 -0
  87. package/lib/methods/translation-error.js +80 -0
  88. package/lib/models.js +258 -0
  89. package/lib/no-translate.js +233 -0
  90. package/lib/output.js +238 -0
  91. package/lib/pairs.js +547 -0
  92. package/lib/plugins.js +447 -0
  93. package/lib/provenance.js +323 -0
  94. package/lib/recommend.js +648 -0
  95. package/lib/registers.js +1185 -0
  96. package/lib/repair-script.js +266 -0
  97. package/lib/scripts.js +994 -0
  98. package/lib/seal.mjs +464 -0
  99. package/lib/sealed-qualifier.mjs +211 -0
  100. package/lib/security.js +59 -0
  101. package/lib/segment.js +369 -0
  102. package/lib/seo.js +275 -0
  103. package/lib/serve.js +854 -0
  104. package/lib/string-classify.js +85 -0
  105. package/lib/submit.mjs +344 -0
  106. package/lib/sync.js +969 -0
  107. package/lib/tags/bcp47.js +202 -0
  108. package/lib/tags/resolve.js +314 -0
  109. package/lib/terminology.js +111 -0
  110. package/lib/tm-seed.js +294 -0
  111. package/lib/tm.js +515 -0
  112. package/lib/translate-pair.js +197 -0
  113. package/lib/translate.js +203 -0
  114. package/lib/types.js +230 -0
  115. package/lib/validate.js +510 -0
  116. package/lib/verify.js +451 -0
  117. package/lib/watch.js +145 -0
  118. package/lib/xliff.js +184 -0
  119. package/package.json +93 -0
  120. package/shared/ATTRIBUTION.md +145 -0
  121. package/shared/CORPORA-CARDS.md +288 -0
  122. package/shared/DATA-SOVEREIGNTY.md +500 -0
  123. package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
  124. package/shared/card-lint-baseline.json +3189 -0
  125. package/shared/cards-fallback.json +1 -0
  126. package/shared/catalogue/card-config.json +6091 -0
  127. package/shared/catalogue/external-results.json +3888 -0
  128. package/shared/catalogue/gender-guidance.json +1038 -0
  129. package/shared/catalogue/method-coverage.json +1751 -0
  130. package/shared/catalogue/metric-coverage.json +170 -0
  131. package/shared/catalogue/metric-reliability.json +1 -0
  132. package/shared/catalogue/register-presets.json +3180 -0
  133. package/shared/catalogue/vitality-scales.json +55 -0
  134. package/shared/cldr-index.json +1115 -0
  135. package/shared/code-bridge.json +253 -0
  136. package/shared/corpora-cards-v1-reference.md +281 -0
  137. package/shared/curated-dictionary-flags.json +35 -0
  138. package/shared/curated-endonyms.json +35 -0
  139. package/shared/curated-fsts.json +51 -0
  140. package/shared/curated-orthography-conventions.json +26 -0
  141. package/shared/curated-sil-resources.json +374 -0
  142. package/shared/curated-tools.json +41 -0
  143. package/shared/docent/corpus.json +11333 -0
  144. package/shared/docent/faq.en.json +564 -0
  145. package/shared/docent/register-blocks.json +60 -0
  146. package/shared/docent/system-prompt.md +144 -0
  147. package/shared/domain-taxonomy.json +35 -0
  148. package/shared/explainers/glossary.json +2975 -0
  149. package/shared/explainers/tc-features.json +20112 -0
  150. package/shared/explainers/term-watchlist.json +147 -0
  151. package/shared/human-services.json +59 -0
  152. package/shared/license-corrections.json +261 -0
  153. package/shared/license-evidence.json +13452 -0
  154. package/shared/licenses.json +6781 -0
  155. package/shared/method-registry.json +236 -0
  156. package/shared/metric-registry.json +620 -0
  157. package/shared/model-aliases.json +7 -0
  158. package/shared/schemas/champollion-plugin.schema.json +206 -0
  159. package/shared/schemas/corpora-card.schema.json +957 -0
  160. package/shared/schemas/domain-taxonomy.schema.json +64 -0
  161. package/shared/schemas/external-results.schema.json +314 -0
  162. package/shared/schemas/human-services.schema.json +90 -0
  163. package/shared/schemas/language-card.schema.json +1308 -0
  164. package/shared/schemas/licenses.schema.json +155 -0
  165. package/shared/schemas/method-card.schema.json +412 -0
  166. package/shared/schemas/method-registry.schema.json +85 -0
  167. package/shared/schemas/metric-registry.schema.json +96 -0
  168. package/shared/schemas/metric-reliability.schema.json +178 -0
  169. package/shared/schemas/model-aliases.schema.json +27 -0
  170. package/shared/schemas/source-snapshot.schema.json +96 -0
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Command: audit
3
+ *
4
+ * Lists untranslated [EN] fallback values across all locale files.
5
+ * Returns exit code 1 if any untranslated keys exist — usable as a CI gate.
6
+ */
7
+
8
+ import { runSync } from '../sync.js';
9
+ import { output } from '../output.js';
10
+
11
+ /**
12
+ * @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
13
+ * @param {string} cwd - Working directory
14
+ * @returns {Promise<number>} Exit code (0 = success, 1 = error)
15
+ */
16
+ async function run(args, cwd) {
17
+ // --json: machine-readable NDJSON (same controller sync uses) — the audit
18
+ // branch in lib/sync.js emits a structured summary with the untranslated
19
+ // key list per locale. --quiet: warnings and errors only.
20
+ if (args.json) output.setMode('json');
21
+ else if (args.quiet) output.setMode('quiet');
22
+
23
+ let result;
24
+ try {
25
+ result = await runSync({
26
+ audit: true,
27
+ cwd,
28
+ cliArgs: args,
29
+ });
30
+ } catch (err) {
31
+ // Keep --json stdout machine-parseable even on a hard error (missing
32
+ // locales dir, bad config) — mirror commands/sync.js.
33
+ if (args.json) {
34
+ output.summary({ command: 'audit', ok: false, error: err.message });
35
+ return 1;
36
+ }
37
+ throw err;
38
+ }
39
+
40
+ // Exit 1 if any keys still need translation OR a configured locale file
41
+ // is missing entirely (parity gate). A missing file means zero translation
42
+ // done for that locale — a CI gate must never go green on it.
43
+ if (result && (result.untranslatedCount > 0 || result.missingLocaleCount > 0)) {
44
+ return 1;
45
+ }
46
+ return 0;
47
+ }
48
+
49
+ export { run };
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Command: card
3
+ *
4
+ * Pretty-prints a language card from shared/language-cards/.
5
+ *
6
+ * Usage:
7
+ * champollion card crk — Show Plains Cree card
8
+ * champollion card spa — Show Spanish card
9
+ * champollion card crk --json — Output raw JSON
10
+ *
11
+ * WHY: Language cards are deeply nested JSON files with 50+ fields.
12
+ * Developers and contributors need a quick way to inspect a card's
13
+ * key properties without scrolling through raw JSON. This command
14
+ * formats the card into logical sections with ANSI colors for
15
+ * scannability.
16
+ *
17
+ * Exit codes:
18
+ * 0 — Card displayed successfully
19
+ * 1 — Missing code argument or card not found
20
+ */
21
+
22
+ import { getLanguageCard, resolveCode } from '../registers.js';
23
+
24
+ // -----------------------------------------------------------------
25
+ // ANSI formatting helpers — built-in, no external deps
26
+ //
27
+ // We use raw ANSI escape sequences instead of chalk/kleur because
28
+ // the project rule is zero external dependencies. These helpers are
29
+ // intentionally simple — just enough for readable terminal output.
30
+ // -----------------------------------------------------------------
31
+
32
+ const isTTY = process.stdout.isTTY;
33
+
34
+ /** Wrap text in an ANSI escape code pair, but only if stdout is a TTY. */
35
+ function ansi(code, resetCode, text) {
36
+ if (!isTTY) return text;
37
+ return `\x1b[${code}m${text}\x1b[${resetCode}m`;
38
+ }
39
+
40
+ const fmt = {
41
+ bold: (t) => ansi('1', '22', t),
42
+ dim: (t) => ansi('2', '22', t),
43
+ cyan: (t) => ansi('36', '39', t),
44
+ green: (t) => ansi('32', '39', t),
45
+ yellow: (t) => ansi('33', '39', t),
46
+ red: (t) => ansi('31', '39', t),
47
+ magenta: (t) => ansi('35', '39', t),
48
+ white: (t) => ansi('37', '39', t),
49
+ };
50
+
51
+ // -----------------------------------------------------------------
52
+ // Section renderers — each handles one logical block of the card
53
+ // -----------------------------------------------------------------
54
+
55
+ /**
56
+ * Print a section heading with a decorative underline.
57
+ * @param {string} title - Section title
58
+ */
59
+ function heading(title) {
60
+ console.log('');
61
+ console.log(` ${fmt.bold(fmt.cyan(title))}`);
62
+ console.log(` ${fmt.dim('─'.repeat(title.length))}`);
63
+ }
64
+
65
+ /**
66
+ * Print a labeled key-value pair, with optional value formatting.
67
+ * @param {string} label - Field label (left-aligned)
68
+ * @param {*} value - Field value (auto-formatted)
69
+ * @param {number} [pad=24] - Label padding width
70
+ */
71
+ function field(label, value, pad = 24) {
72
+ if (value === null || value === undefined || value === '') return;
73
+ let v = value;
74
+ // An attribution envelope reaching a human unrendered is a bug a unit test
75
+ // never sees and a user sees instantly — Family printed as raw JSON. Where
76
+ // sources AGREE the consensus speaks; where they DISAGREE the card shows all
77
+ // of them attributed, which is the project's standing rule, in one line.
78
+ if (v && typeof v === 'object' && typeof v.agreement === 'string' && Array.isArray(v.values)) {
79
+ v = 'consensus' in v
80
+ ? v.consensus
81
+ : v.values.map((x) => `${x.value} (${String(x.source).replace(/-v?[\d.].*$/, '')})`).join(' / ');
82
+ }
83
+ const displayValue = typeof v === 'object' ? JSON.stringify(v) : String(v);
84
+ console.log(` ${fmt.dim(label.padEnd(pad))} ${displayValue}`);
85
+ }
86
+
87
+ /**
88
+ * Print the header section: name, native name, codes, script, vitality.
89
+ */
90
+ function renderHeader(card) {
91
+ // Title line — large and bold
92
+ const vitalityBadge = getVitalityBadge(card.vitality?.unescoStatus);
93
+ console.log('');
94
+ console.log(` ${fmt.bold(card.name)}${card.nativeName ? ` ${fmt.dim('—')} ${card.nativeName}` : ''}`);
95
+ if (vitalityBadge) {
96
+ console.log(` ${vitalityBadge}`);
97
+ }
98
+
99
+ heading('Identification');
100
+ field('ISO 639-3', card.iso639_3);
101
+ field('ISO 639-1', card.iso639_1);
102
+ field('BCP 47', card.bcp47);
103
+ field('Glottocode', card.glottocode);
104
+ field('Script', card.script ? `${card.script}${card.scriptUnicodeName ? ` (${card.scriptUnicodeName})` : ''}` : null);
105
+ field('Direction', card.dir);
106
+ field('Script converter', card.scriptConverter);
107
+ field('Support tier', formatSupportTier(card.supportTier));
108
+ if (card.macrolanguage) {
109
+ field('Macrolanguage', card.macrolanguage);
110
+ }
111
+ if (card.aliases && card.aliases.length > 0) {
112
+ field('Aliases', card.aliases.join(', '));
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Format UNESCO vitality status as a colored badge.
118
+ */
119
+ function getVitalityBadge(status) {
120
+ if (!status) return null;
121
+ const badges = {
122
+ 'safe': fmt.green('● safe'),
123
+ 'vulnerable': fmt.yellow('● vulnerable'),
124
+ 'definitely-endangered': fmt.yellow('● definitely endangered'),
125
+ 'severely-endangered': fmt.red('● severely endangered'),
126
+ 'critically-endangered': fmt.red('● critically endangered'),
127
+ 'extinct': fmt.dim('○ extinct'),
128
+ };
129
+ return badges[status] || fmt.dim(`● ${status}`);
130
+ }
131
+
132
+ /**
133
+ * Format support tier with color.
134
+ */
135
+ function formatSupportTier(tier) {
136
+ if (!tier) return null;
137
+ const tiers = {
138
+ 'supported': fmt.green(tier),
139
+ 'experimental': fmt.yellow(tier),
140
+ 'developing': fmt.yellow(tier),
141
+ 'cataloged': fmt.dim(tier),
142
+ 'community': fmt.cyan(tier),
143
+ };
144
+ return tiers[tier] || tier;
145
+ }
146
+
147
+ /**
148
+ * Print classification section: family, genus, macroarea.
149
+ */
150
+ function renderClassification(card) {
151
+ if (!card.classification && !card.macroarea) return;
152
+
153
+ heading('Classification');
154
+ if (card.classification) {
155
+ field('Family', card.classification.family);
156
+ field('Genus', card.classification.genus);
157
+ if (card.classification.ancestry) {
158
+ field('Ancestry', card.classification.ancestry.join(' → '));
159
+ }
160
+ }
161
+ field('Macroarea', card.macroarea);
162
+ if (card.isIsolate) {
163
+ field('Language isolate', fmt.yellow('yes'));
164
+ }
165
+ if (card.countries && card.countries.length > 0) {
166
+ field('Countries', card.countries.join(', '));
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Print speaker estimates section.
172
+ */
173
+ function renderSpeakers(card) {
174
+ const estimates = card.speakerEstimates;
175
+ const vitalityCount = card.vitality?.speakerCount;
176
+
177
+ if ((!estimates || estimates.length === 0) && !vitalityCount) return;
178
+
179
+ heading('Speaker Estimates');
180
+
181
+ if (vitalityCount) {
182
+ field('Total (vitality)', typeof vitalityCount === 'number' ? vitalityCount.toLocaleString() : vitalityCount);
183
+ }
184
+ if (estimates) {
185
+ for (const est of estimates) {
186
+ const label = `${est.source}${est.date ? ` (${est.date})` : ''}`;
187
+ field(label, typeof est.count === 'number' ? est.count.toLocaleString() : est.count);
188
+ }
189
+ }
190
+ if (card.vitality?.trend) {
191
+ field('Trend', card.vitality.trend);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Print typological profile section.
197
+ */
198
+ function renderTypology(card) {
199
+ // Pull from encyclopedic.typology (richer) or typologicalProfile
200
+ const typ = card.encyclopedic?.typology;
201
+ const profile = card.typologicalProfile;
202
+
203
+ if (!typ && !profile) return;
204
+
205
+ heading('Typological Profile');
206
+
207
+ if (typ) {
208
+ field('Word order', typ.wordOrder);
209
+ field('Adpositions', typ.adpositionType);
210
+ field('Affixation', typ.affixType);
211
+ field('Verb synthesis', typ.verbSynthesis);
212
+ field('Morphological fusion', typ.morphologicalFusion);
213
+ field('Consonants', typ.consonantInventory);
214
+ field('Vowels', typ.vowelInventory);
215
+ field('Tone', typ.tone);
216
+ field('Cases', typ.cases);
217
+ field('Genders', typ.genders);
218
+ } else if (profile) {
219
+ // PRINT WHAT THE CARD CARRIES, not a list of field names typed in once.
220
+ //
221
+ // This used to name ten fixed keys — wordOrderDominant, hasGenderSystem,
222
+ // hasCaseMorphology and so on. The atlas emits the Grambank/WALS feature
223
+ // names instead (subjectVerbOrder, hasCoreCase, affixPreference,
224
+ // marksPastTense …), so every one of those lookups returned undefined and
225
+ // the section printed its heading over nothing while the card held twenty
226
+ // populated features.
227
+ //
228
+ // Enumerating the object cannot go stale that way: a feature the atlas
229
+ // starts publishing shows up without an edit here, and one it stops
230
+ // publishing disappears rather than printing blank.
231
+ const label = (key) => {
232
+ const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase();
233
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
234
+ };
235
+ for (const [key, value] of Object.entries(profile)) {
236
+ if (key === 'source' || key.startsWith('_')) continue;
237
+ if (value === null || value === undefined || value === '') continue;
238
+ field(label(key), typeof value === 'boolean' ? (value ? 'yes' : 'no') : value);
239
+ }
240
+ }
241
+
242
+ if (profile?.source) {
243
+ field('Source', fmt.dim(profile.source));
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Print corpus availability section.
249
+ */
250
+ function renderCorpus(card) {
251
+ const corpus = card.corpusAvailability;
252
+ if (!corpus) return;
253
+
254
+ heading('Corpus Availability');
255
+
256
+ if (corpus.opus) {
257
+ const opusInfo = [];
258
+ if (corpus.opus.corpora) opusInfo.push(`${corpus.opus.corpora} corpora`);
259
+ if (corpus.opus.languagePairs) opusInfo.push(`${corpus.opus.languagePairs} lang pairs`);
260
+ if (corpus.opus.totalAlignmentPairs) opusInfo.push(`${corpus.opus.totalAlignmentPairs.toLocaleString()} alignment pairs`);
261
+ field('OPUS', opusInfo.join(', '));
262
+ if (corpus.opus.corpusNames && corpus.opus.corpusNames.length > 0) {
263
+ field(' Top corpora', corpus.opus.corpusNames.slice(0, 5).join(', '));
264
+ }
265
+ }
266
+ if (corpus.lexibank) {
267
+ field('Lexibank', `${corpus.lexibank.datasets} datasets, ${corpus.lexibank.totalForms} forms`);
268
+ }
269
+ if (corpus.ud) {
270
+ field('Universal Deps', `${corpus.ud.treebanks} treebanks: ${corpus.ud.treebankNames?.join(', ') || ''}`);
271
+ }
272
+ if (corpus.asjpWordlists) field('ASJP', `${corpus.asjpWordlists} wordlist(s), ${corpus.asjpForms || '?'} forms`);
273
+ if (corpus.huggingFaceDatasets) field('HuggingFace', `${corpus.huggingFaceDatasets} datasets`);
274
+ if (corpus.unimorphParadigms) field('UniMorph', fmt.green('✓'));
275
+ if (corpus.wiktionaryStructuredDump) field('Wiktionary dump', fmt.green('✓'));
276
+ if (corpus.openMultilingualWordnet) field('Open Multilingual WN', fmt.green('✓'));
277
+ }
278
+
279
+ /**
280
+ * Print eval datasets section.
281
+ */
282
+ function renderEval(card) {
283
+ if (!card.evalDatasets || card.evalDatasets.length === 0) return;
284
+
285
+ heading('Eval Datasets');
286
+ for (const ds of card.evalDatasets) {
287
+ console.log(` ${fmt.cyan('•')} ${ds}`);
288
+ }
289
+ if (card.omt1600) {
290
+ // The OMT-1600 paper publishes no per-language tier table, so `tier` is
291
+ // null on most covered languages. Print the clause only when there is a
292
+ // cited tier — never "tier null".
293
+ const tier = card.omt1600.tier ? ` — tier ${card.omt1600.tier}` : '';
294
+ field('OMT-1600', card.omt1600.covered ? `${fmt.green('covered')}${tier}` : fmt.dim('not covered'));
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Print pipeline readiness section.
300
+ */
301
+ function renderPipeline(card) {
302
+ const pr = card.pipelineReadiness;
303
+ if (!pr) return;
304
+
305
+ heading('Pipeline Readiness');
306
+
307
+ // Score bar — visual indicator of readiness
308
+ const score = typeof pr.score === 'number' ? pr.score : null;
309
+ if (score !== null) {
310
+ const barWidth = 20;
311
+ const filled = Math.round((score / 100) * barWidth);
312
+ const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
313
+ const colorFn = score >= 70 ? fmt.green : score >= 40 ? fmt.yellow : fmt.red;
314
+ field('Score', `${colorFn(bar)} ${score}/100 (${pr.tier || ''})`);
315
+ } else {
316
+ field('Tier', pr.tier);
317
+ }
318
+
319
+ // Component checklist
320
+ if (pr.components) {
321
+ const checks = Object.entries(pr.components)
322
+ .map(([key, val]) => `${val ? fmt.green('✓') : fmt.dim('✗')} ${key}`)
323
+ .join(' ');
324
+ console.log(` ${checks}`);
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Print method support section.
330
+ */
331
+ function renderMethods(card) {
332
+ const methods = card.methodSupport;
333
+ if (!methods) return;
334
+
335
+ heading('Method Support');
336
+
337
+ const methodNames = {
338
+ googleTranslate: 'Google Translate',
339
+ deepl: 'DeepL',
340
+ microsoftTranslator: 'Microsoft Translator',
341
+ libreTranslate: 'LibreTranslate',
342
+ nllb: 'NLLB-200',
343
+ llm: 'LLM (OpenRouter)',
344
+ microsoft: 'Microsoft',
345
+ };
346
+
347
+ for (const [key, info] of Object.entries(methods)) {
348
+ const label = methodNames[key] || key;
349
+ const supported = info?.supported;
350
+ const badge = supported ? fmt.green('✓ supported') : fmt.dim('✗ unsupported');
351
+ let extra = '';
352
+ if (info?.formality) extra += ` ${fmt.cyan('(formality)')}`;
353
+ if (info?.code) extra += ` ${fmt.dim(`[${info.code}]`)}`;
354
+ if (info?.verifiedDate) extra += ` ${fmt.dim(`verified ${info.verifiedDate}`)}`;
355
+ field(label, `${badge}${extra}`);
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Print data sources count and cultural aphorism.
361
+ */
362
+ function renderFooter(card) {
363
+ // Data sources count
364
+ if (card.dataSources && card.dataSources.length > 0) {
365
+ heading('Data Sources');
366
+ field('Count', `${card.dataSources.length} sources`);
367
+ // Show first few and last few if there are many
368
+ if (card.dataSources.length <= 8) {
369
+ field('Sources', card.dataSources.join(', '));
370
+ } else {
371
+ const preview = [...card.dataSources.slice(0, 5), `… +${card.dataSources.length - 5} more`];
372
+ field('Sources', preview.join(', '));
373
+ }
374
+ }
375
+
376
+ // Cultural aphorism — a nice touch at the bottom
377
+ if (card.culturalAphorism) {
378
+ heading('Cultural Aphorism');
379
+ console.log(` ${fmt.magenta(`"${card.culturalAphorism.text}"`)}`);
380
+ if (card.culturalAphorism.translation) {
381
+ console.log(` ${fmt.dim(`— "${card.culturalAphorism.translation}"`)}`);
382
+ }
383
+ }
384
+
385
+ // Notes
386
+ if (card.notes) {
387
+ console.log('');
388
+ console.log(` ${fmt.dim('Note:')} ${fmt.dim(card.notes)}`);
389
+ }
390
+
391
+ console.log('');
392
+ }
393
+
394
+ // -----------------------------------------------------------------
395
+ // Main entry point
396
+ // -----------------------------------------------------------------
397
+
398
+ /**
399
+ * @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
400
+ * @param {string} cwd - Working directory
401
+ * @returns {Promise<number>} Exit code (0 = success, 1 = error)
402
+ */
403
+ async function run(args, cwd) {
404
+ const code = args._[1];
405
+
406
+ // ── Validate: code argument is required ──
407
+ if (!code) {
408
+ console.error('[ERR] Missing language code argument.');
409
+ console.error('');
410
+ console.error(' Usage: champollion card <code> [--json]');
411
+ console.error('');
412
+ console.error(' Examples:');
413
+ console.error(' champollion card crk # Plains Cree');
414
+ console.error(' champollion card spa # Spanish');
415
+ console.error(' champollion card cmn --json # Raw JSON');
416
+ console.error('');
417
+ return 1;
418
+ }
419
+
420
+ // ── Resolve the code (follow aliases like 'fr' → 'fra') ──
421
+ const resolvedCode = resolveCode(code);
422
+ const card = getLanguageCard(resolvedCode);
423
+
424
+ if (!card) {
425
+ console.error(`[ERR] No language card found for "${code}".`);
426
+ if (code !== resolvedCode) {
427
+ console.error(` (resolved alias to "${resolvedCode}", but no card exists)`);
428
+ }
429
+ console.error('');
430
+ console.error(' Run "champollion doctor cards" to check card coverage.');
431
+ return 1;
432
+ }
433
+
434
+ // ── --json mode: output raw JSON and exit ──
435
+ if (args.json) {
436
+ console.log(JSON.stringify(card, null, 2));
437
+ return 0;
438
+ }
439
+
440
+ // ── Pretty-print mode: render each section ──
441
+ renderHeader(card);
442
+ renderClassification(card);
443
+ renderSpeakers(card);
444
+ renderTypology(card);
445
+ renderCorpus(card);
446
+ renderEval(card);
447
+ renderPipeline(card);
448
+ renderMethods(card);
449
+ renderFooter(card);
450
+
451
+ return 0;
452
+ }
453
+
454
+ export { run };