- Fix i18n string indexing by adding proper type checking - Add Express Request type extensions for locale and t properties - Fix ProfanityFilter interface mismatches with proper return types - Update route signatures to accept union types for fallback filter - Resolve all TypeScript compilation errors while maintaining functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
174 lines
No EOL
4.8 KiB
TypeScript
174 lines
No EOL
4.8 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
export interface TranslationData {
|
|
[key: string]: string | TranslationData;
|
|
}
|
|
|
|
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: string | TranslationData = translations;
|
|
|
|
for (const key of keys) {
|
|
if (typeof value === 'string') {
|
|
// If we hit a string before traversing all keys, the path is invalid
|
|
break;
|
|
}
|
|
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; |