UNPKG

i18ntk

Version:

i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.

337 lines (319 loc) 16.3 kB
'use strict'; // Universal runtime core. This module intentionally imports no Node.js APIs so // it can run in browsers, Edge workers, React Native, Deno, Bun, and SSR bundles. class RuntimeError extends Error { constructor(message, code, details = {}) { super(message); this.name = this.constructor.name; this.code = code; this.details = details; } } class RuntimeValidationError extends RuntimeError { constructor(message, details) { super(message, 'I18NTK_RUNTIME_VALIDATION', details); } } class RuntimeLoadError extends RuntimeError { constructor(message, details) { super(message, 'I18NTK_RUNTIME_LOAD', details); } } function canonicalizeLocale(value, fallback = 'en') { if (typeof value !== 'string' || !value.trim()) return fallback; const locale = value.trim().replace(/_/g, '-'); try { return Intl.getCanonicalLocales(locale)[0] || fallback; } catch (_) { return fallback; } } function requireValidLocale(value, label = 'locale') { if (typeof value !== 'string' || !value.trim() || value.length > 100) { throw new RuntimeValidationError(`${label} must be a valid locale identifier`, { value }); } const locale = value.trim().replace(/_/g, '-'); try { const canonical = Intl.getCanonicalLocales(locale)[0]; if (!canonical) throw new Error('Locale is empty'); return canonical; } catch (_) { throw new RuntimeValidationError(`${label} must be a valid locale identifier`, { value }); } } function cloneResource(value, seen = new WeakSet()) { if (value === null || typeof value !== 'object') return value; if (seen.has(value)) throw new RuntimeValidationError('Translation resources cannot contain cycles'); seen.add(value); if (Array.isArray(value)) return value.map(item => cloneResource(item, seen)); const result = Object.create(null); for (const [key, child] of Object.entries(value)) { if (key === '__proto__' || key === 'prototype' || key === 'constructor') continue; result[key] = cloneResource(child, seen); } seen.delete(value); return result; } function deepMerge(target, source) { const output = cloneResource(target || {}); for (const [key, value] of Object.entries(source || {})) { const current = output[key]; output[key] = value && typeof value === 'object' && !Array.isArray(value) && current && typeof current === 'object' && !Array.isArray(current) ? deepMerge(current, value) : cloneResource(value); } return output; } function resolvePath(resource, key, separator) { let current = resource; for (const segment of key.split(separator)) { if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, segment)) return undefined; current = current[segment]; } return current; } function fallbackChain(locale, fallbackLocale) { const result = []; const add = value => { if (value && !result.includes(value)) result.push(value); }; const addWithParents = value => { const canonical = canonicalizeLocale(value, ''); if (!canonical) return; const parts = canonical.split('-'); while (parts.length) { add(parts.join('-')); parts.pop(); } }; addWithParents(locale); addWithParents(fallbackLocale); return result; } function interpolate(value, params) { if (typeof value !== 'string') return value; const safeParams = params && typeof params === 'object' ? params : {}; return value.replace(/\{\{?(\w+)\}?\}/g, (match, name) => Object.prototype.hasOwnProperty.call(safeParams, name) ? String(safeParams[name]) : match); } function createRuntime(options = {}) { const config = { locale: requireValidLocale(options.locale || options.language || options.defaultLanguage || 'en', 'locale'), fallbackLocale: requireValidLocale(options.fallbackLocale || options.fallbackLanguage || 'en', 'fallbackLocale'), keySeparator: typeof options.keySeparator === 'string' && options.keySeparator ? options.keySeparator : '.', missingKeyPolicy: options.missingKeyPolicy || 'key', loadErrorPolicy: options.loadErrorPolicy || 'throw', maxKeyLength: Number.isInteger(options.maxKeyLength) ? Math.max(1, Math.min(options.maxKeyLength, 10000)) : 1000 }; const resources = new Map(); const discoveredLocales = new Set(); const listeners = new Set(); const plugins = []; const diagnostics = []; const inFlight = new Map(); let disposed = false; const metrics = { translationCount: 0, translationDurationMsTotal: 0, missingKeyCount: 0, loadCount: 0, loadErrorCount: 0 }; const assertActive = () => { if (disposed) throw new RuntimeError('Runtime has been disposed', 'I18NTK_RUNTIME_DISPOSED'); }; const emit = event => { for (const listener of [...listeners]) { try { listener(Object.freeze({ ...event })); } catch (_) { /* Listener failures must not corrupt runtime state. */ } } }; const addDiagnostic = diagnostic => { const safe = Object.freeze({ timestamp: new Date().toISOString(), ...diagnostic }); diagnostics.push(safe); if (diagnostics.length > 100) diagnostics.shift(); emit({ type: 'diagnostic', diagnostic: safe }); }; const localeNamespaces = locale => { const canonical = requireValidLocale(locale); if (!resources.has(canonical)) resources.set(canonical, new Map()); return resources.get(canonical); }; const resolveLocales = translateOptions => ({ locale: requireValidLocale(translateOptions.locale || translateOptions.language || config.locale, 'locale'), fallbackLocale: requireValidLocale( translateOptions.fallbackLocale || translateOptions.fallbackLanguage || config.fallbackLocale, 'fallbackLocale' ) }); const runtime = { t(key, params = {}, translateOptions = {}) { assertActive(); if (typeof key !== 'string' || !key || key.length > config.maxKeyLength) { const error = new RuntimeValidationError('Translation key must be a non-empty string within maxKeyLength', { keyType: typeof key }); emit({ type: 'translationError', error }); throw error; } const started = Date.now(); const { locale, fallbackLocale } = resolveLocales(translateOptions); const namespace = translateOptions.namespace || 'default'; let value; let resolvedLocale; for (const candidate of fallbackChain(locale, fallbackLocale)) { const namespaceResource = resources.get(candidate)?.get(namespace); value = resolvePath(namespaceResource, key, config.keySeparator); if (value !== undefined) { resolvedLocale = candidate; break; } } if (value === undefined) { metrics.missingKeyCount++; const event = { type: 'missingKey', key, locale, namespace }; addDiagnostic({ category: 'missing-key', key, locale, namespace }); const policy = translateOptions.missingKeyPolicy || config.missingKeyPolicy; if (policy === 'throw') throw new RuntimeError(`Missing translation key: ${key}`, 'I18NTK_RUNTIME_MISSING_KEY', event); value = typeof policy === 'function' ? policy(event) : policy === 'empty' ? '' : key; } value = interpolate(value, params); for (const plugin of plugins) { if (typeof plugin.transform !== 'function') continue; try { value = plugin.transform(value, params, { ...translateOptions, locale, namespace }); } catch (error) { addDiagnostic({ category: 'plugin-error', plugin: plugin.name, code: error?.code, message: error?.message || String(error) }); emit({ type: 'translationError', key, locale, namespace, plugin: plugin.name, error }); } } metrics.translationCount++; metrics.translationDurationMsTotal += Date.now() - started; emit({ type: 'translation', key, locale: resolvedLocale || locale, namespace, value }); return typeof value === 'string' ? value : String(value ?? ''); }, translate(key, params, translateOptions) { return runtime.t(key, params, translateOptions); }, has(key, translateOptions = {}) { if (typeof key !== 'string' || !key) return false; const { locale, fallbackLocale } = resolveLocales(translateOptions); const namespace = translateOptions.namespace || 'default'; return fallbackChain(locale, fallbackLocale) .some(candidate => resolvePath(resources.get(candidate)?.get(namespace), key, config.keySeparator) !== undefined); }, translateBatch(keys, params = {}, translateOptions = {}) { return Array.isArray(keys) ? keys.map((key, index) => runtime.t(key, Array.isArray(params) ? params[index] || {} : params, translateOptions)) : []; }, setLocale(locale) { assertActive(); const next = requireValidLocale(locale); if (next !== config.locale) { const previous = config.locale; config.locale = next; emit({ type: 'localeChanged', locale: next, previous }); } }, setLanguage(locale) { runtime.setLocale(locale); }, getLocale() { return config.locale; }, getLanguage() { return config.locale; }, listLocales() { return [...new Set([...discoveredLocales, ...resources.keys()])].sort(); }, getAvailableLanguages() { return runtime.listLocales(); }, addResources(locale, namespace = 'default', data, resourceOptions = {}) { assertActive(); if (typeof namespace !== 'string' || !namespace || !data || typeof data !== 'object') { throw new RuntimeValidationError('Resources require a namespace and object data'); } const current = localeNamespaces(locale).get(namespace) || {}; const precedence = resourceOptions.precedence || 'override'; if (precedence !== 'override' && precedence !== 'fallback') { throw new RuntimeValidationError('Resource precedence must be override or fallback', { precedence }); } localeNamespaces(locale).set(namespace, precedence === 'fallback' ? deepMerge(data, current) : deepMerge(current, data)); emit({ type: 'resourcesChanged', locale: requireValidLocale(locale), namespace }); return runtime; }, removeResources(locale, namespace) { const canonical = requireValidLocale(locale); if (!resources.has(canonical)) return; if (namespace) resources.get(canonical).delete(namespace); else resources.delete(canonical); emit({ type: 'resourcesChanged', locale: canonical, namespace: namespace || null }); }, async load(locale = config.locale, namespaces) { assertActive(); if (!options.loader || typeof options.loader.load !== 'function') return runtime; const canonical = requireValidLocale(locale); const requested = namespaces === undefined ? undefined : (Array.isArray(namespaces) ? namespaces : [namespaces]); const loadKey = `${canonical}\0${(requested || []).join(',')}`; if (inFlight.has(loadKey)) return inFlight.get(loadKey); const promise = Promise.resolve().then(() => options.loader.load(canonical, requested)).then(loaded => { if (disposed) return runtime; if (!loaded || typeof loaded !== 'object') throw new RuntimeLoadError('Loader returned an invalid resource', { locale: canonical }); const namespaced = requested || loaded.default !== undefined || Object.keys(loaded).some(key => key !== 'default'); if (namespaced) Object.entries(loaded).forEach(([namespace, data]) => runtime.addResources(canonical, namespace, data)); else runtime.addResources(canonical, 'default', loaded); metrics.loadCount++; return runtime; }).catch(error => { metrics.loadErrorCount++; const wrapped = error instanceof RuntimeError ? error : new RuntimeLoadError(error.message, { locale: canonical }); addDiagnostic({ category: 'load-error', locale: canonical, code: wrapped.code, message: wrapped.message }); if (config.loadErrorPolicy === 'throw') throw wrapped; return runtime; }).finally(() => inFlight.delete(loadKey)); inFlight.set(loadKey, promise); return promise; }, async refresh(locale = config.locale, namespaces) { runtime.removeResources(locale); return runtime.load(locale, namespaces); }, async refreshLocales() { assertActive(); if (!options.loader || typeof options.loader.listLocales !== 'function') return runtime.listLocales(); try { const listed = await options.loader.listLocales(); if (!Array.isArray(listed)) throw new RuntimeLoadError('Loader listLocales() must return an array'); const next = listed.map(locale => requireValidLocale(locale, 'loader locale')); discoveredLocales.clear(); next.forEach(locale => discoveredLocales.add(locale)); return runtime.listLocales(); } catch (error) { const wrapped = error instanceof RuntimeError ? error : new RuntimeLoadError(error.message); metrics.loadErrorCount++; addDiagnostic({ category: 'load-error', operation: 'list-locales', code: wrapped.code, message: wrapped.message }); if (config.loadErrorPolicy === 'throw') throw wrapped; return runtime.listLocales(); } }, subscribe(listener) { if (typeof listener !== 'function') throw new RuntimeValidationError('Runtime listener must be a function'); listeners.add(listener); return () => listeners.delete(listener); }, addPlugin(plugin) { if (!plugin || typeof plugin !== 'object' || typeof plugin.name !== 'string') throw new RuntimeValidationError('Plugin requires a name'); if (plugins.some(existing => existing.name === plugin.name)) throw new RuntimeValidationError(`Plugin already registered: ${plugin.name}`); plugins.push(plugin); return () => runtime.removePlugin(plugin.name); }, removePlugin(name) { const index = plugins.findIndex(plugin => plugin.name === name); if (index >= 0) plugins.splice(index, 1); }, getDiagnostics() { return diagnostics.map(item => ({ ...item })); }, getMetrics() { return { ...metrics, averageTranslationDurationMs: metrics.translationCount ? metrics.translationDurationMsTotal / metrics.translationCount : 0 }; }, formatNumber(value, formatOptions, locale = config.locale) { return new Intl.NumberFormat(locale, formatOptions).format(value); }, formatDate(value, formatOptions, locale = config.locale) { return new Intl.DateTimeFormat(locale, formatOptions).format(value); }, formatRelativeTime(value, unit, formatOptions, locale = config.locale) { return new Intl.RelativeTimeFormat(locale, formatOptions).format(value, unit); }, formatList(value, formatOptions, locale = config.locale) { return new Intl.ListFormat(locale, formatOptions).format(value); }, getPluralCategory(value, pluralOptions, locale = config.locale) { return new Intl.PluralRules(locale, pluralOptions).select(value); }, getConfig() { return Object.freeze({ ...config }); }, dispose() { if (disposed) return; disposed = true; resources.clear(); listeners.clear(); plugins.length = 0; inFlight.clear(); } }; for (const [locale, localeResources] of Object.entries(options.resources || {})) { const looksNamespaced = localeResources && typeof localeResources === 'object' && Object.values(localeResources).some(value => value && typeof value === 'object' && !Array.isArray(value)); if (looksNamespaced && options.resourcesAreNamespaced !== false) { for (const [namespace, data] of Object.entries(localeResources)) runtime.addResources(locale, namespace, data); } else runtime.addResources(locale, 'default', localeResources); } return runtime; } async function initRuntime(options = {}) { const runtime = createRuntime(options); if (options.loader && typeof options.loader.listLocales === 'function') await runtime.refreshLocales(); if (options.loader && options.preload !== false) { await runtime.load(runtime.getLocale(), options.namespaces); const fallback = runtime.getConfig().fallbackLocale; if (fallback && fallback !== runtime.getLocale()) await runtime.load(fallback, options.namespaces); } return runtime; } module.exports = { createRuntime, initRuntime, canonicalizeLocale, requireValidLocale, RuntimeError, RuntimeValidationError, RuntimeLoadError };