feat(i18n): nine languages, each at its own address; release v6.3.0
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

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:
lockbitchat
2026-08-29 12:46:15 -04:00
parent 943e04e7ff
commit 98d42ac2fb
73 changed files with 33861 additions and 1299 deletions
+170
View File
@@ -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
);
}