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
@@ -4,9 +4,9 @@
4
4
  * Pretty-prints a language card from shared/language-cards/.
5
5
  *
6
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
7
+ * champollion network card crk — Show Plains Cree card
8
+ * champollion network card spa — Show Spanish card
9
+ * champollion network card crk --json — Output raw JSON
10
10
  *
11
11
  * WHY: Language cards are deeply nested JSON files with 50+ fields.
12
12
  * Developers and contributors need a quick way to inspect a card's
@@ -14,12 +14,31 @@
14
14
  * formats the card into logical sections with ANSI colors for
15
15
  * scannability.
16
16
  *
17
+ * TWO RULES THE OUTPUT KEEPS (the index invariant, made visible):
18
+ * 1. Where sources disagree, every value is printed with its source and
19
+ * none is elected. A disputed attribution envelope never collapses to
20
+ * one line — endangerment printed as a single "● vulnerable" once hid
21
+ * five assessments on three scales for Plains Cree.
22
+ * 2. A section heading is never printed over nothing. Absence on a card
23
+ * means UNKNOWN, so an empty section says "not recorded on this card"
24
+ * and, where there is one, names the command that answers the question
25
+ * for a pair. A bare heading reads as a rendering failure.
26
+ *
17
27
  * Exit codes:
18
28
  * 0 — Card displayed successfully
19
29
  * 1 — Missing code argument or card not found
20
30
  */
21
31
 
22
32
  import { getLanguageCard, resolveCode } from '../registers.js';
33
+ import {
34
+ AGREEMENT,
35
+ attributedFields,
36
+ attributions,
37
+ display,
38
+ isAttributed,
39
+ isDisputed,
40
+ readCard,
41
+ } from '../cards/reader.js';
23
42
 
24
43
  // -----------------------------------------------------------------
25
44
  // ANSI formatting helpers — built-in, no external deps
@@ -48,69 +67,229 @@ const fmt = {
48
67
  white: (t) => ansi('37', '39', t),
49
68
  };
50
69
 
70
+ const PAD = 24;
71
+ const NOTE_MAX = 100;
72
+ const LIST_CAP = 5;
73
+
74
+ /** The words for an empty section. Absence is unknown — never "none". */
75
+ const NOT_RECORDED = 'not recorded on this card (unknown, not "none")';
76
+
77
+ /** What a disagreement looks like, in the reader's words, per agreement. */
78
+ const AGREEMENT_NOTE = {
79
+ [AGREEMENT.CONFLICTING]: 'sources differ — every value shown, none elected',
80
+ [AGREEMENT.INCOMMENSURABLE]: 'sources use different scales — every value shown, none elected',
81
+ [AGREEMENT.MULTIPLE_ASSESSMENTS]: 'several assessments — every value shown',
82
+ [AGREEMENT.MULTIPLE_VARIANTS]: 'several variants — every value shown',
83
+ [AGREEMENT.UNANIMOUS]: 'sources agree',
84
+ };
85
+
51
86
  // -----------------------------------------------------------------
52
- // Section renderers — each handles one logical block of the card
87
+ // Output primitives — every renderer appends to ctx.out
53
88
  // -----------------------------------------------------------------
54
89
 
55
90
  /**
56
91
  * Print a section heading with a decorative underline.
57
- * @param {string} title - Section title
58
92
  */
59
- function heading(title) {
60
- console.log('');
61
- console.log(` ${fmt.bold(fmt.cyan(title))}`);
62
- console.log(` ${fmt.dim('─'.repeat(title.length))}`);
93
+ function heading(ctx, title) {
94
+ ctx.out.push('');
95
+ ctx.out.push(` ${fmt.bold(fmt.cyan(title))}`);
96
+ ctx.out.push(` ${fmt.dim('─'.repeat(title.length))}`);
63
97
  }
64
98
 
65
99
  /**
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
100
+ * A section whose body is guaranteed to say something.
101
+ *
102
+ * If the body prints nothing, the empty lines are printed instead — so a
103
+ * heading can never stand over a blank, whatever shape the card arrived in
104
+ * (the published projection ships `{}` for fields no source fills).
105
+ *
106
+ * @param {object} ctx
107
+ * @param {string} title
108
+ * @param {() => void} body
109
+ * @param {string[]} [emptyLines]
70
110
  */
71
- function field(label, value, pad = 24) {
111
+ function section(ctx, title, body, emptyLines = [NOT_RECORDED]) {
112
+ heading(ctx, title);
113
+ const before = ctx.out.length;
114
+ body();
115
+ if (ctx.out.length === before) {
116
+ for (const line of emptyLines) ctx.out.push(` ${fmt.dim(line)}`);
117
+ }
118
+ }
119
+
120
+ /** True when an envelope has no single value a reader may be shown alone. */
121
+ function needsAllValues(env) {
122
+ return isDisputed(env) || display(env) === undefined;
123
+ }
124
+
125
+ /**
126
+ * A claim's note as one short line, or '' when there is none.
127
+ *
128
+ * Notes carry scope ("British Columbia only") and certainty, which is why
129
+ * they are shown at all; some are WALS example HTML, so tags go.
130
+ */
131
+ function noteText(note) {
132
+ if (!note) return '';
133
+ const flat = String(note).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
134
+ return flat.length > NOTE_MAX ? `${flat.slice(0, NOTE_MAX - 1)}…` : flat;
135
+ }
136
+
137
+ /** One value-with-source, as a printable line fragment. */
138
+ function claimText(c) {
139
+ const v = c?.value ?? c?.count;
140
+ const value = v === null || v === undefined || v === ''
141
+ ? '(empty)'
142
+ : typeof v === 'object' ? JSON.stringify(v) : String(v);
143
+ const variant = c?.variant ? ` (${c.variant})` : '';
144
+ const when = c?.year ?? c?.date;
145
+ const date = when ? ` (${when})` : '';
146
+ const src = c?.source ? `[${c.source}]` : '[source not stated]';
147
+ const note = noteText(c?.note);
148
+ return `${value}${variant}${date} ${fmt.dim(`${src}${note ? ` — ${note}` : ''}`)}`;
149
+ }
150
+
151
+ /** Every claim on its own line under a label, with what the agreement means. */
152
+ function claimsList(ctx, label, claims, agreementNote) {
153
+ ctx.out.push(` ${fmt.dim(label.padEnd(PAD))} ${fmt.yellow(agreementNote)}`);
154
+ for (const c of claims) ctx.out.push(` ${fmt.cyan('•')} ${claimText(c)}`);
155
+ }
156
+
157
+ /**
158
+ * Print a labeled value.
159
+ *
160
+ * An attribution envelope where sources agree prints its consensus on one
161
+ * line. Where they disagree — or where there is no consensus at all — every
162
+ * value is printed with its source, one per line. Electing one, or squashing
163
+ * them into a single line a reader skims as an answer, is the failure this
164
+ * exists to prevent.
165
+ */
166
+ function field(ctx, label, value) {
72
167
  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(' / ');
168
+ if (isAttributed(value)) {
169
+ // display() throws on an agreement it does not know — loud by design.
170
+ if (needsAllValues(value)) {
171
+ claimsList(ctx, label, attributions(value),
172
+ AGREEMENT_NOTE[value.agreement] ?? 'every value shown');
173
+ return;
174
+ }
175
+ value = display(value);
82
176
  }
83
- const displayValue = typeof v === 'object' ? JSON.stringify(v) : String(v);
84
- console.log(` ${fmt.dim(label.padEnd(pad))} ${displayValue}`);
177
+ const displayValue = typeof value === 'object' ? JSON.stringify(value) : String(value);
178
+ ctx.out.push(` ${fmt.dim(label.padEnd(PAD))} ${displayValue}`);
179
+ }
180
+
181
+ /** A label followed by several lines, the first beside it, the rest aligned. */
182
+ function listField(ctx, label, lines, cap = LIST_CAP) {
183
+ if (!lines.length) return;
184
+ const shown = lines.slice(0, cap);
185
+ if (lines.length > cap) shown.push(fmt.dim(`… +${lines.length - cap} more (--json for all)`));
186
+ shown.forEach((line, i) => {
187
+ ctx.out.push(` ${fmt.dim((i === 0 ? label : '').padEnd(PAD))} ${line}`);
188
+ });
189
+ }
190
+
191
+ /** Objects only, from something that may not be an array. */
192
+ function listOf(v) {
193
+ return Array.isArray(v) ? v.filter((x) => x && typeof x === 'object') : [];
194
+ }
195
+
196
+ /** camelCase key → "Sentence case" label. */
197
+ function labelFor(key) {
198
+ const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase();
199
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
200
+ }
201
+
202
+ /** The code a pair command takes for this card (a locale's is its language). */
203
+ function pairCode(card) {
204
+ return card.iso639_3 ?? card.locale?.language ?? card.code;
85
205
  }
86
206
 
207
+ /**
208
+ * Every endangerment assessment the card carries.
209
+ *
210
+ * The atlas card holds an envelope; the published projection writes
211
+ * `{agreement: null, values: []}` when no source assesses the language,
212
+ * which is not an envelope and must read as "none", not as one odd value.
213
+ */
214
+ function endangermentClaims(card) {
215
+ const env = card.endangerment;
216
+ if (isAttributed(env)) return attributions(env);
217
+ if (env && typeof env === 'object' && Array.isArray(env.values)) return listOf(env.values);
218
+ return [];
219
+ }
220
+
221
+ // -----------------------------------------------------------------
222
+ // Section renderers — each handles one logical block of the card
223
+ // -----------------------------------------------------------------
224
+
87
225
  /**
88
226
  * Print the header section: name, native name, codes, script, vitality.
89
227
  */
90
- function renderHeader(card) {
91
- // Title line — large and bold
228
+ function renderHeader(card, ctx) {
229
+ const raw = ctx.rawCard;
230
+ const title = typeof card.name === 'string'
231
+ ? card.name
232
+ : display(card.name, { onDisagreement: 'first' }) ?? card.code;
233
+ const native = typeof card.nativeName === 'string' ? card.nativeName : null;
234
+ ctx.out.push('');
235
+ ctx.out.push(` ${fmt.bold(title)}${native ? ` ${fmt.dim('—')} ${native}` : ''}`);
236
+
237
+ // The badge is ONE display tier the reader derives from one cited source in
238
+ // a declared authority order. Printed bare, it reads as the index's verdict;
239
+ // so it says where it came from, and that the other assessments exist.
92
240
  const vitalityBadge = getVitalityBadge(card.vitality?.unescoStatus);
93
- console.log('');
94
- console.log(` ${fmt.bold(card.name)}${card.nativeName ? ` ${fmt.dim('—')} ${card.nativeName}` : ''}`);
95
241
  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);
242
+ const by = card.vitality?.assessedBy;
243
+ const claims = endangermentClaims(card);
244
+ const differ = new Set(claims.map((c) => String(c.value).toLowerCase())).size > 1;
245
+ let qualifier = by ? `display tier, champollion-derived from ${by}` : 'display tier, champollion-derived';
246
+ if (claims.length > 1) {
247
+ qualifier += differ
248
+ ? ` — ${claims.length} assessments differ, all shown under Endangerment`
249
+ : ` — ${claims.length} assessments under Endangerment`;
250
+ }
251
+ ctx.out.push(` ${vitalityBadge} ${fmt.dim(`(${qualifier})`)}`);
110
252
  }
111
- if (card.aliases && card.aliases.length > 0) {
112
- field('Aliases', card.aliases.join(', '));
253
+ if (card._remote) {
254
+ // A card rebuilt from the published tables carries fewer fields than the
255
+ // atlas card it was projected from, so its blanks are blanker still.
256
+ const when = card._remote.updatedAt ? `, published ${String(card._remote.updatedAt).slice(0, 10)}` : '';
257
+ ctx.out.push(` ${fmt.dim(`Served from champollion.dev's published card projection${when}: it carries fewer fields than the full atlas card, so anything absent below is unknown, not "none".`)}`);
113
258
  }
259
+
260
+ section(ctx, 'Identification', () => {
261
+ // The title above is a LABEL: where registries disagree on the name, the
262
+ // reader takes the first value so lists and logs have something to say.
263
+ // The disagreement itself is printed here. Only the on-disk atlas card
264
+ // still holds the name envelope (the registry view flattened it).
265
+ const nameEnv = isAttributed(raw?.name) ? raw.name : isAttributed(card.name) ? card.name : null;
266
+ if (nameEnv && needsAllValues(nameEnv)) {
267
+ field(ctx, 'Name', nameEnv);
268
+ ctx.rendered.add('name');
269
+ }
270
+ const endonymEnv = isAttributed(raw?.endonym) ? raw.endonym
271
+ : isAttributed(card.endonym) ? card.endonym : null;
272
+ if (endonymEnv && needsAllValues(endonymEnv)) {
273
+ field(ctx, 'Endonym', endonymEnv);
274
+ ctx.rendered.add('endonym');
275
+ }
276
+ field(ctx, 'ISO 639-3', card.iso639_3);
277
+ field(ctx, 'ISO 639-1', card.iso639_1);
278
+ field(ctx, 'BCP 47', card.bcp47);
279
+ field(ctx, 'Glottocode', card.glottocode);
280
+ field(ctx, 'Script', card.script ? `${card.script}${card.scriptUnicodeName ? ` (${card.scriptUnicodeName})` : ''}` : null);
281
+ field(ctx, 'Direction', card.dir);
282
+ field(ctx, 'Script converter', card.scriptConverter);
283
+ field(ctx, 'Support tier', formatSupportTier(card.supportTier));
284
+ if (card.macrolanguage) {
285
+ field(ctx, 'Macrolanguage', card.macrolanguage);
286
+ }
287
+ if (card.aliases && card.aliases.length > 0) {
288
+ field(ctx, 'Aliases', card.aliases.join(', '));
289
+ }
290
+ });
291
+ ctx.rendered.add('name');
292
+ ctx.rendered.add('endonym');
114
293
  }
115
294
 
116
295
  /**
@@ -147,193 +326,297 @@ function formatSupportTier(tier) {
147
326
  /**
148
327
  * Print classification section: family, genus, macroarea.
149
328
  */
150
- function renderClassification(card) {
329
+ function renderClassification(card, ctx) {
151
330
  if (!card.classification && !card.macroarea) return;
152
331
 
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(' → '));
332
+ section(ctx, 'Classification', () => {
333
+ const cls = card.classification;
334
+ if (cls) {
335
+ // The published projection carries family as a label plus the full
336
+ // attribution list; the atlas card carries the envelope itself.
337
+ const famClaims = listOf(cls.familyAttributions);
338
+ if (!isAttributed(cls.family)
339
+ && new Set(famClaims.map((c) => String(c.value))).size > 1) {
340
+ claimsList(ctx, 'Family', famClaims, AGREEMENT_NOTE[AGREEMENT.CONFLICTING]);
341
+ } else {
342
+ field(ctx, 'Family', cls.family);
343
+ }
344
+ field(ctx, 'Genus', cls.genus);
345
+ if (Array.isArray(cls.ancestry) && cls.ancestry.length) {
346
+ field(ctx, 'Ancestry', cls.ancestry.join(' → '));
347
+ }
159
348
  }
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
- }
349
+ field(ctx, 'Macroarea', card.macroarea);
350
+ if (card.isIsolate) {
351
+ field(ctx, 'Language isolate', fmt.yellow('yes'));
352
+ }
353
+ if (card.countries && card.countries.length > 0) {
354
+ field(ctx, 'Countries', card.countries.join(', '));
355
+ }
356
+ });
357
+ ctx.rendered.add('classification.family');
358
+ ctx.rendered.add('classification.genus');
168
359
  }
169
360
 
170
361
  /**
171
362
  * Print speaker estimates section.
172
363
  */
173
- function renderSpeakers(card) {
174
- const estimates = card.speakerEstimates;
364
+ function renderSpeakers(card, ctx) {
365
+ // normalizeCard turns the envelope into a list of {count, source, date,
366
+ // note}; a card that skipped the adapter still carries the envelope.
367
+ const estimates = isAttributed(card.speakerEstimates)
368
+ ? attributions(card.speakerEstimates).map((c) => ({
369
+ count: c.value, source: c.source, date: c.year, note: c.note,
370
+ }))
371
+ : listOf(card.speakerEstimates);
175
372
  const vitalityCount = card.vitality?.speakerCount;
176
373
 
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) {
374
+ section(ctx, 'Speaker Estimates', () => {
375
+ if (vitalityCount) {
376
+ field(ctx, 'Total (vitality)', typeof vitalityCount === 'number' ? vitalityCount.toLocaleString() : vitalityCount);
377
+ }
378
+ if (estimates.length > 1 && new Set(estimates.map((e) => String(e.count))).size > 1) {
379
+ ctx.out.push(` ${fmt.yellow('sources differ — every claim shown, none elected')}`);
380
+ }
185
381
  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);
382
+ const label = `${est.source ?? 'source not stated'}${est.date ? ` (${est.date})` : ''}`;
383
+ const count = typeof est.count === 'number' ? est.count.toLocaleString() : est.count;
384
+ // The note is the claim's scope ("British Columbia only") — dropping it
385
+ // once made a provincial count read as the language's total.
386
+ const note = noteText(est.note);
387
+ field(ctx, label, `${count}${note ? fmt.dim(` — ${note}`) : ''}`);
188
388
  }
189
- }
190
- if (card.vitality?.trend) {
191
- field('Trend', card.vitality.trend);
192
- }
389
+ }, ['no cited estimate on this card (unknown, not zero)']);
390
+ ctx.rendered.add('speakerEstimates');
391
+ }
392
+
393
+ /**
394
+ * Print every endangerment assessment the card carries, each on its own
395
+ * source's scale, then the one display tier the reader derives from them.
396
+ */
397
+ function renderEndangerment(card, ctx) {
398
+ section(ctx, 'Endangerment', () => {
399
+ const claims = endangermentClaims(card);
400
+ if (claims.length === 1) {
401
+ field(ctx, 'Assessment', claimText(claims[0]));
402
+ } else if (claims.length > 1) {
403
+ const agreement = isAttributed(card.endangerment) ? card.endangerment.agreement : null;
404
+ const differ = new Set(claims.map((c) => String(c.value).toLowerCase())).size > 1;
405
+ claimsList(ctx, 'Assessments', claims,
406
+ AGREEMENT_NOTE[agreement] ?? (differ ? AGREEMENT_NOTE[AGREEMENT.CONFLICTING] : 'every value shown'));
407
+ }
408
+ if (card.vitality?.unescoStatus) {
409
+ const by = card.vitality.assessedBy;
410
+ field(ctx, 'Display tier',
411
+ `${card.vitality.unescoStatus} ${fmt.dim(`— champollion-derived from ${by ?? 'one cited assessment'}; a reading of one source, not a consensus`)}`);
412
+ if (!claims.length) {
413
+ ctx.out.push(` ${fmt.dim('the per-source assessments are not carried by this card')}`);
414
+ }
415
+ }
416
+ }, ['no source on this card assesses it (unknown, never "safe")']);
417
+ ctx.rendered.add('endangerment');
193
418
  }
194
419
 
195
420
  /**
196
421
  * Print typological profile section.
197
422
  */
198
- function renderTypology(card) {
423
+ function renderTypology(card, ctx) {
199
424
  // Pull from encyclopedic.typology (richer) or typologicalProfile
200
425
  const typ = card.encyclopedic?.typology;
201
426
  const profile = card.typologicalProfile;
202
427
 
203
428
  if (!typ && !profile) return;
204
429
 
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);
430
+ section(ctx, 'Typological Profile', () => {
431
+ if (typ) {
432
+ field(ctx, 'Word order', typ.wordOrder);
433
+ field(ctx, 'Adpositions', typ.adpositionType);
434
+ field(ctx, 'Affixation', typ.affixType);
435
+ field(ctx, 'Verb synthesis', typ.verbSynthesis);
436
+ field(ctx, 'Morphological fusion', typ.morphologicalFusion);
437
+ field(ctx, 'Consonants', typ.consonantInventory);
438
+ field(ctx, 'Vowels', typ.vowelInventory);
439
+ field(ctx, 'Tone', typ.tone);
440
+ field(ctx, 'Cases', typ.cases);
441
+ field(ctx, 'Genders', typ.genders);
442
+ } else if (profile) {
443
+ // PRINT WHAT THE CARD CARRIES, not a list of field names typed in once.
444
+ //
445
+ // This used to name ten fixed keys — wordOrderDominant, hasGenderSystem,
446
+ // hasCaseMorphology and so on. The atlas emits the Grambank/WALS feature
447
+ // names instead (subjectVerbOrder, hasCoreCase, affixPreference,
448
+ // marksPastTense …), so every one of those lookups returned undefined and
449
+ // the section printed its heading over nothing while the card held twenty
450
+ // populated features.
451
+ //
452
+ // Enumerating the object cannot go stale that way: a feature the atlas
453
+ // starts publishing shows up without an edit here, and one it stops
454
+ // publishing disappears rather than printing blank.
455
+ for (const [key, value] of Object.entries(profile)) {
456
+ if (key === 'source' || key.startsWith('_')) continue;
457
+ if (value === null || value === undefined || value === '') continue;
458
+ field(ctx, labelFor(key), typeof value === 'boolean' ? (value ? 'yes' : 'no') : value);
459
+ ctx.rendered.add(`typologicalProfile.${key}`);
460
+ }
239
461
  }
240
- }
241
462
 
242
- if (profile?.source) {
243
- field('Source', fmt.dim(profile.source));
244
- }
463
+ if (profile?.source) {
464
+ field(ctx, 'Source', fmt.dim(profile.source));
465
+ }
466
+ });
245
467
  }
246
468
 
247
469
  /**
248
470
  * Print corpus availability section.
471
+ *
472
+ * The atlas records corpora where its sources put them — OPUS parallel
473
+ * corpora, monolingual corpora, UD treebanks and speech under `resources`,
474
+ * wordlists under `lexicalResources.datasets`. The legacy `corpusAvailability`
475
+ * block is read too, for old-shape cards; the published projection ships it
476
+ * as `{}`, which once printed this heading over nothing.
249
477
  */
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(', '));
478
+ function renderCorpus(card, ctx) {
479
+ const code = pairCode(card);
480
+ section(ctx, 'Corpus Availability', () => {
481
+ const res = card.resources && typeof card.resources === 'object' ? card.resources : {};
482
+ const lex = card.lexicalResources && typeof card.lexicalResources === 'object' ? card.lexicalResources : {};
483
+
484
+ listField(ctx, 'Parallel (OPUS)', listOf(res.corpora).map((c) => {
485
+ const partners = listOf(c.topPartners).slice(0, 3)
486
+ .map((p) => `${p.code}:${p.alignmentPairs}`).join(', ');
487
+ const pairs = typeof c.alignmentPairsTotal === 'number'
488
+ ? `${c.alignmentPairsTotal.toLocaleString()} aligned pairs` : 'size not published';
489
+ return `${c.corpus ?? c.corpusId ?? '?'} — ${pairs}${partners ? fmt.dim(` (top partners ${partners})`) : ''}`;
490
+ }));
491
+ listField(ctx, 'Monolingual', listOf(res.monolingualCorpora).map(toolLine));
492
+ listField(ctx, 'Treebanks (UD)', listOf(res.treebanks).map((t) => (
493
+ `${t.treebank ?? '?'}${typeof t.sentences === 'number' ? ` — ${t.sentences.toLocaleString()} sentences` : ''}${t.release ? fmt.dim(` (release ${t.release})`) : ''}`
494
+ )));
495
+ listField(ctx, 'Speech', listOf(res.speech).map((s) => (
496
+ `${s.dataset ?? '?'}${s.locale ? ` ${s.locale}` : ''}${typeof s.validatedHours === 'number' ? ` — ${s.validatedHours} validated hours` : ''}${s.release ? fmt.dim(` (${s.release})`) : ''}`
497
+ )));
498
+ listField(ctx, 'Wordlists', listOf(lex.datasets).map((d) => (
499
+ `${d.dataset ?? '?'}${typeof d.forms === 'number' ? ` — ${d.forms.toLocaleString()} forms` : ''}${d.release ? fmt.dim(` (${d.release})`) : ''}`
500
+ )));
501
+
502
+ const corpus = card.corpusAvailability && typeof card.corpusAvailability === 'object'
503
+ ? card.corpusAvailability : {};
504
+ if (corpus.opus) {
505
+ const opusInfo = [];
506
+ if (corpus.opus.corpora) opusInfo.push(`${corpus.opus.corpora} corpora`);
507
+ if (corpus.opus.languagePairs) opusInfo.push(`${corpus.opus.languagePairs} lang pairs`);
508
+ if (corpus.opus.totalAlignmentPairs) opusInfo.push(`${corpus.opus.totalAlignmentPairs.toLocaleString()} alignment pairs`);
509
+ field(ctx, 'OPUS', opusInfo.join(', '));
510
+ if (corpus.opus.corpusNames && corpus.opus.corpusNames.length > 0) {
511
+ field(ctx, ' Top corpora', corpus.opus.corpusNames.slice(0, 5).join(', '));
512
+ }
264
513
  }
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('✓'));
514
+ if (corpus.lexibank) {
515
+ field(ctx, 'Lexibank', `${corpus.lexibank.datasets} datasets, ${corpus.lexibank.totalForms} forms`);
516
+ }
517
+ if (corpus.ud) {
518
+ field(ctx, 'Universal Deps', `${corpus.ud.treebanks} treebanks: ${corpus.ud.treebankNames?.join(', ') || ''}`);
519
+ }
520
+ if (corpus.asjpWordlists) field(ctx, 'ASJP', `${corpus.asjpWordlists} wordlist(s), ${corpus.asjpForms || '?'} forms`);
521
+ if (corpus.huggingFaceDatasets) field(ctx, 'HuggingFace', `${corpus.huggingFaceDatasets} datasets`);
522
+ if (corpus.unimorphParadigms) field(ctx, 'UniMorph', fmt.green('✓'));
523
+ if (corpus.wiktionaryStructuredDump) field(ctx, 'Wiktionary dump', fmt.green('✓'));
524
+ if (corpus.openMultilingualWordnet) field(ctx, 'Open Multilingual WN', fmt.green('✓'));
525
+ }, [
526
+ NOT_RECORDED,
527
+ `registered test sets for a pair: mt-eval corpora --source <src> --target ${code}`,
528
+ ]);
529
+ }
530
+
531
+ /** A named tool or archive: who publishes it, and under what licence. */
532
+ function toolLine(t) {
533
+ const lic = t.license
534
+ ? `${t.license}${t.licenceEstablished === false ? ' (licence NOT established)' : ''}`
535
+ : 'licence not stated';
536
+ return `${t.name ?? '?'} — ${t.publisher ?? 'publisher not stated'}, ${lic}${t.archived ? ', archived' : ''}`;
537
+ }
538
+
539
+ /**
540
+ * Print what exists for the language beyond corpora: dictionaries,
541
+ * morphological analyzers, keyboards, and how far it is documented.
542
+ * Existence only — a listing, never a quality claim.
543
+ */
544
+ function renderResources(card, ctx) {
545
+ section(ctx, 'Language Resources', () => {
546
+ const res = card.resources && typeof card.resources === 'object' ? card.resources : {};
547
+ const lex = card.lexicalResources && typeof card.lexicalResources === 'object' ? card.lexicalResources : {};
548
+ listField(ctx, 'Dictionaries', listOf(lex.dictionaries).map((d) => (
549
+ `${toolLine(d)}${d.pairedWith ? fmt.dim(` (paired with ${d.pairedWith})`) : ''}`
550
+ )));
551
+ listField(ctx, 'Analyzers (FST)', listOf(res.fsts).map(toolLine));
552
+ listField(ctx, 'Keyboards', listOf(res.keyboards).map(toolLine));
553
+ if (card.documentation?.medLevel) {
554
+ const src = card._fieldSources?.['documentation.medLevel'];
555
+ const cited = Array.isArray(src) ? src.join(', ') : src;
556
+ field(ctx, 'Documentation level', `${card.documentation.medLevel}${cited ? fmt.dim(` [${cited}]`) : ''}`);
557
+ }
558
+ }, ['no dictionary, analyzer, keyboard or documentation level recorded on this card (unknown, not "none")']);
277
559
  }
278
560
 
279
561
  /**
280
562
  * Print eval datasets section.
281
563
  */
282
- function renderEval(card) {
564
+ function renderEval(card, ctx) {
283
565
  if (!card.evalDatasets || card.evalDatasets.length === 0) return;
284
566
 
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
- }
567
+ section(ctx, 'Eval Datasets', () => {
568
+ for (const ds of card.evalDatasets) {
569
+ ctx.out.push(` ${fmt.cyan('•')} ${ds}`);
570
+ }
571
+ if (card.omt1600) {
572
+ // The OMT-1600 paper publishes no per-language tier table, so `tier` is
573
+ // null on most covered languages. Print the clause only when there is a
574
+ // cited tier — never "tier null".
575
+ const tier = card.omt1600.tier ? ` — tier ${card.omt1600.tier}` : '';
576
+ field(ctx, 'OMT-1600', card.omt1600.covered ? `${fmt.green('covered')}${tier}` : fmt.dim('not covered'));
577
+ }
578
+ });
296
579
  }
297
580
 
298
581
  /**
299
582
  * Print pipeline readiness section.
300
583
  */
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
- }
584
+ function renderPipeline(card, ctx) {
585
+ const code = pairCode(card);
586
+ section(ctx, 'Pipeline Readiness', () => {
587
+ const pr = card.pipelineReadiness;
588
+ if (!pr || typeof pr !== 'object') return;
589
+
590
+ // Score bar — visual indicator of readiness
591
+ const score = typeof pr.score === 'number' ? pr.score : null;
592
+ if (score !== null) {
593
+ const barWidth = 20;
594
+ const filled = Math.round((score / 100) * barWidth);
595
+ const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
596
+ const colorFn = score >= 70 ? fmt.green : score >= 40 ? fmt.yellow : fmt.red;
597
+ field(ctx, 'Score', `${colorFn(bar)} ${score}/100 (${pr.tier || ''})`);
598
+ } else {
599
+ field(ctx, 'Tier', pr.tier);
600
+ }
318
601
 
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
- }
602
+ // Component checklist
603
+ if (pr.components && typeof pr.components === 'object' && Object.keys(pr.components).length) {
604
+ const checks = Object.entries(pr.components)
605
+ .map(([key, val]) => `${val ? fmt.green('✓') : fmt.dim('✗')} ${key}`)
606
+ .join(' ');
607
+ ctx.out.push(` ${checks}`);
608
+ }
609
+ }, [
610
+ NOT_RECORDED,
611
+ `what you can run for a pair today: champollion network recommend <src> ${code}`,
612
+ ]);
326
613
  }
327
614
 
328
615
  /**
329
616
  * Print method support section.
330
617
  */
331
- function renderMethods(card) {
332
- const methods = card.methodSupport;
333
- if (!methods) return;
334
-
335
- heading('Method Support');
336
-
618
+ function renderMethods(card, ctx) {
619
+ const code = pairCode(card);
337
620
  const methodNames = {
338
621
  googleTranslate: 'Google Translate',
339
622
  deepl: 'DeepL',
@@ -344,51 +627,117 @@ function renderMethods(card) {
344
627
  microsoft: 'Microsoft',
345
628
  };
346
629
 
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}`);
630
+ section(ctx, 'Method Support', () => {
631
+ const methods = card.methodSupport;
632
+ if (!methods || typeof methods !== 'object') return;
633
+ for (const [key, info] of Object.entries(methods)) {
634
+ // Only a yes/no listing entry is a method. The atlas's evidence shape
635
+ // ({total, byTier, named}) reaching here unflattened must not print its
636
+ // bookkeeping keys as three "unsupported" engines.
637
+ if (!info || typeof info !== 'object' || !('supported' in info)) continue;
638
+ const label = methodNames[key] || key;
639
+ const badge = info.supported ? fmt.green('✓ supported') : fmt.dim('✗ unsupported');
640
+ let extra = '';
641
+ if (info.formality) extra += ` ${fmt.cyan('(formality)')}`;
642
+ if (info.code) extra += ` ${fmt.dim(`[${info.code}]`)}`;
643
+ if (info.verifiedDate) extra += ` ${fmt.dim(`verified ${info.verifiedDate}`)}`;
644
+ field(ctx, label, `${badge}${extra}`);
645
+ }
646
+ }, [
647
+ NOT_RECORDED,
648
+ `engines you can run for a pair, with the evidence for each: champollion network recommend <src> ${code}`,
649
+ ]);
650
+ }
651
+
652
+ /**
653
+ * Print every disputed attribution envelope no section above printed.
654
+ *
655
+ * The sections name the fields they render; a field the atlas attributes
656
+ * that no section knows (politenessDistinction, bcp47FullTag, …) would
657
+ * otherwise carry its disagreement in silence. Walking the card finds them,
658
+ * including ones added after this file was written.
659
+ */
660
+ function renderOtherDisputes(card, ctx) {
661
+ const byPath = new Map();
662
+ for (const source of [ctx.rawCard, card]) {
663
+ if (!source) continue;
664
+ for (const { path, value } of attributedFields(source)) {
665
+ if (!byPath.has(path)) byPath.set(path, value);
666
+ }
356
667
  }
668
+ const rest = [...byPath].filter(([path, value]) => isDisputed(value) && !ctx.rendered.has(path));
669
+ if (!rest.length) return;
670
+
671
+ section(ctx, 'Other Fields Where Sources Differ', () => {
672
+ for (const [path, value] of rest) {
673
+ const label = path.split('.').map((seg) => labelFor(seg)).join(' › ');
674
+ field(ctx, label, value);
675
+ }
676
+ });
357
677
  }
358
678
 
359
679
  /**
360
680
  * Print data sources count and cultural aphorism.
361
681
  */
362
- function renderFooter(card) {
682
+ function renderFooter(card, ctx) {
363
683
  // Data sources count
364
684
  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
- }
685
+ section(ctx, 'Data Sources', () => {
686
+ field(ctx, 'Count', `${card.dataSources.length} sources`);
687
+ // Show first few and last few if there are many
688
+ if (card.dataSources.length <= 8) {
689
+ field(ctx, 'Sources', card.dataSources.join(', '));
690
+ } else {
691
+ const preview = [...card.dataSources.slice(0, 5), `… +${card.dataSources.length - 5} more`];
692
+ field(ctx, 'Sources', preview.join(', '));
693
+ }
694
+ });
374
695
  }
375
696
 
376
697
  // 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
- }
698
+ if (card.culturalAphorism?.text) {
699
+ section(ctx, 'Cultural Aphorism', () => {
700
+ ctx.out.push(` ${fmt.magenta(`"${card.culturalAphorism.text}"`)}`);
701
+ if (card.culturalAphorism.translation) {
702
+ ctx.out.push(` ${fmt.dim(`— "${card.culturalAphorism.translation}"`)}`);
703
+ }
704
+ });
383
705
  }
384
706
 
385
707
  // Notes
386
708
  if (card.notes) {
387
- console.log('');
388
- console.log(` ${fmt.dim('Note:')} ${fmt.dim(card.notes)}`);
709
+ ctx.out.push('');
710
+ ctx.out.push(` ${fmt.dim('Note:')} ${fmt.dim(card.notes)}`);
389
711
  }
390
712
 
391
- console.log('');
713
+ ctx.out.push('');
714
+ }
715
+
716
+ /**
717
+ * Render a card as the terminal text `champollion network card` prints.
718
+ *
719
+ * @param {object} card the registry's view (getLanguageCard) — normalized
720
+ * @param {object} [opts]
721
+ * @param {object|null} [opts.rawCard] the on-disk atlas card when readable
722
+ * (repo mode). The registry view flattens `name` to a label; the raw card
723
+ * still holds its envelope, so a disputed name can be shown in full.
724
+ * @returns {string}
725
+ */
726
+ function renderCard(card, { rawCard = null } = {}) {
727
+ const ctx = { out: [], rendered: new Set(), rawCard };
728
+ renderHeader(card, ctx);
729
+ renderClassification(card, ctx);
730
+ renderSpeakers(card, ctx);
731
+ renderEndangerment(card, ctx);
732
+ renderTypology(card, ctx);
733
+ renderCorpus(card, ctx);
734
+ renderResources(card, ctx);
735
+ renderEval(card, ctx);
736
+ renderPipeline(card, ctx);
737
+ renderMethods(card, ctx);
738
+ renderOtherDisputes(card, ctx);
739
+ renderFooter(card, ctx);
740
+ return ctx.out.join('\n');
392
741
  }
393
742
 
394
743
  // -----------------------------------------------------------------
@@ -407,12 +756,12 @@ async function run(args, cwd) {
407
756
  if (!code) {
408
757
  console.error('[ERR] Missing language code argument.');
409
758
  console.error('');
410
- console.error(' Usage: champollion card <code> [--json]');
759
+ console.error(' Usage: champollion network card <code> [--json]');
411
760
  console.error('');
412
761
  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');
762
+ console.error(' champollion network card crk # Plains Cree');
763
+ console.error(' champollion network card spa # Spanish');
764
+ console.error(' champollion network card cmn --json # Raw JSON');
416
765
  console.error('');
417
766
  return 1;
418
767
  }
@@ -431,24 +780,32 @@ async function run(args, cwd) {
431
780
  return 1;
432
781
  }
433
782
 
783
+ // ── Locale fell back to its language: say so ──
784
+ // Packaged installs carry only the 840 bundled locale deltas, and no
785
+ // locale code is ever published as a trading card, so a long-tail
786
+ // locale is served by its base language's card. Print the substitution
787
+ // rather than passing the language off as the locale.
788
+ if (!args.json && code.includes('-') && resolvedCode !== code) {
789
+ console.error(
790
+ `[NOTE] No locale card for "${code}" — showing the base language card "${resolvedCode}".`,
791
+ );
792
+ }
793
+
434
794
  // ── --json mode: output raw JSON and exit ──
435
795
  if (args.json) {
436
796
  console.log(JSON.stringify(card, null, 2));
437
797
  return 0;
438
798
  }
439
799
 
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);
800
+ // ── Pretty-print mode ──
801
+ // The on-disk atlas card (repo mode only; null in an npm install, whose
802
+ // bundled and fetched cards are already the flattened view) supplies the
803
+ // envelopes the registry view flattened, through the reader — never a
804
+ // private JSON.parse.
805
+ const rawCard = readCard(card.code ?? resolvedCode);
806
+ console.log(renderCard(card, { rawCard }));
450
807
 
451
808
  return 0;
452
809
  }
453
810
 
454
- export { run };
811
+ export { run, renderCard };