This major feature introduces complete internationalization architecture with Spanish (Mexico) as the first additional language: ## New Features: - **I18n Architecture**: Complete client-side and server-side internationalization system - **Spanish (Mexico) Support**: Full es-MX translations for all user-facing text - **Language Selector**: Dynamic language switching UI component in header - **API Endpoints**: RESTful endpoints for serving translations (/api/i18n) - **Progressive Enhancement**: Language detection via Accept-Language header and cookies ## Technical Implementation: - **Frontend**: Client-side i18n.js with automatic DOM translation and language selector - **Backend**: Server-side i18n service with locale detection middleware - **Build Process**: Automated copying of translation files to dist/ directory - **Responsive Design**: Language selector integrated into header controls layout ## Files Added: - public/i18n.js - Client-side internationalization library - src/i18n/index.ts - Server-side i18n service - src/i18n/locales/en.json - English translations - src/i18n/locales/es-MX.json - Spanish (Mexico) translations - src/routes/i18n.ts - API endpoints for translations ## Files Modified: - package.json - Updated build process to include i18n files - public/index.html - Added i18n attributes and language selector - public/app.js - Integrated dynamic translation updates - src/server.ts - Added locale detection middleware - src/scss/pages/_index.scss - Language selector styling This implementation supports easy addition of future languages and maintains backward compatibility while providing a seamless multilingual experience. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
170 lines
No EOL
4.6 KiB
TypeScript
170 lines
No EOL
4.6 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
export interface TranslationData {
|
|
[key: string]: any;
|
|
}
|
|
|
|
export class I18nService {
|
|
private translations: Map<string, TranslationData> = new Map();
|
|
private defaultLocale = 'en';
|
|
private availableLocales = ['en', 'es-MX'];
|
|
|
|
constructor() {
|
|
this.loadTranslations();
|
|
}
|
|
|
|
/**
|
|
* Load all translation files from the locales directory
|
|
*/
|
|
private loadTranslations(): void {
|
|
const localesDir = path.join(__dirname, 'locales');
|
|
|
|
for (const locale of this.availableLocales) {
|
|
try {
|
|
const filePath = path.join(localesDir, `${locale}.json`);
|
|
const translationData = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
this.translations.set(locale, translationData);
|
|
console.log(`Loaded translations for locale: ${locale}`);
|
|
} catch (error) {
|
|
console.error(`Failed to load translations for locale ${locale}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a translated string by key path (e.g., 'common.appName')
|
|
*/
|
|
public t(keyPath: string, locale: string = this.defaultLocale, params?: Record<string, string>): string {
|
|
const translations = this.translations.get(locale) || this.translations.get(this.defaultLocale);
|
|
|
|
if (!translations) {
|
|
console.warn(`No translations found for locale: ${locale}`);
|
|
return keyPath;
|
|
}
|
|
|
|
const keys = keyPath.split('.');
|
|
let value: any = translations;
|
|
|
|
for (const key of keys) {
|
|
value = value?.[key];
|
|
if (value === undefined) {
|
|
// Fallback to default locale if key not found
|
|
if (locale !== this.defaultLocale) {
|
|
return this.t(keyPath, this.defaultLocale, params);
|
|
}
|
|
console.warn(`Translation key not found: ${keyPath} for locale: ${locale}`);
|
|
return keyPath;
|
|
}
|
|
}
|
|
|
|
if (typeof value !== 'string') {
|
|
console.warn(`Translation value is not a string for key: ${keyPath}`);
|
|
return keyPath;
|
|
}
|
|
|
|
// Replace parameters if provided
|
|
if (params) {
|
|
return this.replaceParams(value, params);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Replace parameters in translation strings (e.g., "Hello {{name}}" with {name: "John"})
|
|
*/
|
|
private replaceParams(text: string, params: Record<string, string>): string {
|
|
return text.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
return params[key] || match;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get all translations for a specific locale
|
|
*/
|
|
public getTranslations(locale: string): TranslationData | null {
|
|
return this.translations.get(locale) || null;
|
|
}
|
|
|
|
/**
|
|
* Get list of available locales
|
|
*/
|
|
public getAvailableLocales(): string[] {
|
|
return [...this.availableLocales];
|
|
}
|
|
|
|
/**
|
|
* Check if a locale is supported
|
|
*/
|
|
public isLocaleSupported(locale: string): boolean {
|
|
return this.availableLocales.includes(locale);
|
|
}
|
|
|
|
/**
|
|
* Get the default locale
|
|
*/
|
|
public getDefaultLocale(): string {
|
|
return this.defaultLocale;
|
|
}
|
|
|
|
/**
|
|
* Detect user's preferred locale from Accept-Language header
|
|
*/
|
|
public detectLocale(acceptLanguageHeader?: string): string {
|
|
if (!acceptLanguageHeader) {
|
|
return this.defaultLocale;
|
|
}
|
|
|
|
// Parse Accept-Language header (e.g., "es-MX,es;q=0.9,en;q=0.8")
|
|
const languages = acceptLanguageHeader
|
|
.split(',')
|
|
.map(lang => {
|
|
const parts = lang.trim().split(';');
|
|
const code = parts[0];
|
|
const quality = parts[1] ? parseFloat(parts[1].split('=')[1]) : 1;
|
|
return { code, quality };
|
|
})
|
|
.sort((a, b) => b.quality - a.quality);
|
|
|
|
// Find the first supported locale
|
|
for (const lang of languages) {
|
|
// Check for exact match first (e.g., "es-MX")
|
|
if (this.isLocaleSupported(lang.code)) {
|
|
return lang.code;
|
|
}
|
|
|
|
// Check for language match (e.g., "es" matches "es-MX")
|
|
const languageCode = lang.code.split('-')[0];
|
|
const matchingLocale = this.availableLocales.find(locale =>
|
|
locale.startsWith(languageCode)
|
|
);
|
|
if (matchingLocale) {
|
|
return matchingLocale;
|
|
}
|
|
}
|
|
|
|
return this.defaultLocale;
|
|
}
|
|
|
|
/**
|
|
* Get locale display names
|
|
*/
|
|
public getLocaleDisplayName(locale: string): string {
|
|
const displayNames: Record<string, string> = {
|
|
'en': 'English',
|
|
'es-MX': 'Español (México)'
|
|
};
|
|
return displayNames[locale] || locale;
|
|
}
|
|
}
|
|
|
|
// Create singleton instance
|
|
export const i18nService = new I18nService();
|
|
|
|
// Helper function for easier access
|
|
export function t(keyPath: string, locale?: string, params?: Record<string, string>): string {
|
|
return i18nService.t(keyPath, locale, params);
|
|
}
|
|
|
|
export default i18nService; |