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,387 @@
1
+ /**
2
+ * Remote card source — read-only access to the production Supabase
3
+ * trading-card tables, and reconstruction of language cards from them.
4
+ *
5
+ * TABLES (mt-eval-arena/supabase/migrations/012_create_trading_cards.sql):
6
+ * trading_card_index — one compact row per language (grid fields),
7
+ * per-language updated_at
8
+ * trading_card_detail — { code, detail JSONB, updated_at }; detail is
9
+ * the pre-resolved (extends already merged)
10
+ * card projection built by
11
+ * mt-eval-arena/scripts/build-trading-card-data.mjs
12
+ *
13
+ * Access is anon-key, SELECT-only (enforced by RLS). This module never
14
+ * writes to Supabase.
15
+ *
16
+ * RECONSTRUCTION FIDELITY: the detail blob carries most reference
17
+ * fields under their card names (classification, vitality, formality,
18
+ * methodSupport, …) but NOT the runtime-only fields `registers`,
19
+ * `rules`, `gender`, or `aliases`:
20
+ * - registers are re-derived from `formality` with the same templates
21
+ * the card build uses (scripts/derive-registers-from-formality.mjs)
22
+ * - rules/gender stay null — all consumers treat them as optional
23
+ * (lib/types.js documents rules as object|null)
24
+ * - aliases come from the bundled manifest (shared/cards-fallback.json)
25
+ * Languages with hand-curated registers ship in the bundled fallback
26
+ * set, so reconstruction only ever serves the long tail.
27
+ *
28
+ * Shared by the CLI's lazy card loader (lib/registers.js) and the
29
+ * website data build (cli/website/scripts/build-data-from-supabase.mjs).
30
+ * Zero dependencies — global fetch (Node >= 20.11).
31
+ */
32
+
33
+ import { SUPABASE_URL, SUPABASE_ANON_KEY, isValidCardCode } from './env.js';
34
+
35
+ const REST_BASE = `${SUPABASE_URL}/rest/v1`;
36
+
37
+ /** PostgREST caps responses at 1000 rows per request on Supabase. */
38
+ const MAX_PAGE = 1000;
39
+
40
+ function headers(extra = {}) {
41
+ return {
42
+ apikey: SUPABASE_ANON_KEY,
43
+ Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
44
+ ...extra,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * GET a REST URL and parse JSON, with bounded retries on network
50
+ * errors and 5xx. 4xx responses throw immediately (they won't heal).
51
+ */
52
+ export async function fetchJson(url, { timeoutMs = 15000, retries = 2, accept } = {}) {
53
+ let lastErr;
54
+ for (let attempt = 0; attempt <= retries; attempt++) {
55
+ try {
56
+ const res = await fetch(url, {
57
+ headers: headers(accept ? { Accept: accept } : {}),
58
+ signal: AbortSignal.timeout(timeoutMs),
59
+ });
60
+ if (res.ok) return await res.json();
61
+ const body = await res.text().catch(() => '');
62
+ const err = new Error(`Supabase request failed: HTTP ${res.status} ${body.slice(0, 200)}`);
63
+ err.status = res.status;
64
+ if (res.status >= 400 && res.status < 500) throw err;
65
+ lastErr = err;
66
+ } catch (err) {
67
+ if (err.status >= 400 && err.status < 500) throw err;
68
+ lastErr = err;
69
+ }
70
+ if (attempt < retries) {
71
+ await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
72
+ }
73
+ }
74
+ throw lastErr;
75
+ }
76
+
77
+ /**
78
+ * Fetch rows from trading_card_index, paginating until exhausted.
79
+ *
80
+ * @param {object} [opts]
81
+ * @param {string} [opts.select] - PostgREST select list (default: all columns)
82
+ * @param {string} [opts.since] - ISO timestamp; only rows with updated_at > since
83
+ * @param {number} [opts.pageSize]
84
+ * @param {number} [opts.timeoutMs]
85
+ * @returns {Promise<object[]>} snake_case rows
86
+ */
87
+ export async function fetchIndexRows({ select = '*', since, pageSize = MAX_PAGE, timeoutMs = 30000 } = {}) {
88
+ const rows = [];
89
+ const size = Math.min(pageSize, MAX_PAGE);
90
+ for (let offset = 0; ; offset += size) {
91
+ const params = new URLSearchParams({
92
+ select,
93
+ order: 'code.asc',
94
+ limit: String(size),
95
+ offset: String(offset),
96
+ });
97
+ if (since) params.set('updated_at', `gt.${since}`);
98
+ const page = await fetchJson(`${REST_BASE}/trading_card_index?${params}`, { timeoutMs });
99
+ rows.push(...page);
100
+ if (page.length < size) break;
101
+ }
102
+ return rows;
103
+ }
104
+
105
+ /** Fetch one index row by code, or null if the language isn't in prod. */
106
+ export async function fetchIndexRow(code, { timeoutMs = 10000 } = {}) {
107
+ if (!isValidCardCode(code)) return null;
108
+ const params = new URLSearchParams({ select: '*', code: `eq.${code}`, limit: '1' });
109
+ const rows = await fetchJson(`${REST_BASE}/trading_card_index?${params}`, { timeoutMs });
110
+ return rows[0] || null;
111
+ }
112
+
113
+ /** Fetch one detail row ({ code, detail, updated_at }) by code, or null. */
114
+ export async function fetchDetailRow(code, { timeoutMs = 10000 } = {}) {
115
+ if (!isValidCardCode(code)) return null;
116
+ const params = new URLSearchParams({
117
+ select: 'code,detail,updated_at',
118
+ code: `eq.${code}`,
119
+ limit: '1',
120
+ });
121
+ const rows = await fetchJson(`${REST_BASE}/trading_card_detail?${params}`, { timeoutMs });
122
+ return rows[0] || null;
123
+ }
124
+
125
+ /**
126
+ * Fetch every detail row, paginating in modest pages (rows average
127
+ * ~9 KB; large pages risk statement timeouts). Used by website data
128
+ * builds, not by the CLI hot path.
129
+ *
130
+ * @param {object} [opts]
131
+ * @param {number} [opts.pageSize]
132
+ * @param {(rows: object[], offset: number) => void} [opts.onPage] - streaming hook
133
+ */
134
+ export async function fetchAllDetailRows({ pageSize = 200, timeoutMs = 60000, onPage } = {}) {
135
+ const all = [];
136
+ for (let offset = 0; ; offset += pageSize) {
137
+ const params = new URLSearchParams({
138
+ select: 'code,detail,updated_at',
139
+ order: 'code.asc',
140
+ limit: String(pageSize),
141
+ offset: String(offset),
142
+ });
143
+ const page = await fetchJson(`${REST_BASE}/trading_card_detail?${params}`, { timeoutMs });
144
+ if (onPage) onPage(page, offset);
145
+ else all.push(...page);
146
+ if (page.length < pageSize) break;
147
+ }
148
+ return all;
149
+ }
150
+
151
+ // -----------------------------------------------------------------
152
+ // Registers derivation — ported verbatim from
153
+ // scripts/derive-registers-from-formality.mjs so cards reconstructed
154
+ // from Supabase carry the same derived presets as their on-disk
155
+ // counterparts. Keep the two in sync if templates ever change.
156
+ // -----------------------------------------------------------------
157
+
158
+ const REGISTER_TEMPLATES = {
159
+ 'T-V': {
160
+ formal: {
161
+ label: 'Formal (V-form)',
162
+ description: 'Polite/formal address using V-form pronouns (e.g., vous, Sie, usted, вы). Default for software UI, official communication, and addressing strangers.',
163
+ prompt: 'Use formal/polite address forms (V-form pronouns). Write in a professional, respectful tone appropriate for software UI and official communication.',
164
+ isDefault: true,
165
+ },
166
+ informal: {
167
+ label: 'Informal (T-form)',
168
+ description: 'Familiar/informal address using T-form pronouns (e.g., tu, du, tú, ты). Used with close friends, family, children, and peers of similar age.',
169
+ prompt: 'Use informal/familiar address forms (T-form pronouns). Write in a casual, friendly tone appropriate for peer-to-peer communication.',
170
+ isDefault: false,
171
+ },
172
+ },
173
+ 'speech-levels': {
174
+ formal: {
175
+ label: 'Formal/Honorific',
176
+ description: 'Highest politeness level with dedicated verb morphology and honorific vocabulary. Used in formal contexts, with elders, officials, and strangers.',
177
+ prompt: 'Use the highest politeness level with honorific verb forms and respectful vocabulary. Address the reader with maximum deference.',
178
+ isDefault: false,
179
+ },
180
+ polite: {
181
+ label: 'Polite/Standard',
182
+ description: 'Standard polite register. Appropriate for most software UI, everyday formal interactions, and default translation output.',
183
+ prompt: 'Use standard polite register. Professional and respectful without excessive formality. Appropriate for software UI and general communication.',
184
+ isDefault: true,
185
+ },
186
+ informal: {
187
+ label: 'Informal/Casual',
188
+ description: 'Casual register used among close friends, peers, and in informal settings. Not appropriate for software UI or official content.',
189
+ prompt: 'Use casual/informal register. Relaxed tone appropriate for friends and peers. Avoid honorifics and formal verb endings.',
190
+ isDefault: false,
191
+ },
192
+ },
193
+ 'avoidance': {
194
+ respectful: {
195
+ label: 'Respectful/Indirect',
196
+ description: 'Address through indirect reference, titles, kinship terms, or circumlocution. Primary mode for respectful communication.',
197
+ prompt: 'Use indirect address strategies: titles, kinship terms, or circumlocution rather than direct pronouns. Maintain respectful distance.',
198
+ isDefault: true,
199
+ },
200
+ neutral: {
201
+ label: 'Neutral/Direct',
202
+ description: 'Direct address without avoidance strategies. Used in informal or intimate contexts where indirectness is unnecessary.',
203
+ prompt: 'Use direct address without avoidance strategies. Straightforward and clear communication.',
204
+ isDefault: false,
205
+ },
206
+ },
207
+ 'none': {
208
+ standard: {
209
+ label: 'Standard',
210
+ description: 'No grammaticalized politeness distinctions. A single register serves all social contexts, though lexical choices may vary by formality.',
211
+ prompt: 'Use standard register. This language does not grammaticalize politeness distinctions. Write clearly and naturally.',
212
+ isDefault: true,
213
+ },
214
+ },
215
+ 'politeness-present': {
216
+ formal: {
217
+ label: 'Formal/Polite',
218
+ description: 'Polite register using politeness-marked pronoun forms. Default for software UI and formal contexts.',
219
+ prompt: 'Use polite/formal pronoun forms and politeness markers. Professional tone appropriate for software UI and formal contexts.',
220
+ isDefault: true,
221
+ },
222
+ informal: {
223
+ label: 'Informal/Familiar',
224
+ description: 'Informal register using unmarked/familiar pronoun forms. Used with close relations.',
225
+ prompt: 'Use unmarked/familiar pronoun forms. Casual tone appropriate for friends and family.',
226
+ isDefault: false,
227
+ },
228
+ },
229
+ };
230
+
231
+ /**
232
+ * Derive a registers block from a formality field, mirroring the
233
+ * build-time enrichment. Returns { registers, defaultKey } or null
234
+ * when the formality system is unknown.
235
+ */
236
+ export function deriveRegistersFromFormality(formality) {
237
+ if (!formality || !formality.system) return null;
238
+ const template = REGISTER_TEMPLATES[formality.system];
239
+ if (!template) return null;
240
+
241
+ const registers = {};
242
+ let defaultKey = null;
243
+ for (const [key, val] of Object.entries(template)) {
244
+ registers[key] = {
245
+ ...val,
246
+ source: `derived-from-formality (${formality.source || 'unknown'})`,
247
+ };
248
+ if (val.isDefault) defaultKey = key;
249
+ }
250
+ return { registers, defaultKey };
251
+ }
252
+
253
+ // -----------------------------------------------------------------
254
+ // Card reconstruction
255
+ // -----------------------------------------------------------------
256
+
257
+ /**
258
+ * Detail-blob fields that map 1:1 onto card fields. Display-only
259
+ * assemblies whose shape differs from the raw cards (vocabulary,
260
+ * typology, phonology, statSources, provenance, …) are deliberately
261
+ * not mapped — fabricating card fields with mismatched shapes would
262
+ * be worse than omitting them.
263
+ */
264
+ const DETAIL_PASSTHROUGH = [
265
+ 'classification',
266
+ 'vitality',
267
+ 'speakerEstimates',
268
+ 'linguisticChallenges',
269
+ 'contactInfluences',
270
+ 'formality',
271
+ 'methodSupport',
272
+ 'resources',
273
+ 'evalDatasets',
274
+ 'pipelineReadiness',
275
+ 'digitalPresence',
276
+ 'corpusAvailability',
277
+ 'databaseCoverage',
278
+ 'omt1600',
279
+ 'regions',
280
+ 'countries',
281
+ 'coordinates',
282
+ 'orthographicStatus',
283
+ 'notes',
284
+ 'encyclopedic',
285
+ 'experts',
286
+ 'alternateNames',
287
+ 'modality',
288
+ 'isoType',
289
+ 'isoScope',
290
+ 'macrolanguage',
291
+ 'members',
292
+ 'taxonomyNotes',
293
+ ];
294
+
295
+ /**
296
+ * Build a language card from remote rows. The result is card-shaped
297
+ * (same field names as shared/language-cards/*.json) and ready for
298
+ * the registry — it has no `extends` because the upload pipeline
299
+ * resolves inheritance before publishing.
300
+ *
301
+ * @param {object} indexRow - snake_case trading_card_index row
302
+ * @param {object|null} detailRow - { detail, updated_at } or null
303
+ * @param {object} [opts]
304
+ * @param {string[]} [opts.aliases] - aliases from the bundled manifest
305
+ * @returns {object} reconstructed card
306
+ */
307
+ export function buildCardFromRemote(indexRow, detailRow, { aliases } = {}) {
308
+ const detail = detailRow?.detail || {};
309
+ const code = indexRow.code;
310
+
311
+ const card = {
312
+ code,
313
+ name: indexRow.name || code,
314
+ nativeName: indexRow.native_name ?? null,
315
+ iso639_3: /^[a-z]{3}$/.test(code) ? code : null,
316
+ iso639_1: null,
317
+ bcp47: code,
318
+ glottocode: detail.glottocode ?? indexRow.glottocode ?? null,
319
+ isIsolate: indexRow.is_isolate ?? false,
320
+ macroarea: indexRow.macroarea ?? null,
321
+ // Taxonomy badging — carried on the index row since migration 056.
322
+ // The detail blob's values (DETAIL_PASSTHROUGH below) override these
323
+ // when present; rows uploaded before the migration leave them null.
324
+ modality: indexRow.modality ?? null,
325
+ isoType: indexRow.iso_type ?? null,
326
+ isoScope: indexRow.iso_scope ?? null,
327
+ macrolanguage: indexRow.macrolanguage ?? null,
328
+ script: indexRow.script ?? null,
329
+ scripts: indexRow.scripts ?? null,
330
+ dir: indexRow.dir || 'ltr',
331
+ dialectCount: indexRow.dialect_count ?? null,
332
+ culturalAphorism: indexRow.cultural_aphorism ?? null,
333
+ registers: null,
334
+ rules: null,
335
+ gender: null,
336
+ };
337
+
338
+ if (Array.isArray(aliases) && aliases.length > 0) card.aliases = aliases;
339
+
340
+ for (const field of DETAIL_PASSTHROUGH) {
341
+ if (detail[field] !== undefined && detail[field] !== null) {
342
+ card[field] = detail[field];
343
+ }
344
+ }
345
+
346
+ // Re-derive registers the same way the card build does, so
347
+ // getRegister()/getRegisterPresets() behave identically to the
348
+ // on-disk stub cards.
349
+ const derived = deriveRegistersFromFormality(card.formality);
350
+ if (derived) {
351
+ card.registers = derived.registers;
352
+ if (derived.defaultKey && !card.formality.default) {
353
+ card.formality = { ...card.formality, default: derived.defaultKey };
354
+ }
355
+ card._fieldSources = {
356
+ registers: `derived-from-formality (${card.formality.source || 'unknown'})`,
357
+ };
358
+ }
359
+
360
+ card._remote = {
361
+ source: 'supabase',
362
+ updatedAt: detailRow?.updated_at || indexRow.updated_at || null,
363
+ };
364
+
365
+ return card;
366
+ }
367
+
368
+ /**
369
+ * Fetch and reconstruct one card.
370
+ *
371
+ * @returns {Promise<{card: object, updatedAt: string|null}|{missing: true}>}
372
+ * missing:true when the language doesn't exist in prod (definitive
373
+ * 404-equivalent — callers may tombstone it). Network/server errors
374
+ * throw instead.
375
+ */
376
+ export async function fetchRemoteCard(code, { aliases, timeoutMs = 10000 } = {}) {
377
+ if (!isValidCardCode(code)) return { missing: true };
378
+ const [indexRow, detailRow] = await Promise.all([
379
+ fetchIndexRow(code, { timeoutMs }),
380
+ fetchDetailRow(code, { timeoutMs }),
381
+ ]);
382
+ if (!indexRow) return { missing: true };
383
+ const card = buildCardFromRemote(indexRow, detailRow, { aliases });
384
+ return { card, updatedAt: card._remote.updatedAt };
385
+ }
386
+
387
+ export { SUPABASE_URL, SUPABASE_ANON_KEY };