@_linked/translation 0.2.2
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 +102 -0
- package/lib/cjs/data/translation.json +8 -0
- package/lib/esm/archive.d.ts +133 -0
- package/lib/esm/archive.js +567 -0
- package/lib/esm/archive.js.map +1 -0
- package/lib/esm/authorization.d.ts +12 -0
- package/lib/esm/authorization.js +10 -0
- package/lib/esm/authorization.js.map +1 -0
- package/lib/esm/backend.d.ts +1 -0
- package/lib/esm/backend.js +3 -0
- package/lib/esm/backend.js.map +1 -0
- package/lib/esm/cdn-loader.d.ts +40 -0
- package/lib/esm/cdn-loader.js +200 -0
- package/lib/esm/cdn-loader.js.map +1 -0
- package/lib/esm/checks.d.ts +56 -0
- package/lib/esm/checks.js +288 -0
- package/lib/esm/checks.js.map +1 -0
- package/lib/esm/compile.d.ts +64 -0
- package/lib/esm/compile.js +99 -0
- package/lib/esm/compile.js.map +1 -0
- package/lib/esm/core/messages.d.ts +25 -0
- package/lib/esm/core/messages.js +76 -0
- package/lib/esm/core/messages.js.map +1 -0
- package/lib/esm/data/translation.json +8 -0
- package/lib/esm/discovery.d.ts +97 -0
- package/lib/esm/discovery.js +365 -0
- package/lib/esm/discovery.js.map +1 -0
- package/lib/esm/exchange.d.ts +100 -0
- package/lib/esm/exchange.js +223 -0
- package/lib/esm/exchange.js.map +1 -0
- package/lib/esm/export.d.ts +28 -0
- package/lib/esm/export.js +92 -0
- package/lib/esm/export.js.map +1 -0
- package/lib/esm/formats/csv.d.ts +8 -0
- package/lib/esm/formats/csv.js +397 -0
- package/lib/esm/formats/csv.js.map +1 -0
- package/lib/esm/formats/json.d.ts +30 -0
- package/lib/esm/formats/json.js +459 -0
- package/lib/esm/formats/json.js.map +1 -0
- package/lib/esm/formats/xliff.d.ts +12 -0
- package/lib/esm/formats/xliff.js +577 -0
- package/lib/esm/formats/xliff.js.map +1 -0
- package/lib/esm/import.d.ts +197 -0
- package/lib/esm/import.js +391 -0
- package/lib/esm/import.js.map +1 -0
- package/lib/esm/index.d.ts +42 -0
- package/lib/esm/index.js +34 -0
- package/lib/esm/index.js.map +1 -0
- package/lib/esm/key-sync-node.d.ts +29 -0
- package/lib/esm/key-sync-node.js +174 -0
- package/lib/esm/key-sync-node.js.map +1 -0
- package/lib/esm/key-sync.d.ts +46 -0
- package/lib/esm/key-sync.js +114 -0
- package/lib/esm/key-sync.js.map +1 -0
- package/lib/esm/key-version.d.ts +50 -0
- package/lib/esm/key-version.js +134 -0
- package/lib/esm/key-version.js.map +1 -0
- package/lib/esm/languages.d.ts +18 -0
- package/lib/esm/languages.js +74 -0
- package/lib/esm/languages.js.map +1 -0
- package/lib/esm/linked-discovery.d.ts +44 -0
- package/lib/esm/linked-discovery.js +267 -0
- package/lib/esm/linked-discovery.js.map +1 -0
- package/lib/esm/message-format.d.ts +41 -0
- package/lib/esm/message-format.js +133 -0
- package/lib/esm/message-format.js.map +1 -0
- package/lib/esm/mt.d.ts +111 -0
- package/lib/esm/mt.js +142 -0
- package/lib/esm/mt.js.map +1 -0
- package/lib/esm/ontologies/translation.d.ts +203 -0
- package/lib/esm/ontologies/translation.js +219 -0
- package/lib/esm/ontologies/translation.js.map +1 -0
- package/lib/esm/package.d.ts +4 -0
- package/lib/esm/package.js +11 -0
- package/lib/esm/package.js.map +1 -0
- package/lib/esm/react.d.ts +55 -0
- package/lib/esm/react.js +127 -0
- package/lib/esm/react.js.map +1 -0
- package/lib/esm/records.d.ts +151 -0
- package/lib/esm/records.js +2 -0
- package/lib/esm/records.js.map +1 -0
- package/lib/esm/release.d.ts +198 -0
- package/lib/esm/release.js +435 -0
- package/lib/esm/release.js.map +1 -0
- package/lib/esm/shape-catalog.d.ts +79 -0
- package/lib/esm/shape-catalog.js +118 -0
- package/lib/esm/shape-catalog.js.map +1 -0
- package/lib/esm/shapes/GlossaryTerm.d.ts +28 -0
- package/lib/esm/shapes/GlossaryTerm.js +94 -0
- package/lib/esm/shapes/GlossaryTerm.js.map +1 -0
- package/lib/esm/shapes/TranslationInventoryOrigin.d.ts +33 -0
- package/lib/esm/shapes/TranslationInventoryOrigin.js +209 -0
- package/lib/esm/shapes/TranslationInventoryOrigin.js.map +1 -0
- package/lib/esm/shapes/TranslationKey.d.ts +92 -0
- package/lib/esm/shapes/TranslationKey.js +194 -0
- package/lib/esm/shapes/TranslationKey.js.map +1 -0
- package/lib/esm/shapes/TranslationKeyVersion.d.ts +19 -0
- package/lib/esm/shapes/TranslationKeyVersion.js +125 -0
- package/lib/esm/shapes/TranslationKeyVersion.js.map +1 -0
- package/lib/esm/shapes/TranslationLanguage.d.ts +15 -0
- package/lib/esm/shapes/TranslationLanguage.js +102 -0
- package/lib/esm/shapes/TranslationLanguage.js.map +1 -0
- package/lib/esm/shapes/TranslationProvider.d.ts +409 -0
- package/lib/esm/shapes/TranslationProvider.js +1376 -0
- package/lib/esm/shapes/TranslationProvider.js.map +1 -0
- package/lib/esm/shapes/TranslationRelease.d.ts +36 -0
- package/lib/esm/shapes/TranslationRelease.js +149 -0
- package/lib/esm/shapes/TranslationRelease.js.map +1 -0
- package/lib/esm/shapes/TranslationRevision.d.ts +39 -0
- package/lib/esm/shapes/TranslationRevision.js +159 -0
- package/lib/esm/shapes/TranslationRevision.js.map +1 -0
- package/lib/esm/shapes/TranslationUnit.d.ts +26 -0
- package/lib/esm/shapes/TranslationUnit.js +111 -0
- package/lib/esm/shapes/TranslationUnit.js.map +1 -0
- package/package.json +134 -0
- package/src/archive.ts +894 -0
- package/src/authorization.ts +33 -0
- package/src/backend.ts +2 -0
- package/src/cdn-loader.ts +264 -0
- package/src/checks.ts +413 -0
- package/src/compile.ts +168 -0
- package/src/core/messages.ts +106 -0
- package/src/data/translation.json +8 -0
- package/src/discovery.ts +594 -0
- package/src/exchange.ts +428 -0
- package/src/export.ts +135 -0
- package/src/formats/csv.ts +459 -0
- package/src/formats/json.ts +587 -0
- package/src/formats/xliff.ts +708 -0
- package/src/import.ts +648 -0
- package/src/index.ts +207 -0
- package/src/key-sync-node.ts +269 -0
- package/src/key-sync.ts +168 -0
- package/src/key-version.ts +206 -0
- package/src/languages.ts +102 -0
- package/src/linked-discovery.ts +385 -0
- package/src/message-format.ts +214 -0
- package/src/mt.ts +248 -0
- package/src/ontologies/translation.ts +234 -0
- package/src/package.ts +19 -0
- package/src/react.tsx +250 -0
- package/src/records.ts +180 -0
- package/src/release.ts +726 -0
- package/src/shape-catalog.ts +198 -0
- package/src/shapes/GlossaryTerm.ts +61 -0
- package/src/shapes/TranslationInventoryOrigin.ts +131 -0
- package/src/shapes/TranslationKey.ts +174 -0
- package/src/shapes/TranslationKeyVersion.ts +77 -0
- package/src/shapes/TranslationLanguage.ts +51 -0
- package/src/shapes/TranslationProvider.ts +1755 -0
- package/src/shapes/TranslationRelease.ts +114 -0
- package/src/shapes/TranslationRevision.ts +105 -0
- package/src/shapes/TranslationUnit.ts +72 -0
package/src/checks.ts
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translation quality CHECKS engine (Plan 017 B2-1). One pure function turns
|
|
3
|
+
* entries + the termbase into findings; the same engine runs live in the
|
|
4
|
+
* Studio (inline badges + Checks tab), post-MT (reject bad drafts before they
|
|
5
|
+
* are saved), and at import. Enforcement philosophy (ratified 2026-07-23):
|
|
6
|
+
* warn everywhere, gate only at publish — findings never block a save.
|
|
7
|
+
*/
|
|
8
|
+
import type { GlossaryTermRecord, TranslationEntryRecord } from './records.js';
|
|
9
|
+
import { findInconsistentTranslations, type ConsistencyEntry } from './mt.js';
|
|
10
|
+
import {
|
|
11
|
+
compareMessageArguments,
|
|
12
|
+
detectLegacyPluralSuffixes,
|
|
13
|
+
} from './message-format.js';
|
|
14
|
+
|
|
15
|
+
export type CheckSeverity = 'error' | 'warning';
|
|
16
|
+
|
|
17
|
+
export interface CheckFinding {
|
|
18
|
+
key: string;
|
|
19
|
+
language: string;
|
|
20
|
+
/** placeholder-integrity | term-keep | term-prefer | term-required | term-forbid | source-divergence */
|
|
21
|
+
rule: string;
|
|
22
|
+
severity: CheckSeverity;
|
|
23
|
+
message: string;
|
|
24
|
+
/** A concrete replacement text or wording hint, when one exists. */
|
|
25
|
+
suggestion?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type TermHarvestReason = 'repeated' | 'capitalized' | 'untranslated';
|
|
29
|
+
|
|
30
|
+
export interface TermHarvestCandidate {
|
|
31
|
+
term: string;
|
|
32
|
+
occurrences: number;
|
|
33
|
+
keys: string[];
|
|
34
|
+
languages: string[];
|
|
35
|
+
reasons: TermHarvestReason[];
|
|
36
|
+
suggestedType: 'prefer' | 'keep';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const HARVEST_STOP_WORDS = new Set([
|
|
40
|
+
'and',
|
|
41
|
+
'are',
|
|
42
|
+
'but',
|
|
43
|
+
'for',
|
|
44
|
+
'from',
|
|
45
|
+
'has',
|
|
46
|
+
'have',
|
|
47
|
+
'into',
|
|
48
|
+
'not',
|
|
49
|
+
'that',
|
|
50
|
+
'the',
|
|
51
|
+
'their',
|
|
52
|
+
'this',
|
|
53
|
+
'was',
|
|
54
|
+
'were',
|
|
55
|
+
'will',
|
|
56
|
+
'with',
|
|
57
|
+
'you',
|
|
58
|
+
'your',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
interface HarvestToken {
|
|
62
|
+
value: string;
|
|
63
|
+
normalized: string;
|
|
64
|
+
capitalized: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function harvestTokens(text: string): HarvestToken[] {
|
|
68
|
+
const matches = [...text.matchAll(/[\p{L}\p{N}][\p{L}\p{M}\p{N}'’.-]*/gu)];
|
|
69
|
+
return matches.flatMap((match, index) => {
|
|
70
|
+
const value = match[0].replace(/[.'’-]+$/u, '');
|
|
71
|
+
const normalized = value.toLocaleLowerCase();
|
|
72
|
+
if (
|
|
73
|
+
value.length < 3 ||
|
|
74
|
+
HARVEST_STOP_WORDS.has(normalized) ||
|
|
75
|
+
/^\d+$/u.test(value)
|
|
76
|
+
) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
const first = value[0];
|
|
80
|
+
const capitalized =
|
|
81
|
+
(index > 0 &&
|
|
82
|
+
first === first.toLocaleUpperCase() &&
|
|
83
|
+
first !== first.toLocaleLowerCase()) ||
|
|
84
|
+
/^\p{Lu}{2,}[\p{Lu}\p{N}-]*$/u.test(value);
|
|
85
|
+
return [{ value, normalized, capitalized }];
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Mine review candidates from source catalogs. This is intentionally
|
|
91
|
+
* conservative and pure: it suggests repeated terms, likely proper names,
|
|
92
|
+
* and source words that survive unchanged in translations. A human still
|
|
93
|
+
* chooses the rule and saves the term.
|
|
94
|
+
*/
|
|
95
|
+
export function harvestTermCandidates(
|
|
96
|
+
entries: TranslationEntryRecord[],
|
|
97
|
+
languages: string[],
|
|
98
|
+
termbase: GlossaryTermRecord[] = []
|
|
99
|
+
): TermHarvestCandidate[] {
|
|
100
|
+
const existing = new Set(
|
|
101
|
+
termbase.map((entry) => entry.term.toLocaleLowerCase())
|
|
102
|
+
);
|
|
103
|
+
const aggregate = new Map<
|
|
104
|
+
string,
|
|
105
|
+
{
|
|
106
|
+
display: string;
|
|
107
|
+
occurrences: number;
|
|
108
|
+
keys: Set<string>;
|
|
109
|
+
capitalizedKeys: Set<string>;
|
|
110
|
+
untranslatedLanguages: Set<string>;
|
|
111
|
+
}
|
|
112
|
+
>();
|
|
113
|
+
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const tokens = harvestTokens(entry.sourceText);
|
|
116
|
+
for (const token of tokens) {
|
|
117
|
+
const value = aggregate.get(token.normalized) ?? {
|
|
118
|
+
display: token.value,
|
|
119
|
+
occurrences: 0,
|
|
120
|
+
keys: new Set<string>(),
|
|
121
|
+
capitalizedKeys: new Set<string>(),
|
|
122
|
+
untranslatedLanguages: new Set<string>(),
|
|
123
|
+
};
|
|
124
|
+
value.occurrences++;
|
|
125
|
+
value.keys.add(entry.key);
|
|
126
|
+
if (token.capitalized) {
|
|
127
|
+
value.display = token.value;
|
|
128
|
+
value.capitalizedKeys.add(entry.key);
|
|
129
|
+
}
|
|
130
|
+
for (const language of languages) {
|
|
131
|
+
const translated = entry.units[language]?.text;
|
|
132
|
+
if (translated && containsTerm(translated, token.value, true)) {
|
|
133
|
+
value.untranslatedLanguages.add(language);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
aggregate.set(token.normalized, value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return [...aggregate.entries()]
|
|
141
|
+
.flatMap(([normalized, value]): TermHarvestCandidate[] => {
|
|
142
|
+
if (existing.has(normalized)) return [];
|
|
143
|
+
const repeated = value.keys.size >= 3;
|
|
144
|
+
const capitalized = value.capitalizedKeys.size >= 2;
|
|
145
|
+
const untranslated = value.untranslatedLanguages.size > 0;
|
|
146
|
+
if (!repeated && !capitalized && !untranslated) return [];
|
|
147
|
+
const reasons: TermHarvestReason[] = [];
|
|
148
|
+
if (repeated) reasons.push('repeated');
|
|
149
|
+
if (capitalized) reasons.push('capitalized');
|
|
150
|
+
if (untranslated) reasons.push('untranslated');
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
term: value.display,
|
|
154
|
+
occurrences: value.occurrences,
|
|
155
|
+
keys: [...value.keys].sort(),
|
|
156
|
+
languages: [...value.untranslatedLanguages].sort(),
|
|
157
|
+
reasons,
|
|
158
|
+
suggestedType: capitalized || untranslated ? 'keep' : 'prefer',
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
})
|
|
162
|
+
.sort(
|
|
163
|
+
(left, right) =>
|
|
164
|
+
right.keys.length - left.keys.length ||
|
|
165
|
+
right.reasons.length - left.reasons.length ||
|
|
166
|
+
left.term.localeCompare(right.term)
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Escape a literal for embedding in a RegExp. */
|
|
171
|
+
const escapeRe = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whole-word-ish containment test. Word boundaries only apply when the term
|
|
175
|
+
* starts/ends with word characters (so "e.V." or "#tag" still match), and
|
|
176
|
+
* matching is Unicode-aware.
|
|
177
|
+
*/
|
|
178
|
+
export function containsTerm(
|
|
179
|
+
text: string,
|
|
180
|
+
term: string,
|
|
181
|
+
caseSensitive = false,
|
|
182
|
+
locale?: string,
|
|
183
|
+
): boolean {
|
|
184
|
+
// Unicode regex folding alone does not handle Turkish dotted/dotless I.
|
|
185
|
+
// Only target text uses its locale; an English source still uses English
|
|
186
|
+
// matching even when the translation is Turkish.
|
|
187
|
+
if (!caseSensitive && locale) {
|
|
188
|
+
try {
|
|
189
|
+
text = text.toLocaleLowerCase(locale);
|
|
190
|
+
term = term.toLocaleLowerCase(locale);
|
|
191
|
+
} catch {
|
|
192
|
+
// Private/custom language tags can be valid application identifiers
|
|
193
|
+
// without being supported by the host's locale implementation.
|
|
194
|
+
text = text.toLowerCase();
|
|
195
|
+
term = term.toLowerCase();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Chinese and Japanese phrases routinely touch other letters without spaces.
|
|
199
|
+
// Requiring whitespace-like boundaries would reject valid terms such as 合一.
|
|
200
|
+
const unspaced =
|
|
201
|
+
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(term);
|
|
202
|
+
const boundedStart =
|
|
203
|
+
!unspaced && /^[\p{L}\p{N}]/u.test(term) ? '(?<![\\p{L}\\p{N}])' : '';
|
|
204
|
+
const boundedEnd =
|
|
205
|
+
!unspaced && /[\p{L}\p{N}]$/u.test(term) ? '(?![\\p{L}\\p{N}])' : '';
|
|
206
|
+
const re = new RegExp(
|
|
207
|
+
`${boundedStart}${escapeRe(term)}${boundedEnd}`,
|
|
208
|
+
caseSensitive ? 'u' : 'iu'
|
|
209
|
+
);
|
|
210
|
+
return re.test(text);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const forLanguage = (termbase: GlossaryTermRecord[], language: string) =>
|
|
214
|
+
termbase.filter((t) => t.term && (!t.language || t.language === language));
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Termbase violations for ONE translated text. Exported separately so the
|
|
218
|
+
* post-MT guard can reject a draft with the same rules the Studio shows.
|
|
219
|
+
*/
|
|
220
|
+
export function termbaseViolations(
|
|
221
|
+
sourceText: string,
|
|
222
|
+
translatedText: string,
|
|
223
|
+
termbase: GlossaryTermRecord[],
|
|
224
|
+
language: string
|
|
225
|
+
): Array<Pick<CheckFinding, 'rule' | 'severity' | 'message' | 'suggestion'>> {
|
|
226
|
+
const findings: Array<
|
|
227
|
+
Pick<CheckFinding, 'rule' | 'severity' | 'message' | 'suggestion'>
|
|
228
|
+
> = [];
|
|
229
|
+
for (const entry of forLanguage(termbase, language)) {
|
|
230
|
+
if (entry.termType === 'keep') {
|
|
231
|
+
// A keep-term appearing in the source must survive VERBATIM (exact
|
|
232
|
+
// casing — that's the point of keep: proper nouns, brand styling,
|
|
233
|
+
// stay-one-language words).
|
|
234
|
+
if (
|
|
235
|
+
containsTerm(sourceText, entry.term, entry.caseSensitive ?? false) &&
|
|
236
|
+
!containsTerm(translatedText, entry.term, true)
|
|
237
|
+
) {
|
|
238
|
+
findings.push({
|
|
239
|
+
rule: 'term-keep',
|
|
240
|
+
severity: 'error',
|
|
241
|
+
message: `“${entry.term}” must stay exactly as written.`,
|
|
242
|
+
suggestion: entry.term,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
} else if (entry.termType === 'forbid') {
|
|
246
|
+
if (
|
|
247
|
+
containsTerm(translatedText, entry.term, entry.caseSensitive ?? false, language)
|
|
248
|
+
) {
|
|
249
|
+
findings.push({
|
|
250
|
+
rule: 'term-forbid',
|
|
251
|
+
severity: 'error',
|
|
252
|
+
message: entry.useInstead
|
|
253
|
+
? `“${entry.term}” is not allowed — use “${entry.useInstead}”.`
|
|
254
|
+
: `“${entry.term}” is not allowed here.`,
|
|
255
|
+
suggestion: entry.useInstead,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
// Prefer remains advisory. Require is a source-scoped contract: it
|
|
260
|
+
// prevents two distinct concepts from collapsing into the same label.
|
|
261
|
+
if (
|
|
262
|
+
entry.translation &&
|
|
263
|
+
containsTerm(sourceText, entry.term, entry.caseSensitive ?? false) &&
|
|
264
|
+
!containsTerm(
|
|
265
|
+
translatedText,
|
|
266
|
+
entry.translation,
|
|
267
|
+
entry.caseSensitive ?? false,
|
|
268
|
+
language,
|
|
269
|
+
)
|
|
270
|
+
) {
|
|
271
|
+
findings.push({
|
|
272
|
+
rule: entry.termType === 'require' ? 'term-required' : 'term-prefer',
|
|
273
|
+
severity: entry.termType === 'require' ? 'error' : 'warning',
|
|
274
|
+
message:
|
|
275
|
+
entry.termType === 'require'
|
|
276
|
+
? `“${entry.term}” must be translated as “${entry.translation}”.`
|
|
277
|
+
: `“${entry.term}” is preferably translated as “${entry.translation}”.`,
|
|
278
|
+
suggestion: entry.translation,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return findings;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** True when a draft breaks an ERROR-severity termbase rule (post-MT gate). */
|
|
287
|
+
export function violatesTermbase(
|
|
288
|
+
sourceText: string,
|
|
289
|
+
translatedText: string,
|
|
290
|
+
termbase: GlossaryTermRecord[],
|
|
291
|
+
language: string
|
|
292
|
+
): boolean {
|
|
293
|
+
return termbaseViolations(
|
|
294
|
+
sourceText,
|
|
295
|
+
translatedText,
|
|
296
|
+
termbase,
|
|
297
|
+
language
|
|
298
|
+
).some((f) => f.severity === 'error');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Run every check for one language across the app's entries. Pure and
|
|
303
|
+
* client-safe — the Studio computes findings locally from data it already has.
|
|
304
|
+
*/
|
|
305
|
+
export function runChecks(
|
|
306
|
+
entries: ConsistencyEntry[],
|
|
307
|
+
language: string,
|
|
308
|
+
termbase: GlossaryTermRecord[] = []
|
|
309
|
+
): CheckFinding[] {
|
|
310
|
+
const findings: CheckFinding[] = [];
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
const text = entry.units[language]?.text?.trim();
|
|
313
|
+
if (!text || !entry.sourceText.trim()) continue;
|
|
314
|
+
const format = entry.format === 'icu' ? 'icu' : 'simple';
|
|
315
|
+
const comparison = compareMessageArguments(entry.sourceText, text, format);
|
|
316
|
+
if (comparison.sourceError || comparison.targetError) {
|
|
317
|
+
findings.push({
|
|
318
|
+
key: entry.key,
|
|
319
|
+
language,
|
|
320
|
+
rule: 'message-syntax',
|
|
321
|
+
severity: 'error',
|
|
322
|
+
message: comparison.sourceError
|
|
323
|
+
? `The source ${format.toUpperCase()} message is invalid: ${
|
|
324
|
+
comparison.sourceError
|
|
325
|
+
}`
|
|
326
|
+
: `The translation ${format.toUpperCase()} message is invalid: ${
|
|
327
|
+
comparison.targetError
|
|
328
|
+
}`,
|
|
329
|
+
});
|
|
330
|
+
} else if (!comparison.valid) {
|
|
331
|
+
const details = [
|
|
332
|
+
comparison.missing.length
|
|
333
|
+
? `Missing ${comparison.missing
|
|
334
|
+
.map((name) => `{${name}}`)
|
|
335
|
+
.join(', ')}.`
|
|
336
|
+
: '',
|
|
337
|
+
comparison.extra.length
|
|
338
|
+
? `Unexpected ${comparison.extra
|
|
339
|
+
.map((name) => `{${name}}`)
|
|
340
|
+
.join(', ')}.`
|
|
341
|
+
: '',
|
|
342
|
+
...comparison.typeMismatches.map(
|
|
343
|
+
({ name, source, target }) =>
|
|
344
|
+
`{${name}} changes from ${source.join('/')} to ${target.join('/')}.`
|
|
345
|
+
),
|
|
346
|
+
].filter(Boolean);
|
|
347
|
+
findings.push({
|
|
348
|
+
key: entry.key,
|
|
349
|
+
language,
|
|
350
|
+
rule: 'placeholder-integrity',
|
|
351
|
+
severity: 'error',
|
|
352
|
+
message: details.join(' '),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
const legacyPlural = detectLegacyPluralSuffixes(entry.sourceText);
|
|
356
|
+
if (format === 'simple' && legacyPlural.length > 0) {
|
|
357
|
+
findings.push({
|
|
358
|
+
key: entry.key,
|
|
359
|
+
language,
|
|
360
|
+
rule: 'legacy-plural-suffix',
|
|
361
|
+
severity: 'warning',
|
|
362
|
+
message: `${legacyPlural
|
|
363
|
+
.map(({ suffixArgument }) => `{${suffixArgument}}`)
|
|
364
|
+
.join(
|
|
365
|
+
', '
|
|
366
|
+
)} is an English suffix variable, not pluralization. It may be omitted in this translation; migrate this key to an ICU plural for locale-aware grammar.`,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
for (const violation of termbaseViolations(
|
|
370
|
+
entry.sourceText,
|
|
371
|
+
text,
|
|
372
|
+
termbase,
|
|
373
|
+
language
|
|
374
|
+
)) {
|
|
375
|
+
findings.push({ key: entry.key, language, ...violation });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Identical source ⇒ divergent target. Suggest the majority variant.
|
|
379
|
+
for (const conflict of findInconsistentTranslations(entries, language)) {
|
|
380
|
+
const majority = [...conflict.variants].sort(
|
|
381
|
+
(a, b) => b.keys.length - a.keys.length || a.text.localeCompare(b.text)
|
|
382
|
+
)[0];
|
|
383
|
+
for (const variant of conflict.variants) {
|
|
384
|
+
if (variant === majority) continue;
|
|
385
|
+
for (const key of variant.keys) {
|
|
386
|
+
findings.push({
|
|
387
|
+
key,
|
|
388
|
+
language,
|
|
389
|
+
rule: 'source-divergence',
|
|
390
|
+
severity: 'warning',
|
|
391
|
+
message: `“${conflict.sourceText}” is translated “${
|
|
392
|
+
variant.text
|
|
393
|
+
}” here but “${majority.text}” on ${majority.keys.length} other key${
|
|
394
|
+
majority.keys.length === 1 ? '' : 's'
|
|
395
|
+
}.`,
|
|
396
|
+
suggestion: majority.text,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return findings;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Findings grouped per key for inline cell badges. */
|
|
405
|
+
export function findingsByKey(
|
|
406
|
+
findings: CheckFinding[]
|
|
407
|
+
): Map<string, CheckFinding[]> {
|
|
408
|
+
const map = new Map<string, CheckFinding[]>();
|
|
409
|
+
for (const finding of findings) {
|
|
410
|
+
map.set(finding.key, [...(map.get(finding.key) ?? []), finding]);
|
|
411
|
+
}
|
|
412
|
+
return map;
|
|
413
|
+
}
|
package/src/compile.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
languageFallbacks,
|
|
3
|
+
type TranslationLanguageDefinition,
|
|
4
|
+
} from './languages.js';
|
|
5
|
+
/**
|
|
6
|
+
* Compile app translations into publishable per-language payloads + a manifest
|
|
7
|
+
* (Plan 016 P2.2, AD-K/AD-I). Pure and I/O-free: it takes the same
|
|
8
|
+
* `TranslationEntryRecord[]` that `TranslationProvider.listEntries` already
|
|
9
|
+
* produces and emits the `{lang}.json` maps + a content-hashed `manifest.json`.
|
|
10
|
+
* The R2 publish route (P2.3) and the eject-honest CLI (R11) are thin wrappers
|
|
11
|
+
* that read the entries, call this, and write the result.
|
|
12
|
+
*
|
|
13
|
+
* Only `kind:'ui'` keys are published; `kind:'content'` resolves query-time from
|
|
14
|
+
* the app's own dataset (AD-I). Per-language publish policy (AD-K/R9): a
|
|
15
|
+
* `reviewedOnly` language ships the reviewed value else falls back to the English
|
|
16
|
+
* `sourceText` — never a machine/stale draft. Every payload is a COMPLETE
|
|
17
|
+
* catalog (English fallback for anything missing), so it stands alone without the
|
|
18
|
+
* app's inline defaults.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
directionFor,
|
|
22
|
+
type TranslationMessage,
|
|
23
|
+
type TranslationMessages,
|
|
24
|
+
} from './core/messages.js';
|
|
25
|
+
import type { TranslationEntryRecord } from './records.js';
|
|
26
|
+
|
|
27
|
+
export type TranslationPayload = TranslationMessages;
|
|
28
|
+
|
|
29
|
+
export interface TranslationManifestEntry {
|
|
30
|
+
hash: string;
|
|
31
|
+
dir: 'ltr' | 'rtl';
|
|
32
|
+
count: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TranslationManifest {
|
|
36
|
+
schemaVersion: 1;
|
|
37
|
+
defaultLanguage: string;
|
|
38
|
+
generatedAt?: string;
|
|
39
|
+
languageResources?: TranslationLanguageDefinition[];
|
|
40
|
+
languages: Record<string, TranslationManifestEntry>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CompileOptions {
|
|
44
|
+
languageResources?: TranslationLanguageDefinition[];
|
|
45
|
+
languages: string[];
|
|
46
|
+
defaultLanguage: string;
|
|
47
|
+
/** BCP-47 tags published reviewed-only (absence ⇒ machine-ok). */
|
|
48
|
+
reviewedOnlyLanguages?: Iterable<string>;
|
|
49
|
+
/** Stamp the manifest; omit for deterministic output (tests). */
|
|
50
|
+
generatedAt?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Deterministic FNV-1a (32-bit) hex over a string — dep-free, browser-safe. */
|
|
54
|
+
export function hashString(input: string): string {
|
|
55
|
+
let h = 0x811c9dc5;
|
|
56
|
+
for (let i = 0; i < input.length; i++) {
|
|
57
|
+
h ^= input.charCodeAt(i);
|
|
58
|
+
h = Math.imul(h, 0x01000193);
|
|
59
|
+
}
|
|
60
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Canonical JSON with sorted keys, so the hash is stable across runs. */
|
|
64
|
+
export function stableStringify(payload: TranslationPayload): string {
|
|
65
|
+
const keys = Object.keys(payload).sort();
|
|
66
|
+
return JSON.stringify(Object.fromEntries(keys.map((k) => [k, payload[k]])));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Content hash of a payload (drives the `{lang}.{hash}.json` filename). */
|
|
70
|
+
export function hashPayload(payload: TranslationPayload): string {
|
|
71
|
+
return hashString(stableStringify(payload));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Compile one language's complete catalog from the app's UI entries, honoring
|
|
76
|
+
* publish policy. The default/source language emits `sourceText` for every key.
|
|
77
|
+
*/
|
|
78
|
+
export function compileLanguage(
|
|
79
|
+
entries: TranslationEntryRecord[],
|
|
80
|
+
language: string,
|
|
81
|
+
options: {
|
|
82
|
+
defaultLanguage: string;
|
|
83
|
+
reviewedOnly?: boolean;
|
|
84
|
+
languageResources?: TranslationLanguageDefinition[];
|
|
85
|
+
}
|
|
86
|
+
): TranslationPayload {
|
|
87
|
+
const isSource = language === options.defaultLanguage;
|
|
88
|
+
const payload: TranslationPayload = {};
|
|
89
|
+
const valueFor = (
|
|
90
|
+
entry: TranslationEntryRecord,
|
|
91
|
+
message: string
|
|
92
|
+
): TranslationMessage =>
|
|
93
|
+
entry.format === 'icu' ? { message, format: 'icu' } : message;
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (entry.kind !== 'ui') continue; // AD-I: content is query-time, not published.
|
|
96
|
+
const source = entry.sourceText ?? '';
|
|
97
|
+
if (isSource) {
|
|
98
|
+
payload[entry.key] = valueFor(entry, source);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const candidates = languageFallbacks(
|
|
102
|
+
language,
|
|
103
|
+
options.defaultLanguage,
|
|
104
|
+
options.languageResources
|
|
105
|
+
);
|
|
106
|
+
const unit = candidates
|
|
107
|
+
.filter((code) => code !== options.defaultLanguage)
|
|
108
|
+
.map((code) => entry.units?.[code])
|
|
109
|
+
.find(
|
|
110
|
+
(candidate) =>
|
|
111
|
+
candidate?.text &&
|
|
112
|
+
(!options.reviewedOnly || candidate.state === 'reviewed')
|
|
113
|
+
);
|
|
114
|
+
payload[entry.key] = valueFor(entry, unit?.text ?? source);
|
|
115
|
+
}
|
|
116
|
+
return payload;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Compile every requested language + a content-hashed manifest. */
|
|
120
|
+
export function compileCatalogs(
|
|
121
|
+
entries: TranslationEntryRecord[],
|
|
122
|
+
options: CompileOptions
|
|
123
|
+
): {
|
|
124
|
+
payloads: Record<string, TranslationPayload>;
|
|
125
|
+
manifest: TranslationManifest;
|
|
126
|
+
} {
|
|
127
|
+
const reviewedOnly = new Set(options.reviewedOnlyLanguages ?? []);
|
|
128
|
+
const payloads: Record<string, TranslationPayload> = {};
|
|
129
|
+
const languages: Record<string, TranslationManifestEntry> = {};
|
|
130
|
+
|
|
131
|
+
for (const language of options.languages) {
|
|
132
|
+
const payload = compileLanguage(entries, language, {
|
|
133
|
+
defaultLanguage: options.defaultLanguage,
|
|
134
|
+
reviewedOnly: reviewedOnly.has(language),
|
|
135
|
+
languageResources: options.languageResources,
|
|
136
|
+
});
|
|
137
|
+
payloads[language] = payload;
|
|
138
|
+
languages[language] = {
|
|
139
|
+
hash: hashPayload(payload),
|
|
140
|
+
dir:
|
|
141
|
+
options.languageResources?.find((item) => item.code === language)
|
|
142
|
+
?.direction ?? directionFor(language),
|
|
143
|
+
count: Object.keys(payload).length,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
payloads,
|
|
149
|
+
manifest: {
|
|
150
|
+
schemaVersion: 1,
|
|
151
|
+
defaultLanguage: options.defaultLanguage,
|
|
152
|
+
...(options.languageResources
|
|
153
|
+
? { languageResources: options.languageResources }
|
|
154
|
+
: {}),
|
|
155
|
+
...(options.generatedAt ? { generatedAt: options.generatedAt } : {}),
|
|
156
|
+
languages,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Filename for a compiled payload: `{lang}.json` (simple) or `{lang}.{hash}.json`. */
|
|
162
|
+
export function payloadFileName(
|
|
163
|
+
language: string,
|
|
164
|
+
hash: string,
|
|
165
|
+
opts: { hashed?: boolean } = {}
|
|
166
|
+
): string {
|
|
167
|
+
return opts.hashed ? `${language}.${hash}.json` : `${language}.json`;
|
|
168
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-light translation core (Plan 014, P1.5). Pure TS, dependency-free —
|
|
3
|
+
* the portable heart shared by the React subpath and any non-React consumer, so
|
|
4
|
+
* a hosted app can use it without pulling React.
|
|
5
|
+
*
|
|
6
|
+
* Preserves Tolgee's `t(key, defaultValue?, params?)` semantics so serve's
|
|
7
|
+
* ~1,302 call sites (with inline English defaults) are a drop-in swap:
|
|
8
|
+
* - default formatting is FormatSimple-style `{named}` interpolation (exactly
|
|
9
|
+
* what serve uses today), so imported strings render identically;
|
|
10
|
+
* - keys marked `format:'icu'` use standards-based ICU MessageFormat with
|
|
11
|
+
* the active BCP-47 locale, including plural/select/selectordinal.
|
|
12
|
+
*/
|
|
13
|
+
import { localeDirection } from '../languages.js';
|
|
14
|
+
import IntlMessageFormat from 'intl-messageformat';
|
|
15
|
+
|
|
16
|
+
export type MessageFormat = 'simple' | 'icu';
|
|
17
|
+
|
|
18
|
+
/** ICU keys retain their format beside the string in mixed-format catalogs. */
|
|
19
|
+
export interface FormattedTranslationMessage {
|
|
20
|
+
message: string;
|
|
21
|
+
format: MessageFormat;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type TranslationMessage = string | FormattedTranslationMessage;
|
|
25
|
+
|
|
26
|
+
/** key → message template, for a single language. */
|
|
27
|
+
export type TranslationMessages = Record<string, TranslationMessage>;
|
|
28
|
+
|
|
29
|
+
/** FormatSimple: replace `{name}` placeholders with `params.name`. Unknown → left as-is. */
|
|
30
|
+
export function interpolate(
|
|
31
|
+
template: string,
|
|
32
|
+
params?: Record<string, unknown>,
|
|
33
|
+
): string {
|
|
34
|
+
if (!params) return template;
|
|
35
|
+
return template.replace(
|
|
36
|
+
/\{\s*([\p{L}\p{N}_][\p{L}\p{N}\p{M}_.-]*)\s*\}/gu,
|
|
37
|
+
(whole, name) =>
|
|
38
|
+
name in params ? String(params[name] ?? '') : whole,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ICU_CACHE_LIMIT = 500;
|
|
43
|
+
const icuCache = new Map<string, IntlMessageFormat>();
|
|
44
|
+
|
|
45
|
+
function compiledIcu(template: string, locale: string): IntlMessageFormat {
|
|
46
|
+
const key = `${locale}\u0000${template}`;
|
|
47
|
+
const cached = icuCache.get(key);
|
|
48
|
+
if (cached) return cached;
|
|
49
|
+
const compiled = new IntlMessageFormat(template, locale, undefined, {
|
|
50
|
+
ignoreTag: true,
|
|
51
|
+
});
|
|
52
|
+
if (icuCache.size >= ICU_CACHE_LIMIT) {
|
|
53
|
+
icuCache.delete(icuCache.keys().next().value as string);
|
|
54
|
+
}
|
|
55
|
+
icuCache.set(key, compiled);
|
|
56
|
+
return compiled;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Full ICU MessageFormat using the active locale's CLDR plural rules. Invalid
|
|
61
|
+
* authoring data never crashes the host app: the unformatted template remains
|
|
62
|
+
* visible and Translation Studio reports the syntax error for repair.
|
|
63
|
+
*/
|
|
64
|
+
export function formatIcu(
|
|
65
|
+
template: string,
|
|
66
|
+
params?: Record<string, unknown>,
|
|
67
|
+
locale = 'en',
|
|
68
|
+
): string {
|
|
69
|
+
try {
|
|
70
|
+
const result = compiledIcu(template, locale).format(params as any);
|
|
71
|
+
return Array.isArray(result) ? result.join('') : String(result);
|
|
72
|
+
} catch {
|
|
73
|
+
return interpolate(template, params);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve `key` against a message map, falling back to the inline default (then
|
|
79
|
+
* the key itself), and format with params. This is what the client `t()` calls.
|
|
80
|
+
*/
|
|
81
|
+
export function translate(
|
|
82
|
+
messages: TranslationMessages,
|
|
83
|
+
key: string,
|
|
84
|
+
defaultValue?: string,
|
|
85
|
+
params?: Record<string, unknown>,
|
|
86
|
+
format: MessageFormat = 'simple',
|
|
87
|
+
locale = 'en',
|
|
88
|
+
): string {
|
|
89
|
+
const stored = messages[key];
|
|
90
|
+
const template =
|
|
91
|
+
typeof stored === 'string'
|
|
92
|
+
? stored
|
|
93
|
+
: stored?.message ?? defaultValue ?? key;
|
|
94
|
+
const resolvedFormat =
|
|
95
|
+
typeof stored === 'object' ? stored.format : format;
|
|
96
|
+
return resolvedFormat === 'icu'
|
|
97
|
+
? formatIcu(template, params, locale)
|
|
98
|
+
: interpolate(template, params);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @deprecated Use directionFor; this historical set is not a support boundary. */
|
|
102
|
+
export const RTL_LANGUAGES = new Set(['ar', 'ur']);
|
|
103
|
+
|
|
104
|
+
export function directionFor(language: string): 'ltr' | 'rtl' {
|
|
105
|
+
try { return localeDirection(language); } catch { return 'ltr'; }
|
|
106
|
+
}
|