@_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.
Files changed (153) hide show
  1. package/README.md +102 -0
  2. package/lib/cjs/data/translation.json +8 -0
  3. package/lib/esm/archive.d.ts +133 -0
  4. package/lib/esm/archive.js +567 -0
  5. package/lib/esm/archive.js.map +1 -0
  6. package/lib/esm/authorization.d.ts +12 -0
  7. package/lib/esm/authorization.js +10 -0
  8. package/lib/esm/authorization.js.map +1 -0
  9. package/lib/esm/backend.d.ts +1 -0
  10. package/lib/esm/backend.js +3 -0
  11. package/lib/esm/backend.js.map +1 -0
  12. package/lib/esm/cdn-loader.d.ts +40 -0
  13. package/lib/esm/cdn-loader.js +200 -0
  14. package/lib/esm/cdn-loader.js.map +1 -0
  15. package/lib/esm/checks.d.ts +56 -0
  16. package/lib/esm/checks.js +288 -0
  17. package/lib/esm/checks.js.map +1 -0
  18. package/lib/esm/compile.d.ts +64 -0
  19. package/lib/esm/compile.js +99 -0
  20. package/lib/esm/compile.js.map +1 -0
  21. package/lib/esm/core/messages.d.ts +25 -0
  22. package/lib/esm/core/messages.js +76 -0
  23. package/lib/esm/core/messages.js.map +1 -0
  24. package/lib/esm/data/translation.json +8 -0
  25. package/lib/esm/discovery.d.ts +97 -0
  26. package/lib/esm/discovery.js +365 -0
  27. package/lib/esm/discovery.js.map +1 -0
  28. package/lib/esm/exchange.d.ts +100 -0
  29. package/lib/esm/exchange.js +223 -0
  30. package/lib/esm/exchange.js.map +1 -0
  31. package/lib/esm/export.d.ts +28 -0
  32. package/lib/esm/export.js +92 -0
  33. package/lib/esm/export.js.map +1 -0
  34. package/lib/esm/formats/csv.d.ts +8 -0
  35. package/lib/esm/formats/csv.js +397 -0
  36. package/lib/esm/formats/csv.js.map +1 -0
  37. package/lib/esm/formats/json.d.ts +30 -0
  38. package/lib/esm/formats/json.js +459 -0
  39. package/lib/esm/formats/json.js.map +1 -0
  40. package/lib/esm/formats/xliff.d.ts +12 -0
  41. package/lib/esm/formats/xliff.js +577 -0
  42. package/lib/esm/formats/xliff.js.map +1 -0
  43. package/lib/esm/import.d.ts +197 -0
  44. package/lib/esm/import.js +391 -0
  45. package/lib/esm/import.js.map +1 -0
  46. package/lib/esm/index.d.ts +42 -0
  47. package/lib/esm/index.js +34 -0
  48. package/lib/esm/index.js.map +1 -0
  49. package/lib/esm/key-sync-node.d.ts +29 -0
  50. package/lib/esm/key-sync-node.js +174 -0
  51. package/lib/esm/key-sync-node.js.map +1 -0
  52. package/lib/esm/key-sync.d.ts +46 -0
  53. package/lib/esm/key-sync.js +114 -0
  54. package/lib/esm/key-sync.js.map +1 -0
  55. package/lib/esm/key-version.d.ts +50 -0
  56. package/lib/esm/key-version.js +134 -0
  57. package/lib/esm/key-version.js.map +1 -0
  58. package/lib/esm/languages.d.ts +18 -0
  59. package/lib/esm/languages.js +74 -0
  60. package/lib/esm/languages.js.map +1 -0
  61. package/lib/esm/linked-discovery.d.ts +44 -0
  62. package/lib/esm/linked-discovery.js +267 -0
  63. package/lib/esm/linked-discovery.js.map +1 -0
  64. package/lib/esm/message-format.d.ts +41 -0
  65. package/lib/esm/message-format.js +133 -0
  66. package/lib/esm/message-format.js.map +1 -0
  67. package/lib/esm/mt.d.ts +111 -0
  68. package/lib/esm/mt.js +142 -0
  69. package/lib/esm/mt.js.map +1 -0
  70. package/lib/esm/ontologies/translation.d.ts +203 -0
  71. package/lib/esm/ontologies/translation.js +219 -0
  72. package/lib/esm/ontologies/translation.js.map +1 -0
  73. package/lib/esm/package.d.ts +4 -0
  74. package/lib/esm/package.js +11 -0
  75. package/lib/esm/package.js.map +1 -0
  76. package/lib/esm/react.d.ts +55 -0
  77. package/lib/esm/react.js +127 -0
  78. package/lib/esm/react.js.map +1 -0
  79. package/lib/esm/records.d.ts +151 -0
  80. package/lib/esm/records.js +2 -0
  81. package/lib/esm/records.js.map +1 -0
  82. package/lib/esm/release.d.ts +198 -0
  83. package/lib/esm/release.js +435 -0
  84. package/lib/esm/release.js.map +1 -0
  85. package/lib/esm/shape-catalog.d.ts +79 -0
  86. package/lib/esm/shape-catalog.js +118 -0
  87. package/lib/esm/shape-catalog.js.map +1 -0
  88. package/lib/esm/shapes/GlossaryTerm.d.ts +28 -0
  89. package/lib/esm/shapes/GlossaryTerm.js +94 -0
  90. package/lib/esm/shapes/GlossaryTerm.js.map +1 -0
  91. package/lib/esm/shapes/TranslationInventoryOrigin.d.ts +33 -0
  92. package/lib/esm/shapes/TranslationInventoryOrigin.js +209 -0
  93. package/lib/esm/shapes/TranslationInventoryOrigin.js.map +1 -0
  94. package/lib/esm/shapes/TranslationKey.d.ts +92 -0
  95. package/lib/esm/shapes/TranslationKey.js +194 -0
  96. package/lib/esm/shapes/TranslationKey.js.map +1 -0
  97. package/lib/esm/shapes/TranslationKeyVersion.d.ts +19 -0
  98. package/lib/esm/shapes/TranslationKeyVersion.js +125 -0
  99. package/lib/esm/shapes/TranslationKeyVersion.js.map +1 -0
  100. package/lib/esm/shapes/TranslationLanguage.d.ts +15 -0
  101. package/lib/esm/shapes/TranslationLanguage.js +102 -0
  102. package/lib/esm/shapes/TranslationLanguage.js.map +1 -0
  103. package/lib/esm/shapes/TranslationProvider.d.ts +409 -0
  104. package/lib/esm/shapes/TranslationProvider.js +1376 -0
  105. package/lib/esm/shapes/TranslationProvider.js.map +1 -0
  106. package/lib/esm/shapes/TranslationRelease.d.ts +36 -0
  107. package/lib/esm/shapes/TranslationRelease.js +149 -0
  108. package/lib/esm/shapes/TranslationRelease.js.map +1 -0
  109. package/lib/esm/shapes/TranslationRevision.d.ts +39 -0
  110. package/lib/esm/shapes/TranslationRevision.js +159 -0
  111. package/lib/esm/shapes/TranslationRevision.js.map +1 -0
  112. package/lib/esm/shapes/TranslationUnit.d.ts +26 -0
  113. package/lib/esm/shapes/TranslationUnit.js +111 -0
  114. package/lib/esm/shapes/TranslationUnit.js.map +1 -0
  115. package/package.json +134 -0
  116. package/src/archive.ts +894 -0
  117. package/src/authorization.ts +33 -0
  118. package/src/backend.ts +2 -0
  119. package/src/cdn-loader.ts +264 -0
  120. package/src/checks.ts +413 -0
  121. package/src/compile.ts +168 -0
  122. package/src/core/messages.ts +106 -0
  123. package/src/data/translation.json +8 -0
  124. package/src/discovery.ts +594 -0
  125. package/src/exchange.ts +428 -0
  126. package/src/export.ts +135 -0
  127. package/src/formats/csv.ts +459 -0
  128. package/src/formats/json.ts +587 -0
  129. package/src/formats/xliff.ts +708 -0
  130. package/src/import.ts +648 -0
  131. package/src/index.ts +207 -0
  132. package/src/key-sync-node.ts +269 -0
  133. package/src/key-sync.ts +168 -0
  134. package/src/key-version.ts +206 -0
  135. package/src/languages.ts +102 -0
  136. package/src/linked-discovery.ts +385 -0
  137. package/src/message-format.ts +214 -0
  138. package/src/mt.ts +248 -0
  139. package/src/ontologies/translation.ts +234 -0
  140. package/src/package.ts +19 -0
  141. package/src/react.tsx +250 -0
  142. package/src/records.ts +180 -0
  143. package/src/release.ts +726 -0
  144. package/src/shape-catalog.ts +198 -0
  145. package/src/shapes/GlossaryTerm.ts +61 -0
  146. package/src/shapes/TranslationInventoryOrigin.ts +131 -0
  147. package/src/shapes/TranslationKey.ts +174 -0
  148. package/src/shapes/TranslationKeyVersion.ts +77 -0
  149. package/src/shapes/TranslationLanguage.ts +51 -0
  150. package/src/shapes/TranslationProvider.ts +1755 -0
  151. package/src/shapes/TranslationRelease.ts +114 -0
  152. package/src/shapes/TranslationRevision.ts +105 -0
  153. 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
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "@context": {
3
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
4
+ "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
5
+ "tr": "https://id.linked.cm/translation/vocab#"
6
+ },
7
+ "@graph": []
8
+ }