feat(i18n): Add internationalization support

- Add i18n module with locale loading and translation helpers
- Add English (en) and French (fr) locale files with comprehensive translations
- Inject locale data into dashboard HTML via server
- Add /api/locales endpoint for locale info
- Add t() translation function to dashboard

Translated elements:
- Boot sequence (initialization, connecting, sweep complete)
- Header pills (sweep, sources, delta, risk indicators)
- Left rail panels (sensor grid, nuclear watch, risk gauges, space watch)
- Layer names and descriptions
- Map legend items
- Lower panels (news ticker, sweep delta, macro+markets, trade ideas)
- Right rail (OSINT stream)
- Badges and status indicators

Supported languages: English (default), French
Set CRUCIX_LANG=fr to switch to French
This commit is contained in:
David
2026-03-18 08:36:48 +01:00
parent c29ec93350
commit 9b395b6aa5
5 changed files with 944 additions and 54 deletions

137
lib/i18n.mjs Normal file
View File

@@ -0,0 +1,137 @@
// Internationalization (i18n) Module
// Loads locale files and provides translation functions
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const LOCALES_DIR = join(__dirname, '..', 'locales');
// Supported languages
const SUPPORTED_LOCALES = ['en', 'fr'];
const DEFAULT_LOCALE = 'en';
// Cache loaded locales
const localeCache = new Map();
/**
* Get the current language from environment
* @returns {string} Language code (e.g., 'en', 'fr')
*/
export function getLanguage() {
// CRUCIX_LANG takes priority to avoid conflict with Linux system LANGUAGE variable
const lang = (process.env.CRUCIX_LANG || process.env.LANGUAGE || process.env.LANG || DEFAULT_LOCALE)
.toLowerCase()
.slice(0, 2);
return SUPPORTED_LOCALES.includes(lang) ? lang : DEFAULT_LOCALE;
}
/**
* Load a locale file
* @param {string} lang - Language code
* @returns {object} Locale data
*/
function loadLocale(lang) {
if (localeCache.has(lang)) {
return localeCache.get(lang);
}
const localePath = join(LOCALES_DIR, `${lang}.json`);
if (!existsSync(localePath)) {
console.warn(`[i18n] Locale file not found: ${localePath}, falling back to ${DEFAULT_LOCALE}`);
return loadLocale(DEFAULT_LOCALE);
}
try {
const data = JSON.parse(readFileSync(localePath, 'utf-8'));
localeCache.set(lang, data);
return data;
} catch (err) {
console.error(`[i18n] Failed to load locale ${lang}:`, err.message);
if (lang !== DEFAULT_LOCALE) {
return loadLocale(DEFAULT_LOCALE);
}
return {};
}
}
/**
* Get the current locale data
* @returns {object} Current locale data
*/
export function getLocale() {
return loadLocale(getLanguage());
}
/**
* Translate a key path (e.g., 'dashboard.title')
* @param {string} keyPath - Dot-separated key path
* @param {object} params - Optional parameters for interpolation
* @returns {string} Translated string or key if not found
*/
export function t(keyPath, params = {}) {
const locale = getLocale();
const keys = keyPath.split('.');
let value = locale;
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key];
} else {
console.warn(`[i18n] Missing translation: ${keyPath}`);
return keyPath;
}
}
if (typeof value !== 'string') {
return keyPath;
}
// Interpolate parameters: {param} -> value
return value.replace(/\{(\w+)\}/g, (_, key) => {
return params[key] !== undefined ? params[key] : `{${key}}`;
});
}
/**
* Get LLM system prompt in current language
* @returns {string} System prompt for LLM
*/
export function getLLMPrompt() {
const locale = getLocale();
// Use loadLocale('en') for fallback since getLocale() doesn't accept a language argument
const fallbackLocale = loadLocale('en');
return locale.llm?.systemPrompt || fallbackLocale.llm?.systemPrompt || '';
}
/**
* Get all supported locales info
* @returns {Array} Array of locale info objects
*/
export function getSupportedLocales() {
return SUPPORTED_LOCALES.map(code => {
const locale = loadLocale(code);
return {
code,
name: locale.meta?.name || code,
nativeName: locale.meta?.nativeName || code
};
});
}
/**
* Check if a language is supported
* @param {string} lang - Language code
* @returns {boolean}
*/
export function isSupported(lang) {
return SUPPORTED_LOCALES.includes(lang?.toLowerCase()?.slice(0, 2));
}
// Export current language on module load
export const currentLanguage = getLanguage();
console.log(`[i18n] Language: ${currentLanguage}`);