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,435 @@
1
+ /**
2
+ * reader.js — the ONE way JavaScript reads a language card.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * Eight files under cli/lib read the card corpus, each in its own way, and
6
+ * three arena Python modules do the same on their side. That is eleven places
7
+ * to update whenever a card's shape changes — which is precisely why the last
8
+ * cutover attempt failed 211 tests, and why it would have failed again the
9
+ * next time.
10
+ *
11
+ * One reader per runtime means a shape change is fixed ONCE. It is not an
12
+ * abstraction for its own sake; it is the difference between a corpus that
13
+ * can evolve and one that is frozen by its own consumers.
14
+ *
15
+ * WHAT A CARD LOOKS LIKE NOW, AND WHY IT CHANGED
16
+ * The old corpus published a field on every card whether or not anything was
17
+ * known: 41.1% of its field instances were empty — 237,156 published blanks,
18
+ * with six fields empty on 100% of cards and `nativeName` null on 7,300.
19
+ * A blank row asserts that there is nothing to know, when it means nobody
20
+ * told us.
21
+ *
22
+ * The new corpus OMITS what no source asserts. So the first rule of reading a
23
+ * card is that `undefined` means "no source said", and it is normal.
24
+ *
25
+ * ATTRIBUTED FIELDS ARE OBJECTS, ON PURPOSE
26
+ * Where several bodies may speak — name, endangerment, speaker counts,
27
+ * family, endonym, method support — the card carries ALL of them with their
28
+ * sources, plus a word describing the shape of the disagreement. Nothing
29
+ * picks a winner, because picking one destroys the evidence that there was a
30
+ * question.
31
+ *
32
+ * That makes `card.name` an object rather than a string, which is honest and
33
+ * inconvenient. `display()` is the inconvenience handled in one place: it
34
+ * returns something printable and NEVER invents a consensus that does not
35
+ * exist — where sources genuinely disagree it says so rather than quietly
36
+ * choosing the first.
37
+ */
38
+
39
+ import fs from 'node:fs';
40
+ import path from 'node:path';
41
+
42
+ import { CARDS_DIR, isValidCardCode } from './env.js';
43
+
44
+ /**
45
+ * Agreement labels the projector emits. Kept here so a reader can branch on
46
+ * them without string-matching, and so an unrecognised one is caught rather
47
+ * than silently treated as agreement.
48
+ */
49
+ export const AGREEMENT = Object.freeze({
50
+ SINGLE: 'single',
51
+ UNANIMOUS: 'unanimous',
52
+ MULTIPLE_ASSESSMENTS: 'multiple-assessments',
53
+ MULTIPLE_VARIANTS: 'multiple-variants',
54
+ CONFLICTING: 'conflicting',
55
+ INCOMMENSURABLE: 'incommensurable',
56
+ });
57
+
58
+ const KNOWN_AGREEMENT = new Set(Object.values(AGREEMENT));
59
+
60
+ /** An attributed field is an object with `agreement` and `values`. */
61
+ export function isAttributed(value) {
62
+ return Boolean(value)
63
+ && typeof value === 'object'
64
+ && !Array.isArray(value)
65
+ && Array.isArray(value.values)
66
+ && typeof value.agreement === 'string';
67
+ }
68
+
69
+ /**
70
+ * A printable value for a field, whatever its shape.
71
+ *
72
+ * Returns `undefined` when nothing is known — never a placeholder, never an
73
+ * empty string. A caller that wants "Unknown" on screen should write it there,
74
+ * because that is a presentation decision and this is not the presentation
75
+ * layer.
76
+ *
77
+ * For an attributed field it returns the CONSENSUS when there is one. Where
78
+ * sources disagree there is no consensus to return, and inventing one by taking
79
+ * the first value would hide exactly what the attributed shape exists to show —
80
+ * so it returns undefined and the caller must ask for `attributions()`.
81
+ *
82
+ * @param {unknown} value
83
+ * @param {{onDisagreement?: 'undefined'|'first'}} [opts]
84
+ */
85
+ export function display(value, { onDisagreement = 'undefined' } = {}) {
86
+ if (value === null || value === undefined) return undefined;
87
+ if (!isAttributed(value)) {
88
+ if (Array.isArray(value)) return value.length ? value : undefined;
89
+ return value;
90
+ }
91
+ if (!KNOWN_AGREEMENT.has(value.agreement)) {
92
+ throw new Error(
93
+ `Unknown agreement "${value.agreement}". A reader that shrugs at an unrecognised `
94
+ + 'agreement will present a disagreement as a fact.',
95
+ );
96
+ }
97
+ if ('consensus' in value) return value.consensus;
98
+ // No consensus: the sources genuinely differ, or they describe different
99
+ // subjects. Callers opt in to a first-value fallback with their eyes open.
100
+ if (onDisagreement === 'first') return value.values[0]?.value;
101
+ return undefined;
102
+ }
103
+
104
+ /**
105
+ * Every value of a field with its source, always an array.
106
+ *
107
+ * Use this wherever the answer matters — a UI showing endangerment, anything
108
+ * citing a source, anything deciding what to translate. `display()` is for
109
+ * labels and headings; this is for claims.
110
+ */
111
+ export function attributions(value) {
112
+ if (value === null || value === undefined) return [];
113
+ if (isAttributed(value)) return value.values;
114
+ if (Array.isArray(value)) return value.map((v) => ({ value: v }));
115
+ return [{ value }];
116
+ }
117
+
118
+ /** True when sources disagree in a way a reader should be shown. */
119
+ export function isDisputed(value) {
120
+ return isAttributed(value)
121
+ && (value.agreement === AGREEMENT.CONFLICTING
122
+ || value.agreement === AGREEMENT.INCOMMENSURABLE);
123
+ }
124
+
125
+ /**
126
+ * Read one card. Returns null when the language has no card — which is a real
127
+ * answer: a language with no asserted value gets no card, rather than an empty
128
+ * one implying it is documented.
129
+ */
130
+ export function readCard(code, { dir = CARDS_DIR } = {}) {
131
+ // Reuses the corpus's own code validator rather than a second regex — two
132
+ // definitions of a valid code is one more than a corpus can afford.
133
+ if (!isValidCardCode(code)) return null;
134
+ const file = path.join(dir, `${code}.json`);
135
+ if (!fs.existsSync(file)) return null;
136
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
137
+ }
138
+
139
+ /** Every card code the corpus holds. */
140
+ export function listCodes({ dir = CARDS_DIR } = {}) {
141
+ if (!fs.existsSync(dir)) return [];
142
+ return fs.readdirSync(dir)
143
+ .filter((f) => f.endsWith('.json') && f !== 'language-tree.json')
144
+ .map((f) => f.replace(/\.json$/, ''))
145
+ .sort();
146
+ }
147
+
148
+ /**
149
+ * The atlas build a card came from.
150
+ *
151
+ * Cards carry a version and deliberately no build DATE — a date would make two
152
+ * builds from identical pinned sources differ by the calendar, which destroys
153
+ * the one property that lets anyone check the atlas. The version resolves to a
154
+ * date in ATLAS-RELEASE.json.
155
+ */
156
+ export function atlasVersion(card) {
157
+ return card?._atlas?.version ?? null;
158
+ }
159
+
160
+ /**
161
+ * Refuse a corpus older than a consumer requires.
162
+ *
163
+ * Compared as strings, deliberately: versions are opaque labels here, and a
164
+ * reader that tried to parse semver out of them would start guessing about
165
+ * orderings nobody defined. This asks only "is it the build I was written
166
+ * against", which is the question a consumer can actually answer.
167
+ */
168
+ export function requireAtlas(card, expected) {
169
+ const actual = atlasVersion(card);
170
+ if (actual !== expected) {
171
+ throw new Error(
172
+ `Card ${card?.code ?? '?'} was built by atlas ${actual ?? '(unversioned)'}, but this `
173
+ + `consumer was written against ${expected}. Rebuild, or update the consumer — a `
174
+ + 'card read against the wrong shape fails quietly rather than loudly.',
175
+ );
176
+ }
177
+ return card;
178
+ }
179
+
180
+ /**
181
+ * What is NOT known about a language, and why the card is thin.
182
+ *
183
+ * `coverage.notAttested` counts the times a source covered this language and
184
+ * its documentation did not answer — which is different from nobody having
185
+ * looked, and is the number that justifies greying a card out rather than
186
+ * leaving it looking neglected.
187
+ */
188
+ export function coverage(card) {
189
+ const c = card?.coverage;
190
+ if (!c) return null;
191
+ return {
192
+ sources: c.sourceCount ?? 0,
193
+ present: c.componentsPresent ?? 0,
194
+ total: c.componentsTotal ?? 0,
195
+ notAttested: c.notAttested ?? 0,
196
+ fraction: c.componentsTotal ? c.componentsPresent / c.componentsTotal : 0,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Normalise an atlas-shaped card into the field vocabulary the runtime reads.
202
+ *
203
+ * THE READER IS THE ADAPTER — the atlas is not made to lie. The store keeps its
204
+ * honest names (`endonym`, `textDirection`, `codeAliases`, `scripts[]`) and the
205
+ * attribution envelopes that let a card show disagreeing sources side by side.
206
+ * The runtime grew up on the old corpus's flat names, and 24 modules import the
207
+ * registry that serves them. Renaming the atlas to match would bake the legacy
208
+ * vocabulary into the SSOT forever; rewriting 24 modules mid-cutover multiplies
209
+ * risk. One adapter at the single load site does neither.
210
+ *
211
+ * WHAT IT REFUSES TO INVENT
212
+ * `dir` — text direction is asserted for only 162 languages. An absent
213
+ * direction stays absent; defaulting to 'ltr' would assert a fact about
214
+ * ~7,700 languages nobody measured, which is exactly the class of quiet
215
+ * fabrication the rebuild exists to end.
216
+ * `script` — the primary script is the FIRST entry of `scripts[]`, which is
217
+ * LinguaMeta's own ordering (the spec records that). Choosing it is a
218
+ * derivation, and it is only made when the list exists.
219
+ *
220
+ * Old-shape cards pass through untouched (their fields are already flat), so
221
+ * the same runtime serves both corpora during the migration window.
222
+ */
223
+ /**
224
+ * The endangerment-scale decision (authority order + per-scale vocabularies).
225
+ * Read from shared/catalogue/ in either layout — the repo root in a checkout,
226
+ * `cli/shared/` in a published package — for the same reason the prompt config
227
+ * is: a packaged install has no monorepo root above it.
228
+ */
229
+ let _VITALITY_SCALES = null;
230
+ function _vitalityScales() {
231
+ if (_VITALITY_SCALES !== null) return _VITALITY_SCALES;
232
+ for (const rel of ['../../shared/catalogue/', '../../../shared/catalogue/']) {
233
+ try {
234
+ _VITALITY_SCALES = JSON.parse(fs.readFileSync(
235
+ new URL(`${rel}vitality-scales.json`, import.meta.url), 'utf-8',
236
+ ));
237
+ return _VITALITY_SCALES;
238
+ } catch { /* try the next layout */ }
239
+ }
240
+ _VITALITY_SCALES = false;
241
+ return _VITALITY_SCALES;
242
+ }
243
+
244
+ let _SCRIPT_RTL = null;
245
+ function _scriptRtl() {
246
+ if (_SCRIPT_RTL !== null) return _SCRIPT_RTL;
247
+ try {
248
+ const m = JSON.parse(fs.readFileSync(new URL(
249
+ '../../data/cldr-supplemental/scriptMetadata.json', import.meta.url,
250
+ ), 'utf-8')).scriptMetadata;
251
+ _SCRIPT_RTL = Object.fromEntries(
252
+ Object.entries(m).map(([k, v]) => [k, v?.rtl === 'YES']),
253
+ );
254
+ } catch { _SCRIPT_RTL = false; }
255
+ return _SCRIPT_RTL;
256
+ }
257
+
258
+ export function normalizeCard(card) {
259
+ if (!card || typeof card !== 'object') return card;
260
+ const out = card;
261
+
262
+ // Attribution envelopes → the displayable value, by the standing rule:
263
+ // display() returns undefined on genuine disagreement rather than electing a
264
+ // winner among sources.
265
+ // `name` is an IDENTITY field: a card with no display name is unusable by
266
+ // every list, prompt and log line. So this is the documented opt-in —
267
+ // display(v, {onDisagreement:'first'}) — taken with eyes open: on the 439
268
+ // languages where registries disagree, the first recorded value labels the
269
+ // card, deterministically, while the full disagreement stays on the card in
270
+ // `attributions()` for anything that CLAIMS rather than labels.
271
+ if (isAttributed(out.name)) out.name = display(out.name, { onDisagreement: 'first' });
272
+
273
+ if (out.nativeName === undefined && out.endonym !== undefined) {
274
+ const v = display(out.endonym, { onDisagreement: 'first' });
275
+ if (v !== undefined) out.nativeName = v;
276
+ }
277
+ if (out.aliases === undefined && Array.isArray(out.codeAliases)) {
278
+ out.aliases = out.codeAliases;
279
+ }
280
+ if (out.script === undefined) {
281
+ // The primary script comes from the CITED full tag (CLDR likelySubtags +
282
+ // SIL langtags both attest en-Latn-US), never from scripts[0] — that list
283
+ // is deterministic-alphabetical, and taking its head once made English's
284
+ // primary script DESERET. A one-entry list cannot misorder, so it may
285
+ // still answer; a multi-script list without a full tag stays unanswered.
286
+ const tag = isAttributed(out.bcp47FullTag)
287
+ ? display(out.bcp47FullTag, { onDisagreement: 'first' })
288
+ : out.bcp47FullTag;
289
+ const m = typeof tag === 'string' ? /^[a-z]{2,3}-([A-Z][a-z]{3})\b/.exec(tag) : null;
290
+ if (m) {
291
+ out.script = m[1];
292
+ } else if (Array.isArray(out.scripts) && out.scripts.length === 1) {
293
+ const only = out.scripts[0];
294
+ out.script = typeof only === 'string' ? only : only?.code ?? undefined;
295
+ }
296
+ }
297
+ if (out.dir === undefined && out.script && typeof out.textDirection !== 'string') {
298
+ // No per-locale orientation claim exists for ~7,700 languages — but the
299
+ // SCRIPT's direction is CLDR's own per-script metadata (scriptMetadata
300
+ // `rtl`), pinned like everything else. "Arab runs right-to-left" is a
301
+ // fact about the script, so deriving a card's direction from its script
302
+ // is a cited derivation, not a default.
303
+ const rtl = _scriptRtl();
304
+ if (rtl && out.script in rtl) out.dir = rtl[out.script] ? 'rtl' : 'ltr';
305
+ }
306
+ if (out.dir === undefined && typeof out.textDirection === 'string') {
307
+ // The atlas records CLDR's own vocabulary; the runtime's enum is ltr/rtl.
308
+ // Only the two known values map — an unrecognised direction stays absent
309
+ // rather than being guessed into one of two buckets.
310
+ const dir = { 'left-to-right': 'ltr', 'right-to-left': 'rtl' }[out.textDirection];
311
+ if (dir) out.dir = dir;
312
+ }
313
+ // ISO 639-3 publishes the TYPE as a word ("Living", "Extinct", "Ancient");
314
+ // the old corpus stored its initial, and consumers count living languages
315
+ // with `isoType === 'L'`. Without this the count is silently ZERO and
316
+ // `uncoveredLiving` goes NEGATIVE — which is how a public page came to be one
317
+ // build away from displaying minus five hundred and fifty-two.
318
+ if (out.isoType === undefined && typeof out.isoLanguageType === 'string') {
319
+ out.isoType = out.isoLanguageType.charAt(0).toUpperCase();
320
+ if (out._fieldSources?.isoLanguageType && !out._fieldSources.isoType) {
321
+ out._fieldSources.isoType = out._fieldSources.isoLanguageType;
322
+ }
323
+ }
324
+ // Same shape, same reason: `isoScope` is the registry's own letter to every
325
+ // consumer that tests it (`=== 'M'` for a macrolanguage hub). The atlas
326
+ // records the legible word by a deliberate decision, so the initial is
327
+ // offered ALONGSIDE it rather than replacing it.
328
+ if (out.isoScopeInitial === undefined && typeof out.isoScope === 'string') {
329
+ out.isoScopeInitial = out.isoScope.charAt(0).toUpperCase();
330
+ }
331
+
332
+ // SPEAKER ESTIMATES: the envelope IS the list, and that is the whole point.
333
+ //
334
+ // 2,173 languages have sources that genuinely disagree about how many people
335
+ // speak them, so the atlas records every claim with its source. Both display
336
+ // layers were written for the old array-of-estimates shape: the CLI iterated
337
+ // it (`champollion card` crashed on every language — "estimates is not
338
+ // iterable") and the website guarded it with `Array.isArray`, which an
339
+ // envelope fails, so the block that exists precisely to show that sources
340
+ // differ silently vanished from every page.
341
+ //
342
+ // The envelope's `values[]` is that array, one entry per source. Rebuilding
343
+ // it here restores both consumers without either of them changing, and
344
+ // without flattening a disagreement into one number — which the site's own
345
+ // caption ("sources differ, all shown") promises we do not do.
346
+ if (isAttributed(out.speakerEstimates)) {
347
+ const claims = out.speakerEstimates.values ?? [];
348
+ out.speakerEstimates = claims.map((c) => {
349
+ // The store keeps values as text because that is how it keeps them
350
+ // comparable; the UI formats numbers. Convert only when the value really
351
+ // is a number — a range or a qualified figure stays verbatim rather than
352
+ // becoming NaN.
353
+ const n = Number(c?.value);
354
+ return {
355
+ count: Number.isFinite(n) && String(c?.value).trim() !== '' ? n : c?.value,
356
+ source: c?.source ?? null,
357
+ ...(c?.year ? { date: c.year } : {}),
358
+ // The claim's own scope note ("BC only", "L1 speakers") travels with
359
+ // it — dropping it once made ELCat's British-Columbia-only count of
360
+ // Plains Cree read as the language's total.
361
+ ...(c?.note ? { note: c.note } : {}),
362
+ };
363
+ });
364
+ }
365
+
366
+ // VITALITY: one display tier, from ONE named source, never a merge.
367
+ //
368
+ // The old corpus carried `vitality.unescoStatus`, and four generators plus
369
+ // the map colouring still read it. The atlas replaced it with `endangerment`,
370
+ // which records every source's assessment on its own scale — agreement
371
+ // 'incommensurable', because ELCat's "severely endangered", Glottolog's
372
+ // "moribund" and LinguaMeta's "Severely endangered" are three vocabularies,
373
+ // not three votes. Nothing bridged them, so `vitality` was undefined and the
374
+ // public catalogue showed a null endangerment on all 9,934 entries: the same
375
+ // silent-zero class as the living-language count.
376
+ //
377
+ // The bridge reads the FIRST source in the declared authority order that has
378
+ // an assessment, and maps that source's own words on that source's own scale.
379
+ // It never combines two sources, and a language nobody assesses stays
380
+ // unknown rather than defaulting to safe — a silence must not read as
381
+ // reassurance. The authority order and every vocabulary live in
382
+ // shared/catalogue/vitality-scales.json, so the judgement is arguable data
383
+ // rather than a constant buried in a display path.
384
+ if (out.vitality === undefined && out.endangerment !== undefined) {
385
+ const scales = _vitalityScales();
386
+ if (scales) {
387
+ const claims = attributions(out.endangerment);
388
+ for (const source of scales.authorityOrder) {
389
+ const hit = claims.find((c) => String(c.source ?? '').startsWith(source));
390
+ if (!hit) continue;
391
+ const tier = scales.scales[source]?.map?.[String(hit.value).trim()];
392
+ if (!tier) continue;
393
+ out.vitality = {
394
+ unescoStatus: tier,
395
+ assessedBy: hit.source,
396
+ note: 'champollion-derived: one display tier read from a single cited '
397
+ + 'assessment. The full set of assessments stays on `endangerment`.',
398
+ };
399
+ if (out._fieldSources && !out._fieldSources.vitality) {
400
+ out._fieldSources.vitality = [`derived:${hit.source}`];
401
+ }
402
+ break;
403
+ }
404
+ }
405
+ }
406
+
407
+ if (out.dataSources === undefined && out._fieldSources
408
+ && typeof out._fieldSources === 'object') {
409
+ // The atlas stamps provenance per FIELD; the old corpus also carried a
410
+ // flat card-level `dataSources` list, and the licence sweep
411
+ // (cli/lib/card-source-resolution.mjs) reads that list. Against new-shape
412
+ // cards it found nothing and returned an EMPTY set — no error, the sweep
413
+ // just silently covered zero sources, which is the worst possible failure
414
+ // for a licence check. The union of the per-field stamps is the same
415
+ // claim, assembled rather than duplicated.
416
+ const all = new Set();
417
+ for (const v of Object.values(out._fieldSources)) {
418
+ for (const s of Array.isArray(v) ? v : [v]) if (typeof s === 'string') all.add(s);
419
+ }
420
+ if (all.size) out.dataSources = [...all].sort();
421
+ }
422
+ if (out.iso639_3 === undefined && typeof out.locale?.language === 'string') {
423
+ // A locale's ISO 639-3 identity is its LANGUAGE's: `fra-CA` is French. The
424
+ // locale block names the parent explicitly, so this is a lookup, not a
425
+ // parse of the id.
426
+ out.iso639_3 = out.locale.language;
427
+ }
428
+ if (out.iso639_3 === undefined && typeof out.code === 'string'
429
+ && /^[a-z]{3}$/.test(out.code)) {
430
+ // The atlas dropped the copy because the code IS the ISO 639-3 id for
431
+ // three-letter spine rows. Restating it here is projection, not invention.
432
+ out.iso639_3 = out.code;
433
+ }
434
+ return out;
435
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Cache staleness check — invalidates cached cards whose per-language
3
+ * updated_at moved upstream.
4
+ *
5
+ * Called by bin/cli.js before command dispatch (packaged installs
6
+ * only), at most once per REFRESH_TTL_HOURS, and exported through
7
+ * index.js for programmatic consumers. One cheap query asks the index
8
+ * for rows changed since the last check; matching cache entries are
9
+ * deleted and refetched lazily on next use.
10
+ *
11
+ * Two timestamps in _state.json, deliberately distinct:
12
+ * lastRefreshCheckAt — client clock; only drives the TTL gate
13
+ * updatedCursor — server-derived (max updated_at seen); drives
14
+ * the `updated_at=gt.…` query, so client/server
15
+ * clock skew can never skip an update
16
+ *
17
+ * Never throws: a CLI run must not fail because the network did.
18
+ */
19
+
20
+ import { OFFLINE, hasLocalCardsDir, REFRESH_TTL_HOURS } from './env.js';
21
+ import { fetchIndexRows, fetchJson, SUPABASE_URL, SUPABASE_ANON_KEY } from './remote.js';
22
+ import {
23
+ readState,
24
+ writeState,
25
+ inBackoff,
26
+ noteNetworkFailure,
27
+ clearBackoff,
28
+ readCachedCard,
29
+ removeCachedCard,
30
+ listCachedCodes,
31
+ clearTombstones,
32
+ } from './cache.js';
33
+
34
+ /** Server-side max updated_at — skew-free cursor initialization. */
35
+ async function fetchServerCursor(timeoutMs) {
36
+ const params = new URLSearchParams({
37
+ select: 'updated_at',
38
+ order: 'updated_at.desc',
39
+ limit: '1',
40
+ });
41
+ const rows = await fetchJson(
42
+ `${SUPABASE_URL}/rest/v1/trading_card_index?${params}`,
43
+ { timeoutMs },
44
+ );
45
+ return rows[0]?.updated_at || null;
46
+ }
47
+
48
+ /**
49
+ * Check for upstream card updates and drop stale cache entries.
50
+ *
51
+ * @param {object} [opts]
52
+ * @param {boolean} [opts.force] - ignore TTL and backoff gates
53
+ * @param {number} [opts.timeoutMs] - per-request budget (keep small in
54
+ * the CLI hot path; a slow network must not stall commands)
55
+ * @returns {Promise<{skipped?: string, checked?: number, invalidated?: number, error?: string}>}
56
+ */
57
+ export async function maybeRefreshCardCache({ force = false, timeoutMs = 4000 } = {}) {
58
+ if (hasLocalCardsDir()) return { skipped: 'repo-mode' };
59
+ if (OFFLINE) return { skipped: 'offline' };
60
+
61
+ const state = readState();
62
+ if (!force) {
63
+ const last = state.lastRefreshCheckAt ? Date.parse(state.lastRefreshCheckAt) : 0;
64
+ if (Date.now() - last < REFRESH_TTL_HOURS * 3600 * 1000) return { skipped: 'fresh' };
65
+ if (inBackoff(state)) return { skipped: 'backoff' };
66
+ }
67
+
68
+ try {
69
+ // First run: establish the server-time cursor and stop. There is
70
+ // nothing cached from before this moment to invalidate.
71
+ if (!state.updatedCursor) {
72
+ const cursor = (await fetchServerCursor(timeoutMs)) || new Date().toISOString();
73
+ clearBackoff();
74
+ writeState({ lastRefreshCheckAt: new Date().toISOString(), updatedCursor: cursor });
75
+ return { checked: 0, invalidated: 0 };
76
+ }
77
+
78
+ const changed = await fetchIndexRows({
79
+ select: 'code,updated_at',
80
+ since: state.updatedCursor,
81
+ timeoutMs,
82
+ });
83
+
84
+ let invalidated = 0;
85
+ let cursor = state.updatedCursor;
86
+ if (changed.length > 0) {
87
+ const cached = new Set(listCachedCodes());
88
+ for (const row of changed) {
89
+ if (row.updated_at && row.updated_at > cursor) cursor = row.updated_at;
90
+ if (!cached.has(row.code)) continue;
91
+ const entry = readCachedCard(row.code);
92
+ if (!entry || !entry.updatedAt || entry.updatedAt < row.updated_at) {
93
+ removeCachedCard(row.code);
94
+ invalidated++;
95
+ }
96
+ }
97
+ // A language that appeared (or reappeared) upstream is no longer
98
+ // missing — let the next lookup fetch it.
99
+ clearTombstones(changed.map(r => r.code));
100
+ }
101
+
102
+ clearBackoff();
103
+ writeState({ lastRefreshCheckAt: new Date().toISOString(), updatedCursor: cursor });
104
+ return { checked: changed.length, invalidated };
105
+ } catch (err) {
106
+ noteNetworkFailure();
107
+ return { error: err?.message || String(err) };
108
+ }
109
+ }
110
+
111
+ export { SUPABASE_URL, SUPABASE_ANON_KEY };