feat(i18n): nine languages, each at its own address; release v6.3.0
The site now speaks German, French, Spanish, Ukrainian, Russian, Chinese, Korean and Hindi alongside English — 623 strings per language, 5,607 translations, covering the landing page, the key exchange, the chat, group calls and every error along the way. Each locale is a real page at a real URL (/de/, /fr/, …), generated at build time from locales/*.json. That is the whole point: the app is client-rendered, so a language that only swaps strings at runtime has no address for a crawler to index and no link anyone can share. Each page carries its own canonical, a reciprocal hreflang cluster and translated schema.org. The URL decides the language, always. A stored preference only applies where the address does not say, and nothing ever redirects on Accept-Language — that is how sites become invisible to search engines outside one country. robots.txt and sitemap.xml did not exist before; both are generated now. Bugs found on the way, each with a test that would have caught it: - partner logos used page-relative paths and 404'd from any /xx/ page - post-build stamped ?v= only into the root shell, leaving locales behind - "Back online" appeared on every page load, not just after being offline - update timestamps were hard-coded to US format for every reader - t() threw on a partial window, taking whole components down with it
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user