i18ntk
Version:
🚀 The fastest i18n toolkit with 97% performance boost! Zero-dependency, enterprise-grade internationalization for React, Vue, Angular, Python, Java, PHP & more. Features PIN protection, auto framework detection, 7+ UI languages, and comprehensive transla
237 lines (213 loc) • 7.88 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');
let configManager = null;
try { configManager = require('../utils/config-manager'); } catch (_) { /* optional */ }
const state = {
baseDir: null, // absolute path to locales dir (e.g., ./locales)
language: 'en',
fallbackLanguage: 'en',
keySeparator: '.',
cache: new Map(), // lang -> merged translations object
};
// --- 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 = fs.readFileSync(file, 'utf8');
return JSON.parse(stripBOMAndComments(raw));
}
function deepMerge(target, source) {
if (!target || typeof target !== 'object') target = {};
if (!source || typeof source !== 'object') return target;
for (const key of Object.keys(source)) {
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
if (process.env.I18NTK_RUNTIME_DIR) {
return path.resolve(process.env.I18NTK_RUNTIME_DIR);
}
// 2b) Respect config-style env overrides, even without config-manager
if (process.env.I18NTK_I18N_DIR) {
return path.resolve(process.env.I18NTK_I18N_DIR);
}
if (process.env.I18NTK_SOURCE_DIR) {
return path.resolve(process.env.I18NTK_SOURCE_DIR);
}
// 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 root = process.env.I18NTK_PROJECT_ROOT ? path.resolve(process.env.I18NTK_PROJECT_ROOT) : process.cwd();
return path.resolve(root, base);
} catch (_) {
// 4) Fallback to conventional './locales' from project CWD
const root = process.env.I18NTK_PROJECT_ROOT ? path.resolve(process.env.I18NTK_PROJECT_ROOT) : process.cwd();
return path.resolve(root, './locales');
}
}
function listJsonFilesRecursively(dir) {
const results = [];
const stack = [dir];
while (stack.length) {
const d = stack.pop();
if (!fs.existsSync(d)) continue;
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);
}
}
}
return results;
}
function readLanguageFromBase(baseDir, lang) {
const merged = {};
const langFile = path.join(baseDir, `${lang}.json`);
const langDir = path.join(baseDir, lang);
// Prefer folder if exists, otherwise single file
if (fs.existsSync(langDir) && fs.statSync(langDir).isDirectory()) {
const files = listJsonFilesRecursively(langDir);
for (const file of files) {
try {
const data = readJsonSafe(file);
if (data && typeof data === 'object') deepMerge(merged, data);
} catch (e) {
// Skip unreadable/invalid files
}
}
} else if (fs.existsSync(langFile) && fs.statSync(langFile).isFile()) {
try {
const data = readJsonSafe(langFile);
if (data && typeof data === 'object') deepMerge(merged, data);
} catch (_) { /* ignore */ }
}
return merged;
}
function getTranslations(lang) {
if (state.cache.has(lang)) return state.cache.get(lang);
const data = readLanguageFromBase(state.baseDir, lang);
state.cache.set(lang, data);
return data;
}
function interpolate(template, params) {
if (typeof template !== 'string') return template;
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 = '.') {
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 {
return undefined;
}
}
return cur;
}
// --- Public API ---
function initRuntime(options = {}) {
state.baseDir = resolveBaseDir(options.baseDir);
state.language = options.language || state.language || 'en';
state.fallbackLanguage = options.fallbackLanguage || state.fallbackLanguage || 'en';
state.keySeparator = options.keySeparator || state.keySeparator || '.';
// Optional prewarm caches
state.cache.clear();
if (options.preload === true) {
getTranslations(state.language);
if (state.fallbackLanguage && state.fallbackLanguage !== state.language) {
getTranslations(state.fallbackLanguage);
}
}
return {
t: translate,
translate,
setLanguage,
getLanguage,
getAvailableLanguages,
refresh,
};
}
function translate(key, params = {}) {
const langData = getTranslations(state.language);
let value = resolveKey(langData, key, state.keySeparator);
if (typeof value === 'undefined' && state.fallbackLanguage) {
const fbData = getTranslations(state.fallbackLanguage);
value = resolveKey(fbData, key, state.keySeparator);
}
if (typeof value === 'string') return interpolate(value, params);
return typeof value === 'undefined' ? key : value;
}
function setLanguage(lang) {
if (!lang || typeof lang !== 'string') return;
state.language = lang;
}
function getLanguage() {
return state.language;
}
function getAvailableLanguages() {
const langs = new Set();
if (!state.baseDir) state.baseDir = resolveBaseDir();
if (!fs.existsSync(state.baseDir)) return ['en'];
for (const entry of fs.readdirSync(state.baseDir, { withFileTypes: true })) {
if (entry.isFile() && entry.name.toLowerCase().endsWith('.json')) {
langs.add(entry.name.replace(/\.json$/i, ''));
} else if (entry.isDirectory()) {
// language folder convention
const lang = entry.name;
const idx = path.join(state.baseDir, lang, `${lang}.json`);
if (fs.existsSync(idx)) langs.add(lang);
else langs.add(lang); // be permissive
}
}
return Array.from(langs.size ? langs : new Set(['en']));
}
function refresh(lang = state.language) {
if (state.cache.has(lang)) state.cache.delete(lang);
if (lang !== state.fallbackLanguage && state.cache.has(state.fallbackLanguage)) {
// do nothing; keep or clear on demand
}
}
module.exports = {
initRuntime,
translate,
t: translate,
setLanguage,
getLanguage,
getAvailableLanguages,
refresh,
};