champollion 0.3.3 → 0.4.0
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/README.md +52 -37
- package/bin/cli.js +53 -5
- package/index.js +63 -2
- package/lib/api-key.js +17 -4
- package/lib/autofix.js +83 -36
- package/lib/bridge/method_bridge.py +15 -3
- package/lib/cards/reader.js +51 -3
- package/lib/cards/remote.js +15 -0
- package/lib/cards/search-names.js +178 -0
- package/lib/command-help.js +289 -88
- package/lib/commands/audit.js +10 -3
- package/lib/commands/card.js +583 -226
- package/lib/commands/doctor.js +54 -18
- package/lib/commands/help.js +37 -32
- package/lib/commands/init.js +1689 -87
- package/lib/commands/integrity.js +127 -40
- package/lib/commands/leaderboard.js +187 -67
- package/lib/commands/models.js +9 -2
- package/lib/commands/provenance.js +7 -2
- package/lib/commands/recommend.js +43 -14
- package/lib/commands/register-corpus.js +649 -130
- package/lib/commands/seal-corpus.js +1 -1
- package/lib/commands/status.js +564 -27
- package/lib/commands/submit.js +17 -12
- package/lib/commands/sync.js +31 -7
- package/lib/commands/tm.js +16 -10
- package/lib/commands/verify.js +27 -3
- package/lib/commands/wrap.js +63 -5
- package/lib/commands/xliff.js +135 -64
- package/lib/commercial-eligibility.js +1 -1
- package/lib/config.js +196 -14
- package/lib/content-estimate.js +96 -0
- package/lib/content-refusals.js +270 -0
- package/lib/content-review.js +372 -0
- package/lib/content-sync.js +1127 -344
- package/lib/content.js +94 -7
- package/lib/corpus-registration.mjs +197 -38
- package/lib/cost-label.js +29 -0
- package/lib/cost-report.js +726 -78
- package/lib/diff.js +38 -4
- package/lib/docusaurus-sync.js +965 -253
- package/lib/edit-distance.js +31 -0
- package/lib/fallback.js +964 -0
- package/lib/file-scope.js +106 -0
- package/lib/flatten.js +80 -3
- package/lib/flutter-locales.js +124 -0
- package/lib/format.js +266 -12
- package/lib/hash.js +146 -21
- package/lib/icu-structure.js +929 -0
- package/lib/integrity.js +223 -75
- package/lib/language-pair.js +157 -0
- package/lib/lint.js +78 -16
- package/lib/local-only-marks.js +106 -0
- package/lib/locale-layout.js +1103 -0
- package/lib/locale-state.js +571 -0
- package/lib/methods/anthropic.js +5 -0
- package/lib/methods/apertium.js +6 -3
- package/lib/methods/api.js +138 -25
- package/lib/methods/base.js +17 -0
- package/lib/methods/coaching-data.js +153 -0
- package/lib/methods/content-separator.js +43 -0
- package/lib/methods/deepl.js +1 -1
- package/lib/methods/direct-llm.js +252 -103
- package/lib/methods/external.js +146 -63
- package/lib/methods/gemini.js +1 -0
- package/lib/methods/google-translate.js +1 -0
- package/lib/methods/http-utils.js +41 -0
- package/lib/methods/libretranslate.js +7 -2
- package/lib/methods/llm-coached.js +68 -128
- package/lib/methods/llm.js +80 -31
- package/lib/methods/local.js +93 -10
- package/lib/methods/microsoft-translator.js +1 -2
- package/lib/methods/openai.js +4 -2
- package/lib/methods/openrouter-client.js +20 -19
- package/lib/methods/openrouter-pricing.js +150 -13
- package/lib/methods/prompt-methods.js +20 -0
- package/lib/methods/provider-pricing.js +42 -1
- package/lib/methods/request-capture.js +104 -0
- package/lib/methods/tilde.js +1 -1
- package/lib/methods/translated.js +1 -2
- package/lib/missing-key.js +93 -0
- package/lib/models.js +11 -0
- package/lib/name-rules.js +32 -0
- package/lib/named-keys.js +172 -0
- package/lib/no-translate.js +4 -3
- package/lib/output.js +160 -19
- package/lib/pairs.js +586 -30
- package/lib/placeholders.js +394 -0
- package/lib/plugins.js +8 -0
- package/lib/plural-gap-redo.js +109 -0
- package/lib/plurals.js +323 -0
- package/lib/po.js +1187 -0
- package/lib/public-catalogue.js +74 -0
- package/lib/recommend.js +527 -32
- package/lib/redo.js +95 -0
- package/lib/refusal-category.js +44 -0
- package/lib/registers.js +255 -11
- package/lib/repair-script.js +20 -13
- package/lib/scripts.js +193 -106
- package/lib/seal.mjs +6 -5
- package/lib/sealed-qualifier.mjs +2 -2
- package/lib/segment.js +2 -1
- package/lib/seo.js +19 -9
- package/lib/serve.js +43 -6
- package/lib/shared-output-seed.js +164 -0
- package/lib/source-contexts.js +39 -0
- package/lib/submit.mjs +57 -5
- package/lib/sync.js +2923 -474
- package/lib/terminology.js +13 -4
- package/lib/tm-evict.js +179 -0
- package/lib/tm-seed.js +5 -2
- package/lib/tm.js +818 -36
- package/lib/translate-pair.js +639 -34
- package/lib/translate.js +78 -5
- package/lib/types.js +22 -3
- package/lib/validate.js +880 -17
- package/lib/verify.js +1296 -104
- package/lib/watch.js +32 -13
- package/lib/xliff.js +44 -3
- package/package.json +3 -2
- package/shared/CORPORA-CARDS.md +2 -0
- package/shared/DATA-SOVEREIGNTY.md +19 -20
- package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
- package/shared/cards-fallback.json +1 -1
- package/shared/catalogue/card-config.json +1 -1
- package/shared/curated-orthography-conventions.json +26 -8
- package/shared/docent/faq.en.json +14 -16
- package/shared/docent/system-prompt.md +17 -19
- package/shared/explainers/tc-features.json +15 -15
- package/shared/gettext-plural-forms.json +45 -0
- package/shared/human-services.json +1 -1
- package/shared/method-registry.json +2 -0
- package/shared/metric-registry.json +96 -18
- package/shared/schemas/champollion-plugin.schema.json +4 -0
- package/shared/schemas/corpora-card.schema.json +20 -10
- package/shared/schemas/human-services.schema.json +2 -2
- package/shared/schemas/language-card.schema.json +1 -1
- package/shared/schemas/method-card.schema.json +1 -1
- package/shared/schemas/method-index-record.schema.json +67 -0
- package/shared/schemas/method-registry.schema.json +4 -0
- package/shared/schemas/metric-registry.schema.json +55 -1
- package/shared/docent/corpus.json +0 -11333
package/lib/verify.js
CHANGED
|
@@ -15,6 +15,16 @@
|
|
|
15
15
|
* PHILOSOPHY: This is a trust-but-verify gate. Sync does the work,
|
|
16
16
|
* verify confirms the work is correct. Every issue is logged LOUD.
|
|
17
17
|
*
|
|
18
|
+
* DAMAGE LEAVES THE CACHE. A value verify finds damaged — ICU structure,
|
|
19
|
+
* placeholders, hollowed — that the Translation Memory proves the pipeline
|
|
20
|
+
* wrote is evicted from the TM (lib/tm-evict.js), so the next sync (or the
|
|
21
|
+
* `--force-keys` the report names) re-translates it instead of re-serving
|
|
22
|
+
* the damage for free. Hand-written values have no equal TM entry and are
|
|
23
|
+
* never touched; the files themselves are never modified here.
|
|
24
|
+
*
|
|
25
|
+
* SCOPE. `options.locales` limits verification to the locales a run touched
|
|
26
|
+
* — `sync --pair en:fr` verifies French only, not every locale on disk.
|
|
27
|
+
*
|
|
18
28
|
* OUTPUT: The decorative section headers and per-check [OK] lines go through
|
|
19
29
|
* output.raw(), which is suppressed in --json mode. The real findings go
|
|
20
30
|
* through output.ok/warn/error, which json-encode themselves — so
|
|
@@ -24,16 +34,44 @@
|
|
|
24
34
|
|
|
25
35
|
import fs from 'node:fs';
|
|
26
36
|
import path from 'node:path';
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
import { extractDocusaurusMessages } from './format.js';
|
|
38
|
+
import {
|
|
39
|
+
discoverLocaleLayout, loadSourceUnits, expectedForTarget, readLocaleFlat, walkFiles, NS_SEPARATOR, lockKey,
|
|
40
|
+
} from './locale-layout.js';
|
|
41
|
+
import { readLock } from './hash.js';
|
|
42
|
+
import { LockState, localeHealth, decodeForm, decodeWritten, valueHash } from './locale-state.js';
|
|
43
|
+
import { getMethod } from './translate.js';
|
|
44
|
+
import { originKey } from './plurals.js';
|
|
45
|
+
import { auditLocalePair, auditARBDocument } from './integrity.js';
|
|
46
|
+
import { placeholderChanges, placeholderSentenceBreaks } from './placeholders.js';
|
|
47
|
+
import { NON_LATIN_LOCALES, isLatinOnly, hasForeignFullwidthLatin, isProtectedTermValue, SharedOutputIndex, sharedOutputItems, droppedTerminalMarks, describeDroppedMarks, contentGateFault } from './validate.js';
|
|
48
|
+
import { discoverContentFiles, getTargetContentPath, parseContentFile, DEFAULT_TRANSLATABLE_FIELDS } from './content.js';
|
|
49
|
+
import { translatableBlockSources } from './content-estimate.js';
|
|
31
50
|
import { compileNoTranslate } from './no-translate.js';
|
|
32
|
-
import { resolvePairs } from './pairs.js';
|
|
33
|
-
import { loadTM,
|
|
51
|
+
import { resolvePairs, filterPairGraph } from './pairs.js';
|
|
52
|
+
import { loadTM, saveTM, isTMDirty, adoptLegacyCoachingKeys } from './tm.js';
|
|
53
|
+
import { tmKeysForPair, tmHoldsValue } from './fallback.js';
|
|
54
|
+
import { tmTextFor, tmProofTextsFor, createTMEvictor } from './tm-evict.js';
|
|
34
55
|
import { parsePairKey } from './pairs.js';
|
|
56
|
+
import { pluralGaps, pluralCategoryUse, describeCategories, parseMessage, hasBranching, CLDR_CATEGORIES } from './icu-structure.js';
|
|
57
|
+
import { pluralCategoriesFor, pluralExtraKeys } from './plurals.js';
|
|
58
|
+
import { poPluralFindings, poPluralSlots } from './po.js';
|
|
35
59
|
import { output } from './output.js';
|
|
36
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The target locales a `--pair` value names (e.g. "en:fr,en:de"), resolved
|
|
63
|
+
* against the project's pair graph — fails loud on an unknown pair, exactly
|
|
64
|
+
* like `sync --pair`.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} config - Resolved config
|
|
67
|
+
* @param {string} pairFlag - The raw --pair value
|
|
68
|
+
* @param {{ cwd?: string }} [options] - The project directory (coaching files)
|
|
69
|
+
* @returns {string[]} Target locale codes
|
|
70
|
+
*/
|
|
71
|
+
function localesForPairFlag(config, pairFlag, { cwd = process.cwd() } = {}) {
|
|
72
|
+
return [...filterPairGraph(pairFlag, resolvePairs(config, { cwd })).values()].map(p => p.target);
|
|
73
|
+
}
|
|
74
|
+
|
|
37
75
|
/**
|
|
38
76
|
* Target locales the CONFIG promises, independent of what's on disk.
|
|
39
77
|
*
|
|
@@ -59,6 +97,56 @@ function configuredTargetLocales(config) {
|
|
|
59
97
|
return targets;
|
|
60
98
|
}
|
|
61
99
|
|
|
100
|
+
/**
|
|
101
|
+
* How verify names a placeholder finding, per syntax, in report order. The
|
|
102
|
+
* syntax comes from the check that found it: checkICUStructure tags its
|
|
103
|
+
* findings 'icu' or 'printf' (lib/icu-structure.js), and lib/placeholders.js
|
|
104
|
+
* placeholderChanges names a token's syntax by its form ("{{name}}" i18next,
|
|
105
|
+
* "<b>" a tag, "{name}" single-brace outside an ICU message).
|
|
106
|
+
*/
|
|
107
|
+
const PLACEHOLDER_SYNTAX_LABELS = {
|
|
108
|
+
icu: 'ICU structure error(s)',
|
|
109
|
+
printf: 'printf/python-format placeholder mismatch(es)',
|
|
110
|
+
i18next: 'i18next {{…}} placeholder mismatch(es)',
|
|
111
|
+
brace: '{…} placeholder mismatch(es)',
|
|
112
|
+
markup: 'tag placeholder mismatch(es)',
|
|
113
|
+
'sentence-break': 'sentence break(s) inserted beside a placeholder',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* One locale file's placeholder and ICU-structure findings, each named by
|
|
118
|
+
* the syntax involved — one entry per (key, syntax), with what happened:
|
|
119
|
+
* { key: 'Hi %(name)s', syntax: 'printf', issues: ['printf placeholder %(name)s is missing'] }
|
|
120
|
+
* { key: 'greeting', syntax: 'i18next', issues: ['placeholder {{name}} was changed to {{nom}}'] }
|
|
121
|
+
*
|
|
122
|
+
* The token findings follow lib/placeholders.js placeholderChanges — the
|
|
123
|
+
* rule the sync quality gate refuses by, so sync never writes what verify
|
|
124
|
+
* flags (Round 12).
|
|
125
|
+
*
|
|
126
|
+
* @param {object} audit - auditLocalePair() result
|
|
127
|
+
* @returns {Array<{ key: string, syntax: 'icu'|'printf'|'i18next'|'brace'|'markup', issues: string[] }>}
|
|
128
|
+
* (auditTranslations adds 'sentence-break' findings: lib/placeholders.js placeholderSentenceBreaks)
|
|
129
|
+
*/
|
|
130
|
+
function placeholderFindings(audit) {
|
|
131
|
+
const byKey = new Map();
|
|
132
|
+
const add = (syntax, key, issue) => {
|
|
133
|
+
const id = `${syntax}\u0000${key}`;
|
|
134
|
+
if (!byKey.has(id)) byKey.set(id, { key, syntax, issues: [] });
|
|
135
|
+
byKey.get(id).issues.push(issue);
|
|
136
|
+
};
|
|
137
|
+
for (const i of audit.icuIssues || []) {
|
|
138
|
+
i.issues.forEach((issue, n) => add(i.syntaxes?.[n] || 'icu', i.key, issue));
|
|
139
|
+
}
|
|
140
|
+
// The same rule the sync gate refuses by (lib/placeholders.js).
|
|
141
|
+
const markupKeys = new Set((audit.markupIssues || []).map(m => m.key));
|
|
142
|
+
for (const p of audit.placeholderIssues || []) {
|
|
143
|
+
for (const c of placeholderChanges(p.sourceVal, p.targetVal, { markupReported: markupKeys.has(p.key) })) {
|
|
144
|
+
add(c.syntax, p.key, c.issue);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return [...byKey.values()];
|
|
148
|
+
}
|
|
149
|
+
|
|
62
150
|
/**
|
|
63
151
|
* Run the per-locale correctness checks against a source/target flat map pair.
|
|
64
152
|
*
|
|
@@ -74,9 +162,25 @@ function configuredTargetLocales(config) {
|
|
|
74
162
|
* Compiled no-translate matcher. Exempt keys are excluded from the
|
|
75
163
|
* source-echo warning (identical IS correct for them) and checked for
|
|
76
164
|
* drift instead, which is an error.
|
|
77
|
-
* @
|
|
165
|
+
* @param {Function} [isConfirmedEcho] - (key, sourceValue) => true when the TM
|
|
166
|
+
* settled an identical value as correct
|
|
167
|
+
* @param {{ fixFor?: (keys: string[]) => string }} [options] - fixFor: the
|
|
168
|
+
* command that re-translates these keys (the caller knows the pair and the
|
|
169
|
+
* namespace); default `champollion sync --redo keys:<keys>`
|
|
170
|
+
* @returns {{ errors: string[], warnings: string[], sourceKeyCount: number, targetKeyCount: number,
|
|
171
|
+
* damaged: Array<{ key: string, value: string, sourceValue: string }>,
|
|
172
|
+
* placeholders: Array<{ key: string, syntax: string, issues: string[] }> }} `damaged`: values
|
|
173
|
+
* the quality gate refuses today (ICU structure, placeholders, hollowed) — the
|
|
174
|
+
* caller evicts the TM entries that produced them. `placeholders`: the
|
|
175
|
+
* placeholder/ICU findings by syntax (placeholderFindings), for --json
|
|
78
176
|
*/
|
|
79
|
-
function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate = null, isConfirmedEcho = null
|
|
177
|
+
function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate = null, isConfirmedEcho = null,
|
|
178
|
+
{ fixFor = (keys, opts) => redoCommand(keys, opts), pluralSlots = null, fillCommand = 'champollion sync' } = {}) {
|
|
179
|
+
// A damaged value already on disk reads as settled to sync: a plain sync
|
|
180
|
+
// keeps it. Every damage finding therefore names the command that repairs
|
|
181
|
+
// it (synthetic Django/i18next personas, 2026-10: verify reported a
|
|
182
|
+
// placeholder mismatch and stopped there).
|
|
183
|
+
const fix = (items) => ` — fix: \`${fixFor([...new Set(items.map(i => i.key))])}\``;
|
|
80
184
|
const errors = [];
|
|
81
185
|
const warnings = [];
|
|
82
186
|
const sourceKeyCount = Object.keys(sourceFlat).length;
|
|
@@ -84,10 +188,18 @@ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate =
|
|
|
84
188
|
|
|
85
189
|
// 1. Key parity — are all source keys present in target?
|
|
86
190
|
const missingKeys = Object.keys(sourceFlat).filter(k => !(k in targetFlat));
|
|
191
|
+
// The repair is named, as for every other finding (Round 9, i18next
|
|
192
|
+
// persona: a missing plural form `count_many` said only "missing key(s)").
|
|
193
|
+
// A few keys: the redo that asks for exactly them — it works even for a key
|
|
194
|
+
// refused before and held back. Many: a sync of the pair fills every
|
|
195
|
+
// missing key.
|
|
87
196
|
if (missingKeys.length > 0) {
|
|
88
197
|
const preview = missingKeys.slice(0, 5).join(', ');
|
|
89
198
|
const suffix = missingKeys.length > 5 ? '...' : '';
|
|
90
|
-
|
|
199
|
+
const repair = missingKeys.length <= 5
|
|
200
|
+
? `\`${fixFor(missingKeys)}\``
|
|
201
|
+
: `\`${fillCommand}\` (a sync fills every missing key)`;
|
|
202
|
+
errors.push(`${missingKeys.length} missing key(s): ${preview}${suffix} — fix: ${repair}`);
|
|
91
203
|
}
|
|
92
204
|
|
|
93
205
|
// 2. [EN] fallback marker scan — any legacy [EN]-prefixed values?
|
|
@@ -111,8 +223,23 @@ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate =
|
|
|
111
223
|
errors.push(`${emptyKeys.length} empty translation(s): ${preview}${suffix}`);
|
|
112
224
|
}
|
|
113
225
|
|
|
114
|
-
// 4. Script compliance — non-Latin locales
|
|
226
|
+
// 4. Script compliance — non-Latin locales must not hold Latin-only text.
|
|
227
|
+
// Letters are classified by Unicode SCRIPT, not by byte: an ASCII test let
|
|
228
|
+
// fullwidth Latin ("Book") and accented Latin ("Bóók") through in a
|
|
229
|
+
// Russian catalog (Round 4, Django persona). And fullwidth Latin letters
|
|
230
|
+
// are wrong in any value outside CJK typography.
|
|
115
231
|
const isNonLatin = NON_LATIN_LOCALES.has(locale) || NON_LATIN_LOCALES.has(locale.split('-')[0]);
|
|
232
|
+
const fullwidthKeys = Object.keys(targetFlat).filter(k => {
|
|
233
|
+
const val = targetFlat[k];
|
|
234
|
+
if (typeof val !== 'string' || val === sourceFlat[k]) return false;
|
|
235
|
+
if (noTranslate && noTranslate.matches(k, sourceFlat[k])) return false;
|
|
236
|
+
if (isProtectedTermValue(val, config.protectedTerms)) return false;
|
|
237
|
+
return hasForeignFullwidthLatin(val, locale);
|
|
238
|
+
});
|
|
239
|
+
if (fullwidthKeys.length > 0) {
|
|
240
|
+
errors.push(`${fullwidthKeys.length} wrong script (fullwidth Latin letters — English in disguise): `
|
|
241
|
+
+ `${fullwidthKeys.slice(0, 3).join(', ')}${fullwidthKeys.length > 3 ? '...' : ''}${fix(fullwidthKeys.map(key => ({ key })))}`);
|
|
242
|
+
}
|
|
116
243
|
if (isNonLatin) {
|
|
117
244
|
const asciiOnlyKeys = Object.keys(targetFlat).filter(k => {
|
|
118
245
|
const val = targetFlat[k];
|
|
@@ -123,22 +250,55 @@ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate =
|
|
|
123
250
|
// Arabic locale is CORRECT, and flagging it as wrong-script would make
|
|
124
251
|
// a clean sync fail its own post-sync verification.
|
|
125
252
|
if (noTranslate && noTranslate.matches(k, sourceFlat[k])) return false;
|
|
126
|
-
|
|
253
|
+
// Names kept as written: declared ones (config.protectedTerms), and
|
|
254
|
+
// ones the quality gate settled as names (a TM-stamped echo — the
|
|
255
|
+
// model kept it in Latin script after being asked to translate it).
|
|
256
|
+
// The gate already accepted these; verify must agree, or a clean sync
|
|
257
|
+
// fails its own verification (curtisforbes.com had to run every sync
|
|
258
|
+
// with --no-verify for this).
|
|
259
|
+
if (isProtectedTermValue(val, config.protectedTerms)) return false;
|
|
260
|
+
if (isConfirmedEcho && val === sourceFlat[k] && isConfirmedEcho(k, sourceFlat[k])) return false;
|
|
261
|
+
if (fullwidthKeys.includes(k)) return false; // reported above
|
|
262
|
+
return isLatinOnly(val, locale);
|
|
127
263
|
});
|
|
128
264
|
if (asciiOnlyKeys.length > 0) {
|
|
129
265
|
const preview = asciiOnlyKeys.slice(0, 3).join(', ');
|
|
130
266
|
const suffix = asciiOnlyKeys.length > 3 ? '...' : '';
|
|
131
|
-
errors.push(`${asciiOnlyKeys.length} wrong script (
|
|
267
|
+
errors.push(`${asciiOnlyKeys.length} wrong script (Latin letters only, expected ${locale}'s script): ${preview}${suffix}`);
|
|
132
268
|
}
|
|
133
269
|
}
|
|
134
270
|
|
|
135
|
-
// 5.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
271
|
+
// 5. Placeholders and ICU structure, named by the syntax involved — run the
|
|
272
|
+
// integrity audit for placeholder + encoding checks. ICU structure damage
|
|
273
|
+
// (a translated variable, keyword or selector, a lost #) and lost printf
|
|
274
|
+
// conversions come from the gate's own check (lib/icu-structure.js); the
|
|
275
|
+
// quality gate refuses these now, so this reports the ones written before
|
|
276
|
+
// it did (they read as settled to sync). One line per syntax: a gettext
|
|
277
|
+
// catalog's lost %(name)s used to be called an "ICU structure error" in a
|
|
278
|
+
// catalog with no ICU in it (Round 12, Django persona), and an i18next
|
|
279
|
+
// {{name}} loss a bare "placeholder mismatch".
|
|
280
|
+
const audit = auditLocalePair(sourceFlat, targetFlat, locale, { noTranslate, isConfirmedEcho, pluralSlots });
|
|
281
|
+
const placeholders = placeholderFindings(audit);
|
|
282
|
+
// A sentence break inserted right beside a placeholder where the source
|
|
283
|
+
// has none ("… sina. {time}." for "Take this medicine at {time}.") — the
|
|
284
|
+
// rule the sync gate refuses by (lib/placeholders.js), so a value written
|
|
285
|
+
// before the gate checked it is flagged here (Round 14, hospital persona).
|
|
286
|
+
// Keys the gate never sees are left out: no-translate keys and declared
|
|
287
|
+
// names (protectedTerms).
|
|
288
|
+
for (const [k, src] of Object.entries(sourceFlat)) {
|
|
289
|
+
const tgt = targetFlat[k];
|
|
290
|
+
if (typeof src !== 'string' || typeof tgt !== 'string') continue;
|
|
291
|
+
if (noTranslate && noTranslate.matches(k, src)) continue;
|
|
292
|
+
if (isProtectedTermValue(tgt, config.protectedTerms)) continue;
|
|
293
|
+
const breaks = placeholderSentenceBreaks(src, tgt);
|
|
294
|
+
if (breaks.length > 0) placeholders.push({ key: k, syntax: 'sentence-break', issues: breaks.map(b => b.issue) });
|
|
295
|
+
}
|
|
296
|
+
for (const syntax of Object.keys(PLACEHOLDER_SYNTAX_LABELS)) {
|
|
297
|
+
const found = placeholders.filter(p => p.syntax === syntax);
|
|
298
|
+
if (found.length === 0) continue;
|
|
299
|
+
const preview = found.slice(0, 3).map(p => `${p.key} (${p.issues[0]})`).join(', ');
|
|
300
|
+
const suffix = found.length > 3 ? '...' : '';
|
|
301
|
+
errors.push(`${found.length} ${PLACEHOLDER_SYNTAX_LABELS[syntax]}: ${preview}${suffix}${fix(found)}`);
|
|
142
302
|
}
|
|
143
303
|
|
|
144
304
|
// 6. Encoding issues (warning)
|
|
@@ -149,10 +309,12 @@ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate =
|
|
|
149
309
|
}
|
|
150
310
|
|
|
151
311
|
// 7. Source echo — untranslated copies (warning, not error — some are legitimate)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
312
|
+
// A declared name (config.protectedTerms) is supposed to equal the source.
|
|
313
|
+
const copies = audit.copies.filter(k => !isProtectedTermValue(targetFlat[k], config.protectedTerms));
|
|
314
|
+
if (copies.length > 0) {
|
|
315
|
+
const preview = copies.slice(0, 5).join(', ');
|
|
316
|
+
const suffix = copies.length > 5 ? '...' : '';
|
|
317
|
+
warnings.push(`${copies.length} source echo(es): ${preview}${suffix}`);
|
|
156
318
|
}
|
|
157
319
|
|
|
158
320
|
// 8. Hollowed values — the source with its letters deleted, written by a
|
|
@@ -164,7 +326,7 @@ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate =
|
|
|
164
326
|
const suffix = audit.hollowedValues.length > 3 ? '...' : '';
|
|
165
327
|
errors.push(
|
|
166
328
|
`${audit.hollowedValues.length} hollowed value(s) (source with letters deleted): ${preview}${suffix}`
|
|
167
|
-
+
|
|
329
|
+
+ fix(audit.hollowedValues),
|
|
168
330
|
);
|
|
169
331
|
}
|
|
170
332
|
|
|
@@ -181,7 +343,143 @@ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate =
|
|
|
181
343
|
);
|
|
182
344
|
}
|
|
183
345
|
|
|
184
|
-
|
|
346
|
+
// 10. (ICU structure damage is reported with the placeholders, by syntax — 5.)
|
|
347
|
+
|
|
348
|
+
// 10b. Markup damage — a tag opened, closed or nested differently from the
|
|
349
|
+
// source (a lost `</strong>` used to pass: tag names were compared as a set).
|
|
350
|
+
if (audit.markupIssues && audit.markupIssues.length > 0) {
|
|
351
|
+
const preview = audit.markupIssues.slice(0, 3).map(i => `${i.key} (${i.issues[0]})`).join(', ');
|
|
352
|
+
const suffix = audit.markupIssues.length > 3 ? '...' : '';
|
|
353
|
+
errors.push(`${audit.markupIssues.length} markup error(s): ${preview}${suffix}${fix(audit.markupIssues)}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 11. Plural messages without a form the target language uses for
|
|
357
|
+
// ordinary counts (Russian few/many): the runtime shows the "other" form
|
|
358
|
+
// for 2, 3, 4 … Not damage — the message is valid ICU — but wrong
|
|
359
|
+
// grammar nobody asked for (Round 3, Django persona). A gettext catalog
|
|
360
|
+
// reads back with every msgstr[] filled; its repeated forms are found
|
|
361
|
+
// from the catalog's own marker (poPluralFindings, in verifyLocales).
|
|
362
|
+
// --fresh in the fix: the cache holds the same incomplete answer.
|
|
363
|
+
const gapKeys = [];
|
|
364
|
+
for (const [k, src] of Object.entries(sourceFlat)) {
|
|
365
|
+
const tgt = targetFlat[k];
|
|
366
|
+
if (typeof src !== 'string' || typeof tgt !== 'string' || !src.includes('{') || tgt === src) continue;
|
|
367
|
+
const gaps = pluralGaps(src, tgt, locale, pluralSlots).filter(g => g.everyday.length > 0);
|
|
368
|
+
if (gaps.length > 0) gapKeys.push({ key: k, missing: [...new Set(gaps.flatMap(g => g.everyday))] });
|
|
369
|
+
}
|
|
370
|
+
if (gapKeys.length > 0) {
|
|
371
|
+
const preview = gapKeys.slice(0, 3).map(g => `${g.key} (${g.missing.join(', ')})`).join(', ');
|
|
372
|
+
const suffix = gapKeys.length > 3 ? '...' : '';
|
|
373
|
+
warnings.push(
|
|
374
|
+
`${gapKeys.length} plural message(s) without a form ${locale} uses for ordinary counts: ${preview}${suffix}`
|
|
375
|
+
+ ` — the "other" form is shown for those counts; write the missing branches, or ask the model again — fix: \`${fixFor([...new Set(gapKeys.map(i => i.key))], { fresh: true })}\``,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 12. A question or exclamation that lost its closing "?" / "!" (Round 6,
|
|
380
|
+
// hospital persona: "Where does it hurt?" written as a statement). A
|
|
381
|
+
// warning: some languages mark a question with a particle instead.
|
|
382
|
+
const dropped = droppedTerminalMarks(Object.keys(targetFlat)
|
|
383
|
+
.filter(k => typeof sourceFlat[k] === 'string' && typeof targetFlat[k] === 'string')
|
|
384
|
+
.filter(k => !(noTranslate && noTranslate.matches(k, sourceFlat[k])) && !isProtectedTermValue(targetFlat[k], config.protectedTerms))
|
|
385
|
+
.map(k => [k, sourceFlat[k], targetFlat[k]]));
|
|
386
|
+
if (dropped.length > 0) warnings.push(describeDroppedMarks(dropped, fixFor(dropped.map(f => f.key), { fresh: true })));
|
|
387
|
+
|
|
388
|
+
// Only what was reported: a token difference placeholderFindings set aside
|
|
389
|
+
// (a one-word ICU plural branch "{Brak}" read as a placeholder) is not
|
|
390
|
+
// damage — evicting its cache entry would throw away a good translation.
|
|
391
|
+
const reportedPlaceholderKeys = new Set(placeholders.filter(p => p.syntax !== 'sentence-break').map(p => p.key));
|
|
392
|
+
const damaged = [
|
|
393
|
+
...audit.icuIssues.map(i => ({ key: i.key, value: i.actual })),
|
|
394
|
+
...(audit.markupIssues || []).map(i => ({ key: i.key, value: i.actual })),
|
|
395
|
+
...audit.placeholderIssues.filter(i => reportedPlaceholderKeys.has(i.key)).map(i => ({ key: i.key, value: i.targetVal })),
|
|
396
|
+
...audit.hollowedValues.map(h => ({ key: h.key, value: h.actual })),
|
|
397
|
+
// The gate refuses these now: the cache entry that produced one is evicted like the rest.
|
|
398
|
+
...placeholders.filter(p => p.syntax === 'sentence-break').map(p => ({ key: p.key, value: targetFlat[p.key] })),
|
|
399
|
+
].map(d => ({ ...d, sourceValue: sourceFlat[d.key] }));
|
|
400
|
+
|
|
401
|
+
return { errors, warnings, sourceKeyCount, targetKeyCount, damaged, placeholders };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Make keys printable: a gettext key with a context is `msgctxt\u0004msgid`,
|
|
406
|
+
* and U+0004 is invisible on a terminal — print it as "␄" (U+2404), which
|
|
407
|
+
* `--force-keys` accepts back (lib/locale-layout.js keysForNamespace).
|
|
408
|
+
*/
|
|
409
|
+
function showKeys(text) {
|
|
410
|
+
return String(text).replace(/\u0004/g, '\u2404');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** One shell word, quoted only when it has to be (a msgid has spaces, `\,`…). */
|
|
414
|
+
function shellWord(word) {
|
|
415
|
+
return /^[A-Za-z0-9_.:\/@%+=,-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The command that re-translates exactly these keys — and nothing a human
|
|
420
|
+
* wrote elsewhere: `--redo keys:` (lib/redo.js), scoped to the pair.
|
|
421
|
+
* - a namespaced layout names `<ns>::<key>` (one file's key; a bare key
|
|
422
|
+
* would re-queue it in every file that has it)
|
|
423
|
+
* - a comma inside a key is written `\,` (gettext msgids are sentences)
|
|
424
|
+
* - a gettext context separator is written ␄ — the way reports and the
|
|
425
|
+
* docs show it — and, when there is one, a shell comment says it can be
|
|
426
|
+
* typed as `\x04` (--redo accepts both; Round 7, Django persona: the
|
|
427
|
+
* docs said ␄, the suggested command \x04, and neither said the other
|
|
428
|
+
* works). The comment is part of the line, so the whole line still
|
|
429
|
+
* pastes into a shell.
|
|
430
|
+
*
|
|
431
|
+
* @param {string[]} keys
|
|
432
|
+
* @param {{ pair?: string|null, ns?: string }} [scope]
|
|
433
|
+
* @returns {string}
|
|
434
|
+
*/
|
|
435
|
+
function redoCommand(keys, { pair = null, ns = '', fresh = false } = {}) {
|
|
436
|
+
// A gettext context separator (U+0004) is written ␄, as key lists and the
|
|
437
|
+
// docs show it; the comment names the typeable `\x04`. --redo takes both.
|
|
438
|
+
const names = keys.map(k => String(ns ? `${ns}${NS_SEPARATOR}${k}` : k).replace(/\u0004|\\x04/g, '\u2404').replace(/,/g, '\\,'));
|
|
439
|
+
const typed = names.some(n => n.includes('\u2404')) ? ' # type ␄ as \\x04 if you cannot (both work)' : '';
|
|
440
|
+
return `champollion sync${pair ? ` --pair ${shellWord(pair)}` : ''} --redo ${shellWord(`keys:${names.join(',')}`)}${fresh ? ' --fresh' : ''}${typed}`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Persist evictions, and say once whether the repair commands need --fresh.
|
|
445
|
+
* They do not: every cached copy of a damaged value (under any model key)
|
|
446
|
+
* has just been evicted from this project's cache, so `--redo keys:` either
|
|
447
|
+
* re-translates the text or serves the cache's own, different translation
|
|
448
|
+
* of it — never the damaged value again.
|
|
449
|
+
*/
|
|
450
|
+
function finishEviction(cwd, tm, evicted, result, damagedCount = 0) {
|
|
451
|
+
if (evicted > 0 && tm && isTMDirty(tm)) {
|
|
452
|
+
saveTM(cwd, tm);
|
|
453
|
+
output.warn(
|
|
454
|
+
`[TM] Evicted ${evicted} cached translation(s) that produced damaged values. A plain `
|
|
455
|
+
+ '`champollion sync` keeps values already on disk: run the `--redo keys:` command shown with each '
|
|
456
|
+
+ 'finding — no `--fresh` needed, the cache can no longer serve the damaged text.');
|
|
457
|
+
} else if (damagedCount > 0) {
|
|
458
|
+
output.warn(
|
|
459
|
+
'[VERIFY] A plain `champollion sync` keeps values already on disk: run the `--redo keys:` command '
|
|
460
|
+
+ 'shown with each damage finding. No `--fresh` needed — none of the damaged values is in this '
|
|
461
|
+
+ 'project\'s translation cache.');
|
|
462
|
+
}
|
|
463
|
+
return evicted > 0 ? { ...result, tmEvicted: evicted } : result;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The result of a verify that could not check anything: one error line that
|
|
468
|
+
* names what it looked for and the setting that points there — and exit 1
|
|
469
|
+
* through the caller (an error count), never a quiet pass.
|
|
470
|
+
*
|
|
471
|
+
* @returns {Promise<{ errors: number, warnings: number, nothingChecked: true }>}
|
|
472
|
+
*/
|
|
473
|
+
async function nothingVerified(cwd, config, reason) {
|
|
474
|
+
let hint = '';
|
|
475
|
+
try {
|
|
476
|
+
// What init would configure from the files on disk (sync's own hint).
|
|
477
|
+
const { describeLocaleSetupHint } = await import('./commands/init.js');
|
|
478
|
+
const found = describeLocaleSetupHint(cwd, config.inputLocale);
|
|
479
|
+
if (found) hint = ` ${found}`;
|
|
480
|
+
} catch { /* the hint is a courtesy */ }
|
|
481
|
+
output.error(`[VERIFY] Nothing verified: ${reason}${hint}`, { command: 'verify', errors: 1, warnings: 0, nothingChecked: true });
|
|
482
|
+
return { errors: 1, warnings: 0, nothingChecked: true };
|
|
185
483
|
}
|
|
186
484
|
|
|
187
485
|
/**
|
|
@@ -191,17 +489,63 @@ function auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate =
|
|
|
191
489
|
* @param {number} totalWarnings
|
|
192
490
|
* @returns {{ errors: number, warnings: number }}
|
|
193
491
|
*/
|
|
194
|
-
function printSummary(totalErrors, totalWarnings) {
|
|
492
|
+
function printSummary(totalErrors, totalWarnings, { strict = false, incomplete = null, scope = null } = {}) {
|
|
493
|
+
// --json: the closing line keeps its level and message; it also carries
|
|
494
|
+
// the counts (the per-locale records came before it as `verify` events),
|
|
495
|
+
// and which locales were checked when not all of them were.
|
|
496
|
+
const counts = {
|
|
497
|
+
command: 'verify', errors: totalErrors, warnings: totalWarnings,
|
|
498
|
+
...(scope && { checked: scope.locales, scope: scope.afterSync ? 'synced-pairs' : 'pair-flag' }),
|
|
499
|
+
};
|
|
500
|
+
// A scoped check names what it looked at, and that the rest was not
|
|
501
|
+
// looked at: after `sync --pair en:fr` the closing line said "intact in
|
|
502
|
+
// every locale" over French alone while Spanish had a warning (Round 14,
|
|
503
|
+
// i18next persona).
|
|
504
|
+
const forWhere = scope ? ` for ${scope.locales.join(', ')}` : '';
|
|
505
|
+
const which = scope ? ` (${describeScope(scope)})` : '';
|
|
506
|
+
// Right after a sync that did not finish (keys not translated or held back,
|
|
507
|
+
// a plural message without an everyday form): never an [OK] line as the
|
|
508
|
+
// run's last word over an exit 2 (Round 9, Django persona: "[OK]
|
|
509
|
+
// Verification passed with 1 warning(s)" closed a run that exited 2).
|
|
510
|
+
if (incomplete && totalErrors === 0 && !(strict && totalWarnings > 0)) {
|
|
511
|
+
output.warn(`Verification: ${scope ? `the files of ${scope.locales.join(', ')} are` : 'the files are'} structurally intact`
|
|
512
|
+
+ `${totalWarnings > 0 ? ` (${totalWarnings} warning(s), listed above)` : ''}${which}, `
|
|
513
|
+
+ `but this sync is incomplete — ${incomplete} (see the summary above).`, counts);
|
|
514
|
+
return { errors: totalErrors, warnings: totalWarnings };
|
|
515
|
+
}
|
|
516
|
+
// What a pass means, in one line: verify checks structure, never meaning.
|
|
517
|
+
// "All locales look good" read like a sign-off on a phrasebook whose
|
|
518
|
+
// "Where does it hurt?" was an unrelated sentence (Round 3, hospital persona).
|
|
195
519
|
if (totalErrors === 0 && totalWarnings === 0) {
|
|
196
|
-
output.ok(
|
|
520
|
+
output.ok(`Verification passed${forWhere}: keys, placeholders, plurals, markup and script are intact${scope ? which : ' in every locale'} — `
|
|
521
|
+
+ 'the meaning is not checked; have a speaker review before relying on it.', counts);
|
|
522
|
+
} else if (totalErrors === 0 && strict) {
|
|
523
|
+
// Under --strict a warning fails the run (exit 1): never an [OK] line
|
|
524
|
+
// over it (Round 8, Django persona: "[OK] … passed with 1 warning(s)",
|
|
525
|
+
// then exit 1).
|
|
526
|
+
output.error(`[FAIL] ${totalWarnings} warning(s)${forWhere} — --strict treats warnings as failures (listed above${scope ? `; ${describeScope(scope)}` : ''}).`, counts);
|
|
197
527
|
} else if (totalErrors === 0) {
|
|
198
|
-
output.ok(`Verification passed with ${totalWarnings} warning(s)
|
|
528
|
+
output.ok(`Verification passed with ${totalWarnings} warning(s)${forWhere} — structure only; the meaning is not checked${which}.`, counts);
|
|
199
529
|
} else {
|
|
200
|
-
output.error(`Verification: ${totalErrors} error(s), ${totalWarnings} warning(s)
|
|
530
|
+
output.error(`Verification: ${totalErrors} error(s), ${totalWarnings} warning(s)${forWhere}${which}.`, counts);
|
|
201
531
|
}
|
|
202
532
|
return { errors: totalErrors, warnings: totalWarnings };
|
|
203
533
|
}
|
|
204
534
|
|
|
535
|
+
/**
|
|
536
|
+
* A scoped check in words: what was synced (or asked for), so only those
|
|
537
|
+
* locales were checked, and the command that checks all of them.
|
|
538
|
+
*
|
|
539
|
+
* @param {{ locales: string[], pairs: string[], afterSync: boolean }} scope
|
|
540
|
+
* @returns {string}
|
|
541
|
+
*/
|
|
542
|
+
function describeScope(scope) {
|
|
543
|
+
const pairs = scope.pairs.join(', ');
|
|
544
|
+
return scope.afterSync
|
|
545
|
+
? `only ${pairs} ${scope.pairs.length > 1 ? 'were' : 'was'} synced; \`champollion verify\` checks every locale`
|
|
546
|
+
: `only ${pairs}, as --pair asked; \`champollion verify\` without --pair checks every locale`;
|
|
547
|
+
}
|
|
548
|
+
|
|
205
549
|
/**
|
|
206
550
|
* Verify all target locale files against the source.
|
|
207
551
|
*
|
|
@@ -215,131 +559,950 @@ function printSummary(totalErrors, totalWarnings) {
|
|
|
215
559
|
* Compiled matcher. Pass the SAME instance the sync used so verification
|
|
216
560
|
* judges the files by the rules that wrote them. Derived from config when
|
|
217
561
|
* omitted (the standalone `verify` command).
|
|
218
|
-
* @
|
|
562
|
+
* @param {string[]|null} [options.locales] - Verify only these target
|
|
563
|
+
* locales (the pairs a `sync --pair` run touched). null = every locale.
|
|
564
|
+
* @param {object|null} [options.tm] - The TM object to consult and evict
|
|
565
|
+
* from (sync passes its own); loaded from disk when omitted
|
|
566
|
+
* @param {boolean} [options.afterSync] - Called by sync right after it wrote
|
|
567
|
+
* the files: the heading says "Post-Sync Verification" (else "Verification")
|
|
568
|
+
* @param {string|null} [options.incomplete] - Called by a sync that did not
|
|
569
|
+
* finish (what it left undone, in words): the closing line is a warning
|
|
570
|
+
* naming it, never an [OK] over a run that exits 2
|
|
571
|
+
* @param {boolean} [options.noTM] - The run did not READ the TM (--fresh /
|
|
572
|
+
* --no-tm). Its results were still cached, so damage is evicted as usual
|
|
573
|
+
* @returns {Promise<{ errors: number, warnings: number, tmEvicted?: number }>} tmEvicted is
|
|
574
|
+
* present when cache entries were evicted
|
|
219
575
|
*/
|
|
220
576
|
async function verifyLocales(config, cwd, options = {}) {
|
|
221
577
|
const noTranslate = options.noTranslate || compileNoTranslate(config);
|
|
578
|
+
const scope = Array.isArray(options.locales) ? new Set(options.locales) : null;
|
|
579
|
+
const inScope = (locale) => !scope || scope.has(locale);
|
|
222
580
|
|
|
223
581
|
// TM-confirmed echoes are settled facts, not findings — the same
|
|
224
582
|
// suppression the sync diff and `integrity` apply, so the three tools
|
|
225
|
-
// cannot disagree about a healthy file.
|
|
226
|
-
const tm = loadTM(cwd);
|
|
583
|
+
// cannot disagree about a healthy file.
|
|
584
|
+
const tm = options.tm || loadTM(cwd);
|
|
585
|
+
// Per locale: the pair's own TM key, then its fallback's — a value the
|
|
586
|
+
// fallback produced is cached under the fallback (lib/fallback.js).
|
|
227
587
|
const tmKeys = new Map();
|
|
228
588
|
try {
|
|
229
|
-
|
|
589
|
+
const graph = resolvePairs(config, { cwd });
|
|
590
|
+
// A cache from before coaching was keyed reads as sync reads it
|
|
591
|
+
// (lib/tm.js adoptLegacyCoachingKeys — once per cache).
|
|
592
|
+
adoptLegacyCoachingKeys(tm, graph.values());
|
|
593
|
+
for (const [, pc] of graph) tmKeys.set(pc.target, tmKeysForPair(pc));
|
|
230
594
|
} catch { /* invalid pair config — sync reports it; verify still runs */ }
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
595
|
+
// A borrowed i18next plural form is cached under its own text (tmTextFor).
|
|
596
|
+
const echoPredicateFor = (locale, expansion = null) => {
|
|
597
|
+
const keys = tmKeys.get(locale);
|
|
598
|
+
return keys
|
|
599
|
+
? (key, sourceValue) => tmHoldsValue(tm, tmTextFor(key, sourceValue, expansion), locale, keys, sourceValue)
|
|
235
600
|
: null;
|
|
236
601
|
};
|
|
237
602
|
|
|
603
|
+
// The pair each target locale belongs to, for the repair command
|
|
604
|
+
// (`sync --pair en:fr --redo keys:…`).
|
|
605
|
+
const pairOf = new Map();
|
|
606
|
+
try {
|
|
607
|
+
for (const [pairKey, pc] of resolvePairs(config, { cwd })) if (!pairOf.has(pc.target)) pairOf.set(pc.target, pairKey);
|
|
608
|
+
} catch { /* invalid pair config — sync reports it */ }
|
|
609
|
+
const fixFor = (locale, ns = '') => (keys, opts = {}) => redoCommand(keys, {
|
|
610
|
+
pair: pairOf.get(locale) || `${config.inputLocale}:${locale}`, ns, ...opts,
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// Damaged values the TM proves the pipeline wrote → evict those entries.
|
|
614
|
+
const evictor = createTMEvictor(tm);
|
|
615
|
+
let tmEvicted = 0;
|
|
616
|
+
let damagedCount = 0;
|
|
617
|
+
const evictDamaged = (locale, damaged, expansion = null) => {
|
|
618
|
+
damagedCount += damaged.length;
|
|
619
|
+
if (!evictor) return;
|
|
620
|
+
const keys = tmKeys.get(locale) || [];
|
|
621
|
+
for (const d of damaged) {
|
|
622
|
+
if (typeof d.sourceValue !== 'string' || typeof d.value !== 'string') continue;
|
|
623
|
+
for (const text of tmProofTextsFor(d.key, d.sourceValue, expansion)) {
|
|
624
|
+
tmEvicted += evictor.evictProducing(text, locale, d.value, keys);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
238
629
|
// Docusaurus uses a directory-per-locale layout (i18n/<locale>/code.json,
|
|
239
630
|
// …/<plugin>/*.json) — NOT a flat i18n/<locale>.json file. The flat path
|
|
240
631
|
// below would look for i18n/en.json, never find it, and exit 0 — a
|
|
241
632
|
// false-green gate. Route Docusaurus projects to their own verifier.
|
|
242
633
|
if (config.format === 'docusaurus') {
|
|
243
|
-
|
|
634
|
+
const result = await verifyDocusaurusLocales(config, cwd, noTranslate, echoPredicateFor, {
|
|
635
|
+
inScope, evictDamaged, fixFor, afterSync: !!options.afterSync, strict: !!options.strict, incomplete: options.incomplete || null,
|
|
636
|
+
describeChecked: scope ? (locales) => checkedScope(locales, pairOf, config, !!options.afterSync) : null,
|
|
637
|
+
});
|
|
638
|
+
return finishEviction(cwd, tm, tmEvicted, result, damagedCount);
|
|
244
639
|
}
|
|
245
640
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
641
|
+
// Every locale's files come from the ONE layout module (flat, folder per
|
|
642
|
+
// locale, or localesPattern) — the same files sync wrote.
|
|
643
|
+
const rel = (p) => path.relative(cwd, p).split(path.sep).join('/') || '.';
|
|
644
|
+
const setting = config.localesPattern ? '"localesPattern"' : '"localesDir"';
|
|
645
|
+
if (!config.localesPattern && !fs.existsSync(config.localesDir)) {
|
|
646
|
+
return nothingVerified(cwd, config,
|
|
647
|
+
`the locales folder ${rel(config.localesDir)} does not exist (${setting} in champollion.config.json, default ./locales).`);
|
|
648
|
+
}
|
|
649
|
+
const layout = discoverLocaleLayout(config, { cwd });
|
|
650
|
+
const sourceMissing = layout.namespaced
|
|
651
|
+
? layout.sourceFiles.length === 0
|
|
652
|
+
: !fs.existsSync(layout.sourceFiles[0].path);
|
|
653
|
+
if (sourceMissing) {
|
|
654
|
+
// A verify that checked nothing is not a pass. It used to warn and exit
|
|
655
|
+
// 0, so a CI gate with a mistyped localesDir stayed green while checking
|
|
656
|
+
// nothing (Round 3, i18next persona).
|
|
657
|
+
const where = layout.namespaced
|
|
658
|
+
? `${rel(layout.baseDir)}/${config.inputLocale}/ (no source files there)`
|
|
659
|
+
: rel(layout.sourceFiles[0].path);
|
|
660
|
+
return nothingVerified(cwd, config,
|
|
661
|
+
`the source locale was not found — looked for ${where} (${setting} and "inputLocale": "${config.inputLocale}" in champollion.config.json).`);
|
|
257
662
|
}
|
|
258
663
|
|
|
259
|
-
const
|
|
260
|
-
const
|
|
261
|
-
const sourceKeyCount = Object.keys(sourceFlat).length;
|
|
664
|
+
const units = loadSourceUnits(layout);
|
|
665
|
+
const sourceKeyCount = units.reduce((n, u) => n + Object.keys(u.flat).length, 0);
|
|
262
666
|
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
.map(f => f.replace(ext, ''));
|
|
667
|
+
// Target locales present on disk — EXACT code match. (The old listing
|
|
668
|
+
// dropped every file that merely STARTED with the source code, so with
|
|
669
|
+
// source "en" an en-GB.json was never verified.)
|
|
670
|
+
const targetLocales = layout.listLocales().filter(inScope);
|
|
268
671
|
|
|
269
|
-
// Configured locales
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
672
|
+
// Configured locales with no file at all. These are invisible to the
|
|
673
|
+
// listing above, so without this check a CI `verify` gate passed with zero
|
|
674
|
+
// translation done. Only enforced when the source has keys to translate —
|
|
675
|
+
// an empty source promises nothing.
|
|
273
676
|
const missingTargets = sourceKeyCount === 0 ? [] :
|
|
274
677
|
[...configuredTargetLocales(config)]
|
|
275
|
-
.filter(
|
|
678
|
+
.filter(inScope)
|
|
679
|
+
.filter(l => !layout.filesFor(l).some(f => fs.existsSync(f.path)))
|
|
276
680
|
.sort();
|
|
277
681
|
|
|
278
682
|
if (targetLocales.length === 0 && missingTargets.length === 0) {
|
|
279
|
-
|
|
683
|
+
// An empty source promises nothing; anything else with no target at all
|
|
684
|
+
// (none on disk, none configured) is a gate that checked nothing.
|
|
685
|
+
if (sourceKeyCount === 0) {
|
|
686
|
+
output.info('[VERIFY] The source has no keys — nothing to verify.', { command: 'verify', errors: 0, warnings: 0 });
|
|
687
|
+
return { errors: 0, warnings: 0 };
|
|
688
|
+
}
|
|
689
|
+
return nothingVerified(cwd, config, scope
|
|
690
|
+
? `no target locale file for ${[...scope].join(', ')} next to the source (${layout.display}).`
|
|
691
|
+
: `no target locale files next to the source (${layout.display}) and no "languages" in champollion.config.json.`);
|
|
280
692
|
}
|
|
281
693
|
|
|
282
|
-
|
|
694
|
+
// "Post-Sync" only right after a sync: a standalone `verify` ran no sync
|
|
695
|
+
// (Round 7, Django persona).
|
|
696
|
+
output.raw(options.afterSync
|
|
697
|
+
? '\n ── Post-Sync Verification ───────────────────────────────\n'
|
|
698
|
+
: '\n ── Verification ─────────────────────────────────────────\n');
|
|
283
699
|
|
|
284
700
|
let totalErrors = 0;
|
|
285
701
|
let totalWarnings = 0;
|
|
286
702
|
|
|
287
703
|
for (const locale of missingTargets) {
|
|
288
|
-
|
|
704
|
+
const expectedFiles = layout.filesFor(locale).map(f => f.rel);
|
|
705
|
+
const where = layout.namespaced
|
|
706
|
+
? `expected ${expectedFiles.slice(0, 3).join(', ')}${expectedFiles.length > 3 ? `, +${expectedFiles.length - 3} more` : ''}`
|
|
707
|
+
: expectedFiles[0];
|
|
708
|
+
const message = `${locale}: locale file missing (${where}) — configured target has no translations. Run \`champollion sync\` to create it.`;
|
|
709
|
+
output.error(`[VERIFY] ${message}`);
|
|
289
710
|
totalErrors++;
|
|
711
|
+
output.event('verify', {
|
|
712
|
+
locale, pair: pairOf.get(locale) || `${config.inputLocale}:${locale}`, ok: false, fileMissing: true,
|
|
713
|
+
expectedFiles: layout.filesFor(locale).map(f => f.rel), errors: [message], warnings: [], infos: [],
|
|
714
|
+
});
|
|
290
715
|
}
|
|
291
716
|
|
|
717
|
+
// Every locale's values, for the cross-locale check after the loop.
|
|
718
|
+
const valuesByLocale = new Map(targetLocales.map(l => [l, new Map()]));
|
|
719
|
+
const sourceByKey = new Map();
|
|
720
|
+
|
|
721
|
+
// The lock's per-locale record: which translations were made from an
|
|
722
|
+
// older source text (lib/locale-state.js). Unreadable → sync says so loud;
|
|
723
|
+
// verify skips this one check rather than fail on it.
|
|
724
|
+
let lock = null;
|
|
725
|
+
try { lock = readLock(cwd); } catch { lock = null; }
|
|
726
|
+
const lockState = lock ? new LockState(lock.locales) : null;
|
|
727
|
+
const pairConfigs = new Map();
|
|
728
|
+
try {
|
|
729
|
+
for (const [, pc] of resolvePairs(config, { cwd })) if (!pairConfigs.has(pc.target)) pairConfigs.set(pc.target, pc);
|
|
730
|
+
} catch { /* invalid pair config — sync reports it */ }
|
|
731
|
+
|
|
292
732
|
for (const locale of targetLocales) {
|
|
293
|
-
const
|
|
294
|
-
|
|
733
|
+
const localeErrors = [];
|
|
734
|
+
const localeWarnings = [];
|
|
735
|
+
// Said, never counted: not a warning (`verify --strict` does not fail on it).
|
|
736
|
+
const localeInfos = [];
|
|
737
|
+
let targetKeyTotal = 0;
|
|
738
|
+
let expectedKeyTotal = 0;
|
|
739
|
+
// Keys the locale has that it should not (an `es` plural `count_two`),
|
|
740
|
+
// and expected keys it lacks: either one means the count line is not OK.
|
|
741
|
+
const extraKeyNames = [];
|
|
742
|
+
let missingKeyTotal = 0;
|
|
743
|
+
let anyFile = false;
|
|
744
|
+
// For --json and the plural line: findings by placeholder syntax, and
|
|
745
|
+
// the plural forms the locale's files are expected to have.
|
|
746
|
+
const localePlaceholders = [];
|
|
747
|
+
const pluralCoverage = new Map();
|
|
748
|
+
|
|
749
|
+
for (const unit of units) {
|
|
750
|
+
const file = layout.fileFor(locale, unit.ns);
|
|
751
|
+
// Single-file layouts skip a vanished file (the listing raced a
|
|
752
|
+
// delete). In a namespaced locale a missing namespace file is a real
|
|
753
|
+
// gap — every key in it is untranslated — so it is audited as empty.
|
|
754
|
+
if (!fs.existsSync(file.path) && !layout.namespaced) continue;
|
|
755
|
+
anyFile = true;
|
|
756
|
+
const targetFlat = fs.existsSync(file.path) ? readLocaleFlat(file) : {};
|
|
757
|
+
// The keys THIS locale should have (i18next plurals → its own CLDR
|
|
758
|
+
// categories), the same expectation sync translated against.
|
|
759
|
+
const { flat: expected, expansion } = expectedForTarget(unit, config.inputLocale, locale);
|
|
760
|
+
// A gettext catalog holds only the plural forms its header has slots for.
|
|
761
|
+
const poText = file.format === 'po' && fs.existsSync(file.path) ? fs.readFileSync(file.path, 'utf-8') : null;
|
|
762
|
+
const pluralSlots = poText !== null ? poPluralSlots(poText, locale) : null;
|
|
763
|
+
const { errors, warnings, targetKeyCount, damaged, placeholders } = auditTranslations(
|
|
764
|
+
expected, targetFlat, locale, config, noTranslate, echoPredicateFor(locale, expansion),
|
|
765
|
+
{ fixFor: fixFor(locale, layout.namespaced ? unit.ns : ''), pluralSlots,
|
|
766
|
+
fillCommand: `champollion sync --pair ${shellWord(pairOf.get(locale) || `${config.inputLocale}:${locale}`)}` });
|
|
767
|
+
evictDamaged(locale, damaged, expansion);
|
|
768
|
+
const keyName = (k) => (layout.namespaced ? `${unit.ns}${NS_SEPARATOR}${k}` : k);
|
|
769
|
+
for (const p of placeholders) localePlaceholders.push({ ...p, key: keyName(p.key) });
|
|
770
|
+
// gettext forms sync marked as repeating `other` (the line the plural
|
|
771
|
+
// warning below reads too); an unparsable catalog is readLocaleFlat's
|
|
772
|
+
// error, reported above — nothing is marked in it.
|
|
773
|
+
let marked = [];
|
|
774
|
+
if (poText !== null) {
|
|
775
|
+
try { marked = poPluralFindings(poText, { locale, filePath: file.rel }).copied; } catch { marked = []; }
|
|
776
|
+
}
|
|
777
|
+
addPluralCoverage(pluralCoverage, {
|
|
778
|
+
unit, expected, expansion, targetFlat, locale, gettext: file.format === 'po', slots: pluralSlots, marked, keyName,
|
|
779
|
+
});
|
|
780
|
+
targetKeyTotal += targetKeyCount;
|
|
781
|
+
expectedKeyTotal += Object.keys(expected).length;
|
|
782
|
+
for (const k of Object.keys(targetFlat)) {
|
|
783
|
+
if (!Object.prototype.hasOwnProperty.call(expected, k)) extraKeyNames.push(layout.namespaced ? `${unit.ns}${NS_SEPARATOR}${k}` : k);
|
|
784
|
+
}
|
|
785
|
+
missingKeyTotal += Object.keys(expected).filter(k => !Object.prototype.hasOwnProperty.call(targetFlat, k)).length;
|
|
786
|
+
const prefix = layout.namespaced ? `${unit.ns}: ` : '';
|
|
787
|
+
for (const e of errors) localeErrors.push(prefix + e);
|
|
788
|
+
for (const w of warnings) localeWarnings.push(prefix + w);
|
|
789
|
+
const plural = pluralFileWarnings(unit, file, targetFlat, locale, config, fixFor(locale, layout.namespaced ? unit.ns : ''), {
|
|
790
|
+
expansion, localeState: lockState ? lockState.peek(locale) : null, lockKeyOf: (k) => lockKey(layout, unit.ns, k),
|
|
791
|
+
pruneCommand: () => `champollion sync --pair ${shellWord(pairOf.get(locale) || `${config.inputLocale}:${locale}`)} --prune plural-extras`,
|
|
792
|
+
});
|
|
793
|
+
for (const w of plural.warnings) localeWarnings.push(prefix + w);
|
|
794
|
+
for (const i of plural.infos) localeInfos.push(prefix + i);
|
|
795
|
+
for (const [k, v] of Object.entries(targetFlat)) {
|
|
796
|
+
if (typeof v === 'string') valuesByLocale.get(locale).set(layout.namespaced ? `${unit.ns}${NS_SEPARATOR}${k}` : k, v);
|
|
797
|
+
}
|
|
798
|
+
for (const [k, v] of Object.entries(expected)) {
|
|
799
|
+
if (typeof v === 'string') sourceByKey.set(layout.namespaced ? `${unit.ns}${NS_SEPARATOR}${k}` : k, v);
|
|
800
|
+
}
|
|
801
|
+
// ARB: the document around the messages (@@locale, placeholder types).
|
|
802
|
+
if (file.format === 'arb' && fs.existsSync(file.path)) {
|
|
803
|
+
const docIssues = auditARBDocument(unit.file.path, file.path, locale);
|
|
804
|
+
if (docIssues.length > 0) {
|
|
805
|
+
localeErrors.push(`${prefix}${docIssues.length} ARB file error(s): ${docIssues.slice(0, 3).map(d => d.reason).join('; ')}`
|
|
806
|
+
+ ` — any sync that rewrites ${file.rel} repairs it (e.g. \`champollion sync --pair ${config.inputLocale}:${locale} --force\`, served from the cache)`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (!anyFile) continue;
|
|
811
|
+
|
|
812
|
+
// Out of date: a value made from an older source text — structurally
|
|
813
|
+
// intact (so a warning here; `audit` is the completeness gate that fails
|
|
814
|
+
// on it, and `verify --strict` fails on any warning). A source edit whose
|
|
815
|
+
// re-translation failed used to leave the old text behind with verify
|
|
816
|
+
// and audit both green (Round 4, i18next persona).
|
|
817
|
+
if (lockState) {
|
|
818
|
+
try {
|
|
819
|
+
const health = localeHealth({
|
|
820
|
+
layout, units, inputLocale: config.inputLocale, code: locale, localeState: lockState.peek(locale),
|
|
821
|
+
manifest: lock.source, tm, pairConfig: pairConfigs.get(locale) || null,
|
|
822
|
+
helpers: { expectedForTarget, readLocaleFlat, lockKey, originKey, fallbackPrefix: config.fallbackPrefix || '[EN] ' },
|
|
823
|
+
});
|
|
824
|
+
if (health.stale.length > 0) {
|
|
825
|
+
const pair = pairOf.get(locale) || `${config.inputLocale}:${locale}`;
|
|
826
|
+
localeWarnings.push(`${health.stale.length} translation(s) out of date — made from an older source text: `
|
|
827
|
+
+ `${health.stale.slice(0, 5).join(', ')}${health.stale.length > 5 ? '...' : ''} — re-translate: \`champollion sync --pair ${shellWord(pair)}\``
|
|
828
|
+
+ (health.stale.some(k => health.held.includes(k))
|
|
829
|
+
? ` (refused before, held back until named: \`${redoCommand(health.stale.filter(k => health.held.includes(k)).slice(0, 8), { pair })}\`)`
|
|
830
|
+
: ''));
|
|
831
|
+
}
|
|
832
|
+
} catch { /* a check that cannot run is not a finding */ }
|
|
833
|
+
}
|
|
295
834
|
|
|
296
|
-
|
|
297
|
-
|
|
835
|
+
// One text written for several different source strings — key values,
|
|
836
|
+
// ICU branches and the locale's Markdown pages counted together: a model
|
|
837
|
+
// repeating a memorized sentence (Round 4 and 5 personas). Sync's gate
|
|
838
|
+
// refuses it; on disk it is an error, said inside this locale's block,
|
|
839
|
+
// never after a "[OK]" line.
|
|
840
|
+
const contentItems = contentItemsFor(config, cwd, locale);
|
|
841
|
+
for (const e of sharedOutputErrors(locale, valuesByLocale.get(locale), sourceByKey, config, noTranslate, pairOf,
|
|
842
|
+
contentItems, tm?._meta?.memorized?.[locale] || [])) {
|
|
843
|
+
localeErrors.push(e);
|
|
844
|
+
}
|
|
845
|
+
// Markdown blocks and front-matter fields the quality gate refuses today
|
|
846
|
+
// (a heading turned into a sentence): sync refuses them since Round 7;
|
|
847
|
+
// what is on disk from before is named here, with the one repair.
|
|
848
|
+
for (const w of contentGateWarnings(locale, contentItems, pairConfigs.get(locale) || { target: locale },
|
|
849
|
+
pairOf.get(locale) || `${config.inputLocale}:${locale}`, config.fallbackPrefix || '[EN] ')) {
|
|
850
|
+
localeWarnings.push(w);
|
|
851
|
+
}
|
|
298
852
|
|
|
299
853
|
output.raw(` ── ${locale} ──────────────────────────────────────`);
|
|
300
854
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
855
|
+
// [OK] only for an exact match: "[OK] 9/8 keys present" was printed for a
|
|
856
|
+
// locale holding an extra plural form, right above the warning about it
|
|
857
|
+
// (Round 8, i18next persona).
|
|
858
|
+
if (missingKeyTotal === 0 && extraKeyNames.length === 0) {
|
|
859
|
+
output.raw(` [OK] ${targetKeyTotal}/${expectedKeyTotal} keys present`);
|
|
860
|
+
} else {
|
|
861
|
+
const shown = extraKeyNames.slice(0, 5).map(k => k.replace(/\u0004/g, '\u2404')).join(', ') + (extraKeyNames.length > 5 ? ', …' : '');
|
|
862
|
+
const parts = [
|
|
863
|
+
missingKeyTotal > 0 && `${missingKeyTotal} missing`,
|
|
864
|
+
extraKeyNames.length > 0 && `${extraKeyNames.length} extra: ${shown}`,
|
|
865
|
+
].filter(Boolean).join('; ');
|
|
866
|
+
output.raw(` ${expectedKeyTotal} expected, ${targetKeyTotal} present (${parts})`);
|
|
305
867
|
}
|
|
868
|
+
// The plural forms this locale is expected to have, and whether its
|
|
869
|
+
// files have them — one line per kind (i18next keys, ICU messages,
|
|
870
|
+
// gettext entries); none when the files carry no plurals.
|
|
871
|
+
for (const line of pluralCoverageLines(locale, pluralCoverage)) output.raw(line);
|
|
306
872
|
|
|
307
|
-
for (const err of
|
|
308
|
-
output.error(`[VERIFY] ${locale}: ${err}`);
|
|
873
|
+
for (const err of localeErrors) {
|
|
874
|
+
output.error(`[VERIFY] ${locale}: ${showKeys(err)}`);
|
|
309
875
|
totalErrors++;
|
|
310
876
|
}
|
|
311
|
-
for (const warn of
|
|
312
|
-
output.warn(`[VERIFY] ${locale}: ${warn}`);
|
|
877
|
+
for (const warn of localeWarnings) {
|
|
878
|
+
output.warn(`[VERIFY] ${locale}: ${showKeys(warn)}`);
|
|
313
879
|
totalWarnings++;
|
|
314
880
|
}
|
|
881
|
+
for (const info of localeInfos) output.info(`[VERIFY] ${locale}: ${showKeys(info)}`);
|
|
315
882
|
|
|
316
|
-
if (
|
|
317
|
-
output.raw(
|
|
883
|
+
if (localeErrors.length === 0 && localeWarnings.length === 0) {
|
|
884
|
+
output.raw(STRUCTURE_ONLY_OK);
|
|
318
885
|
}
|
|
319
886
|
output.raw('');
|
|
887
|
+
// --json: this locale's results as one record on stdout (the findings
|
|
888
|
+
// are also the [VERIFY] lines on stderr). Keys in structured fields are
|
|
889
|
+
// exact (a gettext context key keeps its U+0004); messages show it as ␄.
|
|
890
|
+
output.event('verify', {
|
|
891
|
+
locale,
|
|
892
|
+
pair: pairOf.get(locale) || `${config.inputLocale}:${locale}`,
|
|
893
|
+
ok: localeErrors.length === 0 && localeWarnings.length === 0,
|
|
894
|
+
keys: { expected: expectedKeyTotal, present: targetKeyTotal, missing: missingKeyTotal, extra: extraKeyNames },
|
|
895
|
+
errors: localeErrors.map(showKeys),
|
|
896
|
+
warnings: localeWarnings.map(showKeys),
|
|
897
|
+
infos: localeInfos.map(showKeys),
|
|
898
|
+
placeholders: localePlaceholders,
|
|
899
|
+
plurals: pluralCoverageData(pluralCoverage),
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Two locales holding the same text: one of them is probably in the
|
|
904
|
+
// other's language (or a model ignored the target language).
|
|
905
|
+
for (const w of identicalLocaleWarnings(valuesByLocale, sourceByKey, config, noTranslate, pairOf)) {
|
|
906
|
+
output.warn(`[VERIFY] ${showKeys(w)}`);
|
|
907
|
+
totalWarnings++;
|
|
320
908
|
}
|
|
321
909
|
|
|
322
|
-
|
|
910
|
+
// Scoped, and some locale left out: the closing line names what was checked.
|
|
911
|
+
// (A --pair that names every locale is a full check, said as one.)
|
|
912
|
+
const leftOut = scope && [...new Set([...layout.listLocales(), ...configuredTargetLocales(config)])]
|
|
913
|
+
.some(l => l !== config.inputLocale && !inScope(l));
|
|
914
|
+
return finishEviction(cwd, tm, tmEvicted, printSummary(totalErrors, totalWarnings, {
|
|
915
|
+
strict: !!options.strict, incomplete: options.incomplete || null,
|
|
916
|
+
scope: leftOut ? checkedScope([...missingTargets, ...targetLocales], pairOf, config, !!options.afterSync) : null,
|
|
917
|
+
}), damagedCount);
|
|
323
918
|
}
|
|
324
919
|
|
|
325
920
|
/**
|
|
326
|
-
*
|
|
921
|
+
* What a scoped verify checked: its locales (sorted) and their pairs.
|
|
922
|
+
*
|
|
923
|
+
* @param {string[]} locales - The locales checked
|
|
924
|
+
* @param {Map<string, string>} pairOf - locale → pair key
|
|
925
|
+
* @param {object} config
|
|
926
|
+
* @param {boolean} afterSync
|
|
927
|
+
* @returns {{ locales: string[], pairs: string[], afterSync: boolean }}
|
|
928
|
+
*/
|
|
929
|
+
function checkedScope(locales, pairOf, config, afterSync) {
|
|
930
|
+
const sorted = [...new Set(locales)].sort();
|
|
931
|
+
return { locales: sorted, pairs: sorted.map(l => pairOf.get(l) || `${config.inputLocale}:${l}`), afterSync };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* The method named by a lock `by` key ("deepl|||") when it cannot be told
|
|
936
|
+
* which plural form to write (it reads no per-key instructions), else null.
|
|
937
|
+
*/
|
|
938
|
+
function untoldMethod(methodKey) {
|
|
939
|
+
const method = String(methodKey).split('|')[0] || 'llm';
|
|
940
|
+
try { return getMethod(method, { method }).acceptsKeyInstructions === true ? null : method; } catch { return null; }
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** The per-locale pass line: what was checked, and that meaning was not. */
|
|
944
|
+
const STRUCTURE_ONLY_OK = ' [OK] Structural checks passed (meaning is not checked)';
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Plural findings that need the FILE, not just its flat map:
|
|
948
|
+
* - i18next: a plural key whose CLDR category the locale does not have
|
|
949
|
+
* (French `count_two`) — i18next never selects it. `_zero` is never
|
|
950
|
+
* flagged: i18next uses it for 0 in every language.
|
|
951
|
+
* - i18next: a borrowed form holding the text of the form it is translated
|
|
952
|
+
* from, with no record of the model writing it as that form (an info
|
|
953
|
+
* line — decided from the committed lock, never from the cache).
|
|
954
|
+
* - gettext: forms marked as repeating `other` that still do (sync wrote
|
|
955
|
+
* them because the translation had no such form), and msgstr[n] beyond
|
|
956
|
+
* the catalog's nplurals.
|
|
957
|
+
*
|
|
958
|
+
* @returns {{ warnings: string[], infos: string[] }}
|
|
959
|
+
*/
|
|
960
|
+
function pluralFileWarnings(unit, file, targetFlat, locale, config, fixFor, { expansion = null, localeState = null, lockKeyOf = (k) => k, pruneCommand = null } = {}) {
|
|
961
|
+
const out = [];
|
|
962
|
+
const infos = [];
|
|
963
|
+
// i18next: a borrowed form (French `count_many`, translated from the
|
|
964
|
+
// English `count_other` text) holding exactly the text of the form it
|
|
965
|
+
// borrows from. Some languages write the two alike; what tells "the model
|
|
966
|
+
// wrote it this way" from "filled from the other form" is the lock's
|
|
967
|
+
// forms-record (lib/locale-state.js), committed with the files — so every
|
|
968
|
+
// clone of a commit gets the same answer. This used to consult each
|
|
969
|
+
// machine's own cache: one commit passed `verify --strict` on the machine
|
|
970
|
+
// that translated it and failed in CI, where it also suggested a redo that
|
|
971
|
+
// would re-bill (Round 7, i18next persona).
|
|
972
|
+
if (expansion?.borrowed) {
|
|
973
|
+
const unproven = [];
|
|
974
|
+
for (const [b, form] of Object.entries(expansion.borrowed)) {
|
|
975
|
+
const s = expansion.origin?.[b];
|
|
976
|
+
if (!s || expansion.origin[s] !== s || expansion.borrowed[s]) continue;
|
|
977
|
+
const vb = targetFlat[b];
|
|
978
|
+
if (typeof vb !== 'string' || vb.trim() === '' || vb !== targetFlat[s]) continue;
|
|
979
|
+
const lk = lockKeyOf(b);
|
|
980
|
+
const asked = decodeForm(localeState?.forms?.[lk]);
|
|
981
|
+
if (asked && asked.value === valueHash(vb)) continue; // asked for this form; it answered so
|
|
982
|
+
const written = decodeWritten(localeState?.written?.[lk]);
|
|
983
|
+
const bySync = !!written && written.value === valueHash(vb);
|
|
984
|
+
const by = bySync ? (localeState?.by?.[lk] || null) : null;
|
|
985
|
+
unproven.push({ key: b, sibling: s, form, value: vb, bySync, untold: by ? untoldMethod(by) : null });
|
|
986
|
+
}
|
|
987
|
+
const preview = (list) => list.slice(0, 3).map(x => `${x.key} = ${x.sibling} (${JSON.stringify(x.value.length > 40 ? `${x.value.slice(0, 37)}...` : x.value)})`).join(', ')
|
|
988
|
+
+ (list.length > 3 ? '...' : '');
|
|
989
|
+
const formsOf = (list) => describeCategories(locale, [...new Set(list.map(x => x.form.replace(/^ordinal-/, '')))],
|
|
990
|
+
list[0].form.startsWith('ordinal-') ? 'ordinal' : 'cardinal');
|
|
991
|
+
const redo = (list) => `\`${fixFor(list.map(x => x.key))}\` (sends ${list.length} key(s) to the model)`;
|
|
992
|
+
const untold = unproven.filter(x => x.untold);
|
|
993
|
+
const predates = unproven.filter(x => x.bySync && !x.untold);
|
|
994
|
+
const noRecord = unproven.filter(x => !x.bySync);
|
|
995
|
+
if (untold.length > 0) {
|
|
996
|
+
infos.push(`${untold.length} plural form(s) hold the same text as the form they are translated from: ${preview(untold)}`
|
|
997
|
+
+ ` — written by ${untold[0].untold}, which cannot be told which plural form to write (${locale} uses ${formsOf(untold)}). `
|
|
998
|
+
+ 'Review them, or use an LLM method for this pair.');
|
|
999
|
+
}
|
|
1000
|
+
if (predates.length > 0) {
|
|
1001
|
+
infos.push(`${predates.length} plural form(s) hold the same text as the form they are translated from: ${preview(predates)}`
|
|
1002
|
+
+ ` — written before each form had its own cache entry, when a cached answer for one form could be written into both `
|
|
1003
|
+
+ `(${locale} uses ${formsOf(predates)}). If they should differ, ask again: ${redo(predates)}`);
|
|
1004
|
+
}
|
|
1005
|
+
if (noRecord.length > 0) {
|
|
1006
|
+
infos.push(`${noRecord.length} plural form(s) hold the same text as the form they are translated from: ${preview(noRecord)}`
|
|
1007
|
+
+ ` — Champollion has no record of the model writing them as that form (written by hand, by another tool, or by a `
|
|
1008
|
+
+ `version before 0.4.0; ${locale} uses ${formsOf(noRecord)}). If they should differ: ${redo(noRecord)}`);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
// i18next keys for a form the language does not have — with the command
|
|
1012
|
+
// that removes exactly them (lib/plurals.js pluralExtraKeys, the same rule).
|
|
1013
|
+
const extra = pluralExtraKeys(unit, targetFlat, locale);
|
|
1014
|
+
if (extra.length > 0) {
|
|
1015
|
+
out.push(`${extra.length} plural key(s) for a form ${locale} does not have: ${extra.slice(0, 5).map(e => e.key).join(', ')}`
|
|
1016
|
+
+ `${extra.length > 5 ? '...' : ''} (${locale} plural forms: ${extra[0].cats.join(', ')}) — i18next never uses them. `
|
|
1017
|
+
+ `Remove them: \`${pruneCommand ? pruneCommand() : 'champollion sync --prune plural-extras'}\` (deletes only plural keys for forms ${locale} does not have, `
|
|
1018
|
+
+ 'lists each one, sends nothing), or delete them by hand.');
|
|
1019
|
+
}
|
|
1020
|
+
if (file.format === 'po' && fs.existsSync(file.path)) {
|
|
1021
|
+
let findings = null;
|
|
1022
|
+
try { findings = poPluralFindings(fs.readFileSync(file.path, 'utf-8'), { locale, filePath: file.rel }); } catch { findings = null; }
|
|
1023
|
+
if (findings) {
|
|
1024
|
+
const everydayOf = (cats) => cats.filter(c => (pluralCategoryUseFor(locale) || []).includes(c));
|
|
1025
|
+
const copied = findings.copied.map(f => ({ key: f.key, cats: everydayOf(f.categories) })).filter(f => f.cats.length > 0);
|
|
1026
|
+
if (copied.length > 0) {
|
|
1027
|
+
const preview = copied.slice(0, 3).map(f => `${f.key} (${f.cats.join(', ')})`).join(', ');
|
|
1028
|
+
out.push(`${copied.length} plural entr(y/ies) repeat the "other" form where ${locale} has its own: ${preview}`
|
|
1029
|
+
+ `${copied.length > 3 ? '...' : ''} — the translation did not supply those forms (marked "# champollion:" in ${file.rel}). `
|
|
1030
|
+
+ `Write them and delete that line, or ask again: \`${fixFor(copied.map(f => f.key), { fresh: true })}\``);
|
|
1031
|
+
}
|
|
1032
|
+
if (findings.extraForms.length > 0) {
|
|
1033
|
+
const f0 = findings.extraForms[0];
|
|
1034
|
+
out.push(`${findings.extraForms.length} plural entr(y/ies) with more forms than the catalog's nplurals=${f0.nplurals} `
|
|
1035
|
+
+ `(${locale}: ${f0.categories.join(', ')}): ${findings.extraForms.slice(0, 3).map(f => `${f.key} (msgstr[0..${f.forms - 1}])`).join(', ')}`
|
|
1036
|
+
+ ' — msgfmt rejects them; delete the extra msgstr[] lines (sync then treats the entry as translated again).');
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return { warnings: out, infos };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Plural messages in one target file that lack a form the language uses for
|
|
1045
|
+
* ORDINARY counts (Russian few/many) — what `verify --strict` fails on, so
|
|
1046
|
+
* `audit` (the completeness gate) counts the same entries as incomplete
|
|
1047
|
+
* (Round 7, Django persona: verify warned, audit said "fully translated",
|
|
1048
|
+
* and wrong Russian plurals shipped with a green build).
|
|
1049
|
+
* - ICU plural values (next-intl, ARB, i18next ICU): a missing branch;
|
|
1050
|
+
* - gettext: msgstr[] forms sync marked "# champollion:" as repeating
|
|
1051
|
+
* `other` (the translation had no such form).
|
|
1052
|
+
*
|
|
1053
|
+
* @returns {Array<{ key: string, missing: string[], marked: boolean }>}
|
|
1054
|
+
*/
|
|
1055
|
+
function pluralGapsInFile({ file, expected, targetFlat, locale }) {
|
|
1056
|
+
const out = [];
|
|
1057
|
+
const isPo = file.format === 'po' && fs.existsSync(file.path);
|
|
1058
|
+
const text = isPo ? fs.readFileSync(file.path, 'utf-8') : null;
|
|
1059
|
+
const pluralSlots = isPo ? poPluralSlots(text, locale) : null;
|
|
1060
|
+
for (const [k, src] of Object.entries(expected)) {
|
|
1061
|
+
const tgt = targetFlat[k];
|
|
1062
|
+
if (typeof src !== 'string' || typeof tgt !== 'string' || !src.includes('{') || tgt === src) continue;
|
|
1063
|
+
const gaps = pluralGaps(src, tgt, locale, pluralSlots).filter(g => g.everyday.length > 0);
|
|
1064
|
+
if (gaps.length > 0) out.push({ key: k, missing: [...new Set(gaps.flatMap(g => g.everyday))], marked: false });
|
|
1065
|
+
}
|
|
1066
|
+
if (isPo) {
|
|
1067
|
+
let findings = null;
|
|
1068
|
+
try { findings = poPluralFindings(text, { locale, filePath: file.rel }); } catch { findings = null; }
|
|
1069
|
+
for (const f of findings?.copied || []) {
|
|
1070
|
+
const cats = f.categories.filter(c => (pluralCategoryUseFor(locale) || []).includes(c));
|
|
1071
|
+
if (cats.length > 0 && !out.some(o => o.key === f.key)) out.push({ key: f.key, missing: cats, marked: true });
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
return out;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** Everyday plural categories of a locale (lib/icu-structure.js), memoized. */
|
|
1078
|
+
const everydayCache = new Map();
|
|
1079
|
+
function pluralCategoryUseFor(locale) {
|
|
1080
|
+
if (!everydayCache.has(locale)) everydayCache.set(locale, pluralCategoryUse(locale)?.everyday || null);
|
|
1081
|
+
return everydayCache.get(locale);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// -----------------------------------------------------------------
|
|
1085
|
+
// Plural coverage — the forms each locale is expected to have, at a glance
|
|
1086
|
+
// -----------------------------------------------------------------
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* The plural / selectordinal arguments of an ICU message, nested ones too
|
|
1090
|
+
* (a gettext msgid_plural entry reads as one: lib/po.js). `inflects` is
|
|
1091
|
+
* false for an argument with nothing but `other` — count-agnostic by design,
|
|
1092
|
+
* and not judged (the rule lib/icu-structure.js pluralGaps applies).
|
|
1093
|
+
* Parsed under the apostrophe readings pluralGaps tries, in its order.
|
|
1094
|
+
*
|
|
1095
|
+
* @param {string} text
|
|
1096
|
+
* @returns {Array<{ name: string, type: 'cardinal'|'ordinal', inflects: boolean }>}
|
|
1097
|
+
*/
|
|
1098
|
+
function pluralArguments(text) {
|
|
1099
|
+
if (typeof text !== 'string' || !text.includes('{')) return [];
|
|
1100
|
+
for (const apostrophes of ['literal', 'icu']) {
|
|
1101
|
+
const parsed = parseMessage(text, { apostrophes });
|
|
1102
|
+
if (!parsed.ok || !hasBranching(parsed.nodes)) continue;
|
|
1103
|
+
const out = [];
|
|
1104
|
+
const visit = (nodes) => {
|
|
1105
|
+
for (const node of nodes) {
|
|
1106
|
+
if (node.type !== 'branching') continue;
|
|
1107
|
+
if (node.keyword === 'plural' || node.keyword === 'selectordinal') {
|
|
1108
|
+
out.push({
|
|
1109
|
+
name: node.name,
|
|
1110
|
+
type: node.keyword === 'selectordinal' ? 'ordinal' : 'cardinal',
|
|
1111
|
+
inflects: node.options.some(o => o.selector !== 'other'),
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
for (const opt of node.options) visit(opt.nodes);
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
visit(parsed.nodes);
|
|
1118
|
+
return out;
|
|
1119
|
+
}
|
|
1120
|
+
return [];
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** A value that holds text (a missing or empty form is not a form). */
|
|
1124
|
+
const holdsText = (v) => typeof v === 'string' && v.trim() !== '';
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Add one target file's plurals to its locale's coverage (`acc`, a Map the
|
|
1128
|
+
* caller keeps per locale). Two kinds, each judged by the expectation sync
|
|
1129
|
+
* translates against — never a hardcoded list:
|
|
1130
|
+
* - i18next suffixed keys (`count_one`, `count_many`, …): the keys the
|
|
1131
|
+
* locale's expansion expects (lib/plurals.js — CLDR's categories for the
|
|
1132
|
+
* locale, `_zero` when the source has it; the source's own forms when
|
|
1133
|
+
* CLDR has no rules for the locale). A group is complete when every
|
|
1134
|
+
* expected key holds text.
|
|
1135
|
+
* - ICU plural messages (next-intl, ARB, i18next ICU) and gettext
|
|
1136
|
+
* msgid_plural entries: CLDR's categories (lib/icu-structure.js
|
|
1137
|
+
* pluralCategoryUse), in a gettext catalog only those its Plural-Forms
|
|
1138
|
+
* has a slot for. A message is complete when it keeps its plural and
|
|
1139
|
+
* supplies every form ordinary counts use (no pluralGaps everyday gap,
|
|
1140
|
+
* no msgstr[] marked as repeating `other`). A form only numbers above
|
|
1141
|
+
* 1000 or fractions use (French `many`) is counted apart: "other"
|
|
1142
|
+
* stands in for it, which is not a finding.
|
|
1143
|
+
* Missing forms are already findings (a missing key, a plural warning);
|
|
1144
|
+
* this is the summary that confirms the plurals (Round 12, i18next persona:
|
|
1145
|
+
* confirming the forms needed a separate check).
|
|
1146
|
+
*
|
|
1147
|
+
* @param {Map} acc - Per-locale accumulator: `${kind}|${type}` → bucket
|
|
1148
|
+
* @param {object} args
|
|
1149
|
+
* @param {object} args.unit - Source unit (lib/locale-layout.js)
|
|
1150
|
+
* @param {object} args.expected - The keys this locale should have (expectedForTarget)
|
|
1151
|
+
* @param {object|null} args.expansion - Its plural expansion
|
|
1152
|
+
* @param {object} args.targetFlat - The locale file's values
|
|
1153
|
+
* @param {string} args.locale
|
|
1154
|
+
* @param {boolean} [args.gettext] - The file is a gettext catalog
|
|
1155
|
+
* @param {string[]|null} [args.slots] - Its plural slots (poPluralSlots; null = CLDR's)
|
|
1156
|
+
* @param {Array<{ key: string, categories: string[] }>} args.marked - gettext forms marked as repeating `other`
|
|
1157
|
+
* @param {(key: string) => string} args.keyName - Display key (namespaced)
|
|
1158
|
+
*/
|
|
1159
|
+
function addPluralCoverage(acc, { unit, expected, expansion, targetFlat, locale, gettext = false, slots = null, marked = [], keyName = (k) => k }) {
|
|
1160
|
+
const bucket = (kind, type, basis) => {
|
|
1161
|
+
const id = `${kind}|${type}`;
|
|
1162
|
+
if (!acc.has(id)) {
|
|
1163
|
+
acc.set(id, { kind, type, basis, categories: new Set(), rare: new Set(), total: 0, complete: 0, incomplete: [], leftToOther: new Map() });
|
|
1164
|
+
}
|
|
1165
|
+
return acc.get(id);
|
|
1166
|
+
};
|
|
1167
|
+
const has = (k) => Object.prototype.hasOwnProperty.call(expected, k);
|
|
1168
|
+
|
|
1169
|
+
// i18next suffixed keys.
|
|
1170
|
+
if (expansion && unit.pluralGroups && unit.pluralGroups.size > 0) {
|
|
1171
|
+
for (const group of unit.pluralGroups.values()) {
|
|
1172
|
+
const type = group.ordinal ? 'ordinal' : 'cardinal';
|
|
1173
|
+
const prefix = `${group.base}${group.ordinal ? '_ordinal' : ''}_`;
|
|
1174
|
+
const cats = CLDR_CATEGORIES.filter(c => has(`${prefix}${c}`));
|
|
1175
|
+
if (cats.length === 0) continue;
|
|
1176
|
+
const b = bucket('i18next-keys', type, expansion.unknownLocale ? 'source' : 'cldr');
|
|
1177
|
+
for (const c of cats) b.categories.add(c);
|
|
1178
|
+
b.total++;
|
|
1179
|
+
const missing = cats.filter(c => !holdsText(targetFlat[`${prefix}${c}`]));
|
|
1180
|
+
if (missing.length === 0) b.complete++;
|
|
1181
|
+
else b.incomplete.push({ key: keyName(group.base), missing, keys: missing.map(c => keyName(`${prefix}${c}`)) });
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ICU plural messages / gettext plural entries.
|
|
1186
|
+
const kind = gettext ? 'gettext-entries' : 'icu-messages';
|
|
1187
|
+
const markedOf = new Map(marked.map(f => [f.key, f.categories]));
|
|
1188
|
+
for (const [k, src] of Object.entries(expected)) {
|
|
1189
|
+
const srcArgs = pluralArguments(src).filter(a => a.inflects);
|
|
1190
|
+
if (srcArgs.length === 0) continue;
|
|
1191
|
+
const tgt = targetFlat[k];
|
|
1192
|
+
const tgtArgs = holdsText(tgt) ? pluralArguments(tgt) : [];
|
|
1193
|
+
const gaps = holdsText(tgt) ? pluralGaps(src, tgt, locale, slots) : [];
|
|
1194
|
+
for (const type of new Set(srcArgs.map(a => a.type))) {
|
|
1195
|
+
const use = pluralCategoryUse(locale, type);
|
|
1196
|
+
const holds = (c) => !slots || type !== 'cardinal' || slots.includes(c);
|
|
1197
|
+
const b = bucket(kind, type, !use ? 'none' : (slots && type === 'cardinal' ? 'gettext' : 'cldr'));
|
|
1198
|
+
b.total++;
|
|
1199
|
+
if (!use) continue; // CLDR has no rules for the locale: nothing to judge against
|
|
1200
|
+
const cats = use.categories.filter(holds);
|
|
1201
|
+
for (const c of cats) b.categories.add(c);
|
|
1202
|
+
for (const c of use.rare.filter(holds)) b.rare.add(c);
|
|
1203
|
+
const lost = srcArgs.some(a => a.type === type && !tgtArgs.some(t => t.name === a.name && t.type === type));
|
|
1204
|
+
let missing;
|
|
1205
|
+
const rareLeft = new Set();
|
|
1206
|
+
if (lost) {
|
|
1207
|
+
missing = cats; // the value has no such plural at all (or no text)
|
|
1208
|
+
} else {
|
|
1209
|
+
const ofType = gaps.filter(g => g.type === type);
|
|
1210
|
+
missing = [...new Set(ofType.flatMap(g => g.everyday))];
|
|
1211
|
+
for (const g of ofType) for (const c of g.rare) rareLeft.add(c);
|
|
1212
|
+
if (type === 'cardinal') {
|
|
1213
|
+
for (const c of markedOf.get(k) || []) {
|
|
1214
|
+
if (use.everyday.includes(c)) { if (!missing.includes(c)) missing.push(c); } else if (use.rare.includes(c)) rareLeft.add(c);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
for (const c of rareLeft) b.leftToOther.set(c, (b.leftToOther.get(c) || 0) + 1);
|
|
1219
|
+
if (missing.length === 0) b.complete++;
|
|
1220
|
+
else b.incomplete.push({ key: keyName(k), missing: CLDR_CATEGORIES.filter(c => missing.includes(c)) });
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/** A locale's plural coverage as --json data (one entry per kind and type). */
|
|
1226
|
+
function pluralCoverageData(acc) {
|
|
1227
|
+
return [...acc.values()].map(b => ({
|
|
1228
|
+
kind: b.kind,
|
|
1229
|
+
type: b.type,
|
|
1230
|
+
// cldr: CLDR's categories for the locale; gettext: those the catalog's
|
|
1231
|
+
// Plural-Forms holds; source: CLDR has no rules — the source's forms;
|
|
1232
|
+
// none: CLDR has no rules, nothing judged.
|
|
1233
|
+
basis: b.basis,
|
|
1234
|
+
categories: CLDR_CATEGORIES.filter(c => b.categories.has(c)),
|
|
1235
|
+
total: b.total,
|
|
1236
|
+
complete: b.basis === 'none' ? null : b.complete,
|
|
1237
|
+
ok: b.basis === 'none' ? null : b.incomplete.length === 0,
|
|
1238
|
+
incomplete: b.incomplete,
|
|
1239
|
+
...(b.rare.size > 0 && { rare: CLDR_CATEGORIES.filter(c => b.rare.has(c)) }),
|
|
1240
|
+
...(b.leftToOther.size > 0 && { leftToOther: Object.fromEntries(b.leftToOther) }),
|
|
1241
|
+
}));
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* The human lines for a locale's plural coverage, e.g.
|
|
1246
|
+
* Plural forms (CLDR fr): one, many, other ✓ — 2 i18next plural key group(s), every form present
|
|
1247
|
+
*
|
|
1248
|
+
* @param {string} locale
|
|
1249
|
+
* @param {Map} acc
|
|
1250
|
+
* @returns {string[]}
|
|
1251
|
+
*/
|
|
1252
|
+
function pluralCoverageLines(locale, acc) {
|
|
1253
|
+
const nouns = {
|
|
1254
|
+
'i18next-keys': 'i18next plural key group(s)',
|
|
1255
|
+
'icu-messages': 'ICU plural message(s)',
|
|
1256
|
+
'gettext-entries': 'gettext plural entr(y/ies)',
|
|
1257
|
+
};
|
|
1258
|
+
const lines = [];
|
|
1259
|
+
for (const b of pluralCoverageData(acc)) {
|
|
1260
|
+
const forms = b.type === 'ordinal' ? 'Ordinal forms' : 'Plural forms';
|
|
1261
|
+
const noun = nouns[b.kind];
|
|
1262
|
+
if (b.basis === 'none') {
|
|
1263
|
+
lines.push(` ${forms} (CLDR has no plural rules for ${locale}): ${b.total} ${noun}, not checked`);
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
const basis = {
|
|
1267
|
+
cldr: `CLDR ${locale}`,
|
|
1268
|
+
gettext: `CLDR ${locale}, the forms the catalog's Plural-Forms has`,
|
|
1269
|
+
source: `the source's — CLDR has no plural rules for ${locale}`,
|
|
1270
|
+
}[b.basis];
|
|
1271
|
+
const shown = b.incomplete.slice(0, 5).map(i => (i.keys ? i.keys.join(', ') : `${i.key} (${i.missing.join(', ')})`)).join(', ')
|
|
1272
|
+
+ (b.incomplete.length > 5 ? ', …' : '');
|
|
1273
|
+
const left = Object.entries(b.leftToOther || {})
|
|
1274
|
+
.map(([c, n]) => `; ${c} is used only above 1000 or for fractions — "other" stands in for it in ${n}`).join('');
|
|
1275
|
+
const detail = b.ok
|
|
1276
|
+
? `${b.total} ${noun}, every form ${left ? 'ordinary counts use ' : ''}present`
|
|
1277
|
+
: `${b.incomplete.length} of ${b.total} ${noun} lack a form: ${shown}`;
|
|
1278
|
+
// i18next looks `key_zero` up for 0 in every language: a source `_zero`
|
|
1279
|
+
// is expected even where CLDR has no zero category (lib/plurals.js).
|
|
1280
|
+
const cldr = pluralCategoriesFor(locale, b.type) || [];
|
|
1281
|
+
const zero = b.kind === 'i18next-keys' && b.basis === 'cldr' && b.categories.includes('zero') && !cldr.includes('zero')
|
|
1282
|
+
? ' (zero: the source has _zero, which i18next uses for 0)' : '';
|
|
1283
|
+
lines.push(` ${forms} (${basis}): ${b.categories.join(', ')}${zero} ${b.ok ? '✓' : '✗'} — ${detail}${left}`);
|
|
1284
|
+
}
|
|
1285
|
+
return lines;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** A value with words in it — not a number, a placeholder, a URL or punctuation. */
|
|
1289
|
+
function hasWords(value) {
|
|
1290
|
+
const text = String(value)
|
|
1291
|
+
.replace(/https?:\/\/\S+/g, '')
|
|
1292
|
+
.replace(/\{[^{}]*\}/g, '')
|
|
1293
|
+
.replace(/%(\([^)]*\))?[-#0 +]*\d*(\.\d+)?[sdifuxXoeEgGc]/g, '')
|
|
1294
|
+
.replace(/[\d\s\p{P}\p{S}]/gu, '');
|
|
1295
|
+
return text.length >= 2;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* A locale's Markdown pages as shared-output items: each front-matter field
|
|
1300
|
+
* and each block, beside its source (contentDir projects; a page whose block
|
|
1301
|
+
* count differs from its source's is compared field by field only). Sync
|
|
1302
|
+
* seeds its gate with the pages a run leaves alone from the same reading
|
|
1303
|
+
* (lib/shared-output-seed.js), so the two judge the same items.
|
|
1304
|
+
*
|
|
1305
|
+
* @param {object} config
|
|
1306
|
+
* @param {string} cwd
|
|
1307
|
+
* @param {string} locale
|
|
1308
|
+
* @param {{ only?: (sourcePath: string) => boolean }} [opts] - Read only these source pages
|
|
1309
|
+
* @returns {Array<{ key: string, source: string, value: string }>}
|
|
1310
|
+
*/
|
|
1311
|
+
function contentItemsFor(config, cwd, locale, { only = null } = {}) {
|
|
1312
|
+
if (!config.contentDir || config.format === 'docusaurus') return [];
|
|
1313
|
+
const contentDir = path.resolve(cwd, config.contentDir);
|
|
1314
|
+
if (!fs.existsSync(contentDir)) return [];
|
|
1315
|
+
const fields = config.translatableFields || DEFAULT_TRANSLATABLE_FIELDS;
|
|
1316
|
+
const items = [];
|
|
1317
|
+
let sources = [];
|
|
1318
|
+
try { sources = discoverContentFiles(contentDir, config.inputLocale); } catch { return []; }
|
|
1319
|
+
for (const sourcePath of sources) {
|
|
1320
|
+
if (only && !only(sourcePath)) continue;
|
|
1321
|
+
const targetPath = getTargetContentPath(sourcePath, locale, config.inputLocale);
|
|
1322
|
+
if (!fs.existsSync(targetPath)) continue;
|
|
1323
|
+
const rel = path.relative(contentDir, sourcePath).split(path.sep).join('/');
|
|
1324
|
+
let src;
|
|
1325
|
+
let tgt;
|
|
1326
|
+
try {
|
|
1327
|
+
src = parseContentFile(fs.readFileSync(sourcePath, 'utf-8'));
|
|
1328
|
+
tgt = parseContentFile(fs.readFileSync(targetPath, 'utf-8'));
|
|
1329
|
+
} catch { continue; }
|
|
1330
|
+
for (const field of fields) {
|
|
1331
|
+
const a = src.frontMatter?.[field];
|
|
1332
|
+
const b = tgt.frontMatter?.[field];
|
|
1333
|
+
if (typeof a === 'string' && typeof b === 'string') items.push({ key: `content:${rel} front matter:${field}`, source: a, value: b });
|
|
1334
|
+
}
|
|
1335
|
+
const srcBlocks = translatableBlockSources(src.body || '');
|
|
1336
|
+
const tgtBlocks = translatableBlockSources(tgt.body || '');
|
|
1337
|
+
if (srcBlocks.length !== tgtBlocks.length) continue;
|
|
1338
|
+
srcBlocks.forEach((block, i) => items.push({ key: `content:${rel}#${i + 1}`, source: block, value: tgtBlocks[i] }));
|
|
1339
|
+
}
|
|
1340
|
+
return items;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* The command that re-translates one content file — the ONE repair sync and
|
|
1345
|
+
* verify both print for a content finding. `--redo files:` matches the path
|
|
1346
|
+
* relative to the contentDir (as sync names files) or to the project root
|
|
1347
|
+
* (lib/file-scope.js); the memorized sentence a finding names has been
|
|
1348
|
+
* evicted from the cache and is refused if the model answers with it again,
|
|
1349
|
+
* so no --fresh is needed (Round 6).
|
|
1350
|
+
*
|
|
1351
|
+
* @param {string} file - The content file as sync names it (relative to the contentDir)
|
|
1352
|
+
* @param {{ pair?: string|null }} [opts]
|
|
1353
|
+
* @returns {string}
|
|
1354
|
+
*/
|
|
1355
|
+
function contentRedoCommand(file, { pair = null } = {}) {
|
|
1356
|
+
return `champollion sync${pair ? ` --pair ${shellWord(pair)}` : ''} --redo files:${shellWord(file)}`;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Per locale: Markdown blocks and front-matter fields on disk that the
|
|
1361
|
+
* quality gate refuses today (lib/validate.js contentGateFault — the
|
|
1362
|
+
* key-value gate's checks: length inflation, echo, truncation, script). A
|
|
1363
|
+
* warning: sync refuses such output, so on disk it predates the check, was
|
|
1364
|
+
* written around it, or by hand. '[EN] ' fallbacks are their own finding.
|
|
1365
|
+
*
|
|
1366
|
+
* @returns {string[]} warnings
|
|
1367
|
+
*/
|
|
1368
|
+
function contentGateWarnings(locale, items, pairConfig, pair, fallbackPrefix) {
|
|
1369
|
+
const byFile = new Map();
|
|
1370
|
+
for (const it of items || []) {
|
|
1371
|
+
if (typeof it.value !== 'string' || it.value.startsWith(fallbackPrefix)) continue;
|
|
1372
|
+
const reason = contentGateFault(it.source, it.value, pairConfig);
|
|
1373
|
+
if (!reason) continue;
|
|
1374
|
+
const rest = it.key.slice('content:'.length);
|
|
1375
|
+
const m = /^(.*?)(?:#(\d+)| front matter:(.*))$/.exec(rest);
|
|
1376
|
+
const file = m ? m[1] : rest;
|
|
1377
|
+
const where = m && m[2] ? `paragraph ${m[2]}` : m && m[3] !== undefined ? `front matter "${m[3]}"` : rest;
|
|
1378
|
+
if (!byFile.has(file)) byFile.set(file, []);
|
|
1379
|
+
byFile.get(file).push({ where, reason });
|
|
1380
|
+
}
|
|
1381
|
+
const out = [];
|
|
1382
|
+
for (const [file, list] of byFile) {
|
|
1383
|
+
out.push(`${file}: ${list.length} Markdown block(s)/front-matter field(s) the quality gate refuses — `
|
|
1384
|
+
+ list.slice(0, 3).map(x => `${x.where}: ${x.reason}`).join('; ') + (list.length > 3 ? '; …' : '')
|
|
1385
|
+
+ ` — re-translate: \`${contentRedoCommand(file, { pair })}\``);
|
|
1386
|
+
}
|
|
1387
|
+
return out;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/**
|
|
1391
|
+
* Per locale: one value written for several different source strings
|
|
1392
|
+
* (lib/validate.js SharedOutputIndex — the gate's own rule, so synonyms
|
|
1393
|
+
* collapsing to one short word are not flagged). ICU messages count branch
|
|
1394
|
+
* by branch, and the locale's Markdown pages share the index with its keys.
|
|
1395
|
+
*
|
|
1396
|
+
* An ERROR, not a warning: sync's gate refuses the same group, so on disk
|
|
1397
|
+
* it is either older than the gate, written around it, or hand-made — and a
|
|
1398
|
+
* memorized sentence in place of three different clinical prompts passed a
|
|
1399
|
+
* warning-only verify (Round 5, hospital persona).
|
|
1400
|
+
*
|
|
1401
|
+
* @returns {string[]} errors
|
|
1402
|
+
*/
|
|
1403
|
+
function sharedOutputErrors(locale, values, sourceByKey, config, noTranslate, pairOf, contentItems = [], memorized = []) {
|
|
1404
|
+
const index = new SharedOutputIndex({ protectedTerms: config.protectedTerms || [] });
|
|
1405
|
+
// A sentence an earlier sync caught the model repeating (TM _meta.memorized):
|
|
1406
|
+
// on disk even once, it is that sentence, not a translation (Round 6).
|
|
1407
|
+
index.markMemorized(memorized);
|
|
1408
|
+
const items = [];
|
|
1409
|
+
for (const [key, value] of values || []) {
|
|
1410
|
+
const source = sourceByKey.get(key);
|
|
1411
|
+
if (typeof source !== 'string' || (noTranslate && noTranslate.matches(key, source))) continue;
|
|
1412
|
+
items.push(...sharedOutputItems(key, source, value));
|
|
1413
|
+
}
|
|
1414
|
+
items.push(...contentItems);
|
|
1415
|
+
const suspects = index.suspects(items);
|
|
1416
|
+
if (suspects.size === 0) return [];
|
|
1417
|
+
const groups = new Map();
|
|
1418
|
+
const remembered = new Set();
|
|
1419
|
+
for (const [key, g] of suspects) {
|
|
1420
|
+
if (!groups.has(g.value)) groups.set(g.value, []);
|
|
1421
|
+
groups.get(g.value).push(key);
|
|
1422
|
+
if (g.memorized) remembered.add(g.value);
|
|
1423
|
+
}
|
|
1424
|
+
const pair = pairOf.get(locale) || `${config.inputLocale}:${locale}`;
|
|
1425
|
+
const out = [];
|
|
1426
|
+
for (const [value, members] of groups) {
|
|
1427
|
+
const shown = value.length > 60 ? `${value.slice(0, 57)}...` : value;
|
|
1428
|
+
const keys = members.filter(k => !k.startsWith('content:'));
|
|
1429
|
+
const files = [...new Set(members.filter(k => k.startsWith('content:')).map(k => k.slice(8).replace(/(#\d+| front matter:.*)$/, '')))];
|
|
1430
|
+
// The content repair is the one sync prints for the same finding, word
|
|
1431
|
+
// for word (Round 7, school persona: the two named different commands).
|
|
1432
|
+
const redo = [
|
|
1433
|
+
keys.length > 0 && `\`${redoCommand(keys.slice(0, 8), { pair, fresh: true })}\``,
|
|
1434
|
+
...files.slice(0, 3).map(f => `\`${contentRedoCommand(f, { pair })}\``),
|
|
1435
|
+
].filter(Boolean).join(', ');
|
|
1436
|
+
out.push(remembered.has(value)
|
|
1437
|
+
? `${members.length} value(s) hold the sentence an earlier sync caught the model repeating for different source strings — ${JSON.stringify(shown)} for `
|
|
1438
|
+
+ `${members.map(k => k.replace(/^content:/, '')).slice(0, 5).join(', ')}${members.length > 5 ? '...' : ''} — a memorized sentence, `
|
|
1439
|
+
+ `not a translation. Re-translate (a "fallback" method on the pair takes what the model can only answer this way): ${redo}`
|
|
1440
|
+
: `${members.length} value(s) hold the same text for different source strings — ${JSON.stringify(shown)} for `
|
|
1441
|
+
+ `${members.map(k => k.replace(/^content:/, '')).slice(0, 5).join(', ')}${members.length > 5 ? '...' : ''} — a model repeating one memorized sentence, `
|
|
1442
|
+
+ `not translations of each. Re-translate: ${redo}`);
|
|
1443
|
+
}
|
|
1444
|
+
return out;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Two target locales whose values are the same text for most keys: one is
|
|
1449
|
+
* almost certainly in the other's language — the output of a model that
|
|
1450
|
+
* ignored the target language passes every per-locale check (Round 3,
|
|
1451
|
+
* Next.js persona: fr.json and de.json byte-identical, "All checks passed").
|
|
1452
|
+
*
|
|
1453
|
+
* Compared: keys both locales have whose values contain words, differ from
|
|
1454
|
+
* the source (an echo is its own finding), and are not no-translate or
|
|
1455
|
+
* declared names. Locales of one language (pt-BR / pt-PT, fr / fr-CA) are
|
|
1456
|
+
* not compared — they legitimately share most of their text. Flagged at
|
|
1457
|
+
* 80 % or more identical, over at least 4 compared keys.
|
|
1458
|
+
*
|
|
1459
|
+
* @returns {string[]} warnings
|
|
1460
|
+
*/
|
|
1461
|
+
function identicalLocaleWarnings(valuesByLocale, sourceByKey, config, noTranslate, pairOf) {
|
|
1462
|
+
const out = [];
|
|
1463
|
+
const locales = [...valuesByLocale.keys()];
|
|
1464
|
+
const lang = (l) => l.toLowerCase().split(/[-_]/)[0];
|
|
1465
|
+
for (let i = 0; i < locales.length; i++) {
|
|
1466
|
+
for (let j = i + 1; j < locales.length; j++) {
|
|
1467
|
+
const a = locales[i];
|
|
1468
|
+
const b = locales[j];
|
|
1469
|
+
if (lang(a) === lang(b)) continue;
|
|
1470
|
+
const va = valuesByLocale.get(a);
|
|
1471
|
+
const vb = valuesByLocale.get(b);
|
|
1472
|
+
let compared = 0;
|
|
1473
|
+
let identical = 0;
|
|
1474
|
+
for (const [key, x] of va) {
|
|
1475
|
+
const y = vb.get(key);
|
|
1476
|
+
const src = sourceByKey.get(key);
|
|
1477
|
+
if (typeof y !== 'string' || typeof src !== 'string') continue;
|
|
1478
|
+
if (x === src || y === src || !hasWords(x)) continue;
|
|
1479
|
+
if (noTranslate && noTranslate.matches(key, src)) continue;
|
|
1480
|
+
if (isProtectedTermValue(x, config.protectedTerms)) continue;
|
|
1481
|
+
compared++;
|
|
1482
|
+
if (x === y) identical++;
|
|
1483
|
+
}
|
|
1484
|
+
if (compared >= 4 && identical / compared >= 0.8) {
|
|
1485
|
+
const pa = pairOf.get(a) || `${config.inputLocale}:${a}`;
|
|
1486
|
+
const pb = pairOf.get(b) || `${config.inputLocale}:${b}`;
|
|
1487
|
+
out.push(`${a} and ${b}: ${identical} of ${compared} translated values are identical — one of them is probably `
|
|
1488
|
+
+ `in the other's language (or the model ignored the target language). Check both files; re-translate the wrong one: `
|
|
1489
|
+
+ `\`champollion sync --pair ${pa} --redo all --fresh\` (or --pair ${pb}).`);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
return out;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* Recursively collect all .json files under a directory — the shared
|
|
1498
|
+
* folder walk (lib/locale-layout.js), with Docusaurus's historical reach
|
|
1499
|
+
* (hidden entries included), the same walk the Docusaurus sync uses.
|
|
327
1500
|
*
|
|
328
1501
|
* @param {string} dir - Directory to walk
|
|
329
1502
|
* @returns {string[]} Absolute paths to .json files, sorted
|
|
330
1503
|
*/
|
|
331
1504
|
function walkJSONFiles(dir) {
|
|
332
|
-
|
|
333
|
-
function walk(d) {
|
|
334
|
-
if (!fs.existsSync(d)) return;
|
|
335
|
-
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
336
|
-
const full = path.join(d, entry.name);
|
|
337
|
-
if (entry.isDirectory()) walk(full);
|
|
338
|
-
else if (entry.isFile() && entry.name.endsWith('.json')) files.push(full);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
walk(dir);
|
|
342
|
-
return files.sort();
|
|
1505
|
+
return walkFiles(dir, name => name.endsWith('.json'), { skipHidden: false });
|
|
343
1506
|
}
|
|
344
1507
|
|
|
345
1508
|
/**
|
|
@@ -354,46 +1517,57 @@ function walkJSONFiles(dir) {
|
|
|
354
1517
|
* @param {object} config - Resolved config (format === 'docusaurus')
|
|
355
1518
|
* @param {string} cwd - Working directory
|
|
356
1519
|
* @param {import('./no-translate.js').NoTranslateMatcher} [noTranslate] - Compiled matcher
|
|
1520
|
+
* @param {Function} [echoPredicateFor]
|
|
1521
|
+
* @param {{ inScope?: (locale: string) => boolean, evictDamaged?: Function }} [hooks]
|
|
357
1522
|
* @returns {Promise<{ errors: number, warnings: number }>}
|
|
358
1523
|
*/
|
|
359
|
-
async function verifyDocusaurusLocales(config, cwd, noTranslate = null, echoPredicateFor = () => null
|
|
1524
|
+
async function verifyDocusaurusLocales(config, cwd, noTranslate = null, echoPredicateFor = () => null,
|
|
1525
|
+
{ inScope = () => true, evictDamaged = () => {}, fixFor = () => (keys, opts) => redoCommand(keys, opts), afterSync = false, strict = false, incomplete = null,
|
|
1526
|
+
describeChecked = null } = {}) {
|
|
360
1527
|
const sourceLocaleDir = path.join(config.localesDir, config.inputLocale);
|
|
361
1528
|
|
|
1529
|
+
const rel = (p) => path.relative(cwd, p).split(path.sep).join('/') || '.';
|
|
362
1530
|
if (!fs.existsSync(sourceLocaleDir)) {
|
|
363
|
-
|
|
364
|
-
|
|
1531
|
+
return nothingVerified(cwd, config, `the Docusaurus source folder ${rel(sourceLocaleDir)}/ does not exist `
|
|
1532
|
+
+ `("localesDir" and "inputLocale": "${config.inputLocale}" in champollion.config.json) — `
|
|
1533
|
+
+ `\`npx docusaurus write-translations --locale ${config.inputLocale}\` creates it.`);
|
|
365
1534
|
}
|
|
366
1535
|
|
|
367
1536
|
const sourceFiles = walkJSONFiles(sourceLocaleDir);
|
|
368
1537
|
if (sourceFiles.length === 0) {
|
|
369
|
-
|
|
370
|
-
return { errors: 0, warnings: 0 };
|
|
1538
|
+
return nothingVerified(cwd, config, `no source JSON strings under ${rel(sourceLocaleDir)}/.`);
|
|
371
1539
|
}
|
|
372
1540
|
|
|
373
1541
|
// Target locales = subdirectories of i18n/ other than the source locale.
|
|
374
1542
|
const targetLocales = fs.readdirSync(config.localesDir, { withFileTypes: true })
|
|
375
1543
|
.filter(e => e.isDirectory() && e.name !== config.inputLocale && !e.name.startsWith('.'))
|
|
376
1544
|
.map(e => e.name)
|
|
1545
|
+
.filter(inScope)
|
|
377
1546
|
.sort();
|
|
378
1547
|
|
|
379
1548
|
// Same false-green hole as the flat path: a configured locale with no
|
|
380
1549
|
// i18n/<locale>/ directory is invisible to the listing above. Fail loud.
|
|
381
1550
|
const missingTargets = [...configuredTargetLocales(config)]
|
|
1551
|
+
.filter(inScope)
|
|
382
1552
|
.filter(l => !fs.existsSync(path.join(config.localesDir, l)))
|
|
383
1553
|
.sort();
|
|
384
1554
|
|
|
385
1555
|
if (targetLocales.length === 0 && missingTargets.length === 0) {
|
|
386
|
-
return {
|
|
1556
|
+
return nothingVerified(cwd, config, `no target locale folders under ${rel(config.localesDir)}/ and no "languages" in champollion.config.json.`);
|
|
387
1557
|
}
|
|
388
1558
|
|
|
389
|
-
output.raw(
|
|
1559
|
+
output.raw(afterSync
|
|
1560
|
+
? '\n ── Post-Sync Verification (Docusaurus) ──────────────────\n'
|
|
1561
|
+
: '\n ── Verification (Docusaurus) ────────────────────────────\n');
|
|
390
1562
|
|
|
391
1563
|
let totalErrors = 0;
|
|
392
1564
|
let totalWarnings = 0;
|
|
393
1565
|
|
|
394
1566
|
for (const locale of missingTargets) {
|
|
395
|
-
|
|
1567
|
+
const message = `${locale}: locale directory missing (${path.join(path.basename(config.localesDir), locale)}/) — configured target has no translations. Run \`champollion sync\` to create it.`;
|
|
1568
|
+
output.error(`[VERIFY] ${message}`);
|
|
396
1569
|
totalErrors++;
|
|
1570
|
+
output.event('verify', { locale, pair: `${config.inputLocale}:${locale}`, ok: false, fileMissing: true, errors: [message], warnings: [], infos: [] });
|
|
397
1571
|
}
|
|
398
1572
|
|
|
399
1573
|
for (const locale of targetLocales) {
|
|
@@ -401,6 +1575,7 @@ async function verifyDocusaurusLocales(config, cwd, noTranslate = null, echoPred
|
|
|
401
1575
|
|
|
402
1576
|
const localeErrors = [];
|
|
403
1577
|
const localeWarnings = [];
|
|
1578
|
+
const localePlaceholders = [];
|
|
404
1579
|
|
|
405
1580
|
for (const sourceFile of sourceFiles) {
|
|
406
1581
|
const relPath = path.relative(sourceLocaleDir, sourceFile);
|
|
@@ -426,9 +1601,12 @@ async function verifyDocusaurusLocales(config, cwd, noTranslate = null, echoPred
|
|
|
426
1601
|
}
|
|
427
1602
|
}
|
|
428
1603
|
|
|
429
|
-
const { errors, warnings } = auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate,
|
|
1604
|
+
const { errors, warnings, damaged, placeholders } = auditTranslations(sourceFlat, targetFlat, locale, config, noTranslate,
|
|
1605
|
+
echoPredicateFor(locale), { fixFor: fixFor(locale) });
|
|
1606
|
+
evictDamaged(locale, damaged);
|
|
430
1607
|
for (const e of errors) localeErrors.push(`${relPath}: ${e}`);
|
|
431
1608
|
for (const w of warnings) localeWarnings.push(`${relPath}: ${w}`);
|
|
1609
|
+
for (const p of placeholders) localePlaceholders.push({ ...p, file: relPath.split(path.sep).join('/') });
|
|
432
1610
|
}
|
|
433
1611
|
|
|
434
1612
|
for (const err of localeErrors) {
|
|
@@ -440,12 +1618,26 @@ async function verifyDocusaurusLocales(config, cwd, noTranslate = null, echoPred
|
|
|
440
1618
|
totalWarnings++;
|
|
441
1619
|
}
|
|
442
1620
|
if (localeErrors.length === 0 && localeWarnings.length === 0) {
|
|
443
|
-
output.raw(
|
|
1621
|
+
output.raw(STRUCTURE_ONLY_OK);
|
|
444
1622
|
}
|
|
445
1623
|
output.raw('');
|
|
1624
|
+
output.event('verify', {
|
|
1625
|
+
locale, pair: `${config.inputLocale}:${locale}`,
|
|
1626
|
+
ok: localeErrors.length === 0 && localeWarnings.length === 0,
|
|
1627
|
+
errors: localeErrors, warnings: localeWarnings, infos: [], placeholders: localePlaceholders,
|
|
1628
|
+
});
|
|
446
1629
|
}
|
|
447
1630
|
|
|
448
|
-
|
|
1631
|
+
// Scoped, and some locale left out: the closing line names what was checked.
|
|
1632
|
+
const everyTarget = new Set([
|
|
1633
|
+
...fs.readdirSync(config.localesDir, { withFileTypes: true })
|
|
1634
|
+
.filter(e => e.isDirectory() && e.name !== config.inputLocale && !e.name.startsWith('.')).map(e => e.name),
|
|
1635
|
+
...configuredTargetLocales(config),
|
|
1636
|
+
]);
|
|
1637
|
+
const leftOut = [...everyTarget].some(l => !inScope(l));
|
|
1638
|
+
return printSummary(totalErrors, totalWarnings, {
|
|
1639
|
+
strict, incomplete, scope: describeChecked && leftOut ? describeChecked([...missingTargets, ...targetLocales]) : null,
|
|
1640
|
+
});
|
|
449
1641
|
}
|
|
450
1642
|
|
|
451
|
-
export { verifyLocales, auditTranslations };
|
|
1643
|
+
export { verifyLocales, auditTranslations, localesForPairFlag, redoCommand, contentRedoCommand, shellWord, pluralGapsInFile, contentItemsFor };
|