i18ntk
Version:
i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.
526 lines (468 loc) • 16.4 kB
JavaScript
// utils/i18n-helper.js
const path = require('path');
const fs = require('fs');
const { logger } = require('./logger');
const { envManager } = require('./env-manager');
// Lazy load SecurityUtils to prevent circular dependencies
let securityUtils;
function getSecurityUtils() {
if (!securityUtils) {
try {
securityUtils = require('./security');
} catch (error) {
// Fallback: use basic fs operations with path containment
const path = require('path');
const fallbackBase = path.resolve(__dirname, '..');
return {
safeExistsSync: (targetPath, basePath) => {
try {
const resolved = path.resolve(basePath || fallbackBase, targetPath);
if (!resolved.startsWith(path.resolve(basePath || fallbackBase))) {
return false;
}
require('fs').accessSync(resolved);
return true;
} catch {
return false;
}
},
safeWriteFileSync: (targetPath, data, basePath, encoding = 'utf8') => {
try {
const resolved = path.resolve(basePath || fallbackBase, targetPath);
if (!resolved.startsWith(path.resolve(basePath || fallbackBase))) {
return null;
}
require('fs').mkdirSync(path.dirname(resolved), { recursive: true });
require('fs').writeFileSync(resolved, data, encoding);
return true;
} catch {
return null;
}
},
safeReadFileSync: (targetPath, basePath, encoding = 'utf8') => {
try {
const resolved = path.resolve(basePath || fallbackBase, targetPath);
if (!resolved.startsWith(path.resolve(basePath || fallbackBase))) {
return null;
}
return require('fs').readFileSync(resolved, encoding);
} catch {
return null;
}
},
safeStatSync: (targetPath, basePath) => {
try {
const resolved = path.resolve(basePath || fallbackBase, targetPath);
if (!resolved.startsWith(path.resolve(basePath || fallbackBase))) {
return null;
}
return require('fs').statSync(resolved);
} catch {
return null;
}
}
};
}
}
return securityUtils;
}
// Helper functions for OS-agnostic path handling
function toPosix(p) { return String(p).replace(/\\/g, '/'); }
function isBundledPath(p) {
const s = toPosix(p);
return s.includes('/node_modules/i18ntk/') || s.includes('/i18ntk/ui-locales/');
}
function safeRequireConfig() {
try { return require('./config-manager'); } catch { return null; }
}
function stripBOMAndComments(s) {
if (!s || typeof s !== 'string') return s || '';
if (s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
s = s.replace(/\/\*[\s\S]*?\*\//g, '');
s = s.replace(/^\s*\/\/.*$/mg, '');
return s;
}
function getValidationBase(targetPath) {
const fallbackBase = path.resolve(__dirname, '..');
if (!targetPath || typeof targetPath !== 'string') {
return fallbackBase;
}
let current = path.resolve(path.dirname(targetPath));
while (true) {
try {
const stat = getSecurityUtils().safeStatSync(current, current);
if (stat && stat.isDirectory()) {
return current;
}
} catch {
// Continue upward until we find an existing directory.
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return fallbackBase;
}
function safeReadFile(targetPath, encoding = 'utf8') {
const SecurityUtils = getSecurityUtils();
return SecurityUtils.safeReadFileSync(targetPath, getValidationBase(targetPath), encoding);
}
function readJsonSafe(file) {
const raw = safeReadFile(file, 'utf8');
if (raw === null) {
throw new Error(`Unable to read JSON file: ${file}`);
}
return JSON.parse(stripBOMAndComments(raw));
}
function pkgUiLocalesDirViaThisFile() {
return path.resolve(__dirname, '..', 'ui-locales');
}
function pkgUiLocalesDirViaResolve() {
try {
// Resolve using the current exported package path.
const enJson = require.resolve('i18ntk/ui-locales/en.json');
return path.dirname(enJson);
} catch {
return null;
}
}
// Removed legacyResourcesUiLocalesDir as resources/i18n/ui-locales is deprecated
function resolveLocalesDirs(preferredDir) {
const SecurityUtils = getSecurityUtils();
const dirs = [];
const addDir = (dir) => {
if (typeof dir === 'string' && dir.trim()) {
try {
const normalized = path.normalize(path.resolve(dir.trim()));
const stats = SecurityUtils.safeStatSync(normalized, getValidationBase(normalized));
if (stats && stats.isDirectory()) {
dirs.push(normalized);
}
} catch {
// Silently ignore invalid paths
}
}
};
addDir(preferredDir);
const pkgA = pkgUiLocalesDirViaThisFile();
addDir(pkgA);
const pkgB = pkgUiLocalesDirViaResolve();
if (pkgB && pkgB !== pkgA) {
addDir(pkgB);
}
// Removed legacy directory fallback
// Deduplicate while preserving order
const seen = new Set();
return dirs.filter(dir => {
if (seen.has(dir)) return false;
seen.add(dir);
return true;
});
}
function candidatesForLang(dir, lang) {
return [
path.join(dir, `${lang}.json`), // ui-locales/en.json
path.join(dir, lang, 'index.json') // ui-locales/en/index.json
];
}
function findLocaleFilesAllDirs(lang, preferredDir) {
const SecurityUtils = getSecurityUtils();
const dirs = resolveLocalesDirs(preferredDir);
if (envManager.getBoolean('I18NTK_DEBUG_LOCALES')) {
console.log('🔎 i18ntk locale search dirs:', dirs);
}
const files = [];
const errors = [];
for (const dir of dirs) {
for (const candidate of candidatesForLang(dir, lang)) {
try {
const stats = SecurityUtils.safeStatSync(candidate, getValidationBase(candidate));
if (stats && stats.isFile() && stats.size > 0) {
// Quick JSON validation
const content = safeReadFile(candidate, 'utf8');
if (content) {
if (content.trim().startsWith('{') || content.trim().startsWith('[')) {
files.push(candidate);
} else {
errors.push({ file: candidate, error: 'Invalid JSON format' });
}
} else {
errors.push({ file: candidate, error: 'Empty or unreadable file' });
}
}
} catch (error) {
errors.push({ file: candidate, error: error.message });
}
}
}
if (envManager.getBoolean('I18NTK_DEBUG_LOCALES') && errors.length > 0) {
console.warn(`⚠️ Locale resolution errors for ${lang}:`, errors);
}
return files;
}
let translations = {};
let currentLanguage = 'en';
let isInitialized = false;
const missingKeyCache = new Map();
const missingKeyTtlMs = 5 * 60 * 1000;
const CACHE_PRUNE_INTERVAL_MS = 10 * 60 * 1000;
let lastCachePruneTime = Date.now();
function pruneMissingKeyCache() {
const now = Date.now();
if (now - lastCachePruneTime < CACHE_PRUNE_INTERVAL_MS) return;
lastCachePruneTime = now;
for (const [key, expiresAt] of missingKeyCache) {
if (expiresAt <= now) missingKeyCache.delete(key);
}
}
function shouldReportMissingKey(key) {
const now = Date.now();
pruneMissingKeyCache();
const expiresAt = missingKeyCache.get(key) || 0;
if (expiresAt > now) {
return false;
}
missingKeyCache.set(key, now + missingKeyTtlMs);
return true;
}
function loadTranslations(language) {
const cfg = safeRequireConfig();
const settings = cfg?.getConfig?.() || {};
const configuredLanguage = settings.uiLanguage || settings.language;
// Prioritize CLI argument, then UI language, then language fallback
const requested = (language || configuredLanguage || 'en').toString();
const short = requested.split('-')[0].toLowerCase();
const tryOrder = [requested, short, 'en'];
const loadErrors = [];
const preferredDir = arguments.length > 1 ? arguments[1] : null;
for (const lang of tryOrder) {
const files = findLocaleFilesAllDirs(lang, preferredDir);
// Prioritize bundled locales over project ones
const prioritizedFiles = files.sort((a, b) => Number(isBundledPath(b)) - Number(isBundledPath(a)));
for (const file of prioritizedFiles) {
try {
translations = readJsonSafe(file);
currentLanguage = lang;
isInitialized = true;
if (envManager.getBoolean('I18NTK_DEBUG_LOCALES')) {
console.log(`🗂 Loaded UI locale → ${file} (${lang})`);
}
// Validate translations object
if (typeof translations === 'object' && translations !== null) {
return translations;
} else {
loadErrors.push({ file, error: 'Invalid translation format' });
}
} catch (e) {
loadErrors.push({ file, error: e.message });
if (envManager.getBoolean('I18NTK_DEBUG_LOCALES')) {
console.warn(`⚠️ Failed to parse ${file}: ${e.message}`);
}
}
}
}
// Log comprehensive error summary if debugging
if (envManager.getBoolean('I18NTK_DEBUG_LOCALES') && loadErrors.length > 0) {
console.warn(`📊 Locale loading errors summary:`, {
requested: requested,
triedLanguages: tryOrder,
errors: loadErrors
});
}
// Fallback to built-in minimal translations
translations = {
menu: {
title: '🌍 i18ntk - I18N Management',
separator: '============================================================',
options: {
init: 'Initialize new languages',
analyze: 'Analyze translations',
validate: 'Validate translations',
usage: 'Check key usage',
complete: 'Complete translations',
sizing: 'Analyze sizing',
workflow: 'Run full workflow',
status: 'Show project status',
delete: 'Delete all reports',
settings: 'Settings',
help: 'Help',
debug: 'Debug Tools',
language: 'Change UI language',
exit: 'Exit'
},
selectOptionPrompt: 'Select an option:'
}
};
currentLanguage = 'en';
isInitialized = true;
if (loadErrors.length > 0) {
logger.warn('No valid UI locale files found. Using built-in English strings.', {
errorCount: loadErrors.length
});
}
return translations;
}
/**
* Get a translated string by key
* @param {string} key - Translation key (e.g., 'module.subkey')
* @param {object} params - Parameters to interpolate into the translation
* @returns {string} - Translated string or the key if translation not found
*/
function t(key, params = {}) {
// Auto-initialize translations if not already loaded
if (!isInitialized) {
loadTranslations();
isInitialized = true;
}
// Split the key into parts (e.g., 'module.subkey' -> ['module', 'subkey'])
const keyParts = key.split('.');
let value = translations;
// Try to find the key in the main translations object
for (let i = 0; i < keyParts.length; i++) {
const part = keyParts[i];
if (value && typeof value === 'object' && part in value) {
value = value[part];
} else {
value = undefined; // Key not found in main path
break;
}
}
// If not found, try to find it in hardcodedTexts
if (typeof value === 'undefined' && translations.hardcodedTexts) {
let hardcodedValue = translations.hardcodedTexts;
for (const part of keyParts) {
if (hardcodedValue && typeof hardcodedValue === 'object' && part in hardcodedValue) {
hardcodedValue = hardcodedValue[part];
} else {
hardcodedValue = undefined; // Key not found in hardcodedTexts path
break;
}
}
if (typeof hardcodedValue !== 'undefined') {
value = hardcodedValue;
}
}
if (typeof value === 'undefined') {
if (shouldReportMissingKey(key)) {
logger.logMissingTranslationKey(key, 'Configuration error');
}
return key;
}
// If we found a string, interpolate parameters
if (typeof value === 'string') {
return interpolateParams(value, params);
}
// Return the key if the final value is not a string
if (shouldReportMissingKey(`${key}:non-string`)) {
logger.warn(`Translation key does not resolve to a string: ${key}`);
}
return key;
}
/**
* Interpolate parameters into a translation string
* @param {string} template - Template string with {{param or {param} placeholders
* @param {object} params - Parameters to interpolate
* @returns {string} - Interpolated string
*/
function interpolateParams(template, params) {
// Handle both {{param}} and {param} formats
return template
.replace(/\{\{(\w+)\}\}/g, (match, paramName) => {
if (paramName in params) {
return params[paramName];
}
return match;
})
.replace(/\{(\w+)\}/g, (match, paramName) => {
if (paramName in params) {
return params[paramName];
}
return match;
});
}
/**
* Get the current language
* @returns {string} - Current language code
*/
function getCurrentLanguage() {
return currentLanguage;
}
/**
* Get all available languages
* @returns {string[]} - Array of available language codes
*/
function getAvailableLanguages() {
const dirs = resolveLocalesDirs();
const langs = new Set();
for (const d of dirs) {
try {
const SecurityUtils = getSecurityUtils();
if (!SecurityUtils.safeExistsSync(d, getValidationBase(d))) continue;
const files = SecurityUtils.safeReaddirSync(d, getValidationBase(d), { withFileTypes: true });
if (!files) continue;
for (const f of files) {
if (f.isFile() && f.name.endsWith('.json')) langs.add(path.basename(f.name, '.json'));
}
for (const f of files) {
const nestedPath = path.join(d, f.name, `${f.name}.json`);
if (f.isDirectory() && SecurityUtils.safeExistsSync(nestedPath, getValidationBase(nestedPath))) {
langs.add(f.name);
}
}
} catch {}
}
return Array.from(langs.size ? langs : new Set(['en']));
}
/**
* Deep merges two objects.
* @param {object} target - The target object.
* @param {object} source - The source object.
* @returns {object} - The merged object.
*/
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
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
if (source.hasOwnProperty(key)) {
if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key]) &&
typeof target[key] === 'object' && target[key] !== null && !Array.isArray(target[key])) {
target[key] = deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
return target;
}
/**
* Refresh language from settings manager
* This ensures translations stay in sync with settings changes
*/
function refreshLanguageFromSettings() {
const cfg = safeRequireConfig();
const settings = cfg?.getConfig?.() || {};
const configuredLanguage = settings.uiLanguage || settings.language || 'en';
if (configuredLanguage !== currentLanguage) {
isInitialized = false;
loadTranslations(configuredLanguage);
}
}
/**
* Refresh translations (alias for refreshLanguageFromSettings)
*/
function refreshTranslations() {
refreshLanguageFromSettings();
}
module.exports = {
loadTranslations,
t,
getCurrentLanguage,
getAvailableLanguages,
deepMerge,
refreshTranslations,
refreshLanguageFromSettings
};