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.
- package/LICENSE +133 -0
- package/README.md +387 -0
- package/bin/cli.js +278 -0
- package/index.js +135 -0
- package/lib/api-key.js +127 -0
- package/lib/autofix.js +432 -0
- package/lib/bridge/method_bridge.py +430 -0
- package/lib/card-source-resolution.mjs +284 -0
- package/lib/cards/cache.js +169 -0
- package/lib/cards/env.js +82 -0
- package/lib/cards/fetch-card-child.js +38 -0
- package/lib/cards/reader.js +435 -0
- package/lib/cards/refresh.js +111 -0
- package/lib/cards/remote.js +387 -0
- package/lib/cldf-export.mjs +540 -0
- package/lib/cldf-terms.mjs +62 -0
- package/lib/command-help.js +790 -0
- package/lib/commands/audit.js +49 -0
- package/lib/commands/card.js +454 -0
- package/lib/commands/doctor.js +559 -0
- package/lib/commands/fonts.js +489 -0
- package/lib/commands/help.js +91 -0
- package/lib/commands/init.js +1259 -0
- package/lib/commands/integrity.js +148 -0
- package/lib/commands/leaderboard.js +478 -0
- package/lib/commands/lint.js +30 -0
- package/lib/commands/models.js +177 -0
- package/lib/commands/plugin.js +103 -0
- package/lib/commands/provenance.js +45 -0
- package/lib/commands/recommend.js +75 -0
- package/lib/commands/register-corpus.js +678 -0
- package/lib/commands/repair-script.js +42 -0
- package/lib/commands/seal-corpus.js +355 -0
- package/lib/commands/seo.js +72 -0
- package/lib/commands/serve.js +147 -0
- package/lib/commands/status.js +265 -0
- package/lib/commands/submit.js +332 -0
- package/lib/commands/sync.js +89 -0
- package/lib/commands/tm.js +573 -0
- package/lib/commands/verify.js +39 -0
- package/lib/commands/watch.js +20 -0
- package/lib/commands/wrap.js +138 -0
- package/lib/commands/xliff.js +327 -0
- package/lib/commercial-eligibility.js +235 -0
- package/lib/concurrent.js +87 -0
- package/lib/config.js +523 -0
- package/lib/contamination-lane.js +76 -0
- package/lib/content-sync.js +731 -0
- package/lib/content.js +733 -0
- package/lib/corpus-registration.mjs +608 -0
- package/lib/cost-report.js +346 -0
- package/lib/diff.js +155 -0
- package/lib/docusaurus-sync.js +1256 -0
- package/lib/flatten.js +55 -0
- package/lib/format.js +954 -0
- package/lib/hash.js +159 -0
- package/lib/icu.js +473 -0
- package/lib/integrity.js +689 -0
- package/lib/license-gate.mjs +478 -0
- package/lib/license-identify.mjs +229 -0
- package/lib/lint.js +629 -0
- package/lib/method-manifest.js +60 -0
- package/lib/methods/anthropic.js +140 -0
- package/lib/methods/apertium.js +163 -0
- package/lib/methods/api.js +316 -0
- package/lib/methods/base.js +184 -0
- package/lib/methods/content-separator.js +45 -0
- package/lib/methods/deepl.js +426 -0
- package/lib/methods/direct-llm.js +586 -0
- package/lib/methods/external.js +332 -0
- package/lib/methods/fetch-with-retry.js +124 -0
- package/lib/methods/gemini.js +147 -0
- package/lib/methods/google-translate.js +402 -0
- package/lib/methods/http-utils.js +122 -0
- package/lib/methods/libretranslate.js +314 -0
- package/lib/methods/llm-coached.js +670 -0
- package/lib/methods/llm.js +592 -0
- package/lib/methods/local.js +76 -0
- package/lib/methods/microsoft-translator.js +331 -0
- package/lib/methods/openai.js +131 -0
- package/lib/methods/openrouter-client.js +327 -0
- package/lib/methods/openrouter-pricing.js +156 -0
- package/lib/methods/provider-env.js +115 -0
- package/lib/methods/provider-pricing.js +310 -0
- package/lib/methods/tilde.js +150 -0
- package/lib/methods/translated.js +229 -0
- package/lib/methods/translation-error.js +80 -0
- package/lib/models.js +258 -0
- package/lib/no-translate.js +233 -0
- package/lib/output.js +238 -0
- package/lib/pairs.js +547 -0
- package/lib/plugins.js +447 -0
- package/lib/provenance.js +323 -0
- package/lib/recommend.js +648 -0
- package/lib/registers.js +1185 -0
- package/lib/repair-script.js +266 -0
- package/lib/scripts.js +994 -0
- package/lib/seal.mjs +464 -0
- package/lib/sealed-qualifier.mjs +211 -0
- package/lib/security.js +59 -0
- package/lib/segment.js +369 -0
- package/lib/seo.js +275 -0
- package/lib/serve.js +854 -0
- package/lib/string-classify.js +85 -0
- package/lib/submit.mjs +344 -0
- package/lib/sync.js +969 -0
- package/lib/tags/bcp47.js +202 -0
- package/lib/tags/resolve.js +314 -0
- package/lib/terminology.js +111 -0
- package/lib/tm-seed.js +294 -0
- package/lib/tm.js +515 -0
- package/lib/translate-pair.js +197 -0
- package/lib/translate.js +203 -0
- package/lib/types.js +230 -0
- package/lib/validate.js +510 -0
- package/lib/verify.js +451 -0
- package/lib/watch.js +145 -0
- package/lib/xliff.js +184 -0
- package/package.json +93 -0
- package/shared/ATTRIBUTION.md +145 -0
- package/shared/CORPORA-CARDS.md +288 -0
- package/shared/DATA-SOVEREIGNTY.md +500 -0
- package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
- package/shared/card-lint-baseline.json +3189 -0
- package/shared/cards-fallback.json +1 -0
- package/shared/catalogue/card-config.json +6091 -0
- package/shared/catalogue/external-results.json +3888 -0
- package/shared/catalogue/gender-guidance.json +1038 -0
- package/shared/catalogue/method-coverage.json +1751 -0
- package/shared/catalogue/metric-coverage.json +170 -0
- package/shared/catalogue/metric-reliability.json +1 -0
- package/shared/catalogue/register-presets.json +3180 -0
- package/shared/catalogue/vitality-scales.json +55 -0
- package/shared/cldr-index.json +1115 -0
- package/shared/code-bridge.json +253 -0
- package/shared/corpora-cards-v1-reference.md +281 -0
- package/shared/curated-dictionary-flags.json +35 -0
- package/shared/curated-endonyms.json +35 -0
- package/shared/curated-fsts.json +51 -0
- package/shared/curated-orthography-conventions.json +26 -0
- package/shared/curated-sil-resources.json +374 -0
- package/shared/curated-tools.json +41 -0
- package/shared/docent/corpus.json +11333 -0
- package/shared/docent/faq.en.json +564 -0
- package/shared/docent/register-blocks.json +60 -0
- package/shared/docent/system-prompt.md +144 -0
- package/shared/domain-taxonomy.json +35 -0
- package/shared/explainers/glossary.json +2975 -0
- package/shared/explainers/tc-features.json +20112 -0
- package/shared/explainers/term-watchlist.json +147 -0
- package/shared/human-services.json +59 -0
- package/shared/license-corrections.json +261 -0
- package/shared/license-evidence.json +13452 -0
- package/shared/licenses.json +6781 -0
- package/shared/method-registry.json +236 -0
- package/shared/metric-registry.json +620 -0
- package/shared/model-aliases.json +7 -0
- package/shared/schemas/champollion-plugin.schema.json +206 -0
- package/shared/schemas/corpora-card.schema.json +957 -0
- package/shared/schemas/domain-taxonomy.schema.json +64 -0
- package/shared/schemas/external-results.schema.json +314 -0
- package/shared/schemas/human-services.schema.json +90 -0
- package/shared/schemas/language-card.schema.json +1308 -0
- package/shared/schemas/licenses.schema.json +155 -0
- package/shared/schemas/method-card.schema.json +412 -0
- package/shared/schemas/method-registry.schema.json +85 -0
- package/shared/schemas/metric-registry.schema.json +96 -0
- package/shared/schemas/metric-reliability.schema.json +178 -0
- package/shared/schemas/model-aliases.schema.json +27 -0
- 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 };
|