champollion 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +133 -0
- package/README.md +387 -0
- package/bin/cli.js +278 -0
- package/index.js +135 -0
- package/lib/api-key.js +127 -0
- package/lib/autofix.js +432 -0
- package/lib/bridge/method_bridge.py +430 -0
- package/lib/card-source-resolution.mjs +284 -0
- package/lib/cards/cache.js +169 -0
- package/lib/cards/env.js +82 -0
- package/lib/cards/fetch-card-child.js +38 -0
- package/lib/cards/reader.js +435 -0
- package/lib/cards/refresh.js +111 -0
- package/lib/cards/remote.js +387 -0
- package/lib/cldf-export.mjs +540 -0
- package/lib/cldf-terms.mjs +62 -0
- package/lib/command-help.js +790 -0
- package/lib/commands/audit.js +49 -0
- package/lib/commands/card.js +454 -0
- package/lib/commands/doctor.js +559 -0
- package/lib/commands/fonts.js +489 -0
- package/lib/commands/help.js +91 -0
- package/lib/commands/init.js +1259 -0
- package/lib/commands/integrity.js +148 -0
- package/lib/commands/leaderboard.js +478 -0
- package/lib/commands/lint.js +30 -0
- package/lib/commands/models.js +177 -0
- package/lib/commands/plugin.js +103 -0
- package/lib/commands/provenance.js +45 -0
- package/lib/commands/recommend.js +75 -0
- package/lib/commands/register-corpus.js +678 -0
- package/lib/commands/repair-script.js +42 -0
- package/lib/commands/seal-corpus.js +355 -0
- package/lib/commands/seo.js +72 -0
- package/lib/commands/serve.js +147 -0
- package/lib/commands/status.js +265 -0
- package/lib/commands/submit.js +332 -0
- package/lib/commands/sync.js +89 -0
- package/lib/commands/tm.js +573 -0
- package/lib/commands/verify.js +39 -0
- package/lib/commands/watch.js +20 -0
- package/lib/commands/wrap.js +138 -0
- package/lib/commands/xliff.js +327 -0
- package/lib/commercial-eligibility.js +235 -0
- package/lib/concurrent.js +87 -0
- package/lib/config.js +523 -0
- package/lib/contamination-lane.js +76 -0
- package/lib/content-sync.js +731 -0
- package/lib/content.js +733 -0
- package/lib/corpus-registration.mjs +608 -0
- package/lib/cost-report.js +346 -0
- package/lib/diff.js +155 -0
- package/lib/docusaurus-sync.js +1256 -0
- package/lib/flatten.js +55 -0
- package/lib/format.js +954 -0
- package/lib/hash.js +159 -0
- package/lib/icu.js +473 -0
- package/lib/integrity.js +689 -0
- package/lib/license-gate.mjs +478 -0
- package/lib/license-identify.mjs +229 -0
- package/lib/lint.js +629 -0
- package/lib/method-manifest.js +60 -0
- package/lib/methods/anthropic.js +140 -0
- package/lib/methods/apertium.js +163 -0
- package/lib/methods/api.js +316 -0
- package/lib/methods/base.js +184 -0
- package/lib/methods/content-separator.js +45 -0
- package/lib/methods/deepl.js +426 -0
- package/lib/methods/direct-llm.js +586 -0
- package/lib/methods/external.js +332 -0
- package/lib/methods/fetch-with-retry.js +124 -0
- package/lib/methods/gemini.js +147 -0
- package/lib/methods/google-translate.js +402 -0
- package/lib/methods/http-utils.js +122 -0
- package/lib/methods/libretranslate.js +314 -0
- package/lib/methods/llm-coached.js +670 -0
- package/lib/methods/llm.js +592 -0
- package/lib/methods/local.js +76 -0
- package/lib/methods/microsoft-translator.js +331 -0
- package/lib/methods/openai.js +131 -0
- package/lib/methods/openrouter-client.js +327 -0
- package/lib/methods/openrouter-pricing.js +156 -0
- package/lib/methods/provider-env.js +115 -0
- package/lib/methods/provider-pricing.js +310 -0
- package/lib/methods/tilde.js +150 -0
- package/lib/methods/translated.js +229 -0
- package/lib/methods/translation-error.js +80 -0
- package/lib/models.js +258 -0
- package/lib/no-translate.js +233 -0
- package/lib/output.js +238 -0
- package/lib/pairs.js +547 -0
- package/lib/plugins.js +447 -0
- package/lib/provenance.js +323 -0
- package/lib/recommend.js +648 -0
- package/lib/registers.js +1185 -0
- package/lib/repair-script.js +266 -0
- package/lib/scripts.js +994 -0
- package/lib/seal.mjs +464 -0
- package/lib/sealed-qualifier.mjs +211 -0
- package/lib/security.js +59 -0
- package/lib/segment.js +369 -0
- package/lib/seo.js +275 -0
- package/lib/serve.js +854 -0
- package/lib/string-classify.js +85 -0
- package/lib/submit.mjs +344 -0
- package/lib/sync.js +969 -0
- package/lib/tags/bcp47.js +202 -0
- package/lib/tags/resolve.js +314 -0
- package/lib/terminology.js +111 -0
- package/lib/tm-seed.js +294 -0
- package/lib/tm.js +515 -0
- package/lib/translate-pair.js +197 -0
- package/lib/translate.js +203 -0
- package/lib/types.js +230 -0
- package/lib/validate.js +510 -0
- package/lib/verify.js +451 -0
- package/lib/watch.js +145 -0
- package/lib/xliff.js +184 -0
- package/package.json +93 -0
- package/shared/ATTRIBUTION.md +145 -0
- package/shared/CORPORA-CARDS.md +288 -0
- package/shared/DATA-SOVEREIGNTY.md +500 -0
- package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
- package/shared/card-lint-baseline.json +3189 -0
- package/shared/cards-fallback.json +1 -0
- package/shared/catalogue/card-config.json +6091 -0
- package/shared/catalogue/external-results.json +3888 -0
- package/shared/catalogue/gender-guidance.json +1038 -0
- package/shared/catalogue/method-coverage.json +1751 -0
- package/shared/catalogue/metric-coverage.json +170 -0
- package/shared/catalogue/metric-reliability.json +1 -0
- package/shared/catalogue/register-presets.json +3180 -0
- package/shared/catalogue/vitality-scales.json +55 -0
- package/shared/cldr-index.json +1115 -0
- package/shared/code-bridge.json +253 -0
- package/shared/corpora-cards-v1-reference.md +281 -0
- package/shared/curated-dictionary-flags.json +35 -0
- package/shared/curated-endonyms.json +35 -0
- package/shared/curated-fsts.json +51 -0
- package/shared/curated-orthography-conventions.json +26 -0
- package/shared/curated-sil-resources.json +374 -0
- package/shared/curated-tools.json +41 -0
- package/shared/docent/corpus.json +11333 -0
- package/shared/docent/faq.en.json +564 -0
- package/shared/docent/register-blocks.json +60 -0
- package/shared/docent/system-prompt.md +144 -0
- package/shared/domain-taxonomy.json +35 -0
- package/shared/explainers/glossary.json +2975 -0
- package/shared/explainers/tc-features.json +20112 -0
- package/shared/explainers/term-watchlist.json +147 -0
- package/shared/human-services.json +59 -0
- package/shared/license-corrections.json +261 -0
- package/shared/license-evidence.json +13452 -0
- package/shared/licenses.json +6781 -0
- package/shared/method-registry.json +236 -0
- package/shared/metric-registry.json +620 -0
- package/shared/model-aliases.json +7 -0
- package/shared/schemas/champollion-plugin.schema.json +206 -0
- package/shared/schemas/corpora-card.schema.json +957 -0
- package/shared/schemas/domain-taxonomy.schema.json +64 -0
- package/shared/schemas/external-results.schema.json +314 -0
- package/shared/schemas/human-services.schema.json +90 -0
- package/shared/schemas/language-card.schema.json +1308 -0
- package/shared/schemas/licenses.schema.json +155 -0
- package/shared/schemas/method-card.schema.json +412 -0
- package/shared/schemas/method-registry.schema.json +85 -0
- package/shared/schemas/metric-registry.schema.json +96 -0
- package/shared/schemas/metric-reliability.schema.json +178 -0
- package/shared/schemas/model-aliases.schema.json +27 -0
- package/shared/schemas/source-snapshot.schema.json +96 -0
package/lib/integrity.js
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integrity linter — catches translation defects that silently break UI.
|
|
3
|
+
*
|
|
4
|
+
* THREE CLASSES OF DEFECT:
|
|
5
|
+
*
|
|
6
|
+
* 1. FORMAT LEAKS: ICU placeholders ({name}, {count, plural, ...}) that
|
|
7
|
+
* the translator mangled or dropped. Detected by comparing placeholder
|
|
8
|
+
* tokens between source and target.
|
|
9
|
+
*
|
|
10
|
+
* 2. ENCODING ISSUES: BOM markers, non-UTF-8 sequences, invisible
|
|
11
|
+
* directional marks (LRM/RLM/ZWJ/ZWNJ) where they shouldn't be.
|
|
12
|
+
*
|
|
13
|
+
* 3. STRUCTURAL PARITY: Target file has extra keys the source doesn't,
|
|
14
|
+
* or values that are clearly just the source value copy-pasted (same
|
|
15
|
+
* string in a different locale file = untranslated).
|
|
16
|
+
*
|
|
17
|
+
* Zero external dependencies. All string-based analysis.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { isICUString, parseICU, getRequiredPluralCategories } from './icu.js';
|
|
23
|
+
import { checkContentPreservation } from './validate.js';
|
|
24
|
+
|
|
25
|
+
// -----------------------------------------------------------------
|
|
26
|
+
// Placeholder extraction
|
|
27
|
+
// -----------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Extract ICU-style placeholders from a string.
|
|
31
|
+
*
|
|
32
|
+
* Handles:
|
|
33
|
+
* - Simple: {name}, {count}
|
|
34
|
+
* - Nested ICU: {count, plural, one {# item} other {# items}}
|
|
35
|
+
* - React-intl: <bold>text</bold>
|
|
36
|
+
*
|
|
37
|
+
* @param {string} text - Translation string
|
|
38
|
+
* @returns {string[]} Sorted array of placeholder tokens
|
|
39
|
+
*/
|
|
40
|
+
function extractPlaceholders(text) {
|
|
41
|
+
if (typeof text !== 'string') return [];
|
|
42
|
+
|
|
43
|
+
const placeholders = new Set();
|
|
44
|
+
|
|
45
|
+
// Simple ICU placeholders: {name}, {count}
|
|
46
|
+
// Match top-level braces only (not nested plurals)
|
|
47
|
+
const simplePattern = /\{(\w+)(?:[,}])/g;
|
|
48
|
+
let match;
|
|
49
|
+
while ((match = simplePattern.exec(text)) !== null) {
|
|
50
|
+
placeholders.add(match[1]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// React-intl XML tags: <bold>, </bold>, <link>, </link>
|
|
54
|
+
const xmlPattern = /<\/?(\w+)>/g;
|
|
55
|
+
while ((match = xmlPattern.exec(text)) !== null) {
|
|
56
|
+
placeholders.add(`<${match[1]}>`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return [...placeholders].sort();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compare placeholders between source and target strings.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} sourceValue - Source locale value
|
|
66
|
+
* @param {string} targetValue - Target locale value
|
|
67
|
+
* @returns {{ missing: string[], extra: string[] }} Placeholder differences
|
|
68
|
+
*/
|
|
69
|
+
function comparePlaceholders(sourceValue, targetValue) {
|
|
70
|
+
const sourcePH = extractPlaceholders(sourceValue);
|
|
71
|
+
const targetPH = extractPlaceholders(targetValue);
|
|
72
|
+
|
|
73
|
+
const missing = sourcePH.filter(p => !targetPH.includes(p));
|
|
74
|
+
const extra = targetPH.filter(p => !sourcePH.includes(p));
|
|
75
|
+
|
|
76
|
+
return { missing, extra };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// -----------------------------------------------------------------
|
|
80
|
+
// Encoding checks
|
|
81
|
+
// -----------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Invisible Unicode characters that cause silent UI bugs.
|
|
85
|
+
*
|
|
86
|
+
* Each entry has:
|
|
87
|
+
* - name: Human-readable name
|
|
88
|
+
* - regex: Detection pattern
|
|
89
|
+
* - severity: 'error' (likely bug) or 'warning' (suspicious but maybe intentional)
|
|
90
|
+
*/
|
|
91
|
+
const INVISIBLE_CHARS = [
|
|
92
|
+
{ name: 'BOM (Byte Order Mark)', regex: /\uFEFF/, severity: 'error' },
|
|
93
|
+
{ name: 'Zero-Width Space (ZWSP)', regex: /\u200B/, severity: 'warning' },
|
|
94
|
+
{ name: 'Zero-Width Non-Joiner (ZWNJ)', regex: /\u200C/, severity: 'warning' },
|
|
95
|
+
{ name: 'Zero-Width Joiner (ZWJ)', regex: /\u200D/, severity: 'warning' },
|
|
96
|
+
{ name: 'Left-to-Right Mark (LRM)', regex: /\u200E/, severity: 'warning' },
|
|
97
|
+
{ name: 'Right-to-Left Mark (RLM)', regex: /\u200F/, severity: 'warning' },
|
|
98
|
+
{ name: 'Left-to-Right Override', regex: /\u202D/, severity: 'error' },
|
|
99
|
+
{ name: 'Right-to-Left Override', regex: /\u202E/, severity: 'error' },
|
|
100
|
+
{ name: 'Pop Directional Formatting', regex: /\u202C/, severity: 'warning' },
|
|
101
|
+
{ name: 'Object Replacement Character', regex: /\uFFFC/, severity: 'error' },
|
|
102
|
+
{ name: 'Replacement Character (encoding error)', regex: /\uFFFD/, severity: 'error' },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Characters to escape when PRINTING a value in a report.
|
|
107
|
+
*
|
|
108
|
+
* Broader than INVISIBLE_CHARS above, which decides what counts as a
|
|
109
|
+
* DEFECT; this set only decides what is unreadable on a terminal. A report
|
|
110
|
+
* that prints a raw ZWSP shows two identical-looking lines and reads as a
|
|
111
|
+
* broken tool.
|
|
112
|
+
*/
|
|
113
|
+
const INVISIBLE_FOR_DISPLAY = new RegExp(
|
|
114
|
+
// Invisible format characters, plus the BMP Private Use Area — PUA renders
|
|
115
|
+
// as blank or tofu in a terminal, so a report that prints it raw is
|
|
116
|
+
// unreadable in exactly the cases (pIqaD/Tengwar/Kryptonian damage) where
|
|
117
|
+
// reading it matters most.
|
|
118
|
+
'[\\u00AD\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u206A-\\u206F\\uE000-\\uF8FF\\uFFFC\\uFFFD]',
|
|
119
|
+
'g',
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Check a string value for invisible/problematic Unicode characters.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} value - String to check
|
|
126
|
+
* @returns {{ name: string, severity: string }[]} Array of detected issues
|
|
127
|
+
*/
|
|
128
|
+
function checkEncoding(value) {
|
|
129
|
+
if (typeof value !== 'string') return [];
|
|
130
|
+
|
|
131
|
+
const issues = [];
|
|
132
|
+
for (const check of INVISIBLE_CHARS) {
|
|
133
|
+
if (check.regex.test(value)) {
|
|
134
|
+
issues.push({ name: check.name, severity: check.severity });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return issues;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Check if a file has a UTF-8 BOM at the start.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} filePath - Path to the file
|
|
144
|
+
* @returns {boolean} True if BOM is present
|
|
145
|
+
*/
|
|
146
|
+
function hasBOM(filePath) {
|
|
147
|
+
const buffer = fs.readFileSync(filePath);
|
|
148
|
+
return buffer.length >= 3 &&
|
|
149
|
+
buffer[0] === 0xEF &&
|
|
150
|
+
buffer[1] === 0xBB &&
|
|
151
|
+
buffer[2] === 0xBF;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// -----------------------------------------------------------------
|
|
155
|
+
// Cross-locale parity
|
|
156
|
+
// -----------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Check for untranslated values (target value === source value).
|
|
160
|
+
*
|
|
161
|
+
* Returns keys where the target is an exact copy of the source,
|
|
162
|
+
* excluding keys that look like they SHOULD be the same (brand names,
|
|
163
|
+
* URLs, format strings, numbers).
|
|
164
|
+
*
|
|
165
|
+
* @param {object} sourceFlat - Flattened source locale
|
|
166
|
+
* @param {object} targetFlat - Flattened target locale
|
|
167
|
+
* @param {string} targetLang - Target language code (for RTL direction checks)
|
|
168
|
+
* @param {import('./no-translate.js').NoTranslateMatcher} [noTranslate] -
|
|
169
|
+
* Compiled no-translate matcher. Keys it claims are SUPPOSED to be
|
|
170
|
+
* identical — flagging them as untranslated would turn a correct sync into
|
|
171
|
+
* a failing `champollion integrity` and back the user into the same corner
|
|
172
|
+
* the quality gate did.
|
|
173
|
+
* @param {((key: string, sourceValue: string) => boolean)|null} [isConfirmedEcho] -
|
|
174
|
+
* The SAME predicate the sync diff uses (lib/diff.js): true when the
|
|
175
|
+
* Translation Memory records that the pipeline itself produced this exact
|
|
176
|
+
* source-equal value and the gate approved it. Without it, integrity
|
|
177
|
+
* flagged thousands of settled echoes on a project sync reports as fully
|
|
178
|
+
* synced — two tools reading the same file and disagreeing about it. What
|
|
179
|
+
* remains flagged here is exactly what sync would requeue.
|
|
180
|
+
* @returns {string[]} Keys with identical source/target values
|
|
181
|
+
*/
|
|
182
|
+
function findUntranslatedCopies(sourceFlat, targetFlat, targetLang, noTranslate = null, isConfirmedEcho = null) {
|
|
183
|
+
const copies = [];
|
|
184
|
+
|
|
185
|
+
for (const [key, sourceVal] of Object.entries(sourceFlat)) {
|
|
186
|
+
const targetVal = targetFlat[key];
|
|
187
|
+
if (targetVal === undefined) continue;
|
|
188
|
+
if (typeof sourceVal !== 'string' || typeof targetVal !== 'string') continue;
|
|
189
|
+
|
|
190
|
+
// Skip if values are different — it's translated
|
|
191
|
+
if (sourceVal !== targetVal) continue;
|
|
192
|
+
|
|
193
|
+
// Skip values that are EXPECTED to be the same across locales
|
|
194
|
+
if (isLocaleInvariant(sourceVal)) continue;
|
|
195
|
+
|
|
196
|
+
// Skip keys the project declared no-translate — identical is correct.
|
|
197
|
+
if (noTranslate && noTranslate.matches(key, sourceVal)) continue;
|
|
198
|
+
|
|
199
|
+
// Skip echoes the TM confirms as pipeline-produced and gate-approved.
|
|
200
|
+
if (isConfirmedEcho && isConfirmedEcho(key, sourceVal)) continue;
|
|
201
|
+
|
|
202
|
+
copies.push(key);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return copies;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Find no-translate keys whose target has DRIFTED from the source.
|
|
210
|
+
*
|
|
211
|
+
* The inverse of findUntranslatedCopies, and the check that would have caught
|
|
212
|
+
* the production incident this feature exists for: 48 URL values across 13
|
|
213
|
+
* locales that a model bent just enough to clear the source-echo gate —
|
|
214
|
+
* fabricated fragments, stray trailing characters, an invisible U+200E in
|
|
215
|
+
* Arabic and U+200B in Hindi that broke the links outright.
|
|
216
|
+
*
|
|
217
|
+
* For a declared no-translate key there is exactly one correct target value,
|
|
218
|
+
* so any difference is a defect. `champollion sync` repairs it by copying the
|
|
219
|
+
* source verbatim.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} sourceFlat - Flattened source locale
|
|
222
|
+
* @param {object} targetFlat - Flattened target locale
|
|
223
|
+
* @param {import('./no-translate.js').NoTranslateMatcher} [noTranslate] - Compiled matcher
|
|
224
|
+
* @returns {Array<{ key: string, expected: string, actual: string, reason: string }>}
|
|
225
|
+
*/
|
|
226
|
+
function findNoTranslateDrift(sourceFlat, targetFlat, noTranslate = null) {
|
|
227
|
+
if (!noTranslate || !noTranslate.active) return [];
|
|
228
|
+
|
|
229
|
+
const drift = [];
|
|
230
|
+
for (const [key, sourceVal] of Object.entries(sourceFlat)) {
|
|
231
|
+
if (typeof sourceVal !== 'string') continue;
|
|
232
|
+
const targetVal = targetFlat[key];
|
|
233
|
+
// A key absent from the target is a missing-key finding, reported by the
|
|
234
|
+
// callers' own parity checks — not drift.
|
|
235
|
+
if (targetVal === undefined) continue;
|
|
236
|
+
if (targetVal === sourceVal) continue;
|
|
237
|
+
if (!noTranslate.matches(key, sourceVal)) continue;
|
|
238
|
+
|
|
239
|
+
drift.push({
|
|
240
|
+
key,
|
|
241
|
+
expected: sourceVal,
|
|
242
|
+
actual: typeof targetVal === 'string' ? targetVal : String(targetVal),
|
|
243
|
+
reason: noTranslate.reason(key, sourceVal),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return drift;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Check if a value is expected to be the same across all locales.
|
|
251
|
+
*
|
|
252
|
+
* Brand names, URLs, format patterns, single-word identifiers,
|
|
253
|
+
* numeric values, etc.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} value - The value to check
|
|
256
|
+
* @returns {boolean} True if the value should NOT be flagged as untranslated
|
|
257
|
+
*/
|
|
258
|
+
function isLocaleInvariant(value) {
|
|
259
|
+
const v = value.trim();
|
|
260
|
+
if (v.length === 0) return true;
|
|
261
|
+
|
|
262
|
+
// URLs
|
|
263
|
+
if (/^https?:\/\//.test(v)) return true;
|
|
264
|
+
// Email addresses
|
|
265
|
+
if (/^\S+@\S+\.\S+$/.test(v)) return true;
|
|
266
|
+
// Pure numbers (with optional formatting)
|
|
267
|
+
if (/^[\d.,\-+%$€£¥]+$/.test(v)) return true;
|
|
268
|
+
// Single word under 4 characters (likely a code or abbreviation)
|
|
269
|
+
if (/^\w{1,3}$/.test(v)) return true;
|
|
270
|
+
// Pure placeholder string: {name}
|
|
271
|
+
if (/^\{[\w,.\s]+\}$/.test(v)) return true;
|
|
272
|
+
// Format patterns: YYYY-MM-DD, HH:mm:ss
|
|
273
|
+
if (/^[YMDHhmsSzZ\-/:.\s]+$/.test(v)) return true;
|
|
274
|
+
// Known brand names that shouldn't be translated
|
|
275
|
+
// (Keeping this minimal — better to flag false positives than miss real copies)
|
|
276
|
+
if (/^(GitHub|Google|Facebook|Twitter|LinkedIn|YouTube|Instagram|WhatsApp|Stripe|PayPal|Apple|Microsoft)$/.test(v)) return true;
|
|
277
|
+
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Find values ON DISK that are their source hollowed of its letters.
|
|
283
|
+
*
|
|
284
|
+
* The translation-time gate (validate.js check 5) stops NEW hollowing, but
|
|
285
|
+
* values written by an older pipeline never re-enter the gate: their manifest
|
|
286
|
+
* hashes match the current source, so sync considers them settled forever.
|
|
287
|
+
* Upgraders discovered this the hard way — `" · · êhiêi"`-class damage
|
|
288
|
+
* survived the fix that would have refused to write it.
|
|
289
|
+
*
|
|
290
|
+
* Same two-signal rule as the gate (see checkContentPreservation for why a
|
|
291
|
+
* bare density threshold is unshippable — legitimate CJK sits at the same
|
|
292
|
+
* retention as the bug): low letter retention AND the value is the source
|
|
293
|
+
* with characters deleted. Values identical to their source are the echo
|
|
294
|
+
* checks' business; no-translate keys are the drift check's.
|
|
295
|
+
*
|
|
296
|
+
* The fix is re-translation, which no offline tool can do — so the finding
|
|
297
|
+
* names the sync invocation that does.
|
|
298
|
+
*
|
|
299
|
+
* @param {object} sourceFlat - Flattened source locale
|
|
300
|
+
* @param {object} targetFlat - Flattened target locale
|
|
301
|
+
* @param {import('./no-translate.js').NoTranslateMatcher} [noTranslate] - Compiled matcher
|
|
302
|
+
* @returns {Array<{ key: string, actual: string, reason: string }>}
|
|
303
|
+
*/
|
|
304
|
+
function findHollowedValues(sourceFlat, targetFlat, noTranslate = null) {
|
|
305
|
+
const findings = [];
|
|
306
|
+
for (const [key, sourceVal] of Object.entries(sourceFlat)) {
|
|
307
|
+
const targetVal = targetFlat[key];
|
|
308
|
+
if (typeof sourceVal !== 'string' || typeof targetVal !== 'string') continue;
|
|
309
|
+
if (sourceVal === targetVal) continue;
|
|
310
|
+
if (noTranslate && noTranslate.matches(key, sourceVal)) continue;
|
|
311
|
+
const hollowed = checkContentPreservation(sourceVal, targetVal);
|
|
312
|
+
if (hollowed) {
|
|
313
|
+
findings.push({ key, actual: targetVal, reason: hollowed.reason });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return findings;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Find Private Use Area codepoints where script conversion is switched OFF.
|
|
321
|
+
*
|
|
322
|
+
* PUA renders as nothing without a purpose-built font, and Champollion only
|
|
323
|
+
* ever writes it deliberately — through an opted-in script converter (pIqaD,
|
|
324
|
+
* Tengwar, Kryptonian). When the locale's script resolution says conversion
|
|
325
|
+
* is off, any PUA in the file is damage: either output from the pre-0.3.0
|
|
326
|
+
* unconditional converter, or corruption from elsewhere. Both mean blank
|
|
327
|
+
* strings on the page. `champollion repair-script` restores the romanization.
|
|
328
|
+
*
|
|
329
|
+
* Only runs when the caller passes a resolution for a PUA-capable converter
|
|
330
|
+
* locale — for every other locale PUA is out of scope here (checkEncoding
|
|
331
|
+
* covers the general invisible-character classes).
|
|
332
|
+
*
|
|
333
|
+
* @param {object} targetFlat - Flattened target locale
|
|
334
|
+
* @param {{converts: boolean, locale: string}|null} scriptExpectation -
|
|
335
|
+
* Caller-computed: does this locale's resolution apply a PUA converter?
|
|
336
|
+
* @returns {Array<{ key: string, actual: string, reason: string }>}
|
|
337
|
+
*/
|
|
338
|
+
function findUnexpectedPua(targetFlat, scriptExpectation = null) {
|
|
339
|
+
if (!scriptExpectation || scriptExpectation.converts) return [];
|
|
340
|
+
|
|
341
|
+
const PUA = /[\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/u;
|
|
342
|
+
const findings = [];
|
|
343
|
+
for (const [key, value] of Object.entries(targetFlat)) {
|
|
344
|
+
if (typeof value !== 'string' || !PUA.test(value)) continue;
|
|
345
|
+
findings.push({
|
|
346
|
+
key,
|
|
347
|
+
actual: value,
|
|
348
|
+
reason:
|
|
349
|
+
`Private Use Area codepoints, but script conversion is not enabled for ${scriptExpectation.locale} `
|
|
350
|
+
+ '— this text renders blank without a PUA font. Run `champollion repair-script` to restore the romanization.',
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return findings;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Find keys present in target but NOT in source (orphaned keys).
|
|
358
|
+
*
|
|
359
|
+
* @param {object} sourceFlat - Flattened source locale
|
|
360
|
+
* @param {object} targetFlat - Flattened target locale
|
|
361
|
+
* @returns {string[]} Keys only in target
|
|
362
|
+
*/
|
|
363
|
+
function findOrphanedKeys(sourceFlat, targetFlat) {
|
|
364
|
+
return Object.keys(targetFlat).filter(k => !(k in sourceFlat));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// -----------------------------------------------------------------
|
|
368
|
+
// ICU plural category validation
|
|
369
|
+
// -----------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Check whether ICU plural strings have the correct categories for the target locale.
|
|
373
|
+
*
|
|
374
|
+
* WHY: Arabic requires {zero, one, two, few, many, other} but if the LLM only
|
|
375
|
+
* produces {one, other}, the runtime silently falls back to 'other' for all
|
|
376
|
+
* other quantities — producing wrong text for 0, 2, 3-10, 11-99, etc.
|
|
377
|
+
*
|
|
378
|
+
* This function:
|
|
379
|
+
* 1. Scans source values for ICU plural patterns
|
|
380
|
+
* 2. Parses the corresponding target values
|
|
381
|
+
* 3. Compares their plural categories against what CLDR requires
|
|
382
|
+
* 4. Reports missing categories as warnings
|
|
383
|
+
*
|
|
384
|
+
* Only triggers on keys where the SOURCE value is an ICU plural string.
|
|
385
|
+
* If the source doesn't use ICU plurals, we don't check (the target shouldn't
|
|
386
|
+
* be expected to add plural forms the source doesn't have).
|
|
387
|
+
*
|
|
388
|
+
* @param {object} sourceFlat - Flattened source locale
|
|
389
|
+
* @param {object} targetFlat - Flattened target locale
|
|
390
|
+
* @param {string} targetLang - Target locale code
|
|
391
|
+
* @returns {Array<{ key: string, missing: string[], extra: string[] }>}
|
|
392
|
+
*/
|
|
393
|
+
function checkPluralCategories(sourceFlat, targetFlat, targetLang) {
|
|
394
|
+
const issues = [];
|
|
395
|
+
const requiredCategories = getRequiredPluralCategories(targetLang);
|
|
396
|
+
|
|
397
|
+
for (const [key, sourceVal] of Object.entries(sourceFlat)) {
|
|
398
|
+
if (typeof sourceVal !== 'string') continue;
|
|
399
|
+
|
|
400
|
+
// Only check if the source value contains an ICU plural pattern
|
|
401
|
+
if (!isICUString(sourceVal)) continue;
|
|
402
|
+
|
|
403
|
+
const sourceAST = parseICU(sourceVal);
|
|
404
|
+
const hasPluralNode = sourceAST.some(n => n.type === 'plural');
|
|
405
|
+
if (!hasPluralNode) continue;
|
|
406
|
+
|
|
407
|
+
// Now check the target's plural categories
|
|
408
|
+
const targetVal = targetFlat[key];
|
|
409
|
+
if (typeof targetVal !== 'string' || !isICUString(targetVal)) continue;
|
|
410
|
+
|
|
411
|
+
const targetAST = parseICU(targetVal);
|
|
412
|
+
const targetPluralNode = targetAST.find(n => n.type === 'plural');
|
|
413
|
+
if (!targetPluralNode || !targetPluralNode.options) continue;
|
|
414
|
+
|
|
415
|
+
const targetCategories = Object.keys(targetPluralNode.options);
|
|
416
|
+
|
|
417
|
+
// Find missing required categories (ignoring exact-match like =0, =1)
|
|
418
|
+
const missing = requiredCategories.filter(cat =>
|
|
419
|
+
!targetCategories.includes(cat) &&
|
|
420
|
+
!cat.startsWith('=')
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
// Find unexpected categories (not in CLDR for this locale)
|
|
424
|
+
// Exclude exact-match categories (=0, =1) which are always valid
|
|
425
|
+
const extra = targetCategories.filter(cat =>
|
|
426
|
+
!requiredCategories.includes(cat) &&
|
|
427
|
+
!cat.startsWith('=')
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
431
|
+
issues.push({ key, missing, extra });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return issues;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// -----------------------------------------------------------------
|
|
439
|
+
// Full integrity audit
|
|
440
|
+
// -----------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Run a full integrity audit on a locale pair.
|
|
444
|
+
*
|
|
445
|
+
* @param {object} sourceFlat - Flattened source locale
|
|
446
|
+
* @param {object} targetFlat - Flattened target locale
|
|
447
|
+
* @param {string} targetLang - Target language code
|
|
448
|
+
* @param {object} [options]
|
|
449
|
+
* @param {string} [options.sourceFile] - Path to the source file (for BOM reporting)
|
|
450
|
+
* @param {string} [options.targetFile] - Path to the target file (for BOM reporting)
|
|
451
|
+
* @param {import('./no-translate.js').NoTranslateMatcher} [options.noTranslate] -
|
|
452
|
+
* Compiled no-translate matcher. Its keys are exempted from the
|
|
453
|
+
* untranslated-copies check and checked for drift instead.
|
|
454
|
+
* @returns {{ placeholderIssues: object[], encodingIssues: object[], copies: string[], orphans: string[], noTranslateDrift: object[], bomFiles: string[] }}
|
|
455
|
+
*/
|
|
456
|
+
function auditLocalePair(sourceFlat, targetFlat, targetLang, options = {}) {
|
|
457
|
+
const placeholderIssues = [];
|
|
458
|
+
const encodingIssues = [];
|
|
459
|
+
|
|
460
|
+
for (const [key, sourceVal] of Object.entries(sourceFlat)) {
|
|
461
|
+
const targetVal = targetFlat[key];
|
|
462
|
+
if (targetVal === undefined) continue;
|
|
463
|
+
|
|
464
|
+
// Check placeholder preservation
|
|
465
|
+
if (typeof sourceVal === 'string' && typeof targetVal === 'string') {
|
|
466
|
+
const { missing, extra } = comparePlaceholders(sourceVal, targetVal);
|
|
467
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
468
|
+
placeholderIssues.push({ key, missing, extra, sourceVal, targetVal });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Check encoding in target values
|
|
473
|
+
if (typeof targetVal === 'string') {
|
|
474
|
+
const issues = checkEncoding(targetVal);
|
|
475
|
+
if (issues.length > 0) {
|
|
476
|
+
encodingIssues.push({ key, value: targetVal, issues });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const noTranslate = options.noTranslate || null;
|
|
482
|
+
const copies = findUntranslatedCopies(
|
|
483
|
+
sourceFlat, targetFlat, targetLang, noTranslate, options.isConfirmedEcho || null,
|
|
484
|
+
);
|
|
485
|
+
const noTranslateDrift = findNoTranslateDrift(sourceFlat, targetFlat, noTranslate);
|
|
486
|
+
const unexpectedPua = findUnexpectedPua(targetFlat, options.scriptExpectation || null);
|
|
487
|
+
const hollowedValues = findHollowedValues(sourceFlat, targetFlat, noTranslate);
|
|
488
|
+
const orphans = findOrphanedKeys(sourceFlat, targetFlat);
|
|
489
|
+
const pluralIssues = checkPluralCategories(sourceFlat, targetFlat, targetLang);
|
|
490
|
+
|
|
491
|
+
// File-level BOM check. When the caller passes file paths, REPORT a UTF-8
|
|
492
|
+
// BOM as an issue rather than letting it silently corrupt the first key /
|
|
493
|
+
// crash a downstream JSON.parse. Guarded so a vanished file can't throw.
|
|
494
|
+
const bomFiles = [];
|
|
495
|
+
for (const file of [options.sourceFile, options.targetFile]) {
|
|
496
|
+
try {
|
|
497
|
+
if (file && fs.existsSync(file) && hasBOM(file)) bomFiles.push(file);
|
|
498
|
+
} catch { /* unreadable file — not an integrity finding */ }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return { placeholderIssues, encodingIssues, copies, orphans, noTranslateDrift, unexpectedPua, hollowedValues, pluralIssues, bomFiles };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Render a value for a terminal report with invisible characters made visible.
|
|
506
|
+
*
|
|
507
|
+
* Printing a raw ZWSP or LRM produces two lines that look byte-identical and
|
|
508
|
+
* a reader who concludes the tool is broken. The whole class of corruption
|
|
509
|
+
* this reports is invisible by nature, so it has to be escaped to be read.
|
|
510
|
+
*
|
|
511
|
+
* @param {string} value - Value to display
|
|
512
|
+
* @returns {string} JSON-quoted value with invisible characters as \uXXXX
|
|
513
|
+
*/
|
|
514
|
+
function visualize(value) {
|
|
515
|
+
const escaped = String(value).replace(
|
|
516
|
+
INVISIBLE_FOR_DISPLAY,
|
|
517
|
+
ch => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`,
|
|
518
|
+
);
|
|
519
|
+
return JSON.stringify(escaped);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Format an integrity audit result as a console report.
|
|
524
|
+
*
|
|
525
|
+
* @param {string} targetLang - Target language code
|
|
526
|
+
* @param {object} audit - Result from auditLocalePair
|
|
527
|
+
* @returns {string} Formatted report
|
|
528
|
+
*/
|
|
529
|
+
function formatIntegrityReport(targetLang, audit) {
|
|
530
|
+
const lines = [];
|
|
531
|
+
const { placeholderIssues, encodingIssues, copies, orphans } = audit;
|
|
532
|
+
|
|
533
|
+
const bomFiles = audit.bomFiles || [];
|
|
534
|
+
const drift = audit.noTranslateDrift || [];
|
|
535
|
+
const unexpectedPua = audit.unexpectedPua || [];
|
|
536
|
+
const hollowedValues = audit.hollowedValues || [];
|
|
537
|
+
const totalIssues = placeholderIssues.length + encodingIssues.length +
|
|
538
|
+
copies.length + orphans.length + (audit.pluralIssues?.length || 0) +
|
|
539
|
+
bomFiles.length + drift.length + unexpectedPua.length + hollowedValues.length;
|
|
540
|
+
|
|
541
|
+
lines.push(`\n Integrity Audit: ${targetLang}`);
|
|
542
|
+
lines.push(` ${'─'.repeat(40)}`);
|
|
543
|
+
|
|
544
|
+
if (totalIssues === 0) {
|
|
545
|
+
lines.push(' [OK] All checks passed — no issues found');
|
|
546
|
+
lines.push('');
|
|
547
|
+
return lines.join('\n');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Placeholder issues
|
|
551
|
+
if (placeholderIssues.length > 0) {
|
|
552
|
+
lines.push(`\n PLACEHOLDER ISSUES (${placeholderIssues.length})`);
|
|
553
|
+
for (const issue of placeholderIssues.slice(0, 10)) {
|
|
554
|
+
lines.push(` ├── ${issue.key}`);
|
|
555
|
+
if (issue.missing.length > 0) {
|
|
556
|
+
lines.push(` │ Missing: ${issue.missing.join(', ')}`);
|
|
557
|
+
}
|
|
558
|
+
if (issue.extra.length > 0) {
|
|
559
|
+
lines.push(` │ Extra: ${issue.extra.join(', ')}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (placeholderIssues.length > 10) {
|
|
563
|
+
lines.push(` └── ... and ${placeholderIssues.length - 10} more`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Encoding issues
|
|
568
|
+
if (encodingIssues.length > 0) {
|
|
569
|
+
lines.push(`\n [WARN] ENCODING ISSUES (${encodingIssues.length})`);
|
|
570
|
+
for (const issue of encodingIssues.slice(0, 10)) {
|
|
571
|
+
const names = issue.issues.map(i => i.name).join(', ');
|
|
572
|
+
lines.push(` ├── ${issue.key}: ${names}`);
|
|
573
|
+
}
|
|
574
|
+
if (encodingIssues.length > 10) {
|
|
575
|
+
lines.push(` └── ... and ${encodingIssues.length - 10} more`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Untranslated copies
|
|
580
|
+
if (copies.length > 0) {
|
|
581
|
+
lines.push(`\n [WARN] UNTRANSLATED COPIES (${copies.length})`);
|
|
582
|
+
lines.push(' └── These keys have identical source/target values:');
|
|
583
|
+
for (const key of copies.slice(0, 10)) {
|
|
584
|
+
lines.push(` ${key}`);
|
|
585
|
+
}
|
|
586
|
+
if (copies.length > 10) {
|
|
587
|
+
lines.push(` ... and ${copies.length - 10} more`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// No-translate drift — a declared-verbatim key that isn't verbatim
|
|
592
|
+
if (drift.length > 0) {
|
|
593
|
+
lines.push(`\n NO-TRANSLATE DRIFT (${drift.length})`);
|
|
594
|
+
lines.push(' └── These keys must match the source byte-for-byte. Run `champollion sync` to repair:');
|
|
595
|
+
for (const d of drift.slice(0, 10)) {
|
|
596
|
+
lines.push(` ${d.key} [${d.reason}]`);
|
|
597
|
+
lines.push(` expected: ${visualize(d.expected)}`);
|
|
598
|
+
lines.push(` actual: ${visualize(d.actual)}`);
|
|
599
|
+
}
|
|
600
|
+
if (drift.length > 10) {
|
|
601
|
+
lines.push(` ... and ${drift.length - 10} more`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Unexpected PUA — unrenderable script conversion where none was asked for
|
|
606
|
+
if (unexpectedPua.length > 0) {
|
|
607
|
+
lines.push(`\n UNEXPECTED PUA (${unexpectedPua.length})`);
|
|
608
|
+
lines.push(' └── Private Use Area codepoints with script conversion OFF — renders blank');
|
|
609
|
+
lines.push(' without a PUA font. Run `champollion repair-script` to restore romanization:');
|
|
610
|
+
for (const p of unexpectedPua.slice(0, 10)) {
|
|
611
|
+
lines.push(` ${p.key}: ${visualize(p.actual)}`);
|
|
612
|
+
}
|
|
613
|
+
if (unexpectedPua.length > 10) {
|
|
614
|
+
lines.push(` ... and ${unexpectedPua.length - 10} more`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Hollowed values — old damage the translation-time gate never saw
|
|
619
|
+
if (hollowedValues.length > 0) {
|
|
620
|
+
lines.push(`\n HOLLOWED VALUES (${hollowedValues.length})`)
|
|
621
|
+
lines.push(' └── The source with its letters deleted — damage from an older pipeline.');
|
|
622
|
+
lines.push(' Re-translate: `champollion sync --force-keys <key>` (or `--pair <pair> --force`):');
|
|
623
|
+
for (const h of hollowedValues.slice(0, 10)) {
|
|
624
|
+
lines.push(` ${h.key}: ${visualize(h.actual)}`);
|
|
625
|
+
}
|
|
626
|
+
if (hollowedValues.length > 10) {
|
|
627
|
+
lines.push(` ... and ${hollowedValues.length - 10} more`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Orphaned keys
|
|
632
|
+
if (orphans.length > 0) {
|
|
633
|
+
lines.push(`\n [WARN] ORPHANED KEYS (${orphans.length})`);
|
|
634
|
+
lines.push(' └── Keys in target not present in source:');
|
|
635
|
+
for (const key of orphans.slice(0, 10)) {
|
|
636
|
+
lines.push(` ${key}`);
|
|
637
|
+
}
|
|
638
|
+
if (orphans.length > 10) {
|
|
639
|
+
lines.push(` ... and ${orphans.length - 10} more`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// BOM (file encoding) issues
|
|
644
|
+
if (bomFiles.length > 0) {
|
|
645
|
+
lines.push(`\n [WARN] BYTE ORDER MARK (${bomFiles.length})`);
|
|
646
|
+
lines.push(' └── These files start with a UTF-8 BOM (strip it):');
|
|
647
|
+
for (const file of bomFiles) {
|
|
648
|
+
lines.push(` ${file}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Plural category issues
|
|
653
|
+
if (audit.pluralIssues && audit.pluralIssues.length > 0) {
|
|
654
|
+
lines.push(`\n [WARN] PLURAL CATEGORY ISSUES (${audit.pluralIssues.length})`);
|
|
655
|
+
for (const issue of audit.pluralIssues.slice(0, 10)) {
|
|
656
|
+
lines.push(` ├── ${issue.key}`);
|
|
657
|
+
if (issue.missing.length > 0) {
|
|
658
|
+
lines.push(` │ Missing categories: ${issue.missing.join(', ')}`);
|
|
659
|
+
}
|
|
660
|
+
if (issue.extra.length > 0) {
|
|
661
|
+
lines.push(` │ Unexpected categories: ${issue.extra.join(', ')}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (audit.pluralIssues.length > 10) {
|
|
665
|
+
lines.push(` └── ... and ${audit.pluralIssues.length - 10} more`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
lines.push('');
|
|
670
|
+
return lines.join('\n');
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export {
|
|
674
|
+
extractPlaceholders,
|
|
675
|
+
comparePlaceholders,
|
|
676
|
+
checkEncoding,
|
|
677
|
+
hasBOM,
|
|
678
|
+
findUntranslatedCopies,
|
|
679
|
+
findNoTranslateDrift,
|
|
680
|
+
findUnexpectedPua,
|
|
681
|
+
findHollowedValues,
|
|
682
|
+
isLocaleInvariant,
|
|
683
|
+
findOrphanedKeys,
|
|
684
|
+
checkPluralCategories,
|
|
685
|
+
auditLocalePair,
|
|
686
|
+
formatIntegrityReport,
|
|
687
|
+
visualize,
|
|
688
|
+
INVISIBLE_CHARS,
|
|
689
|
+
};
|