Files
securebit-chat/src/i18n/index.js
T

171 lines
7.0 KiB
JavaScript
Raw Normal View History

/**
* Language selection and string lookup.
*
* Every locale is a real page at a real URL (/, /de/, ...), generated at build time,
* because the app is client-rendered: a language that exists only as a runtime string
* swap has no URL for a crawler to index. This module is the runtime half — it decides
* which locale the current page is, and hands components their strings.
*
* The rule that matters: the URL wins over everything. Someone who opens /de/ gets
* German even if they once chose English here, or a shared link would open in whatever
* language the recipient happened to pick last, which makes links unshareable.
*/
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, LOCALE_META, DICTIONARIES } from './generated.js';
export { DEFAULT_LOCALE, SUPPORTED_LOCALES, LOCALE_META };
const STORAGE_KEY = 'securebit-locale';
/** The locale a path belongs to, or null for the default locale at the root. */
export function localeFromPathname(pathname = '/') {
const segment = String(pathname).split('/')[1];
return SUPPORTED_LOCALES.includes(segment) && segment !== DEFAULT_LOCALE ? segment : null;
}
/**
* Best match for a browser's language list. "de-AT" should get German if German is all
* we have, so the base tag is tried after the full one, in the order the browser gave.
*/
export function localeFromLanguages(languages = []) {
for (const tag of languages) {
const lower = String(tag).toLowerCase();
const exact = SUPPORTED_LOCALES.find((code) => code.toLowerCase() === lower);
if (exact) return exact;
const base = lower.split('-')[0];
const partial = SUPPORTED_LOCALES.find((code) => code.toLowerCase().split('-')[0] === base);
if (partial) return partial;
}
return null;
}
/**
* Which locale to render, given everything we know. Pure, so the precedence can be
* tested without a browser: URL, then a previous explicit choice, then the browser's
* languages, then the default.
*/
export function detectLocale({ pathname = '/', stored = null, languages = [] } = {}) {
const fromPath = localeFromPathname(pathname);
if (fromPath) return fromPath;
// The root path is the default locale's own page, not an absence of information —
// redirecting away from it would break every link to the site's canonical URL.
if (isLocaleRoot(pathname)) return DEFAULT_LOCALE;
if (stored && SUPPORTED_LOCALES.includes(stored)) return stored;
return localeFromLanguages(languages) || DEFAULT_LOCALE;
}
/** True for "/" and "/index.html" — the pages the default locale is served from. */
function isLocaleRoot(pathname) {
return pathname === '/' || pathname === '/index.html';
}
/**
* The URL of the current page in another locale. Switching keeps you where you are
* rather than dumping you back on the home page.
*/
export function localeHref(code, pathname = '/') {
const current = localeFromPathname(pathname);
const rest = current ? String(pathname).slice(current.length + 1) : String(pathname);
const tail = rest.replace(/^\/+/, '');
return code === DEFAULT_LOCALE ? `/${tail}` : `/${code}/${tail}`;
}
/** Remember an explicit choice. Storage can throw in private mode; a preference is not worth an exception. */
export function rememberLocale(code) {
try {
localStorage.setItem(STORAGE_KEY, code);
} catch (_) {
// Private mode or blocked storage: the URL still carries the choice.
}
}
export function storedLocale() {
try {
return localStorage.getItem(STORAGE_KEY);
} catch (_) {
return null;
}
}
/**
* The locale of the page as actually loaded. Resolved once: it cannot change without a
* navigation, and t() is called dozens of times per render — re-reading localStorage
* on each of those would be a synchronous storage hit per string.
*/
let resolvedLocale = null;
export function currentLocale() {
if (resolvedLocale) return resolvedLocale;
// A window may exist without the parts this needs: test harnesses and workers both
// provide partial shims. Reading through them blindly threw and took the caller with
// it, which for t() means a missing string becomes a crash.
const w = typeof window === 'undefined' ? null : window;
if (!w || !w.location) return DEFAULT_LOCALE;
resolvedLocale = detectLocale({
pathname: w.location.pathname || '/',
stored: storedLocale(),
languages: w.navigator?.languages || [],
});
return resolvedLocale;
}
/**
* A locale the visitor would probably rather read, when it is not the one they are on.
* Used to offer a link, never to redirect: an automatic redirect sends Googlebot —
* which crawls from one place — to a single locale and leaves the rest unindexed.
*/
export function suggestedLocale({ pathname = '/', languages = [], stored = null } = {}) {
const shown = localeFromPathname(pathname) || DEFAULT_LOCALE;
// An explicit past choice outranks the browser's list: someone who picked a
// language once meant it, and it is exactly when their page does not match that
// choice that the offer is worth making.
const preferred = (SUPPORTED_LOCALES.includes(stored) && stored) || localeFromLanguages(languages);
return preferred && preferred !== shown ? preferred : null;
}
/**
* A list-valued string, for the handful of places where the copy is a set of short
* labels rather than a sentence — a language may need a different number of them, so
* the count belongs to the translation, not to the component.
*/
export function tList(key, locale = currentLocale()) {
const value = DICTIONARIES[locale]?.[key] ?? DICTIONARIES[DEFAULT_LOCALE]?.[key];
return Array.isArray(value) ? value : [];
}
/**
* The rows a language switcher renders. Built here rather than in the component so the
* URLs can be tested without a DOM, and so the switcher stays what it must be: a list
* of real links. A control that swaps strings in place would leave every language on
* one URL, which is the thing this whole arrangement exists to avoid.
*/
export function languageLinks({ pathname = '/', active = DEFAULT_LOCALE } = {}) {
return SUPPORTED_LOCALES.map((code) => ({
code,
href: localeHref(code, pathname),
hrefLang: LOCALE_META[code]?.htmlLang || code,
// Listed in its own language: someone looking for German is looking for
// "Deutsch", not for the English word for it.
label: LOCALE_META[code]?.nativeName || code,
// Short code for the collapsed switcher.
abbr: LOCALE_META[code]?.abbr || code.toUpperCase(),
isCurrent: code === active,
}));
}
/**
* Look up a string. Falls back to the default locale and then to the key itself, so a
* missing translation shows English rather than blank UI.
*/
export function t(key, vars, locale = currentLocale()) {
const template =
DICTIONARIES[locale]?.[key] ??
DICTIONARIES[DEFAULT_LOCALE]?.[key] ??
key;
if (!vars) return template;
return String(template).replace(/\{(\w+)\}/g, (match, name) =>
Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match
);
}