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
package/lib/tm.js
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translation Memory (TM) — lightweight same-project cache.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS:
|
|
5
|
+
* Without TM, re-running `champollion sync` after changing ONE English key
|
|
6
|
+
* re-translates every key that was modified, including keys that already
|
|
7
|
+
* have perfectly good translations from a previous run with the same
|
|
8
|
+
* source text. This wastes API tokens and adds latency.
|
|
9
|
+
*
|
|
10
|
+
* Common scenarios this helps:
|
|
11
|
+
* 1. Source key reverted to a previous value → TM provides instant hit
|
|
12
|
+
* 2. Same phrase appears in multiple locale files → first translation cached
|
|
13
|
+
* 3. Dry-run followed by real sync → second run reuses TM from first
|
|
14
|
+
* 4. Developer iterating on a single file → only truly new keys hit the API
|
|
15
|
+
*
|
|
16
|
+
* HOW IT WORKS:
|
|
17
|
+
* TM is a JSON file at .champollion/tm.json in the project root.
|
|
18
|
+
*
|
|
19
|
+
* Cache key: SHA-256(sourceValue + '\x00' + targetLocale + '\x00' + method)
|
|
20
|
+
* - Including the method ensures translations from Google Translate aren't
|
|
21
|
+
* served when the user switches to DeepL or a coached LLM model.
|
|
22
|
+
* - The null byte separator prevents "ab" + "c" colliding with "a" + "bc".
|
|
23
|
+
*
|
|
24
|
+
* Cache value: { translation, timestamp }
|
|
25
|
+
* - timestamp is ISO-8601, used for informational/debugging purposes only.
|
|
26
|
+
* - No TTL — translations don't expire. Users can delete .champollion/tm.json
|
|
27
|
+
* to clear the cache entirely.
|
|
28
|
+
*
|
|
29
|
+
* STORAGE FORMAT:
|
|
30
|
+
* {
|
|
31
|
+
* "_meta": { "version": 1, "created": "2026-05-24T05:30:00Z" },
|
|
32
|
+
* "abc123...": { "t": "Bonjour", "ts": "2026-05-24T05:30:00Z" }
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* Keys are abbreviated ('t' for translation, 'ts' for timestamp) to keep
|
|
36
|
+
* the file compact. At 50 languages × 500 keys = 25,000 entries, the file
|
|
37
|
+
* should be ~2-3 MB — comfortably manageable.
|
|
38
|
+
*
|
|
39
|
+
* USAGE:
|
|
40
|
+
* import { loadTM, saveTM, lookupTM, storeTM } from './tm.js';
|
|
41
|
+
*
|
|
42
|
+
* const tm = loadTM(cwd);
|
|
43
|
+
* const cached = lookupTM(tm, sourceValue, 'fr', 'llm');
|
|
44
|
+
* if (cached) { use cached; }
|
|
45
|
+
* else { translate, then storeTM(tm, sourceValue, 'fr', 'llm', translated); }
|
|
46
|
+
* saveTM(cwd, tm);
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import fs from 'node:fs';
|
|
50
|
+
import path from 'node:path';
|
|
51
|
+
import crypto from 'node:crypto';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Current TM format version. If the format changes in a backward-incompatible
|
|
55
|
+
* way, bump this to invalidate old caches.
|
|
56
|
+
*/
|
|
57
|
+
const TM_VERSION = 1;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Default path relative to project root.
|
|
61
|
+
*/
|
|
62
|
+
const TM_DIR = '.champollion';
|
|
63
|
+
const TM_FILENAME = 'tm.json';
|
|
64
|
+
|
|
65
|
+
// -----------------------------------------------------------------
|
|
66
|
+
// Cache key generation
|
|
67
|
+
// -----------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Generate a cache key for a source value + locale + method triple.
|
|
71
|
+
*
|
|
72
|
+
* Uses SHA-256 truncated to 16 hex characters (64 bits). Collision probability
|
|
73
|
+
* at 100k entries: ~3×10⁻¹⁰ — negligible. If a collision occurs, the worst
|
|
74
|
+
* case is serving one wrong cached translation that would be overwritten on
|
|
75
|
+
* the next sync anyway.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} sourceValue - Source language value
|
|
78
|
+
* @param {string} locale - Target locale code
|
|
79
|
+
* @param {string} method - Translation method name
|
|
80
|
+
* @returns {string} 16-char hex hash
|
|
81
|
+
*/
|
|
82
|
+
function cacheKey(sourceValue, locale, method) {
|
|
83
|
+
const input = `${sourceValue}\x00${locale}\x00${method}`;
|
|
84
|
+
return crypto.createHash('sha256').update(input).digest('hex').slice(0, 16);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Short stable hash used inside tmMethodKey parts (register text, coaching).
|
|
89
|
+
* 8 hex chars (32 bits) is plenty: it only needs to distinguish the handful
|
|
90
|
+
* of register/coaching variants a single project cycles through, and a
|
|
91
|
+
* collision merely re-serves a cached translation from another variant of
|
|
92
|
+
* the SAME pair — the quality gate still stands between the TM and the file.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} text - Text to fingerprint
|
|
95
|
+
* @returns {string} 8-char hex hash
|
|
96
|
+
*/
|
|
97
|
+
function _shortHash(text) {
|
|
98
|
+
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 8);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the TM "method" key for a pair — the third component of cacheKey().
|
|
103
|
+
*
|
|
104
|
+
* WHY: the bare method name ("llm") is NOT enough to identify what shaped a
|
|
105
|
+
* translation. Two runs with method "llm" but different models, registers,
|
|
106
|
+
* or coaching prompts produce systematically different output. Keying the TM
|
|
107
|
+
* on the bare method silently re-served old-style translations after the
|
|
108
|
+
* user switched model (gemini-flash → gpt-4o), changed the register
|
|
109
|
+
* (formal → casual-tu), or edited their coaching file — the exact situations
|
|
110
|
+
* where a fresh translation is the whole point of the change.
|
|
111
|
+
*
|
|
112
|
+
* The key folds in everything on the pair config that shapes output:
|
|
113
|
+
* - method: translation strategy (llm, llm-coached, google-translate, …)
|
|
114
|
+
* - model: the specific model, when the method uses one ('' otherwise)
|
|
115
|
+
* - register: the preset key when known (human-readable), else a short
|
|
116
|
+
* hash of the custom register text ('' when unset)
|
|
117
|
+
* - coaching: for llm-coached pairs, a short hash of the resolved coaching
|
|
118
|
+
* prompt text (config.js reads coachingFile into
|
|
119
|
+
* coachingPrompt) plus any structured plugin coachingData.
|
|
120
|
+
* When only a coachingFile path is available (not yet
|
|
121
|
+
* resolved), the path is fingerprinted — a moved/renamed file
|
|
122
|
+
* still invalidates, though an in-place edit that bypassed
|
|
123
|
+
* config resolution would not. '' for non-coached methods.
|
|
124
|
+
*
|
|
125
|
+
* Changing any component makes old entries unreachable (a cache miss, so
|
|
126
|
+
* the API is consulted) WITHOUT nuking valid entries for other pairs —
|
|
127
|
+
* which is why callers must use this instead of bumping TM_VERSION.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} pairConfig - Pair config (method, model, register, registerPreset, coaching*)
|
|
130
|
+
* @returns {string} Stable method-key string, e.g. "llm|google/gemini-3.5-flash|formal|"
|
|
131
|
+
*/
|
|
132
|
+
function tmMethodKey(pairConfig) {
|
|
133
|
+
const method = pairConfig.method || 'llm';
|
|
134
|
+
const model = pairConfig.model || '';
|
|
135
|
+
|
|
136
|
+
const register = pairConfig.registerPreset
|
|
137
|
+
|| (typeof pairConfig.register === 'string' && pairConfig.register.length > 0
|
|
138
|
+
? _shortHash(pairConfig.register)
|
|
139
|
+
: '');
|
|
140
|
+
|
|
141
|
+
let coaching = '';
|
|
142
|
+
if (method === 'llm-coached') {
|
|
143
|
+
const parts = [];
|
|
144
|
+
if (typeof pairConfig.coachingPrompt === 'string' && pairConfig.coachingPrompt.trim().length > 0) {
|
|
145
|
+
parts.push(pairConfig.coachingPrompt);
|
|
146
|
+
} else if (typeof pairConfig.coachingFile === 'string' && pairConfig.coachingFile.trim().length > 0) {
|
|
147
|
+
parts.push(`file:${pairConfig.coachingFile}`);
|
|
148
|
+
}
|
|
149
|
+
if (pairConfig.coachingData) {
|
|
150
|
+
parts.push(JSON.stringify(pairConfig.coachingData));
|
|
151
|
+
}
|
|
152
|
+
if (parts.length > 0) {
|
|
153
|
+
coaching = _shortHash(parts.join('\x00'));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return `${method}|${model}|${register}|${coaching}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// -----------------------------------------------------------------
|
|
161
|
+
// TM lifecycle
|
|
162
|
+
// -----------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Load the translation memory from disk.
|
|
166
|
+
*
|
|
167
|
+
* Returns an empty TM object if the file doesn't exist or is corrupt.
|
|
168
|
+
* Logs a warning on corruption but never throws — a missing TM
|
|
169
|
+
* just means no cache hits (cold start).
|
|
170
|
+
*
|
|
171
|
+
* @param {string} cwd - Project root directory
|
|
172
|
+
* @returns {object} TM object (mutable — callers add entries, then save)
|
|
173
|
+
*/
|
|
174
|
+
function loadTM(cwd) {
|
|
175
|
+
const tmPath = path.join(cwd, TM_DIR, TM_FILENAME);
|
|
176
|
+
|
|
177
|
+
if (!fs.existsSync(tmPath)) {
|
|
178
|
+
return _createEmptyTM();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const raw = fs.readFileSync(tmPath, 'utf-8');
|
|
183
|
+
const data = JSON.parse(raw);
|
|
184
|
+
|
|
185
|
+
// Version check — if format changed, start fresh
|
|
186
|
+
if (!data._meta || data._meta.version !== TM_VERSION) {
|
|
187
|
+
console.error(` [TM] Cache version mismatch (expected ${TM_VERSION}). Starting fresh.`);
|
|
188
|
+
return _createEmptyTM();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return data;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
// FAIL LOUD. "Starting fresh" on an unreadable TM silently discards every
|
|
194
|
+
// cached translation, so the next sync re-translates the entire project at
|
|
195
|
+
// full API cost — a warning line is not adequate notice for a bill.
|
|
196
|
+
//
|
|
197
|
+
// A version mismatch above stays a warning: that path is expected on
|
|
198
|
+
// upgrade, its cause is known, and there is genuinely nothing to preserve.
|
|
199
|
+
// This path is different — the file is corrupt for an unknown reason, and
|
|
200
|
+
// the cheap, correct move is usually to restore it, not to rebuild it.
|
|
201
|
+
if (process.env.CHAMPOLLION_ALLOW_CACHE_RESET === '1') {
|
|
202
|
+
console.error(
|
|
203
|
+
` [TM] ${tmPath} is unreadable (${err.message}). `
|
|
204
|
+
+ `CHAMPOLLION_ALLOW_CACHE_RESET=1 — starting fresh; `
|
|
205
|
+
+ `every key will be re-translated at full cost.`,
|
|
206
|
+
);
|
|
207
|
+
return _createEmptyTM();
|
|
208
|
+
}
|
|
209
|
+
const e = new Error(
|
|
210
|
+
`Translation memory is unreadable: ${err.message}\n\n`
|
|
211
|
+
+ ` ${tmPath}\n\n`
|
|
212
|
+
+ `Refusing to continue: starting fresh would discard every cached `
|
|
213
|
+
+ `translation and re-translate the whole project at full API cost.\n\n`
|
|
214
|
+
+ ` • Restore the file from version control if you can, or\n`
|
|
215
|
+
+ ` • delete it and re-run to accept the re-translation cost, or\n`
|
|
216
|
+
+ ` • re-run with CHAMPOLLION_ALLOW_CACHE_RESET=1 to do that in place.`,
|
|
217
|
+
);
|
|
218
|
+
e.code = 'CHAMPOLLION_TM_UNREADABLE';
|
|
219
|
+
throw e;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Save the translation memory to disk.
|
|
225
|
+
*
|
|
226
|
+
* Creates the .champollion/ directory if it doesn't exist.
|
|
227
|
+
* Writes atomically (write to .tmp, rename) to prevent corruption
|
|
228
|
+
* if the process is killed mid-write.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} cwd - Project root directory
|
|
231
|
+
* @param {object} tm - TM object to save
|
|
232
|
+
*/
|
|
233
|
+
function saveTM(cwd, tm) {
|
|
234
|
+
const dirPath = path.join(cwd, TM_DIR);
|
|
235
|
+
const tmPath = path.join(dirPath, TM_FILENAME);
|
|
236
|
+
const tmpPath = tmPath + '.tmp';
|
|
237
|
+
|
|
238
|
+
// Ensure .champollion/ directory exists
|
|
239
|
+
if (!fs.existsSync(dirPath)) {
|
|
240
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const json = JSON.stringify(tm, null, 0); // compact — no pretty-printing
|
|
244
|
+
fs.writeFileSync(tmpPath, json, 'utf-8');
|
|
245
|
+
fs.renameSync(tmpPath, tmPath);
|
|
246
|
+
tm[_DIRTY] = false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Look up a cached translation.
|
|
251
|
+
*
|
|
252
|
+
* @param {object} tm - TM object (from loadTM)
|
|
253
|
+
* @param {string} sourceValue - Source language value
|
|
254
|
+
* @param {string} locale - Target locale code
|
|
255
|
+
* @param {string} method - Translation method name
|
|
256
|
+
* @returns {string|null} Cached translation, or null for cache miss
|
|
257
|
+
*/
|
|
258
|
+
function lookupTM(tm, sourceValue, locale, method) {
|
|
259
|
+
const key = cacheKey(sourceValue, locale, method);
|
|
260
|
+
const entry = tm[key];
|
|
261
|
+
if (entry && typeof entry.t === 'string') {
|
|
262
|
+
return entry.t;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Look up a cached translation and validate it before serving.
|
|
269
|
+
*
|
|
270
|
+
* WHY: a cache is a time machine — an entry stored before a quality gate
|
|
271
|
+
* existed re-serves output that gate would reject today. The content lanes
|
|
272
|
+
* hit this in production: front-matter and body values hollowed by a
|
|
273
|
+
* pre-0.2.0 pipeline sat in the TM and were served back verbatim, forever,
|
|
274
|
+
* because TM hits skipped the gates that had since been built. Validating at
|
|
275
|
+
* READ time means every gate improvement retroactively cleans the cache —
|
|
276
|
+
* no version bookkeeping, no migration.
|
|
277
|
+
*
|
|
278
|
+
* A hit that fails validation is EVICTED (so the next lookup is an honest
|
|
279
|
+
* miss and the API is consulted) and reported as a miss to the caller.
|
|
280
|
+
*
|
|
281
|
+
* The validator is a callback so this module stays dependency-free: callers
|
|
282
|
+
* bring whatever check fits their lane.
|
|
283
|
+
*
|
|
284
|
+
* @param {object} tm - TM object (mutated on eviction)
|
|
285
|
+
* @param {string} sourceValue - Source language value
|
|
286
|
+
* @param {string} locale - Target locale code
|
|
287
|
+
* @param {string} method - Translation method name
|
|
288
|
+
* @param {(source: string, cached: string) => boolean} isValid - True to serve
|
|
289
|
+
* @returns {string|null} Validated cached translation, or null
|
|
290
|
+
*/
|
|
291
|
+
function lookupTMValidated(tm, sourceValue, locale, method, isValid) {
|
|
292
|
+
const cached = lookupTM(tm, sourceValue, locale, method);
|
|
293
|
+
if (cached === null) return null;
|
|
294
|
+
if (isValid(sourceValue, cached)) return cached;
|
|
295
|
+
evictTM(tm, sourceValue, locale, method);
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Store a translation in the TM.
|
|
301
|
+
*
|
|
302
|
+
* @param {object} tm - TM object (mutated in place)
|
|
303
|
+
* @param {string} sourceValue - Source language value
|
|
304
|
+
* @param {string} locale - Target locale code
|
|
305
|
+
* @param {string} method - Translation method name
|
|
306
|
+
* @param {string} translation - Translated value to cache
|
|
307
|
+
*/
|
|
308
|
+
function storeTM(tm, sourceValue, locale, method, translation) {
|
|
309
|
+
const key = cacheKey(sourceValue, locale, method);
|
|
310
|
+
tm[key] = {
|
|
311
|
+
t: translation,
|
|
312
|
+
ts: new Date().toISOString(),
|
|
313
|
+
l: locale, // locale code — enables per-locale stats and filtering
|
|
314
|
+
m: method, // method name — enables per-method stats
|
|
315
|
+
};
|
|
316
|
+
_markDirty(tm);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Evict a cached translation from the TM.
|
|
321
|
+
*
|
|
322
|
+
* Used by the quality gate: when a TM-served translation fails validation,
|
|
323
|
+
* the entry must be removed — otherwise it is re-served (and re-fails) on
|
|
324
|
+
* every future sync, and the API is never consulted again for that string.
|
|
325
|
+
*
|
|
326
|
+
* @param {object} tm - TM object (mutated in place)
|
|
327
|
+
* @param {string} sourceValue - Source language value
|
|
328
|
+
* @param {string} locale - Target locale code
|
|
329
|
+
* @param {string} method - Translation method name
|
|
330
|
+
* @returns {boolean} True if an entry existed and was removed
|
|
331
|
+
*/
|
|
332
|
+
function evictTM(tm, sourceValue, locale, method) {
|
|
333
|
+
const key = cacheKey(sourceValue, locale, method);
|
|
334
|
+
if (key in tm) {
|
|
335
|
+
delete tm[key];
|
|
336
|
+
_markDirty(tm);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Has this TM been mutated (store or evict) since load/save?
|
|
344
|
+
*
|
|
345
|
+
* Callers use this to decide whether saveTM is needed. A size comparison is
|
|
346
|
+
* NOT equivalent: evicting a poisoned entry and re-storing its replacement
|
|
347
|
+
* under the same cache key leaves the size unchanged, and an eviction-only
|
|
348
|
+
* run shrinks it — both must still be persisted.
|
|
349
|
+
*
|
|
350
|
+
* @param {object} tm - TM object
|
|
351
|
+
* @returns {boolean} True if the TM has unsaved changes
|
|
352
|
+
*/
|
|
353
|
+
function isTMDirty(tm) {
|
|
354
|
+
return tm[_DIRTY] === true;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Non-enumerable dirty marker — invisible to JSON.stringify and Object.keys,
|
|
358
|
+
// so it never leaks into the saved file or entry counts.
|
|
359
|
+
const _DIRTY = Symbol('tm-dirty');
|
|
360
|
+
|
|
361
|
+
function _markDirty(tm) {
|
|
362
|
+
tm[_DIRTY] = true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Prune TM entries by category, mutating the loaded object in place.
|
|
367
|
+
*
|
|
368
|
+
* Categories:
|
|
369
|
+
* - legacy: entries missing the `l`/`m` metadata fields (pre-v3.4 format).
|
|
370
|
+
* These can never be filtered per-locale or per-method, and predate the
|
|
371
|
+
* tmMethodKey cache-key scheme, so they are dead weight.
|
|
372
|
+
* - matching: entries whose cached translation matches `matching` (RegExp).
|
|
373
|
+
* This is the policy-eviction lane: when wording is banned AFTER entries
|
|
374
|
+
* were cached (house-term passes, renames), the source edit orphans the
|
|
375
|
+
* entries but does not remove them — and if the old source string ever
|
|
376
|
+
* reappears, the cache re-serves the banned translation. Same rationale
|
|
377
|
+
* as evictTM's quality-gate eviction, applied in bulk by content.
|
|
378
|
+
* - stale: entries whose `ts` timestamp is older than `olderThanDays`.
|
|
379
|
+
*
|
|
380
|
+
* An entry matching several categories is counted once, under the first in
|
|
381
|
+
* the order above. `_meta` is never touched. Callers decide persistence: for
|
|
382
|
+
* a dry report just discard the mutated object; to actually prune, follow
|
|
383
|
+
* with saveTM (the object is marked dirty here whenever anything was removed).
|
|
384
|
+
*
|
|
385
|
+
* @param {object} tm - TM object (from loadTM; mutated in place)
|
|
386
|
+
* @param {object} [options]
|
|
387
|
+
* @param {boolean} [options.legacy=true] - Remove entries missing l/m metadata
|
|
388
|
+
* @param {RegExp|null} [options.matching=null] - Remove entries whose translation text matches
|
|
389
|
+
* @param {number|null} [options.olderThanDays=null] - Remove entries older than N days (by ts)
|
|
390
|
+
* @returns {{ removed: number, kept: number, byReason: { legacy: number, matching: number, stale: number } }}
|
|
391
|
+
*/
|
|
392
|
+
function pruneTM(tm, { legacy = true, matching = null, olderThanDays = null } = {}) {
|
|
393
|
+
const byReason = { legacy: 0, matching: 0, stale: 0 };
|
|
394
|
+
let kept = 0;
|
|
395
|
+
|
|
396
|
+
const cutoff = olderThanDays !== null
|
|
397
|
+
? new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000).toISOString()
|
|
398
|
+
: null;
|
|
399
|
+
|
|
400
|
+
for (const [key, entry] of Object.entries(tm)) {
|
|
401
|
+
if (key === '_meta') continue;
|
|
402
|
+
|
|
403
|
+
let reason = null;
|
|
404
|
+
if (legacy && (!entry.l || !entry.m)) {
|
|
405
|
+
reason = 'legacy';
|
|
406
|
+
} else if (matching !== null && typeof entry.t === 'string' && _testFresh(matching, entry.t)) {
|
|
407
|
+
reason = 'matching';
|
|
408
|
+
} else if (cutoff !== null && typeof entry.ts === 'string' && entry.ts < cutoff) {
|
|
409
|
+
reason = 'stale';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (reason) {
|
|
413
|
+
delete tm[key];
|
|
414
|
+
byReason[reason]++;
|
|
415
|
+
} else {
|
|
416
|
+
kept++;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const removed = byReason.legacy + byReason.matching + byReason.stale;
|
|
421
|
+
if (removed > 0) _markDirty(tm);
|
|
422
|
+
return { removed, kept, byReason };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* RegExp.test without sticky/global lastIndex carry-over between entries.
|
|
427
|
+
* A caller-supplied /g or /y regex would otherwise skip matches on
|
|
428
|
+
* subsequent entries and make pruning nondeterministic.
|
|
429
|
+
*/
|
|
430
|
+
function _testFresh(re, text) {
|
|
431
|
+
if (re.global || re.sticky) re.lastIndex = 0;
|
|
432
|
+
return re.test(text);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Partition a set of keys into TM hits and TM misses.
|
|
437
|
+
*
|
|
438
|
+
* This is the main entry point for the sync pipeline:
|
|
439
|
+
* 1. Load source values for the keys that need translation
|
|
440
|
+
* 2. Check each against TM
|
|
441
|
+
* 3. Return hits (reuse immediately) and misses (send to API)
|
|
442
|
+
*
|
|
443
|
+
* @param {object} tm - TM object
|
|
444
|
+
* @param {object} sourceFlat - Full source key→value map
|
|
445
|
+
* @param {string[]} keysToTranslate - Keys that need translation
|
|
446
|
+
* @param {string} locale - Target locale code
|
|
447
|
+
* @param {string} method - Translation method name
|
|
448
|
+
* @returns {{ hits: object, misses: string[] }} hits is key→cached translation, misses is keys to translate
|
|
449
|
+
*/
|
|
450
|
+
function partitionByTM(tm, sourceFlat, keysToTranslate, locale, method) {
|
|
451
|
+
const hits = {};
|
|
452
|
+
const misses = [];
|
|
453
|
+
|
|
454
|
+
for (const key of keysToTranslate) {
|
|
455
|
+
const sourceValue = sourceFlat[key];
|
|
456
|
+
if (typeof sourceValue !== 'string') {
|
|
457
|
+
misses.push(key);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const cached = lookupTM(tm, sourceValue, locale, method);
|
|
462
|
+
if (cached !== null) {
|
|
463
|
+
hits[key] = cached;
|
|
464
|
+
} else {
|
|
465
|
+
misses.push(key);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return { hits, misses };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Get the number of cached entries in a TM (excluding metadata).
|
|
474
|
+
*
|
|
475
|
+
* @param {object} tm - TM object
|
|
476
|
+
* @returns {number} Entry count
|
|
477
|
+
*/
|
|
478
|
+
function tmSize(tm) {
|
|
479
|
+
return Object.keys(tm).filter(k => k !== '_meta').length;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// -----------------------------------------------------------------
|
|
483
|
+
// Internal helpers
|
|
484
|
+
// -----------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
function _createEmptyTM() {
|
|
487
|
+
return {
|
|
488
|
+
_meta: {
|
|
489
|
+
version: TM_VERSION,
|
|
490
|
+
created: new Date().toISOString(),
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// -----------------------------------------------------------------
|
|
496
|
+
// Exports
|
|
497
|
+
// -----------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
export {
|
|
500
|
+
loadTM,
|
|
501
|
+
saveTM,
|
|
502
|
+
lookupTM,
|
|
503
|
+
lookupTMValidated,
|
|
504
|
+
storeTM,
|
|
505
|
+
evictTM,
|
|
506
|
+
isTMDirty,
|
|
507
|
+
pruneTM,
|
|
508
|
+
partitionByTM,
|
|
509
|
+
tmSize,
|
|
510
|
+
cacheKey,
|
|
511
|
+
tmMethodKey,
|
|
512
|
+
TM_VERSION,
|
|
513
|
+
TM_DIR,
|
|
514
|
+
TM_FILENAME,
|
|
515
|
+
};
|