Skill Tracer
Skills › Archify › archify › i18n.mjs

i18n.mjs

328 lines · archify/renderers/shared/i18n.mjs at a406594

1import fs from 'node:fs';
2import { fileURLToPath } from 'node:url';
3
4export const DEFAULT_LOCALE = 'en';
5
6const ESCAPE_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
7
8export function escapeHtml(value) {
9 return String(value ?? '').replace(/[&<>"']/g, (character) => ESCAPE_MAP[character]);
12// Bundled catalogs are package data, not renderer code. locales/manifest.json
13// enrolls the catalogs meta.locale selects automatically; only listed files
14// are read, relative to this installed package and never from the working
15// directory, home, environment, or network. Adding a language changes data
16// only. en.json defines the canonical keys and the final fallback.
17const LOCALES_DIR = new URL('../../locales/', import.meta.url);
18// Mirrors common.schema.json#/$defs/locale.
19const LOCALE_TAG = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
20const CATALOG_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
22function readLocaleData(file) {
23 try {
24 return JSON.parse(fs.readFileSync(fileURLToPath(new URL(file, LOCALES_DIR)), 'utf8'));
25 } catch (error) {
26 throw new Error(`Archify locale data ${JSON.stringify(file)} could not be loaded: ${error.message}`);
27 }
30function loadManifest() {
31 const catalogs = readLocaleData('manifest.json')?.catalogs;
32 if (!Array.isArray(catalogs)) throw new Error('Archify locale manifest must list catalogs');
33 const byKey = new Map();
34 for (const entry of catalogs) {
35 const { locale, file } = entry || {};
36 if (typeof locale !== 'string' || locale.length > 35 || !LOCALE_TAG.test(locale)) {
37 throw new Error(`Archify locale manifest has an invalid locale tag ${JSON.stringify(locale)}`);
38 }
39 if (typeof file !== 'string' || !CATALOG_FILE.test(file)) {
40 throw new Error(`Archify locale manifest has an invalid catalog file for ${locale}`);
41 }
42 // Tags match case-insensitively, so two entries differing only in case
43 // would make selection ambiguous.
44 const key = locale.toLowerCase();
45 if (byKey.has(key)) throw new Error(`Archify locale manifest lists ${locale} more than once`);
46 byKey.set(key, { locale, file });
47 }
48 if (byKey.get(DEFAULT_LOCALE)?.locale !== DEFAULT_LOCALE) {
49 throw new Error(`Archify locale manifest must enroll the ${DEFAULT_LOCALE} source catalog`);
50 }
51 return byKey;
54const MANIFEST = loadManifest();
56export const SUPPORTED_LOCALES = Object.freeze([...MANIFEST.values()].map(({ locale }) => locale));
58const EN = Object.freeze(readLocaleData(MANIFEST.get(DEFAULT_LOCALE).file));
59for (const [key, message] of Object.entries(EN)) {
60 if (typeof message !== 'string' || message.length === 0) {
61 throw new Error(`Archify source message ${JSON.stringify(key)} must be a non-empty string`);
62 }
65const CANONICAL_KEYS = Object.keys(EN);
66const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g;
68function extractPlaceholders(message) {
69 return new Set([...String(message).matchAll(PLACEHOLDER_PATTERN)].map((match) => match[1]));
72const CANONICAL_PLACEHOLDERS = Object.fromEntries(
73 CANONICAL_KEYS.map((key) => [key, extractPlaceholders(EN[key])]),
74);
76function placeholdersMatch(expected, actual) {
77 if (expected.size !== actual.size) return false;
78 for (const token of expected) if (!actual.has(token)) return false;
79 return true;
82// Splits translation data into the entries that can replace a canonical
83// message and the ones that cannot. Rejected entries never replace a valid
84// lower-priority message.
85function classifyTranslations(translations) {
86 const supplied = Object.keys(translations || {});
87 const usable = new Map();
88 const unknownKeys = [];
89 const placeholderMismatches = [];
90 for (const key of supplied) {
91 if (!Object.hasOwn(CANONICAL_PLACEHOLDERS, key)) {
92 unknownKeys.push(key);
93 continue;
94 }
95 const value = translations[key];
96 const expected = [...CANONICAL_PLACEHOLDERS[key]].sort();
97 if (typeof value !== 'string' || value.length === 0) {
98 placeholderMismatches.push({ key, expected, actual: null });
99 continue;
100 }
101 const actual = extractPlaceholders(value);
102 if (placeholdersMatch(CANONICAL_PLACEHOLDERS[key], actual)) {
103 usable.set(key, value);
104 } else {
105 placeholderMismatches.push({ key, expected, actual: [...actual].sort() });
106 }
107 }
108 return { supplied, usable, unknownKeys, placeholderMismatches };
111// Validates caller-supplied translation data against the canonical (English)
112// message-key set. Pure and side-effect free; coverage here describes only the
113// supplied data. resolveCatalog() reports the final resolved coverage.
114export function validateTranslations(translations = {}) {
115 const { usable, unknownKeys, placeholderMismatches } = classifyTranslations(translations);
116 return {
117 totalKeys: CANONICAL_KEYS.length,
118 coveredKeys: usable.size,
119 coverage: CANONICAL_KEYS.length ? usable.size / CANONICAL_KEYS.length : 1,
120 missingKeys: CANONICAL_KEYS.filter((key) => !Object.hasOwn(translations || {}, key)),
121 unknownKeys,
122 placeholderMismatches,
123 };
126export function bundledLocaleFor(locale) {
127 if (typeof locale !== 'string') return null;
128 return MANIFEST.get(locale.toLowerCase())?.locale || null;
131function isEnglishTag(locale) {
132 const key = locale.toLowerCase();
133 return key === DEFAULT_LOCALE || key.startsWith(`${DEFAULT_LOCALE}-`);
136// Bundled catalogs load on first use and stay immutable; a diagram's
137// overrides are layered onto a copy.
138const BUNDLED_CATALOGS = new Map([[DEFAULT_LOCALE, { usable: new Map(Object.entries(EN)), messages: EN }]]);
140function bundledCatalog(locale) {
141 if (!BUNDLED_CATALOGS.has(locale)) {
142 const { usable } = classifyTranslations(readLocaleData(MANIFEST.get(locale.toLowerCase()).file));
143 const messages = Object.freeze(Object.fromEntries(
144 CANONICAL_KEYS.map((key) => [key, usable.get(key) ?? EN[key]]),
145 ));
146 BUNDLED_CATALOGS.set(locale, { usable, messages });
147 }
148 return BUNDLED_CATALOGS.get(locale);
151// Resolves each canonical message as: valid meta.translations value →
152// selected bundled-catalog value → English source. Pure; returns the resolved
153// catalog with a report that separates rejected overrides from real English
154// fallback gaps. Omitted or empty translations mean no override. A tag with
155// neither a bundled catalog nor a usable override falls back to English UI
156// and language metadata.
157export function resolveCatalog(locale, translations = undefined) {
158 const override = classifyTranslations(translations);
159 const bundledLocale = bundledLocaleFor(locale);
160 const fallback = Boolean(locale) && !bundledLocale && override.usable.size === 0;
161 const resolvedLocale = !locale || fallback ? DEFAULT_LOCALE : bundledLocale || locale;
162 const bundled = bundledLocale ? bundledCatalog(bundledLocale) : null;
163 const english = isEnglishTag(resolvedLocale);
164 const messages = {};
165 const fallbackKeys = [];
166 for (const key of CANONICAL_KEYS) {
167 if (override.usable.has(key)) {
168 messages[key] = override.usable.get(key);
169 } else if (bundled?.usable.has(key)) {
170 messages[key] = bundled.usable.get(key);
171 } else {
172 messages[key] = EN[key];
173 if (!english) fallbackKeys.push(key);
174 }
175 }
176 const translatedKeys = english ? CANONICAL_KEYS.length : CANONICAL_KEYS.length - fallbackKeys.length;
177 return {
178 messages: Object.freeze(messages),
179 report: {
180 locale: locale || null,
181 resolvedLocale,
182 bundledLocale,
183 fallback,
184 totalKeys: CANONICAL_KEYS.length,
185 translatedKeys,
186 coverage: CANONICAL_KEYS.length ? translatedKeys / CANONICAL_KEYS.length : 1,
187 fallbackKeys,
188 override: {
189 suppliedKeys: override.supplied.length,
190 appliedKeys: override.usable.size,
191 unknownKeys: override.unknownKeys,
192 placeholderMismatches: override.placeholderMismatches,
193 },
194 },
195 };
198// Per-document catalogs installed by registerLocale(). Tags are
199// case-insensitive, so the key is lowercased: renderers may pass the authored
200// meta.locale (zh-cn) or its resolved tag (zh-CN) and reach the same document
201// catalog. They never mutate a bundled catalog.
202const DOCUMENT_CATALOGS = new Map();
204function documentKey(locale) {
205 return typeof locale === 'string' ? locale.toLowerCase() : locale;
208function catalogFor(locale) {
209 const key = documentKey(locale);
210 if (DOCUMENT_CATALOGS.has(key)) return DOCUMENT_CATALOGS.get(key);
211 const bundledLocale = bundledLocaleFor(locale);
212 return bundledLocale ? { locale: bundledLocale, messages: bundledCatalog(bundledLocale).messages } : null;
215// Installs the resolved catalog for one authored locale and returns its
216// report. Re-registering the same tag replaces the previous document's
217// overrides; a fallback clears them.
218export function registerLocale(locale, translations = undefined) {
219 const { messages, report } = resolveCatalog(locale, translations);
220 if (report.fallback || !locale) {
221 DOCUMENT_CATALOGS.delete(documentKey(locale));
222 } else {
223 DOCUMENT_CATALOGS.set(documentKey(locale), { locale: report.resolvedLocale, messages });
224 }
225 return report;
228// Agent-facing warnings for one document's meta.locale/meta.translations.
229// Pure, so the renderer and the CLI receipts (validate/deliver/finalize)
230// report the same diagnostics from the same input.
231export function localeDiagnostics(diagramType, meta) {
232 const locale = meta?.locale;
233 if (!locale) return [];
234 const report = resolveCatalog(locale, meta.translations).report;
235 const diagnostics = [];
236 const { unknownKeys, placeholderMismatches, appliedKeys } = report.override;
237 const rejected = unknownKeys.length + placeholderMismatches.length;
238 if (rejected) {
239 const kept = report.bundledLocale ? `bundled ${report.bundledLocale} message` : 'English message';
240 diagnostics.push({
241 code: 'i18n/invalid-translation',
242 severity: 'warning',
243 message: `meta.translations for locale ${JSON.stringify(locale)} has ${rejected} unusable ${rejected === 1 ? 'entry' : 'entries'} (${unknownKeys.length} unknown, ${placeholderMismatches.length} placeholder mismatch); each keeps its ${kept}.`,
244 subject: { diagramType, path: '/meta/translations' },
245 evidence: {
246 locale,
247 unknownKeys: unknownKeys.slice(0, 10),
248 unknownKeysTotal: unknownKeys.length,
249 placeholderMismatches: placeholderMismatches.slice(0, 10),
250 placeholderMismatchesTotal: placeholderMismatches.length,
251 },
252 supportedFixes: ['Remove each unknown key or rename it to a canonical message key.', 'Use exactly the {placeholders} of the English source string for each mismatched key.'],
253 });
254 }
255 if (report.fallback) {
256 diagnostics.push({
257 code: 'i18n/locale-fallback',
258 severity: 'warning',
259 message: `meta.locale ${JSON.stringify(locale)} has no bundled catalog and no usable meta.translations; the Viewer chrome and <html lang> fall back to English.`,
260 subject: { diagramType, path: '/meta/locale' },
261 evidence: { locale, bundledLocales: SUPPORTED_LOCALES },
262 supportedFixes: ['Supply meta.translations for this locale.', `Use a bundled locale: ${SUPPORTED_LOCALES.join(', ')}.`],
263 });
264 } else if (report.fallbackKeys.length) {
265 const source = [
266 report.bundledLocale && `the bundled ${report.bundledLocale} catalog`,
267 appliedKeys && `${appliedKeys} meta.translations ${appliedKeys === 1 ? 'entry' : 'entries'}`,
268 ].filter(Boolean).join(' and ');
269 const missingKeys = report.fallbackKeys.slice(0, 10);
270 diagnostics.push({
271 code: 'i18n/translation-coverage',
272 severity: 'warning',
273 message: `meta.locale ${JSON.stringify(locale)} resolves ${report.translatedKeys}/${report.totalKeys} renderer-owned messages (${Math.floor(report.coverage * 100)}%) from ${source}; ${report.fallbackKeys.length} fall back to English.`,
274 subject: { diagramType, path: '/meta/translations' },
275 evidence: {
276 locale,
277 resolvedLocale: report.resolvedLocale,
278 bundledLocale: report.bundledLocale,
279 translatedKeys: report.translatedKeys,
280 totalKeys: report.totalKeys,
281 appliedOverrideKeys: appliedKeys,
282 missingKeys,
283 missingKeysTotal: report.fallbackKeys.length,
284 // English source for the listed gaps, so a caller can translate
285 // exactly those keys without re-reading the whole catalog.
286 englishSource: Object.fromEntries(missingKeys.map((key) => [key, EN[key]])),
287 },
288 supportedFixes: ['Add each missing key to meta.translations, translating its English source string and keeping its {placeholders}.'],
289 });
290 }
291 return diagnostics;
294export function resolveLocale(locale) {
295 return catalogFor(locale)?.locale || DEFAULT_LOCALE;
298export function formatMessage(template, values = {}) {
299 return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => (
300 Object.hasOwn(values, key) ? String(values[key]) : match
301 ));
304export function translateMessage(locale, key, values = {}) {
305 const catalog = catalogFor(locale)?.messages || EN;
306 if (!Object.hasOwn(catalog, key)) {
307 throw new Error(`Missing Archify i18n message ${JSON.stringify(key)} for ${resolveLocale(locale)}`);
308 }
309 return formatMessage(catalog[key], values);
312export function translateCount(locale, key, count, values = {}) {
313 const suffix = count === 1 ? 'one' : 'other';
314 return translateMessage(locale, `${key}.${suffix}`, { ...values, count });
317export function viewerCatalog(locale) {
318 const messages = catalogFor(locale)?.messages || EN;
319 return Object.fromEntries(Object.entries(messages).filter(([key]) => key.startsWith('viewer.')));
322export function localizeTemplate(template, locale) {
323 return template.replace(/\{\{i18n:([a-zA-Z0-9_.-]+)\}\}/g, (_match, key) => escapeHtml(translateMessage(locale, key)));
326export function catalogKeys() {
327 return [...CANONICAL_KEYS];