i18ntk
Version:
i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.
809 lines (731 loc) • 32.5 kB
JavaScript
// runtime/index.js
// Lightweight, framework-agnostic runtime translation API for applications.
// Works with both single-file and modular folder structures under a base directory.
// Defaults to config values when available, but can be fully configured via initRuntime().
const fs = require('fs');
const path = require('path');
const SecurityUtils = require('../utils/security');
const { envManager } = require('../utils/env-manager');
const { isLocaleName } = require('../utils/locale-discovery');
let configManager = null;
try { configManager = require('../utils/config-manager'); } catch (_) { /* optional */ }
const SAFE_LANGUAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,35}$/;
function normalizeLanguageCode(value, fallback = 'en') {
if (typeof value !== 'string') return fallback;
const trimmed = value.trim();
return SAFE_LANGUAGE_PATTERN.test(trimmed) ? trimmed : fallback;
}
function getLocaleFallbackChain(language, fallbackLanguage) {
const chain = [];
const add = value => {
const normalized = normalizeLanguageCode(value, '');
if (!normalized) return;
if (!chain.includes(normalized)) chain.push(normalized);
let canonical = normalized.replace(/_/g, '-');
try { canonical = Intl.getCanonicalLocales(canonical)[0] || canonical; } catch (_) { /* retain validated input */ }
const parts = canonical.split('-');
while (parts.length) {
const candidate = parts.join('-');
if (!chain.includes(candidate)) chain.push(candidate);
parts.pop();
}
};
add(language);
add(fallbackLanguage);
return chain;
}
function createState(options = {}) {
const cacheOptions = options.cache && typeof options.cache === 'object' ? options.cache : {};
return {
baseDir: resolveBaseDir(options.baseDir),
language: normalizeLanguageCode(options.language, 'en'),
fallbackLanguage: normalizeLanguageCode(options.fallbackLanguage, 'en'),
keySeparator: options.keySeparator || '.',
cache: new Map(),
cacheMeta: new Map(),
cacheEnabled: cacheOptions.enabled !== false,
cacheMaxSize: Number.isInteger(cacheOptions.maxSize) && cacheOptions.maxSize > 0 ? cacheOptions.maxSize : 1000,
cacheTtl: Number.isFinite(cacheOptions.ttl) && cacheOptions.ttl >= 0 ? cacheOptions.ttl : 0,
now: typeof options.now === 'function' ? options.now : Date.now,
resourceOverrides: new Map(),
lazy: options.lazy === true,
keyManifest: new Map(),
loadedFiles: new Set(),
eagerLoadedLanguages: new Set(),
listeners: new Set(),
plugins: [],
diagnostics: [],
disposed: false,
missingKeyPolicy: options.missingKeyPolicy || 'key',
loadErrorPolicy: options.loadErrorPolicy || 'report-and-fallback',
};
}
const singletonState = {
baseDir: null, // absolute path to locales dir (e.g., ./locales)
language: 'en',
fallbackLanguage: 'en',
keySeparator: '.',
cache: new Map(), // lang -> merged translations object
cacheMeta: new Map(),
cacheEnabled: true,
cacheMaxSize: 1000,
cacheTtl: 0,
now: Date.now,
resourceOverrides: new Map(),
lazy: false,
keyManifest: new Map(), // lang -> Map: keyName -> filePath
loadedFiles: new Set(), // tracks loaded files in lazy mode
eagerLoadedLanguages: new Set(),
listeners: new Set(),
plugins: [],
diagnostics: [],
disposed: false,
missingKeyPolicy: 'key',
};
let singletonInitialized = false;
function cacheGet(runtimeState, lang) {
if (!runtimeState.cacheEnabled || !runtimeState.cache.has(lang)) return undefined;
const now = runtimeState.now();
const meta = runtimeState.cacheMeta.get(lang);
if (runtimeState.cacheTtl > 0 && meta && now - meta.createdAt >= runtimeState.cacheTtl) {
runtimeState.cache.delete(lang);
runtimeState.cacheMeta.delete(lang);
return undefined;
}
runtimeState.cacheMeta.set(lang, { createdAt: meta?.createdAt ?? now, lastAccess: now });
return runtimeState.cache.get(lang);
}
function cacheSet(runtimeState, lang, value) {
if (!runtimeState.cacheEnabled) return value;
const now = runtimeState.now();
runtimeState.cache.set(lang, value);
runtimeState.cacheMeta.set(lang, { createdAt: now, lastAccess: now });
while (runtimeState.cache.size > runtimeState.cacheMaxSize) {
let oldest;
let oldestAccess = Infinity;
for (const [candidate, meta] of runtimeState.cacheMeta) {
if (meta.lastAccess < oldestAccess) { oldest = candidate; oldestAccess = meta.lastAccess; }
}
if (oldest === undefined) break;
runtimeState.cache.delete(oldest);
runtimeState.cacheMeta.delete(oldest);
}
return value;
}
function mergeResourceOverrides(runtimeState, lang, data) {
const overrides = runtimeState.resourceOverrides.get(lang);
if (overrides) deepMerge(data, overrides);
return data;
}
class RuntimeValidationError extends Error {
constructor(message, details = {}) {
super(message);
this.name = 'RuntimeValidationError';
this.code = 'I18NTK_RUNTIME_VALIDATION';
this.details = details;
}
}
function emitForState(runtimeState, event) {
for (const listener of [...(runtimeState.listeners || [])]) {
try { listener(Object.freeze({ ...event })); } catch (_) { /* observers cannot break translation */ }
}
}
function recordLoadError(runtimeState, lang, file, error) {
if (!runtimeState) return;
const resource = runtimeState.baseDir ? path.relative(runtimeState.baseDir, file).replace(/\\/g, '/') : path.basename(file);
const diagnostic = { type: 'loadError', language: lang, resource, code: 'I18NTK_RUNTIME_LOAD', message: error.message };
runtimeState.diagnostics.push(diagnostic);
if (runtimeState.diagnostics.length > 100) runtimeState.diagnostics.shift();
emitForState(runtimeState, diagnostic);
if (runtimeState.loadErrorPolicy === 'throw') {
const loadError = new Error(`Failed to load locale resource ${resource}: ${error.message}`);
loadError.code = 'I18NTK_RUNTIME_LOAD';
throw loadError;
}
}
// --- Utilities ---
function stripBOMAndComments(s) {
if (!s) return s;
if (s.charCodeAt && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
s = s.replace(/\/\*[\s\S]*?\*\//g, '');
s = s.replace(/^\s*\/\/.*$/mg, '');
return s;
}
function readJsonSafe(file) {
const raw = SecurityUtils.safeReadFileSync(file, path.resolve(file, '..'), 'utf8');
if (raw === null || raw === undefined) {
throw new Error(`Unable to read JSON file: ${file}`);
}
const withoutBOM = raw.charCodeAt && raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw;
if (!withoutBOM) {
throw new Error(`Empty JSON file: ${file}`);
}
let depth = 0;
let maxDepth = 0;
for (let i = 0; i < Math.min(withoutBOM.length, 50000); i++) {
if (withoutBOM[i] === '{' || withoutBOM[i] === '[') { depth++; maxDepth = Math.max(maxDepth, depth); }
else if (withoutBOM[i] === '}' || withoutBOM[i] === ']') depth--;
}
if (maxDepth > 1000) {
throw new Error(`JSON nesting too deep (${maxDepth} levels) in file: ${file}`);
}
try {
return stripPrototypeKeys(JSON.parse(withoutBOM));
} catch (parseError) {
const cleaned = stripBOMAndComments(raw);
if (!cleaned) {
throw new Error(`Empty JSON file: ${file}`);
}
try {
return stripPrototypeKeys(JSON.parse(cleaned));
} catch (_) {
throw new Error(`Invalid JSON in file ${file}: ${parseError.message}`);
}
}
}
function stripPrototypeKeys(obj) {
if (!obj || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(item => stripPrototypeKeys(item));
const clean = {};
for (const key of Object.keys(obj)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
const value = obj[key];
clean[key] = (value && typeof value === 'object') ? stripPrototypeKeys(value) : value;
}
return clean;
}
function deepMerge(target, source) {
if (!target || typeof target !== 'object') target = {};
if (!source || typeof source !== 'object') return target;
for (const key of Object.keys(source)) {
// Block prototype pollution via __proto__, constructor, prototype keys
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
const sv = source[key];
const tv = target[key];
if (
sv && typeof sv === 'object' && !Array.isArray(sv) &&
tv && typeof tv === 'object' && !Array.isArray(tv)
) {
target[key] = deepMerge({ ...tv }, sv);
} else {
target[key] = sv;
}
}
return target;
}
function resolveBaseDir(explicitBaseDir) {
// 1) Highest priority: explicit option
if (explicitBaseDir) return path.resolve(explicitBaseDir);
// 2) Environment override for CI/explicit control
const runtimeDir = envManager.get('I18NTK_RUNTIME_DIR');
if (runtimeDir) {
return path.resolve(runtimeDir);
}
// 2b) Respect config-style env overrides, even without config-manager
const envI18nDir = envManager.get('I18NTK_I18N_DIR');
if (envI18nDir) {
return path.resolve(envI18nDir);
}
const envSourceDir = envManager.get('I18NTK_SOURCE_DIR');
if (envSourceDir) {
return path.resolve(envSourceDir);
}
// 3) Use config-manager if available (single source of truth: i18ntk-config.json)
try {
const cfgRaw = configManager?.getConfig?.() || {};
const cfg = configManager?.resolvePaths ? configManager.resolvePaths(cfgRaw) : cfgRaw;
const base = cfg.i18nDir || cfg.sourceDir || './locales';
// If config-manager resolved absolute paths, use as-is; otherwise resolve from project cwd
const isAbs = typeof base === 'string' && path.isAbsolute(base);
if (isAbs) return base;
const envProjectRoot = envManager.get('I18NTK_PROJECT_ROOT');
const root = envProjectRoot ? path.resolve(envProjectRoot) : process.cwd();
return path.resolve(root, base);
} catch (_) {
// 4) Fallback to conventional './locales' from project CWD
const envProjectRoot = envManager.get('I18NTK_PROJECT_ROOT');
const root = envProjectRoot ? path.resolve(envProjectRoot) : process.cwd();
return path.resolve(root, './locales');
}
}
function listJsonFilesRecursively(dir, baseDir = dir) {
const results = [];
const stack = [dir];
while (stack.length) {
const d = stack.pop();
if (!SecurityUtils.safeExistsSync(d, baseDir)) continue;
try {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const full = path.join(d, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.json')) {
results.push(full);
}
}
} catch (_) {
// Skip directories we cannot read
}
}
return results;
}
function loadKeyManifestFromDir(baseDir) {
if (!baseDir || typeof baseDir !== 'string') return new Map();
const validatedBase = SecurityUtils.validatePath(baseDir, path.dirname(baseDir));
const baseStat = SecurityUtils.safeStatSync(validatedBase, path.dirname(validatedBase));
const baseRoot = baseStat && baseStat.isFile() ? path.dirname(validatedBase) : validatedBase;
const files = baseStat && baseStat.isFile() ? [validatedBase] : listJsonFilesRecursively(validatedBase, validatedBase);
const manifest = new Map();
const MAX_SIZE = 100 * 1024;
let currentSize = 0;
for (const file of files) {
let validated;
try { validated = SecurityUtils.validatePath(file, baseRoot); } catch (_) { continue; }
const rel = path.relative(baseRoot, validated);
if (rel.startsWith('..') || path.isAbsolute(rel)) continue;
// Namespace files conventionally map their filename to the first key
// segment (common.json -> common.*). This avoids parsing every locale file
// merely to build a lazy manifest. Unknown layouts fall back to one eager
// language read on the first unmatched key.
const key = path.basename(validated, path.extname(validated));
const entrySize = JSON.stringify(key).length + JSON.stringify(validated).length + 5;
if (!manifest.has(key) && currentSize + entrySize <= MAX_SIZE) {
manifest.set(key, validated);
currentSize += entrySize;
}
if (currentSize >= MAX_SIZE) break;
}
return manifest;
}
function getLanguagePath(baseDir, lang) {
lang = normalizeLanguageCode(lang, '');
if (!lang) return null;
const langDir = path.join(baseDir, lang);
const langFile = path.join(baseDir, `${lang}.json`);
const langDirStat = SecurityUtils.safeStatSync(langDir, baseDir);
if (langDirStat && langDirStat.isDirectory()) return langDir;
const langFileStat = SecurityUtils.safeStatSync(langFile, baseDir);
if (langFileStat && langFileStat.isFile()) return langFile;
return null;
}
function getManifestForLanguage(runtimeState, lang) {
if (!runtimeState.keyManifest) runtimeState.keyManifest = new Map();
if (runtimeState.keyManifest.has(lang)) return runtimeState.keyManifest.get(lang);
const languagePath = getLanguagePath(runtimeState.baseDir, lang);
const manifest = languagePath ? loadKeyManifestFromDir(languagePath) : new Map();
runtimeState.keyManifest.set(lang, manifest);
return manifest;
}
function loadFileLazy(runtimeState, filePath, lang) {
const baseDir = runtimeState.baseDir;
if (!baseDir) throw new Error('baseDir not initialized');
let validatedPath;
try { validatedPath = SecurityUtils.validatePath(filePath, baseDir); } catch (e) {
throw new Error(`Invalid file path for lazy load: ${e.message}`);
}
const rel = path.relative(baseDir, validatedPath);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(`File outside base directory: ${filePath}`);
}
const data = readJsonSafe(validatedPath);
if (data && typeof data === 'object') {
let cacheEntry = cacheGet(runtimeState, lang);
if (!cacheEntry) cacheEntry = {};
deepMerge(cacheEntry, data);
cacheSet(runtimeState, lang, cacheEntry);
}
return data;
}
function readLanguageFromBase(baseDir, lang, runtimeState = null) {
lang = normalizeLanguageCode(lang, '');
if (!lang) return {};
const merged = {};
const langFile = path.join(baseDir, `${lang}.json`);
const langDir = path.join(baseDir, lang);
// Prefer folder if exists, otherwise single file
const langDirStat = SecurityUtils.safeStatSync(langDir, baseDir);
if (langDirStat && langDirStat.isDirectory()) {
const files = listJsonFilesRecursively(langDir, langDir);
for (const file of files) {
try {
const data = readJsonSafe(file);
if (data && typeof data === 'object') deepMerge(merged, data);
} catch (error) { recordLoadError(runtimeState, lang, file, error); }
}
} else {
const langFileStat = SecurityUtils.safeStatSync(langFile, baseDir);
if (langFileStat && langFileStat.isFile()) {
try {
const data = readJsonSafe(langFile);
if (data && typeof data === 'object') deepMerge(merged, data);
} catch (error) { recordLoadError(runtimeState, lang, langFile, error); }
}
}
return merged;
}
function getTranslationsForState(runtimeState, lang) {
if (!runtimeState.baseDir) runtimeState.baseDir = resolveBaseDir();
lang = normalizeLanguageCode(lang, runtimeState.fallbackLanguage || 'en');
if (!runtimeState.cacheEnabled) {
return mergeResourceOverrides(runtimeState, lang, readLanguageFromBase(runtimeState.baseDir, lang, runtimeState));
}
if (runtimeState.lazy) {
const cached = cacheGet(runtimeState, lang);
if (cached) return cached;
cacheSet(runtimeState, lang, mergeResourceOverrides(runtimeState, lang, {}));
getManifestForLanguage(runtimeState, lang);
return cacheGet(runtimeState, lang);
}
const cached = cacheGet(runtimeState, lang);
if (cached) return cached;
const data = mergeResourceOverrides(runtimeState, lang, readLanguageFromBase(runtimeState.baseDir, lang, runtimeState));
return cacheSet(runtimeState, lang, data);
}
function interpolate(template, params) {
if (typeof template !== 'string') return template;
params = params && typeof params === 'object' ? params : {};
return template
.replace(/\{\{(\w+)\}\}/g, (m, p1) => (p1 in params ? String(params[p1]) : m))
.replace(/\{(\w+)\}/g, (m, p1) => (p1 in params ? String(params[p1]) : m));
}
// Resolve a dotted key path from an object
function resolveKey(obj, key, sep = '.', runtimeState = null, lang = null) {
if (!obj || typeof obj !== 'object') return undefined;
if (!key || typeof key !== 'string') return undefined;
const parts = key.split(sep);
let cur = obj;
for (const p of parts) {
if (cur && Object.prototype.hasOwnProperty.call(cur, p)) {
cur = cur[p];
} else {
if (runtimeState && lang && runtimeState.lazy) {
const manifest = getManifestForLanguage(runtimeState, lang);
for (let i = parts.length; i > 0; i--) {
const prefix = parts.slice(0, i).join(sep);
const filePath = manifest.get(prefix);
const loadedFileKey = `${lang}\0${filePath}`;
if (filePath && !runtimeState.loadedFiles.has(loadedFileKey)) {
runtimeState.loadedFiles.add(loadedFileKey);
try {
loadFileLazy(runtimeState, filePath, lang);
} catch (lazyErr) {
recordLoadError(runtimeState, lang, filePath, lazyErr);
// stale manifest entry — log and mark as loaded to prevent retry
if (process.env.I18NTK_DEBUG) {
console.warn(`[i18ntk/runtime] Lazy load failed for ${filePath}: ${lazyErr.message}`);
}
}
const langData = cacheGet(runtimeState, lang);
return resolveKey(langData, key, sep, runtimeState, lang);
}
}
if (!runtimeState.eagerLoadedLanguages.has(lang)) {
const fullData = readLanguageFromBase(runtimeState.baseDir, lang, runtimeState);
const langData = cacheGet(runtimeState, lang) || {};
deepMerge(langData, fullData);
mergeResourceOverrides(runtimeState, lang, langData);
cacheSet(runtimeState, lang, langData);
runtimeState.eagerLoadedLanguages.add(lang);
return resolveKey(langData, key, sep, runtimeState, lang);
}
return undefined;
}
return undefined;
}
}
return cur;
}
// --- Public API ---
function initRuntime(options = {}) {
// Support alias parameter names for better DX
const opts = { ...options };
if (opts.localeDir && !opts.baseDir) {
opts.baseDir = opts.projectRoot ? path.resolve(opts.projectRoot, opts.localeDir) : opts.localeDir;
} else if (opts.baseDir && opts.projectRoot && !path.isAbsolute(opts.baseDir)) {
opts.baseDir = path.resolve(opts.projectRoot, opts.baseDir);
}
if (opts.targetLocale && !opts.language) opts.language = opts.targetLocale;
if (opts.sourceLocale && !opts.fallbackLanguage) opts.fallbackLanguage = opts.sourceLocale;
const runtimeState = createState(opts);
preload(runtimeState, options.preload);
return createNodeRuntime(runtimeState);
}
function initDefaultRuntime(options = {}) {
const opts = { ...options };
if (opts.localeDir && !opts.baseDir) opts.baseDir = opts.projectRoot ? path.resolve(opts.projectRoot, opts.localeDir) : opts.localeDir;
const next = createState(opts);
Object.assign(singletonState, next);
preload(singletonState, options.preload);
singletonInitialized = true;
return createNodeRuntime(singletonState);
}
function preload(runtimeState, shouldPreload) {
if (shouldPreload === true) {
if (runtimeState.lazy) {
getManifestForLanguage(runtimeState, runtimeState.language);
if (!cacheGet(runtimeState, runtimeState.language)) {
cacheSet(runtimeState, runtimeState.language, {});
}
if (runtimeState.fallbackLanguage && runtimeState.fallbackLanguage !== runtimeState.language) {
getManifestForLanguage(runtimeState, runtimeState.fallbackLanguage);
if (!cacheGet(runtimeState, runtimeState.fallbackLanguage)) {
cacheSet(runtimeState, runtimeState.fallbackLanguage, {});
}
}
} else {
getTranslationsForState(runtimeState, runtimeState.language);
if (runtimeState.fallbackLanguage && runtimeState.fallbackLanguage !== runtimeState.language) {
getTranslationsForState(runtimeState, runtimeState.fallbackLanguage);
}
}
}
}
function createNodeRuntime(runtimeState) {
const runtimeTranslate = (key, params, options) => translateWithState(runtimeState, key, params, options);
return {
t: runtimeTranslate,
translate: runtimeTranslate,
translateBatch: (keys, params, options) => translateBatchWithState(runtimeState, keys, params, options),
setLanguage: (lang) => setLanguageForState(runtimeState, lang),
getLanguage: () => getLanguageForState(runtimeState),
getAvailableLanguages: () => getAvailableLanguagesForState(runtimeState),
clearCache: (lang) => clearCacheForState(runtimeState, lang),
getCacheInfo: () => getCacheInfoForState(runtimeState),
refresh: (lang) => refreshForState(runtimeState, lang),
has: (key, options) => hasWithState(runtimeState, key, options),
addResources: (lang, namespace, data) => addResourcesForState(runtimeState, lang, namespace, data),
removeResources: (lang, namespace) => removeResourcesForState(runtimeState, lang, namespace),
subscribe: (listener) => subscribeForState(runtimeState, listener),
addPlugin: (plugin) => addPluginForState(runtimeState, plugin),
removePlugin: (name) => removePluginForState(runtimeState, name),
getDiagnostics: () => [...runtimeState.diagnostics],
dispose: () => disposeForState(runtimeState),
};
}
function translate(key, params = {}, options = {}) {
return translateWithState(singletonState, key, params, options);
}
function translateWithState(runtimeState, key, params = {}, options = {}) {
if (runtimeState.disposed) throw new Error('Runtime has been disposed');
if (typeof key !== 'string' || !key) {
const error = new RuntimeValidationError('Translation key must be a non-empty string', { keyType: typeof key });
emitForState(runtimeState, { type: 'translationError', error });
throw error;
}
params = params && typeof params === 'object' ? params : {};
options = options && typeof options === 'object' ? options : {};
const activeLanguage = normalizeLanguageCode(options.locale || options.language, runtimeState.language);
const fallbackOption = options.fallbackLocale || options.fallbackLanguage;
const fallbackLanguage = typeof fallbackOption === 'string'
? normalizeLanguageCode(fallbackOption, '')
: runtimeState.fallbackLanguage;
const lookupKey = options.namespace && options.namespace !== 'default'
? `${options.namespace}${runtimeState.keySeparator}${key}`
: key;
let value;
for (const candidate of getLocaleFallbackChain(activeLanguage, fallbackLanguage)) {
const languageData = getTranslationsForState(runtimeState, candidate);
value = resolveKey(languageData, lookupKey, runtimeState.keySeparator, runtimeState, candidate);
if (typeof value !== 'undefined') break;
}
if (typeof value === 'undefined') {
const event = { type: 'missingKey', key, language: activeLanguage };
runtimeState.diagnostics.push(event);
if (runtimeState.diagnostics.length > 100) runtimeState.diagnostics.shift();
emitForState(runtimeState, event);
const policy = options.missingKeyPolicy || runtimeState.missingKeyPolicy;
if (policy === 'throw') throw new Error(`Missing translation key: ${key}`);
value = typeof policy === 'function' ? policy(event) : policy === 'empty' ? '' : key;
}
let result = typeof value === 'string' ? interpolate(value, params) : String(value ?? '');
for (const plugin of runtimeState.plugins || []) {
if (typeof plugin.transform !== 'function') continue;
try { result = plugin.transform(result, params, options); }
catch (error) {
const diagnostic = { type: 'pluginError', plugin: plugin.name, code: error?.code, message: error?.message || String(error) };
runtimeState.diagnostics.push(diagnostic);
if (runtimeState.diagnostics.length > 100) runtimeState.diagnostics.shift();
emitForState(runtimeState, diagnostic);
}
}
emitForState(runtimeState, { type: 'translation', key, language: activeLanguage, value: result });
return result;
}
function hasWithState(runtimeState, key, options = {}) {
if (typeof key !== 'string' || !key) return false;
const language = normalizeLanguageCode(options.locale || options.language, runtimeState.language);
const fallback = normalizeLanguageCode(options.fallbackLocale || options.fallbackLanguage, runtimeState.fallbackLanguage);
const lookupKey = options.namespace && options.namespace !== 'default'
? `${options.namespace}${runtimeState.keySeparator}${key}`
: key;
return getLocaleFallbackChain(language, fallback).some(candidate =>
resolveKey(getTranslationsForState(runtimeState, candidate), lookupKey, runtimeState.keySeparator, runtimeState, candidate) !== undefined
);
}
function addResourcesForState(runtimeState, lang, namespace = 'default', data = {}) {
lang = normalizeLanguageCode(lang, '');
if (!lang || !data || typeof data !== 'object') throw new RuntimeValidationError('Resources require a valid language and object data');
const overrides = runtimeState.resourceOverrides.get(lang) || {};
if (namespace && namespace !== 'default') overrides[namespace] = deepMerge(overrides[namespace] || {}, stripPrototypeKeys(data));
else deepMerge(overrides, stripPrototypeKeys(data));
runtimeState.resourceOverrides.set(lang, overrides);
runtimeState.cache.delete(lang);
runtimeState.cacheMeta.delete(lang);
const current = getTranslationsForState(runtimeState, lang);
cacheSet(runtimeState, lang, current);
emitForState(runtimeState, { type: 'resourcesChanged', language: lang, namespace });
}
function removeResourcesForState(runtimeState, lang, namespace) {
lang = normalizeLanguageCode(lang, '');
if (!lang) return;
if (!namespace || namespace === 'default') {
runtimeState.resourceOverrides.delete(lang);
refreshForState(runtimeState, lang);
}
else {
const overrides = runtimeState.resourceOverrides.get(lang);
if (overrides) delete overrides[namespace];
const current = cacheGet(runtimeState, lang);
if (current) delete current[namespace];
}
emitForState(runtimeState, { type: 'resourcesChanged', language: lang, namespace: namespace || null });
}
function subscribeForState(runtimeState, listener) {
if (typeof listener !== 'function') throw new RuntimeValidationError('Runtime listener must be a function');
runtimeState.listeners.add(listener);
return () => runtimeState.listeners.delete(listener);
}
function addPluginForState(runtimeState, plugin) {
if (!plugin || typeof plugin.name !== 'string') throw new RuntimeValidationError('Plugin requires a name');
if (runtimeState.plugins.some(existing => existing.name === plugin.name)) throw new RuntimeValidationError(`Plugin already registered: ${plugin.name}`);
runtimeState.plugins.push(plugin);
return () => removePluginForState(runtimeState, plugin.name);
}
function removePluginForState(runtimeState, name) {
runtimeState.plugins = runtimeState.plugins.filter(plugin => plugin.name !== name);
}
function disposeForState(runtimeState) {
if (runtimeState.disposed) return;
clearCacheForState(runtimeState);
runtimeState.resourceOverrides.clear();
runtimeState.listeners.clear();
runtimeState.plugins.length = 0;
runtimeState.disposed = true;
}
function translateBatch(keys, params = {}, options = {}) {
return translateBatchWithState(singletonState, keys, params, options);
}
function translateBatchWithState(runtimeState, keys, params = {}, options = {}) {
if (!Array.isArray(keys)) return [];
return keys.map((key, index) => {
const perKeyParams = Array.isArray(params) ? (params[index] || {}) : params;
return translateWithState(runtimeState, key, perKeyParams, options);
});
}
function setLanguage(lang) {
setLanguageForState(singletonState, lang);
}
function setLanguageForState(runtimeState, lang) {
const safeLang = normalizeLanguageCode(lang, '');
if (!safeLang) return;
const previous = runtimeState.language;
runtimeState.language = safeLang;
if (previous !== safeLang) emitForState(runtimeState, { type: 'languageChanged', language: safeLang, previous });
}
function getLanguage() {
return getLanguageForState(singletonState);
}
function getLanguageForState(runtimeState) {
return runtimeState.language;
}
function getAvailableLanguages() {
return getAvailableLanguagesForState(singletonState);
}
function getAvailableLanguagesForState(runtimeState) {
const langs = new Set();
if (!runtimeState.baseDir) runtimeState.baseDir = resolveBaseDir();
if (!SecurityUtils.safeExistsSync(runtimeState.baseDir, path.dirname(runtimeState.baseDir))) return ['en'];
try {
for (const entry of fs.readdirSync(runtimeState.baseDir, { withFileTypes: true })) {
if (entry.isFile() && entry.name.toLowerCase().endsWith('.json')) {
const lang = normalizeLanguageCode(entry.name.replace(/\.json$/i, ''), '');
if (lang && isLocaleName(lang)) langs.add(lang);
} else if (entry.isDirectory() && isLocaleName(entry.name)) {
const lang = normalizeLanguageCode(entry.name, '');
if (!lang) continue;
if (listJsonFilesRecursively(path.join(runtimeState.baseDir, entry.name)).length > 0) langs.add(entry.name);
}
}
} catch (_) {
// Unreadable directory
return ['en'];
}
return Array.from(langs.size ? langs : new Set(['en']));
}
function clearCache(lang) {
clearCacheForState(singletonState, lang);
}
function clearCacheForState(runtimeState, lang) {
if (typeof lang === 'string') {
refreshForState(runtimeState, lang);
return;
}
runtimeState.cache.clear();
runtimeState.cacheMeta.clear();
if (runtimeState.keyManifest) runtimeState.keyManifest.clear();
if (runtimeState.loadedFiles) runtimeState.loadedFiles.clear();
if (runtimeState.eagerLoadedLanguages) runtimeState.eagerLoadedLanguages.clear();
}
function getCacheInfo() {
return getCacheInfoForState(singletonState);
}
function getCacheInfoForState(runtimeState) {
return {
language: runtimeState.language,
fallbackLanguage: runtimeState.fallbackLanguage,
lazy: runtimeState.lazy === true,
enabled: runtimeState.cacheEnabled !== false,
maxSize: runtimeState.cacheMaxSize,
ttl: runtimeState.cacheTtl,
cachedLanguages: Array.from(runtimeState.cache.keys()).sort(),
manifestLanguages: runtimeState.keyManifest ? Array.from(runtimeState.keyManifest.keys()).sort() : [],
loadedFileCount: runtimeState.loadedFiles ? runtimeState.loadedFiles.size : 0,
eagerLoadedLanguages: runtimeState.eagerLoadedLanguages ? Array.from(runtimeState.eagerLoadedLanguages).sort() : [],
};
}
function refresh(lang) {
refreshForState(singletonState, lang);
}
function refreshForState(runtimeState, lang = runtimeState.language) {
lang = normalizeLanguageCode(lang, '');
if (!lang) return;
if (runtimeState.cache.has(lang)) runtimeState.cache.delete(lang);
runtimeState.cacheMeta.delete(lang);
if (runtimeState.eagerLoadedLanguages) runtimeState.eagerLoadedLanguages.delete(lang);
if (runtimeState.keyManifest) runtimeState.keyManifest.delete(lang);
if (runtimeState.loadedFiles) {
for (const fileKey of Array.from(runtimeState.loadedFiles)) {
if (fileKey.startsWith(`${lang}\0`)) runtimeState.loadedFiles.delete(fileKey);
}
}
if (lang !== runtimeState.fallbackLanguage && runtimeState.cache.has(runtimeState.fallbackLanguage)) {
// do nothing; keep or clear on demand
}
}
module.exports = {
createRuntime: require('./core').createRuntime,
createUniversalRuntime: require('./core').createRuntime,
initRuntime,
initDefaultRuntime,
translate,
t: translate,
translateBatch,
setLanguage,
getLanguage,
getAvailableLanguages,
clearCache,
getCacheInfo,
refresh,
loadKeyManifest: (baseDir) => loadKeyManifestFromDir(baseDir || singletonState.baseDir),
RuntimeValidationError,
};