feat: Add project-scoped language for unsubscribe footer and contact-facing pages

This commit is contained in:
Dries Augustyns
2025-12-21 13:09:21 +01:00
parent 8a136dde55
commit e1f826357d
15 changed files with 499 additions and 41 deletions
+157
View File
@@ -0,0 +1,157 @@
import type {Language} from './languages.js';
// Static imports for all translation files
import enTranslations from './locales/en.json' with {type: 'json'};
import nlTranslations from './locales/nl.json' with {type: 'json'};
export {
SUPPORTED_LANGUAGES,
DEFAULT_LANGUAGE,
isValidLanguageCode,
getLanguageByCode,
type Language,
} from './languages.js';
export type TranslationKey = string;
export interface Translations {
pages: {
unsubscribe: Record<string, string>;
subscribe: Record<string, string>;
manage: Record<string, string>;
common: Record<string, string>;
};
email: {
footer: Record<string, string>;
};
}
// Static mapping of language codes to translations
const translationsMap: Record<string, Translations> = {
en: enTranslations,
nl: nlTranslations,
};
// In-memory cache for loaded translations
const translationCache = new Map<string, Translations>();
/**
* Load translations for a given language code (async)
* Uses static imports for reliable resolution
*/
export async function loadTranslations(languageCode: string): Promise<Translations> {
// Check cache first
if (translationCache.has(languageCode)) {
return translationCache.get(languageCode)!;
}
// Get translations from static map
const translations = translationsMap[languageCode];
if (translations) {
translationCache.set(languageCode, translations);
return translations;
}
// Fallback to English if language not found
console.warn(`Translation file not found for ${languageCode}, falling back to English`);
if (languageCode !== 'en') {
return loadTranslations('en');
}
throw new Error(`Failed to load translations for ${languageCode}`);
}
/**
* Synchronous translation loader (requires pre-built translations)
* For API server use (compiled at build time)
*/
export function loadTranslationsSync(languageCode: string): Translations {
if (translationCache.has(languageCode)) {
return translationCache.get(languageCode)!;
}
// Get translations from static map
const translations = translationsMap[languageCode];
if (translations) {
translationCache.set(languageCode, translations);
return translations;
}
// Fallback to English if language not found
if (languageCode !== 'en') {
return loadTranslationsSync('en');
}
throw new Error(`Failed to load translations for ${languageCode}`);
}
/**
* Simple template string replacement
* Example: interpolate("Hello {name}", { name: "World" }) => "Hello World"
*/
export function interpolate(template: string, values: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (_, key) => values[key] || `{${key}}`);
}
/**
* Get nested translation value by dot notation path
* Example: getTranslation(translations, "pages.unsubscribe.title")
*/
export function getTranslation(translations: Translations, path: string): string {
const keys = path.split('.');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let value: any = translations;
for (const key of keys) {
value = value?.[key];
if (value === undefined) {
console.warn(`Translation key not found: ${path}`);
return path; // Return the key itself as fallback
}
}
return typeof value === 'string' ? value : path;
}
/**
* Translation helper class for easier use
*/
export class Translator {
private translations: Translations;
private languageCode: string;
constructor(translations: Translations, languageCode: string) {
this.translations = translations;
this.languageCode = languageCode;
}
/**
* Translate a key with optional interpolation
* Example: t("pages.unsubscribe.description", { email: "[email protected]" })
*/
t(key: string, values?: Record<string, string>): string {
const translation = getTranslation(this.translations, key);
return values ? interpolate(translation, values) : translation;
}
getLanguageCode(): string {
return this.languageCode;
}
}
/**
* Create a translator instance for a given language (async)
*/
export async function createTranslator(languageCode: string): Promise<Translator> {
const translations = await loadTranslations(languageCode);
return new Translator(translations, languageCode);
}
/**
* Create a translator instance for a given language (sync)
*/
export function createTranslatorSync(languageCode: string): Translator {
const translations = loadTranslationsSync(languageCode);
return new Translator(translations, languageCode);
}