UNPKG

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
// 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 };