Files
securebit-chat/tests/i18n-runtime.test.mjs
T
lockbitchat 5e32f547b9
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
feat(i18n): Arabic, Hebrew, Persian and Urdu, and a layout that mirrors; release v6.4.0
Four right-to-left languages at /ar/, /he/, /fa/ and /ur/ — thirteen in all.

The layout no longer has a left and a right, it has a start and an end: the
margins, insets and corners are CSS logical properties now, so they follow
dir on <html>. Directional glyphs flip; keys, safety codes and session
descriptors are pinned left-to-right so bidi cannot reorder what two people
compare against each other's screens.

Also fixed: the Service Worker was registered as './sw.js', which 404s from
every locale subdirectory, so twelve of the thirteen pages had no worker at
all. And the bundler ran before the dictionaries were generated, so a newly
added language could reach the page ahead of the app that renders it.
2026-08-29 18:05:20 -04:00

180 lines
9.3 KiB
JavaScript

// What is worth pinning down here is precedence, not lookup. Get the order wrong and
// the bugs are the quiet kind: a shared /de/ link that opens in English because the
// recipient once picked English, or a visitor bounced away from the page they asked
// for. Both look like "the language switcher works" until someone shares a link.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
// The committed generated.js must match what the generator produces, or the strings
// the app ships are not the strings in locales/.
{
const before = readFileSync(path.join(ROOT, 'src/i18n/generated.js'), 'utf8');
execFileSync('node', [path.join(ROOT, 'scripts/build-i18n.js')], { stdio: 'pipe' });
assert.equal(
readFileSync(path.join(ROOT, 'src/i18n/generated.js'), 'utf8'), before,
'src/i18n/generated.js is stale — run `npm run build:i18n` after editing locales/'
);
}
// The dictionaries have to reach the bundle the browser actually downloads, not just
// the module on disk. `build:js` bundles whatever src/i18n/generated.js says at the
// moment it runs, so if generation happened after bundling, a newly added language
// would ship a correct page — right <html lang>, right <html dir> — wrapped around an
// app that has never heard of it and quietly falls back to English. That failure looks
// like a translation bug and is really a build-order bug, so it is pinned here.
{
const bundle = readFileSync(path.join(ROOT, 'dist/app.js'), 'utf8');
const { SUPPORTED_LOCALES, DICTIONARIES } = await import(
pathToFileURL(path.join(ROOT, 'src/i18n/generated.js'))
);
// esbuild escapes anything outside ASCII, so nothing but pure Latin is findable as
// literal text: Latin-1 becomes \xNN, everything above it \uXXXX, both uppercase.
// Escape the needle the same way before looking for it.
const asEmitted = (text) =>
[...text]
.map((ch) => {
const code = ch.codePointAt(0);
if (code < 0x80) return ch;
const hex = (n, width) => n.toString(16).toUpperCase().padStart(width, '0');
if (code <= 0xff) return `\\x${hex(code, 2)}`;
return [...ch].map((unit) => `\\u${hex(unit.charCodeAt(0), 4)}`).join('');
})
.join('');
const absent = SUPPORTED_LOCALES.filter(
(code) => !bundle.includes(asEmitted(DICTIONARIES[code]['hero.headlineTop']))
);
assert.deepEqual(absent, [],
`dist/app.js carries no strings for: ${absent.join(', ')} — run \`npm run build\`, ` +
'and check that build:i18n still runs before build:js');
assert.ok(bundle.includes(`SUPPORTED_LOCALES = ${JSON.stringify(SUPPORTED_LOCALES).replace(/,/g, ', ')}`),
'the bundled locale registry disagrees with src/i18n/generated.js');
}
// The real module, as shipped.
{
const live = await import(pathToFileURL(path.join(ROOT, 'src/i18n/index.js')));
assert.ok(live.SUPPORTED_LOCALES.includes(live.DEFAULT_LOCALE));
assert.equal(live.t('language.label'), 'Language');
assert.equal(live.t('no.such.key'), 'no.such.key', 'a missing key should show itself, not blank UI');
assert.equal(live.t('community.title'), 'Join the future of privacy');
}
// A window without the parts this reads is a real environment, not a hypothetical one:
// test harnesses and workers both provide partial shims. Reading through them blindly
// threw, and since t() runs inside constructors, one missing property took whole
// components down rather than just showing an untranslated string.
{
const saved = globalThis.window;
try {
globalThis.window = {}; // no location, no navigator
const tmp = mkdtempSync(path.join(tmpdir(), 'sb-i18n-win-'));
copyFileSync(path.join(ROOT, 'src/i18n/generated.js'), path.join(tmp, 'generated.js'));
copyFileSync(path.join(ROOT, 'src/i18n/index.js'), path.join(tmp, 'index.js'));
const partial = await import(pathToFileURL(path.join(tmp, 'index.js')));
assert.equal(partial.currentLocale(), partial.DEFAULT_LOCALE,
'a window without location must fall back to the default locale, not throw');
assert.equal(typeof partial.t('language.label'), 'string');
rmSync(tmp, { recursive: true, force: true });
} finally {
if (saved === undefined) delete globalThis.window;
else globalThis.window = saved;
}
}
// Precedence only becomes visible with more than one locale, so build a two-locale
// copy of the module in a temp directory and exercise it there.
{
const tmp = mkdtempSync(path.join(tmpdir(), 'sb-i18n-rt-'));
mkdirSync(tmp, { recursive: true });
writeFileSync(path.join(tmp, 'generated.js'), `
export const DEFAULT_LOCALE = "en";
export const SUPPORTED_LOCALES = ["en", "de"];
export const LOCALE_META = {
en: { htmlLang: "en", nativeName: "English", dir: "ltr", path: "/" },
de: { htmlLang: "de", nativeName: "Deutsch", dir: "ltr", path: "/de/" }
};
export const DICTIONARIES = {
en: { "greeting": "Hello", "only.en": "English only", "welcome": "Hello, {name}" },
de: { "greeting": "Hallo" }
};
`);
copyFileSync(path.join(ROOT, 'src/i18n/index.js'), path.join(tmp, 'index.js'));
const i18n = await import(pathToFileURL(path.join(tmp, 'index.js')));
// Reading a path.
assert.equal(i18n.localeFromPathname('/de/'), 'de');
assert.equal(i18n.localeFromPathname('/de/anything'), 'de');
assert.equal(i18n.localeFromPathname('/'), null);
assert.equal(i18n.localeFromPathname('/deutschland/'), null, 'a prefix match is not a locale match');
// Browser languages, full tag then base tag, in the browser's own order.
assert.equal(i18n.localeFromLanguages(['de-AT', 'en-US']), 'de');
assert.equal(i18n.localeFromLanguages(['fr-FR', 'de']), 'de');
assert.equal(i18n.localeFromLanguages(['fr', 'ja']), null);
// The URL outranks everything. This is what keeps a shared link shareable.
assert.equal(
i18n.detectLocale({ pathname: '/de/', stored: 'en', languages: ['en-US'] }), 'de',
'a /de/ URL must render German even for someone who once chose English'
);
// The root is the default locale's own page, not a blank slate: honour a stored
// choice or the browser there, but never treat it as "no locale".
assert.equal(i18n.detectLocale({ pathname: '/', stored: 'de', languages: ['en'] }), 'en',
'the root URL is the default locale, so it must not silently render another one');
assert.equal(i18n.detectLocale({ pathname: '/somewhere', stored: 'de' }), 'de');
assert.equal(i18n.detectLocale({ pathname: '/somewhere', languages: ['de-DE'] }), 'de');
assert.equal(i18n.detectLocale({ pathname: '/somewhere' }), 'en');
// Switching language keeps you on the page you were reading.
assert.equal(i18n.localeHref('de', '/'), '/de/');
assert.equal(i18n.localeHref('de', '/de/'), '/de/');
assert.equal(i18n.localeHref('en', '/de/'), '/');
assert.equal(i18n.localeHref('en', '/de/index.html'), '/index.html');
assert.equal(i18n.localeHref('de', '/index.html'), '/de/index.html');
// A suggestion, never a redirect: redirecting on Accept-Language sends Googlebot,
// which crawls from one place, into a single locale and leaves the rest unindexed.
assert.equal(i18n.suggestedLocale({ pathname: '/', languages: ['de-DE'] }), 'de');
assert.equal(i18n.suggestedLocale({ pathname: '/de/', languages: ['de-DE'] }), null);
assert.equal(
i18n.suggestedLocale({ pathname: '/', stored: 'de', languages: ['en-US'] }), 'de',
'a past explicit choice is the strongest reason to offer the other page'
);
assert.equal(
i18n.suggestedLocale({ pathname: '/', languages: ['de-DE'], stored: 'en' }), null,
'an explicit choice of the language already shown outranks the browser list'
);
assert.equal(i18n.suggestedLocale({ pathname: '/de/', stored: 'de' }), null);
// The switcher is a list of real links, each pointing at the same page in another
// language, each labelled in that language.
const links = i18n.languageLinks({ pathname: '/de/roadmap', active: 'de' });
assert.deepEqual(links.map((l) => l.code), ['en', 'de']);
assert.deepEqual(links.map((l) => l.href), ['/roadmap', '/de/roadmap']);
assert.deepEqual(links.map((l) => l.label), ['English', 'Deutsch']);
assert.deepEqual(links.map((l) => l.isCurrent), [false, true]);
assert.deepEqual(links.map((l) => l.hrefLang), ['en', 'de']);
// Lookup falls back to the default locale before it falls back to the key.
assert.equal(i18n.t('greeting', null, 'de'), 'Hallo');
assert.equal(i18n.t('only.en', null, 'de'), 'English only', 'an untranslated string shows English, not blank');
assert.equal(i18n.t('missing', null, 'de'), 'missing');
assert.equal(i18n.t('welcome', { name: 'Ada' }, 'en'), 'Hello, Ada');
assert.equal(i18n.t('welcome', {}, 'en'), 'Hello, {name}', 'an absent variable stays literal rather than blank');
rmSync(tmp, { recursive: true, force: true });
}
console.log('i18n-runtime.test.mjs: all assertions passed');