File size: 7,489 Bytes
97ee7cb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | import i18next from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './locales/en.json';
import { resolveEffectiveWelcomeContentLanguage } from './welcome-language';
type TranslationDictionary = Record<string, unknown>;
const SUPPORTED_LANGUAGES = ['en', 'bg', 'cs', 'fr', 'de', 'el', 'es', 'hr', 'hu', 'it', 'pl', 'pt', 'nl', 'sv', 'ru', 'ar', 'fa', 'zh', 'ja', 'ko', 'ro', 'tr', 'th', 'vi', 'hi'] as const;
type SupportedLanguage = typeof SUPPORTED_LANGUAGES[number];
const SUPPORTED_SET = new Set<SupportedLanguage>(SUPPORTED_LANGUAGES);
const loadedLanguages = new Set<SupportedLanguage>(['en']);
const RTL_LANGUAGES = new Set(['ar', 'fa']);
const localeModules = import.meta.glob<TranslationDictionary>(
['./locales/*.json', '!./locales/en.json'],
{ import: 'default' },
);
function normalize(lng: string): SupportedLanguage {
const base = (lng || 'en').split('-')[0]?.toLowerCase() || 'en';
return SUPPORTED_SET.has(base as SupportedLanguage) ? base as SupportedLanguage : 'en';
}
async function ensureLoaded(lng: string): Promise<SupportedLanguage> {
const n = normalize(lng);
if (loadedLanguages.has(n)) return n;
const loader = localeModules[`./locales/${n}.json`];
const translation = loader ? await loader() : en as TranslationDictionary;
i18next.addResourceBundle(n, 'translation', translation, true, true);
loadedLanguages.add(n);
return n;
}
// IETF BCP 47 region codes for og:locale (so social-card renderers pick the
// right preview language). Falls back to ${lang}_${LANG.toUpperCase()} for
// anything not in this map.
//
// Mirror of ogLocaleMap in src/App.ts. The two packages have separate Vite
// roots and bundlers and can't share an import — keep the tables aligned by
// hand when adding a locale here OR there.
const OG_LOCALE: Record<string, string> = {
en: 'en_US', bg: 'bg_BG', cs: 'cs_CZ', fr: 'fr_FR', de: 'de_DE', el: 'el_GR',
es: 'es_ES', hr: 'hr_HR', hu: 'hu_HU', it: 'it_IT', pl: 'pl_PL', pt: 'pt_BR',
nl: 'nl_NL', sv: 'sv_SE', ru: 'ru_RU', ar: 'ar_SA', fa: 'fa_IR', zh: 'zh_CN',
ja: 'ja_JP', ko: 'ko_KR', ro: 'ro_RO', tr: 'tr_TR', th: 'th_TH', vi: 'vi_VN',
hi: 'hi_IN',
};
function applyMetaTags(prefix = 'meta', contentLanguage = currentLanguageBase()): void {
const title = i18next.t(`${prefix}.title`);
const desc = i18next.t(`${prefix}.description`);
const ogTitle = i18next.t(`${prefix}.ogTitle`);
const ogDesc = i18next.t(`${prefix}.ogDescription`);
document.title = title;
const set = (sel: string, val: string) => {
const el = document.querySelector(sel);
if (el) el.setAttribute('content', val);
};
set('meta[name="description"]', desc);
set('meta[property="og:title"]', ogTitle);
set('meta[property="og:description"]', ogDesc);
set(
'meta[property="og:locale"]',
OG_LOCALE[contentLanguage] || `${contentLanguage}_${contentLanguage.toUpperCase()}`,
);
set('meta[name="twitter:title"]', ogTitle);
set('meta[name="twitter:description"]', ogDesc);
}
// Marketing site has no language switcher — querystring (`?lang=fr`) is the
// only manual override. So detection order is querystring → navigator. We
// intentionally drop the default `localStorage` step + auto-cache: a stale
// `i18nextLng=en` stamp from any earlier visit would otherwise pin a French
// browser to English forever and silently bury the localized copy we ship.
//
// CONSEQUENCE: the `?lang=` querystring is EPHEMERAL application state — it
// does not persist across in-page navigations that strip the search string.
// Shareable/bookmarkable locale URLs therefore need to retain `?lang=XX`, but
// they remain base-canonical and are not advertised as indexable hreflang
// documents. If anyone adds in-page links that drop ?lang=, they need to
// propagate the param or surface a language switcher; otherwise the recipient
// lands on browser-default.
export async function initI18n(options?: { metaPrefix?: string }): Promise<void> {
const metaPrefix = options?.metaPrefix ?? 'meta';
if (i18next.isInitialized) return;
// One-time migration: drop the legacy `i18nextLng` auto-cache so users
// whose browser is e.g. French but who got pinned to `en` on any past
// visit get auto-recovered to navigator-driven detection.
try { localStorage.removeItem('i18nextLng'); } catch { /* private mode */ }
await i18next.use(LanguageDetector).init({
resources: { en: { translation: en as TranslationDictionary } },
supportedLngs: [...SUPPORTED_LANGUAGES],
nonExplicitSupportedLngs: true,
fallbackLng: 'en',
interpolation: { escapeValue: false },
detection: { order: ['querystring', 'navigator'], lookupQuerystring: 'lang', caches: [] },
});
const detected = await ensureLoaded(i18next.language || 'en');
if (detected !== 'en') await i18next.changeLanguage(detected);
const base = (i18next.language || detected).split('-')[0] || 'en';
const contentLanguage = metaPrefix === 'welcome.meta'
? effectiveWelcomeContentLanguage()
: base;
if (metaPrefix === 'welcome.meta' && contentLanguage !== base) {
// Keep the translation engine aligned with the content we are about to
// hydrate. Leaving i18next on an untranslated locale would render through
// per-key fallbacks and can diverge from the English SSR tree.
await i18next.changeLanguage(contentLanguage);
}
document.documentElement.setAttribute(
'lang',
contentLanguage === 'zh' ? 'zh-CN' : contentLanguage,
);
if (RTL_LANGUAGES.has(contentLanguage)) {
document.documentElement.setAttribute('dir', 'rtl');
} else {
document.documentElement.removeAttribute('dir');
}
applyMetaTags(metaPrefix, contentLanguage);
}
export async function initStaticI18n(): Promise<void> {
if (i18next.isInitialized) {
if (currentLanguageBase() !== 'en') {
await i18next.changeLanguage('en');
}
return;
}
await i18next.init({
resources: { en: { translation: en as TranslationDictionary } },
lng: 'en',
fallbackLng: 'en',
interpolation: { escapeValue: false },
});
}
export function currentLanguageBase(): string {
return (i18next.language || 'en').split('-')[0] || 'en';
}
/**
* The welcome SSR is English until a locale ships genuinely translated
* `welcome.*` resources. Missing namespaces and copies identical to English
* therefore keep English document metadata and hydrate the truthful SSR.
*/
export function effectiveWelcomeContentLanguage(): string {
const currentLanguage = currentLanguageBase();
const resources = i18next.getResourceBundle(
currentLanguage,
'translation',
) as TranslationDictionary | undefined;
return resolveEffectiveWelcomeContentLanguage(
currentLanguage,
(en as TranslationDictionary).welcome,
resources,
);
}
export function t(key: string, options?: Record<string, unknown>): string {
return i18next.t(key, options);
}
/**
* Look up a translation that's expected to be an array (e.g. localized
* pricing tier feature lists). Returns null when the key resolves to a
* non-array (typical when the locale hasn't translated this entry yet) so
* callers can fall back to their English source-of-truth without leaking
* the raw key as a string.
*/
export function tArray(key: string): string[] | null {
const value: unknown = i18next.t(key, { returnObjects: true, defaultValue: null });
return Array.isArray(value) && value.every((v): v is string => typeof v === 'string') ? value : null;
}
|