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,80 @@
1
+ /**
2
+ * translation-error.js — shared recorder for the most recent translation
3
+ * HTTP failure, used to give accurate setup help.
4
+ *
5
+ * WHY: When a sync fails, getSetupHelp() previously always said "check your
6
+ * dashboard for quota/billing" — wrong and misleading when the real problem
7
+ * was a bad/expired key (HTTP 401/403). The user burns time checking billing
8
+ * when they should be re-checking the key value.
9
+ *
10
+ * The HTTP clients (openrouter-client, direct-llm, fetch-with-retry) record
11
+ * the status of a failing request here as they give up on it. getSetupHelp()
12
+ * then reads the last-seen failure and tailors its guidance:
13
+ * - auth (401/403): the key itself is the problem, not billing
14
+ * - quota (429): rate-limited / over quota → check billing/quota
15
+ * - server (5xx): upstream problem → wait and retry
16
+ *
17
+ * The method instance that ran the translation is discarded before
18
+ * getSetupHelp() is called (sync.js builds a fresh instance), so per-instance
19
+ * state can't carry the status across. A module-level "last error" is the
20
+ * pragmatic carrier: failures are almost always systemic (one bad key fails
21
+ * every pair), so the last-recorded status is representative of the failure
22
+ * the user is about to read help for.
23
+ *
24
+ * Node is single-threaded, so the assignment itself never races. Callers that
25
+ * want a clean slate (e.g. the start of a sync run) call reset first.
26
+ */
27
+
28
+ let _last = null; // { status: number|null, kind: string }
29
+
30
+ /**
31
+ * Classify an HTTP status into a coarse failure kind.
32
+ *
33
+ * @param {number} status - HTTP status code
34
+ * @returns {'auth'|'quota'|'server'|'unknown'}
35
+ */
36
+ function classifyTranslationError(status) {
37
+ if (typeof status !== 'number') return 'unknown';
38
+ if (status === 401 || status === 403) return 'auth';
39
+ if (status === 429) return 'quota';
40
+ if (status >= 500 && status <= 599) return 'server';
41
+ return 'unknown';
42
+ }
43
+
44
+ /**
45
+ * Record a failing HTTP status. No-ops for non-numbers and for 2xx
46
+ * (a successful response is not a failure to report).
47
+ *
48
+ * @param {number} status - HTTP status code of the failed request
49
+ * @returns {{ status: number|null, kind: string }|null} the recorded entry
50
+ */
51
+ function recordTranslationError(status) {
52
+ if (typeof status !== 'number' || (status >= 200 && status < 300)) return _last;
53
+ _last = { status, kind: classifyTranslationError(status) };
54
+ return _last;
55
+ }
56
+
57
+ /**
58
+ * Get the most recently recorded translation failure, or null if none.
59
+ *
60
+ * @returns {{ status: number|null, kind: string }|null}
61
+ */
62
+ function getLastTranslationError() {
63
+ return _last;
64
+ }
65
+
66
+ /**
67
+ * Clear the recorded failure. Call at the start of a fresh sync run so a
68
+ * stale error from a prior in-process sync (e.g. watch mode) can't leak
69
+ * into the next run's help text.
70
+ */
71
+ function resetTranslationError() {
72
+ _last = null;
73
+ }
74
+
75
+ export {
76
+ classifyTranslationError,
77
+ recordTranslationError,
78
+ getLastTranslationError,
79
+ resetTranslationError,
80
+ };
package/lib/models.js ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Model listing service — fetches available models from provider APIs.
3
+ *
4
+ * WHY THIS EXISTS:
5
+ * The init wizard, `champollion models` command, and method-level validation
6
+ * all need the same thing: "what models does this provider offer for my
7
+ * API key?" Previously, each method class had its own _fetchModels()
8
+ * with duplicated fetch logic. This module centralizes it.
9
+ *
10
+ * DESIGN:
11
+ * - Each provider entry defines how to call its model list API and
12
+ * how to filter the results to chat-capable models.
13
+ * - Results are cached per-process to avoid redundant API calls.
14
+ * - Returns null on failure (network, invalid key) — callers decide
15
+ * how to handle (fallback prompt, skip, etc.).
16
+ *
17
+ * PROVIDER API ENDPOINTS:
18
+ * - Gemini: GET https://generativelanguage.googleapis.com/v1beta/models?key=...
19
+ * - OpenAI: GET https://api.openai.com/v1/models (Bearer token)
20
+ * - Anthropic: GET https://api.anthropic.com/v1/models (x-api-key header)
21
+ */
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { getEnvOrFileVar } from './api-key.js';
27
+
28
+ // Per-process cache: provider name → model ID array (or null if fetch failed)
29
+ const _modelCache = new Map();
30
+
31
+ // Lazy-loaded alias map (loaded once from shared/model-aliases.json)
32
+ let _aliasCache = null;
33
+
34
+ /**
35
+ * Load the model alias map from shared/model-aliases.json.
36
+ *
37
+ * Resolves the path relative to the monorepo root (two levels up from cli/lib/).
38
+ * Returns an empty object if the file doesn't exist or fails to parse,
39
+ * so callers can always safely check `aliases[name]`.
40
+ *
41
+ * @returns {Object<string, string>} Short name → full OpenRouter slug
42
+ */
43
+ function _loadAliases() {
44
+ if (_aliasCache) return _aliasCache;
45
+
46
+ try {
47
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
48
+ // Prefer the package-bundled copy (cli/shared/, shipped via the prepack
49
+ // build-cards-fallback.mjs), then fall back to the monorepo-root SSOT for
50
+ // in-repo dev. Without the package copy, an installed CLI found no alias map
51
+ // and `--model <alias>` silently failed to resolve.
52
+ const aliasPaths = [
53
+ path.resolve(__dirname, '..', 'shared', 'model-aliases.json'),
54
+ path.resolve(__dirname, '..', '..', 'shared', 'model-aliases.json'),
55
+ ];
56
+ let raw = null;
57
+ for (const p of aliasPaths) {
58
+ try { raw = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
59
+ }
60
+ const parsed = raw ? JSON.parse(raw) : {};
61
+ // Strip metadata keys (e.g., _comment) — only keep actual aliases
62
+ _aliasCache = {};
63
+ for (const [key, value] of Object.entries(parsed)) {
64
+ if (!key.startsWith('_') && typeof value === 'string') {
65
+ _aliasCache[key] = value;
66
+ }
67
+ }
68
+ } catch {
69
+ _aliasCache = {};
70
+ }
71
+
72
+ return _aliasCache;
73
+ }
74
+
75
+ /**
76
+ * Resolve a model name or alias to a full OpenRouter slug.
77
+ *
78
+ * Resolution order:
79
+ * 1. If contains '/' → already a full slug, pass through
80
+ * 2. Check shared/model-aliases.json for a matching alias
81
+ * 3. Pass through as-is (user may know what they're doing)
82
+ *
83
+ * @param {string} nameOrSlug - User-provided model identifier
84
+ * @returns {string} Full OpenRouter model slug
85
+ */
86
+ function resolveModel(nameOrSlug) {
87
+ if (!nameOrSlug || typeof nameOrSlug !== 'string') return nameOrSlug;
88
+
89
+ // Already a full slug (contains provider prefix)
90
+ if (nameOrSlug.includes('/')) return nameOrSlug;
91
+
92
+ // Check aliases
93
+ const aliases = _loadAliases();
94
+ if (aliases[nameOrSlug]) return aliases[nameOrSlug];
95
+
96
+ // Pass through — could be a provider-specific model name (e.g., gemini-2.0-flash)
97
+ return nameOrSlug;
98
+ }
99
+
100
+ /**
101
+ * Provider configurations — how to fetch and filter models for each provider.
102
+ *
103
+ * Adding a new provider:
104
+ * 1. Add an entry here with fetch + filter functions
105
+ * 2. The method class's _fetchModels() delegates to fetchAvailableModels()
106
+ * 3. The init wizard and `models` command automatically pick it up
107
+ */
108
+ const PROVIDERS = {
109
+ gemini: {
110
+ envVar: 'GEMINI_API_KEY',
111
+ label: 'Google Gemini',
112
+ async fetch(apiKey) {
113
+ const response = await fetch(
114
+ `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`
115
+ );
116
+ if (!response.ok) return null;
117
+ const data = await response.json();
118
+ // Only include models that support generateContent (not embeddings-only).
119
+ // Strip the "models/" prefix that Gemini returns.
120
+ return (data.models || [])
121
+ .filter(m => m.supportedGenerationMethods?.includes('generateContent'))
122
+ .map(m => m.name.replace('models/', ''));
123
+ },
124
+ },
125
+
126
+ openai: {
127
+ envVar: 'OPENAI_API_KEY',
128
+ label: 'OpenAI',
129
+ async fetch(apiKey) {
130
+ const response = await fetch('https://api.openai.com/v1/models', {
131
+ headers: { 'Authorization': `Bearer ${apiKey}` },
132
+ });
133
+ if (!response.ok) return null;
134
+ const data = await response.json();
135
+ // Filter to chat-capable models — skip embeddings, whisper, dall-e, tts, etc.
136
+ return (data.data || [])
137
+ .map(m => m.id)
138
+ .filter(id =>
139
+ id.startsWith('gpt-') ||
140
+ id.startsWith('o1') ||
141
+ id.startsWith('o3') ||
142
+ id.startsWith('o4') ||
143
+ id.startsWith('chatgpt-')
144
+ );
145
+ },
146
+ },
147
+
148
+ anthropic: {
149
+ envVar: 'ANTHROPIC_API_KEY',
150
+ label: 'Anthropic',
151
+ async fetch(apiKey) {
152
+ const response = await fetch('https://api.anthropic.com/v1/models', {
153
+ headers: {
154
+ 'x-api-key': apiKey,
155
+ 'anthropic-version': '2023-06-01',
156
+ },
157
+ });
158
+ if (!response.ok) return null;
159
+ const data = await response.json();
160
+ return (data.data || []).map(m => m.id);
161
+ },
162
+ },
163
+ };
164
+
165
+ /**
166
+ * Fetch available chat-capable models for a provider.
167
+ *
168
+ * Uses the provider's real API to get the actual model list the user's
169
+ * API key has access to. Results are cached per-process.
170
+ *
171
+ * @param {string} provider - Provider name: 'gemini', 'openai', 'anthropic'
172
+ * @param {string} apiKey - The provider's API key
173
+ * @returns {Promise<string[]|null>} Array of model IDs, or null on failure
174
+ */
175
+ async function fetchAvailableModels(provider, apiKey) {
176
+ if (!apiKey) return null;
177
+
178
+ const config = PROVIDERS[provider];
179
+ if (!config) return null;
180
+
181
+ // Check cache — undefined = never tried, null = tried and failed
182
+ const cached = _modelCache.get(provider);
183
+ if (cached !== undefined) return cached;
184
+
185
+ try {
186
+ const models = await config.fetch(apiKey);
187
+ if (models && models.length > 0) {
188
+ _modelCache.set(provider, models);
189
+ return models;
190
+ }
191
+ _modelCache.set(provider, null);
192
+ return null;
193
+ } catch {
194
+ _modelCache.set(provider, null);
195
+ return null;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Resolve an API key for a provider from environment or .env files.
201
+ *
202
+ * @param {string} provider - Provider name
203
+ * @param {string} [cwd] - Working directory for .env file lookup
204
+ * @returns {string|null} API key or null
205
+ */
206
+ function resolveProviderApiKey(provider, cwd) {
207
+ const config = PROVIDERS[provider];
208
+ if (!config) return null;
209
+ return getEnvOrFileVar(config.envVar) || getEnvOrFileVar(config.envVar, cwd);
210
+ }
211
+
212
+ /**
213
+ * Get the display label for a provider.
214
+ *
215
+ * @param {string} provider - Provider name
216
+ * @returns {string} Human-readable label
217
+ */
218
+ function getProviderLabel(provider) {
219
+ return PROVIDERS[provider]?.label || provider;
220
+ }
221
+
222
+ /**
223
+ * Check if a provider has model listing support.
224
+ *
225
+ * @param {string} provider - Provider name
226
+ * @returns {boolean}
227
+ */
228
+ function isListableProvider(provider) {
229
+ return provider in PROVIDERS;
230
+ }
231
+
232
+ /**
233
+ * Get all provider names that support model listing.
234
+ *
235
+ * @returns {string[]}
236
+ */
237
+ function getListableProviders() {
238
+ return Object.keys(PROVIDERS);
239
+ }
240
+
241
+ /**
242
+ * Clear the per-process model cache.
243
+ * Primarily for testing — allows re-fetching in a long-running process.
244
+ */
245
+ function clearModelCache() {
246
+ _modelCache.clear();
247
+ }
248
+
249
+ export {
250
+ fetchAvailableModels,
251
+ resolveModel,
252
+ resolveProviderApiKey,
253
+ getProviderLabel,
254
+ isListableProvider,
255
+ getListableProviders,
256
+ clearModelCache,
257
+ PROVIDERS,
258
+ };
@@ -0,0 +1,233 @@
1
+ /**
2
+ * no-translate.js — keys whose correct translation is the source, verbatim.
3
+ *
4
+ * THE PROBLEM THIS SOLVES:
5
+ * Some values have exactly one correct rendering in every locale: a URL,
6
+ * a repo path, a package name. The post-translation quality gate rejects
7
+ * source-echo (lib/validate.js check 2), so for these keys the CORRECT
8
+ * answer always FAILS. That has two observed failure modes, both real,
9
+ * both seen in production:
10
+ *
11
+ * 1. Weak models learn to defeat the gate by bending the value just
12
+ * enough to stop being an echo. Observed on a live site: 48 corrupted
13
+ * URLs across 13 locales — fabricated fragments (".../view/1954#fr"),
14
+ * stray trailing "#" and "/", a U+200E LEFT-TO-RIGHT MARK prepended
15
+ * in Arabic, a U+200B ZERO WIDTH SPACE appended in Hindi. The
16
+ * invisible-character ones break the link outright.
17
+ * 2. Strong models return the value unchanged, correctly, and fail the
18
+ * gate — so `champollion sync` exits non-zero forever. A pre-commit
19
+ * hook wired to sync can then never be satisfied, and the only way
20
+ * past is to disable the whole gate.
21
+ *
22
+ * There is no threshold that fixes this, because the gate is asking the
23
+ * wrong question. The fix is to declare the key out of scope: never send
24
+ * it to a backend, never gate it, never bill it — copy it verbatim.
25
+ *
26
+ * TWO WAYS A KEY BECOMES NO-TRANSLATE:
27
+ * 1. `noTranslate` config patterns — dot-path keys and/or globs:
28
+ * "noTranslate": ["**.url", "pages.software.*.repo", "meta.appId"]
29
+ * 2. Auto-detected bare URLs — a source value that is nothing but a
30
+ * `scheme://…` URL. On by default (`noTranslateUrls`), because the
31
+ * current behaviour has no correct outcome. Opt out with
32
+ * `"noTranslateUrls": false`.
33
+ *
34
+ * WHERE IT PLUGS IN: `diffLocale` (lib/diff.js) takes the resulting matcher
35
+ * and routes matching keys into a `noTranslate` bucket instead of
36
+ * `toProcess`. Because the cost estimator diffs with the same matcher, the
37
+ * keys are excluded from the bill by construction rather than by a second
38
+ * rule that could drift.
39
+ */
40
+
41
+ /**
42
+ * A value is "a bare URL" when the whole thing, trimmed, is one absolute
43
+ * URL and nothing else.
44
+ *
45
+ * Deliberately NOT a substring match: "Read the paper at https://…" is
46
+ * prose with a URL in it and must still be translated. Only a value that
47
+ * IS the URL has no translatable content.
48
+ *
49
+ * Scheme grammar follows RFC 3986 (ALPHA *( ALPHA / DIGIT / "+" / "-" / "."))
50
+ * and requires the `://` authority form, so `https://`, `ftp://` and
51
+ * `ipfs://` match while `mailto:` and a bare `example.com` do not.
52
+ */
53
+ const BARE_URL = /^[a-z][a-z0-9+.-]*:\/\/\S+$/i;
54
+
55
+ /**
56
+ * Is this source value a bare URL?
57
+ *
58
+ * @param {unknown} value - Source value to test
59
+ * @returns {boolean} True when the trimmed value is exactly one absolute URL
60
+ */
61
+ function isBareUrl(value) {
62
+ return typeof value === 'string' && BARE_URL.test(value.trim());
63
+ }
64
+
65
+ /**
66
+ * Compile one dot-path pattern into a segment matcher.
67
+ *
68
+ * Pattern grammar (segments split on `.`):
69
+ * - a literal segment matches that segment exactly
70
+ * - `*` matches any characters WITHIN one segment (`page*` → `pageTitle`)
71
+ * - `**` matches zero or more whole segments (`**.url` matches `a.b.url`
72
+ * and a top-level `url`)
73
+ * - a pattern with no wildcard is an exact dot-path
74
+ *
75
+ * @param {string} pattern - e.g. '**.url', 'pages.software.*.repo'
76
+ * @returns {(keySegments: string[]) => boolean} Segment-array matcher
77
+ */
78
+ function compilePattern(pattern) {
79
+ const patternSegments = pattern.split('.');
80
+
81
+ // Per-segment regexes, built once. `**` is handled structurally below and
82
+ // never reaches this map.
83
+ const segmentTests = patternSegments.map(seg => {
84
+ if (seg === '**') return null;
85
+ if (!seg.includes('*')) return (s) => s === seg;
86
+ // Escape everything regex-significant, then turn `*` into "any run of
87
+ // characters that is not a segment separator".
88
+ const source = '^' + seg
89
+ .split('*')
90
+ .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
91
+ .join('[^.]*') + '$';
92
+ const re = new RegExp(source);
93
+ return (s) => re.test(s);
94
+ });
95
+
96
+ /**
97
+ * Classic backtracking glob match over segment arrays. Locale key depth is
98
+ * small (single digits), so recursion is cheap and the code stays readable.
99
+ */
100
+ function match(pi, ki, keySegments) {
101
+ if (pi === patternSegments.length) return ki === keySegments.length;
102
+
103
+ if (patternSegments[pi] === '**') {
104
+ // `**` consumes zero or more segments — try every split point.
105
+ for (let skip = ki; skip <= keySegments.length; skip++) {
106
+ if (match(pi + 1, skip, keySegments)) return true;
107
+ }
108
+ return false;
109
+ }
110
+
111
+ if (ki >= keySegments.length) return false;
112
+ if (!segmentTests[pi](keySegments[ki])) return false;
113
+ return match(pi + 1, ki + 1, keySegments);
114
+ }
115
+
116
+ return (keySegments) => match(0, 0, keySegments);
117
+ }
118
+
119
+ /**
120
+ * Validate the no-translate config fields, failing loud on anything the
121
+ * matcher could not honour.
122
+ *
123
+ * A misspelled or wrong-typed `noTranslate` must never degrade to "translate
124
+ * everything": the user wrote it precisely so certain keys would be left
125
+ * alone, and silently ignoring it re-opens the corruption path this module
126
+ * exists to close.
127
+ *
128
+ * @param {unknown} patterns - Raw config.noTranslate
129
+ * @param {unknown} urls - Raw config.noTranslateUrls
130
+ * @throws {Error} With code CHAMPOLLION_CONFIG_INVALID
131
+ */
132
+ function validateNoTranslateConfig(patterns, urls) {
133
+ const fail = (message) => {
134
+ const e = new Error(message);
135
+ e.code = 'CHAMPOLLION_CONFIG_INVALID';
136
+ throw e;
137
+ };
138
+
139
+ if (patterns != null) {
140
+ if (!Array.isArray(patterns)) {
141
+ fail(
142
+ '"noTranslate" must be an array of dot-path keys or glob patterns, '
143
+ + `got ${typeof patterns}. Example: "noTranslate": ["**.url", "pages.software.*.repo"]`,
144
+ );
145
+ }
146
+ for (const p of patterns) {
147
+ if (typeof p !== 'string' || p.trim() === '') {
148
+ fail(`"noTranslate" entries must be non-empty strings — found ${JSON.stringify(p)}.`);
149
+ }
150
+ if (p.includes('..')) {
151
+ fail(`"noTranslate" pattern "${p}" has an empty path segment. Use "**" to match any depth.`);
152
+ }
153
+ }
154
+ }
155
+
156
+ if (urls != null && typeof urls !== 'boolean') {
157
+ fail(`"noTranslateUrls" must be a boolean, got ${typeof urls}. Set it to false to translate URL-valued keys.`);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Build the no-translate matcher for a resolved config.
163
+ *
164
+ * @param {object} config - Resolved config (reads `noTranslate`, `noTranslateUrls`)
165
+ * @returns {NoTranslateMatcher}
166
+ *
167
+ * @typedef {object} NoTranslateMatcher
168
+ * @property {boolean} active - False when nothing is configured and URL
169
+ * auto-detection is off. Callers can skip the whole lane.
170
+ * @property {(key: string, value: unknown) => boolean} matches - Is this key
171
+ * exempt from translation?
172
+ * @property {(key: string, value: unknown) => string|null} reason - Why it is
173
+ * exempt ('pattern "**.url"' / 'auto-detected URL'), or null.
174
+ * @property {string[]} patterns - The configured patterns, for reporting.
175
+ * @property {boolean} urls - Whether URL auto-detection is on.
176
+ */
177
+ function compileNoTranslate(config = {}) {
178
+ validateNoTranslateConfig(config.noTranslate, config.noTranslateUrls);
179
+
180
+ const patterns = Array.isArray(config.noTranslate) ? [...config.noTranslate] : [];
181
+ const urls = config.noTranslateUrls !== false;
182
+ const compiled = patterns.map(p => ({ pattern: p, test: compilePattern(p) }));
183
+
184
+ // Segment splits are pure and repeated across every locale in a run.
185
+ const segmentCache = new Map();
186
+ const segmentsOf = (key) => {
187
+ let segs = segmentCache.get(key);
188
+ if (!segs) {
189
+ segs = key.split('.');
190
+ segmentCache.set(key, segs);
191
+ }
192
+ return segs;
193
+ };
194
+
195
+ const reason = (key, value) => {
196
+ const segs = segmentsOf(key);
197
+ for (const { pattern, test } of compiled) {
198
+ if (test(segs)) return `pattern "${pattern}"`;
199
+ }
200
+ if (urls && isBareUrl(value)) return 'auto-detected URL';
201
+ return null;
202
+ };
203
+
204
+ return {
205
+ active: compiled.length > 0 || urls,
206
+ matches: (key, value) => reason(key, value) !== null,
207
+ reason,
208
+ patterns,
209
+ urls,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Matcher that exempts nothing — for callers with no config in hand.
215
+ *
216
+ * @type {NoTranslateMatcher}
217
+ */
218
+ const NO_TRANSLATE_NONE = {
219
+ active: false,
220
+ matches: () => false,
221
+ reason: () => null,
222
+ patterns: [],
223
+ urls: false,
224
+ };
225
+
226
+ export {
227
+ compileNoTranslate,
228
+ compilePattern,
229
+ isBareUrl,
230
+ validateNoTranslateConfig,
231
+ NO_TRANSLATE_NONE,
232
+ BARE_URL,
233
+ };