Files
Stirling-PDF/frontend/editor/src/core/i18n.ts
T
James Brunton 11ba3814e5 Restructure Portal code to be inside Editor (#6857)
# Description of Changes
We don't have any strong reasons to keep the Portal as a separate Vite
app, and it needs access to so many things from the Editor that it no
longer makes sense to keep them separate. This PR moves the Portal code
to have direct access to the Editor code and gets rid of the shared
folder.
2026-07-03 13:20:02 +00:00

210 lines
6.7 KiB
TypeScript

import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import TomlBackend from "@app/i18n/tomlBackend";
import {
supportedLanguages,
rtlLanguages,
I18N_STORAGE_KEYS,
LanguageSource,
normalizeLanguageCode,
toUnderscoreFormat,
toUnderscoreLanguages,
} from "@app/i18n/languages";
// Language metadata and code helpers are shared with the portal via
// @app/i18n. Re-export them so existing `@app/i18n` consumers are unchanged.
export {
supportedLanguages,
rtlLanguages,
I18N_STORAGE_KEYS,
LanguageSource,
normalizeLanguageCode,
toUnderscoreFormat,
toUnderscoreLanguages,
};
i18n
.use(TomlBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en-US",
supportedLngs: Object.keys(supportedLanguages),
load: "currentOnly",
nonExplicitSupportedLngs: false,
debug: process.env.NODE_ENV === "development",
// Ensure synchronous loading to prevent timing issues
initImmediate: false,
interpolation: {
escapeValue: false, // React already escapes values
},
backend: {
loadPath: (lngs: string[], namespaces: string[]) => {
const lng = lngs[0];
const basePath = import.meta.env.BASE_URL || "/";
const cleanBasePath = basePath.endsWith("/")
? basePath.slice(0, -1)
: basePath;
return `${cleanBasePath}/locales/${lng}/${namespaces[0]}.toml`;
},
},
detection: {
order: ["localStorage", "navigator", "htmlTag"],
caches: [], // Don't cache auto-detected language - only cache when user manually selects
convertDetectedLanguage: (lng: string) => {
// Map bare en to en-US
if (lng === "en") return "en-US";
return lng;
},
},
react: {
useSuspense: true, // Enable suspense to prevent premature rendering
bindI18n: "languageChanged loaded",
bindI18nStore: "added removed",
transEmptyNodeValue: "", // Return empty string for missing keys instead of key name
transSupportBasicHtmlNodes: true,
transKeepBasicHtmlNodesFor: ["br", "strong", "i", "p"],
},
});
// Set document direction based on language
i18n.on("languageChanged", (lng) => {
const isRTL = rtlLanguages.includes(lng);
document.documentElement.dir = isRTL ? "rtl" : "ltr";
document.documentElement.lang = lng;
});
// Track browser-detected language on first initialization
i18n.on("initialized", () => {
// If no source is set yet, mark current language as browser-detected
if (!localStorage.getItem(I18N_STORAGE_KEYS.LANGUAGE_SOURCE)) {
const detectedLang = i18n.language;
if (detectedLang) {
localStorage.setItem(I18N_STORAGE_KEYS.LANGUAGE, detectedLang);
localStorage.setItem(
I18N_STORAGE_KEYS.LANGUAGE_SOURCE,
String(LanguageSource.Browser),
);
}
}
});
/**
* Get the current language source priority
*/
function getCurrentSourcePriority(): LanguageSource {
const sourceStr = localStorage.getItem(I18N_STORAGE_KEYS.LANGUAGE_SOURCE);
const sourceNum = sourceStr ? parseInt(sourceStr, 10) : null;
return sourceNum !== null && !isNaN(sourceNum)
? (sourceNum as LanguageSource)
: LanguageSource.Fallback;
}
/**
* Set language with priority tracking
* Only updates if new source has equal or higher priority than current
*/
function setLanguageWithPriority(
language: string,
source: LanguageSource,
): boolean {
const currentPriority = getCurrentSourcePriority();
const newPriority = source;
// Only apply if new source has higher priority
if (newPriority >= currentPriority) {
i18n.changeLanguage(language);
localStorage.setItem(I18N_STORAGE_KEYS.LANGUAGE, language);
localStorage.setItem(I18N_STORAGE_KEYS.LANGUAGE_SOURCE, String(source));
return true;
}
return false;
}
/**
* Set user-selected language (highest priority)
* Call this from the UI language selector
*/
export function setUserLanguage(language: string): void {
setLanguageWithPriority(language, LanguageSource.User);
}
/**
* Updates the supported languages list dynamically based on config
* If configLanguages is null/empty, all languages remain available
* Otherwise, only the specified languages are enabled with the first valid
* option (preferring en-US when present) used as the fallback language.
*
* @param configLanguages - Optional array of language codes from server config (ui.languages)
* @param defaultLocale - Optional default language for new users (system.defaultLocale)
*/
export function updateSupportedLanguages(
configLanguages?: string[] | null,
defaultLocale?: string | null,
) {
// Normalize and validate default locale if provided
const normalizedDefault = defaultLocale
? normalizeLanguageCode(defaultLocale)
: null;
const validDefault =
normalizedDefault && normalizedDefault in supportedLanguages
? normalizedDefault
: null;
if (!configLanguages || configLanguages.length === 0) {
// No filter specified - keep all languages
// But still apply default locale if provided and user has no preference
if (validDefault) {
applyDefaultLocale(validDefault);
}
return;
}
const validLanguages = configLanguages
.map(normalizeLanguageCode)
.filter((lang) => lang in supportedLanguages);
// If no valid languages were provided, keep existing configuration
if (validLanguages.length === 0) {
return;
}
// Determine fallback: prefer validDefault if in the list, then en-US, then first valid language
const fallback =
validDefault && validLanguages.includes(validDefault)
? validDefault
: validLanguages.includes("en-US")
? "en-US"
: validLanguages[0];
i18n.options.supportedLngs = validLanguages;
i18n.options.fallbackLng = fallback;
// If current language is not in the new supported list, switch to fallback with higher priority to override browser detection
const currentLang = normalizeLanguageCode(i18n.language || "");
if (currentLang && !validLanguages.includes(currentLang)) {
// Use ServerDefault priority to override browser detection when language not in whitelist
setLanguageWithPriority(fallback, LanguageSource.ServerDefault);
} else if (validDefault) {
// Apply server default (respects user choice if already set)
setLanguageWithPriority(validDefault, LanguageSource.ServerDefault);
}
}
/**
* Apply server default locale when user has no saved language preference
* This respects the priority: user-selected language > defaultLocale > browser detection > fallback
*/
function applyDefaultLocale(defaultLocale: string) {
// Apply server default (respects user choice if already set)
setLanguageWithPriority(defaultLocale, LanguageSource.ServerDefault);
}
export default i18n;