, so a crawler only ever sees . A locale
+ * that has no URL of its own has no way of being indexed. Each locale therefore gets
+ * a real file at a real path, with its own title, description, canonical and hreflang
+ * cluster, produced at build time.
+ *
+ * Runs before post-build.js, which re-stamps ?v= and sw.js. The ?v=BUILD_VERSION
+ * placeholders are filled here from meta.json so the generated files are already
+ * correct on their own; post-build's later pass over index.html is idempotent.
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+const TEMPLATE = path.join(ROOT, 'templates', 'index.template.html');
+
+// Overridable so the test can render a throwaway multi-locale site into a temp
+// directory: the machinery that matters here only shows itself with two locales,
+// and a half-translated locale is not something to ship just to exercise it.
+const LOCALES_DIR = process.env.I18N_LOCALES_DIR || path.join(ROOT, 'locales');
+const OUT_ROOT = process.env.I18N_OUT_ROOT || ROOT;
+
+const read = (p) => fs.readFileSync(p, 'utf8');
+const readJson = (p) => JSON.parse(read(p));
+
+/** Escape the four characters that can break out of a double-quoted attribute. */
+const attr = (value) =>
+ String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+
+/** Public URL of a locale. The default locale owns the root, so existing links keep working. */
+function localeUrl(site, code) {
+ return code === site.defaultLocale ? `${site.baseUrl}/` : `${site.baseUrl}/${code}/`;
+}
+
+/** Where the rendered file lands on disk. */
+function outputPath(site, code) {
+ return code === site.defaultLocale
+ ? path.join(OUT_ROOT, 'index.html')
+ : path.join(OUT_ROOT, code, 'index.html');
+}
+
+/**
+ * hreflang cluster. Every locale lists every locale including itself — Google treats a
+ * cluster that omits its own page as invalid and ignores it. A single-locale site gets
+ * no cluster at all, since there is nothing to point at.
+ */
+function hreflangLinks(site) {
+ if (site.locales.length < 2) return '';
+ const links = site.locales.map(
+ (code) => `
`
+ );
+ links.push(`
`);
+ return links.join('\n');
+}
+
+function ogLocaleAlternate(site, code) {
+ if (site.locales.length < 2) return '';
+ return site.locales
+ .filter((other) => other !== code)
+ .map((other) => `
`)
+ .join('\n');
+}
+
+/**
+ * schema.org graph, rebuilt per locale so the descriptions and feature list are
+ * translated too — a page whose visible text is German and whose structured data is
+ * English is describing something other than itself.
+ */
+function structuredData(site, code) {
+ const locale = site.byCode[code];
+ const url = localeUrl(site, code);
+ const graph = {
+ '@context': 'https://schema.org',
+ '@graph': [
+ {
+ '@type': 'WebSite',
+ '@id': `${site.baseUrl}/#website`,
+ name: site.siteName,
+ url: `${site.baseUrl}/`,
+ description: locale.schema.siteDescription,
+ inLanguage: locale.htmlLang,
+ },
+ {
+ '@type': 'WebApplication',
+ name: site.siteName,
+ url,
+ applicationCategory: 'CommunicationApplication',
+ operatingSystem: locale.schema.operatingSystem,
+ browserRequirements: locale.schema.browserRequirements,
+ description: locale.schema.appDescription,
+ inLanguage: locale.htmlLang,
+ image: site.baseUrl + site.socialCard,
+ license: 'https://opensource.org/licenses/MIT',
+ isAccessibleForFree: true,
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
+ featureList: locale.schema.featureList,
+ sameAs: [site.repository],
+ },
+ ],
+ };
+ // Indent to sit under the
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/asset-paths-locale-safe.test.mjs b/tests/asset-paths-locale-safe.test.mjs
new file mode 100644
index 0000000..ffc9c90
--- /dev/null
+++ b/tests/asset-paths-locale-safe.test.mjs
@@ -0,0 +1,46 @@
+// Every locale is served from its own subdirectory, so a relative asset path silently
+// changes meaning depending on which page you are on: 'logo/aegis.png' is /logo/aegis.png
+// from the English page and /de/logo/aegis.png — a 404 — from the German one. The
+// partner logos shipped exactly that bug, and it is invisible in development, where
+// only the root page is ever open.
+//
+// The CSP sets base-uri 'none', so a
cannot rescue relative paths either.
+// Root-absolute is the only option, and this test is what keeps it that way.
+
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url));
+
+// Directories that exist at the site root. A reference to one of them from inside a
+// locale page has to start with a slash.
+const ROOT_DIRS = ['logo', 'assets', 'libs', 'dist', 'config', 'src'];
+
+const files = execFileSync('git', ['ls-files', 'src'], { cwd: ROOT, encoding: 'utf8' })
+ .trim().split('\n')
+ .filter((f) => /\.(js|jsx)$/.test(f) && f !== 'src/i18n/generated.js');
+
+const offenders = [];
+for (const file of files) {
+ const text = readFileSync(path.join(ROOT, file), 'utf8');
+ text.split('\n').forEach((line, i) => {
+ // A quoted path that starts with a root directory name and no leading slash.
+ for (const match of line.matchAll(new RegExp(`['"](?:${ROOT_DIRS.join('|')})/[A-Za-z0-9_./-]+\\.(png|jpe?g|svg|gif|webp|ico|css|mp3|mp4|webm|woff2?)['"]`, 'g'))) {
+ // An ES import specifier is resolved by the bundler at build time, not by the
+ // browser against the page URL, so it is not affected.
+ if (/\b(import|from|require)\b/.test(line)) continue;
+ offenders.push(`${file}:${i + 1} ${match[0]}`);
+ }
+ });
+}
+
+assert.deepEqual(
+ offenders, [],
+ 'these asset paths are relative and will 404 from a locale subdirectory — prefix them with "/":\n' +
+ offenders.join('\n')
+);
+
+console.log(`asset-paths-locale-safe.test.mjs: ${files.length} source files checked, no relative asset paths`);
diff --git a/tests/file-transfer-ui-cleanup.test.mjs b/tests/file-transfer-ui-cleanup.test.mjs
index b68a291..0ccb2c4 100644
--- a/tests/file-transfer-ui-cleanup.test.mjs
+++ b/tests/file-transfer-ui-cleanup.test.mjs
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
+import { t } from '../src/i18n/index.js';
const effects = [];
const setterCalls = [];
@@ -9,6 +10,10 @@ const callbackCalls = [];
const context = {
window: {},
+ // The component reaches its copy through t(); in the browser that arrives as an
+ // ES import, which a bare VM script cannot evaluate. Supply the real function so
+ // the component sees exactly what it sees at runtime.
+ t,
React: {
useState(initialValue) {
const index = stateIndex++;
@@ -26,7 +31,12 @@ const context = {
}
};
-const source = fs.readFileSync(new URL('../src/components/ui/FileTransfer.jsx', import.meta.url), 'utf8');
+// vm.runInNewContext evaluates a script, not a module, so the import statements are
+// stripped and their bindings handed in through the context above — the same
+// substitution the bundler performs, done by hand.
+const source = fs
+ .readFileSync(new URL('../src/components/ui/FileTransfer.jsx', import.meta.url), 'utf8')
+ .replace(/^import\s[^;]*;\s*$/gm, '');
vm.runInNewContext(source, context);
const manager = {
diff --git a/tests/i18n-build.test.mjs b/tests/i18n-build.test.mjs
new file mode 100644
index 0000000..32e1fe0
--- /dev/null
+++ b/tests/i18n-build.test.mjs
@@ -0,0 +1,255 @@
+// The localized pages are generated, so the thing worth asserting is not their
+// content but the invariants a generator can silently break: that the committed
+// HTML is what the template actually produces, that every locale carries its own
+// canonical rather than pointing at the default one, and that the hreflang cluster
+// is complete. A canonical that points elsewhere tells Google not to index the page
+// at all, which is the exact opposite of why the locale exists.
+
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { readFileSync, writeFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url));
+const read = (rel) => readFileSync(path.join(ROOT, rel), 'utf8');
+
+const site = JSON.parse(read('locales/site.json'));
+const localeFile = (code) => JSON.parse(read(`locales/${code}.json`));
+const pageFor = (code) => (code === site.defaultLocale ? 'index.html' : `${code}/index.html`);
+const urlFor = (code) => (code === site.defaultLocale ? `${site.baseUrl}/` : `${site.baseUrl}/${code}/`);
+
+// Every locale in the registry must have a file and a generated page.
+{
+ assert.ok(site.locales.includes(site.defaultLocale), 'defaultLocale must be listed in locales');
+ for (const code of site.locales) {
+ assert.ok(existsSync(path.join(ROOT, `locales/${code}.json`)), `locales/${code}.json is missing`);
+ assert.ok(existsSync(path.join(ROOT, pageFor(code))), `${pageFor(code)} was never generated`);
+ }
+}
+
+// The committed pages must be exactly what the generator produces. If someone edits
+// index.html by hand the change is lost on the next build, so catch it here instead.
+{
+ const before = Object.fromEntries(site.locales.map((code) => [code, read(pageFor(code))]));
+ execFileSync('node', [path.join(ROOT, 'scripts/build-i18n.js')], { stdio: 'pipe' });
+ for (const code of site.locales) {
+ assert.equal(
+ read(pageFor(code)), before[code],
+ `${pageFor(code)} is not what scripts/build-i18n.js generates — edit templates/ or locales/, then run \`npm run build:i18n\``
+ );
+ }
+}
+
+// Per-locale SEO identity.
+for (const code of site.locales) {
+ const html = read(pageFor(code));
+ const locale = localeFile(code);
+
+ assert.match(html, new RegExp(``), `${code}: wrong `);
+ assert.ok(
+ html.includes(`
`),
+ `${code}: canonical must point at its own URL, not another locale's`
+ );
+ assert.ok(html.includes(`
${locale.meta.title}`), `${code}: title not from the locale file`);
+ assert.ok(html.includes(`content="${locale.ogLocale}"`), `${code}: og:locale missing`);
+
+ // The structured data must describe this page in this language.
+ const ld = JSON.parse(html.match(/application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
+ const app = ld['@graph'].find((node) => node['@type'] === 'WebApplication');
+ assert.equal(app.inLanguage, locale.htmlLang, `${code}: JSON-LD inLanguage disagrees with the page`);
+ assert.equal(app.url, urlFor(code), `${code}: JSON-LD url disagrees with the canonical`);
+
+ // A hreflang cluster only counts if every page lists every page, itself included.
+ if (site.locales.length > 1) {
+ for (const other of site.locales) {
+ const lang = localeFile(other).htmlLang;
+ assert.ok(
+ html.includes(`hreflang="${lang}" href="${urlFor(other)}"`),
+ `${code}: hreflang cluster is missing ${other}`
+ );
+ }
+ assert.ok(html.includes('hreflang="x-default"'), `${code}: x-default is missing`);
+ }
+}
+
+// robots.txt must not block what the crawler needs to render the page. The app is
+// client-rendered: block /src/ or /dist/ and Google sees an empty
.
+{
+ const robots = read('robots.txt');
+ assert.ok(robots.includes(`Sitemap: ${site.baseUrl}/sitemap.xml`), 'robots.txt must advertise the sitemap');
+ for (const critical of ['/src/', '/dist/', '/libs/', '/assets/', '/config/']) {
+ assert.equal(
+ robots.includes(`Disallow: ${critical}`), false,
+ `robots.txt disallows ${critical}, which the page needs in order to render for a crawler`
+ );
+ }
+}
+
+// The sitemap must list every locale exactly once.
+{
+ const sitemap = read('sitemap.xml');
+ for (const code of site.locales) {
+ const loc = `
${urlFor(code)}`;
+ assert.equal(sitemap.split(loc).length - 1, 1, `sitemap.xml must list ${urlFor(code)} exactly once`);
+ }
+ assert.equal(
+ (sitemap.match(/
/g) || []).length, site.locales.length,
+ 'sitemap.xml has entries for URLs that are not locales'
+ );
+}
+
+// Every shell must carry the same build stamp. scripts/build-i18n.js fills in ?v= from
+// meta.json, but meta.json is regenerated afterwards by post-build.js — which for a
+// while re-stamped only the root page, leaving the localized ones pointing at the
+// previous build.
+{
+ const stamps = new Map();
+ for (const code of site.locales) {
+ const found = [...new Set(read(pageFor(code)).match(/\?v=[0-9A-Z_]+/g) || [])];
+ assert.equal(found.length, 1, `${pageFor(code)} carries more than one build stamp: ${found.join(', ')}`);
+ stamps.set(code, found[0]);
+ }
+ const distinct = new Set(stamps.values());
+ assert.equal(
+ distinct.size, 1,
+ `the app shells disagree on the build stamp: ${[...stamps].map(([c, v]) => `${c}=${v}`).join(', ')}`
+ );
+ const meta = JSON.parse(read('meta.json'));
+ assert.equal([...distinct][0], `?v=${meta.version}`, 'the shells are stamped with a version other than meta.json');
+}
+
+// Every key a component asks for must exist, and every key defined must be asked for.
+// A typo in t('community.titel') renders the key itself on the page — visible, but only
+// to whoever happens to look; and a string nobody uses is a string a translator pays to
+// translate. Only files that actually import the module are scanned, so an unrelated
+// local function called t() cannot be mistaken for a lookup.
+{
+ // Match the module however it is reached — app.jsx sits a level up from the
+ // components and imports it by a shorter path.
+ const sources = execFileSync('grep', ['-rlE', "from '[^']*i18n/index\\.js'", path.join(ROOT, 'src')], {
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
+ }).trim().split('\n').filter(Boolean);
+
+ const used = new Set();
+ // Keys built from a template literal — t(`roadmap.${d.k}.title`) — cannot be read
+ // literally, so each becomes a pattern and any defined key it matches counts as used.
+ const patterns = [];
+ for (const file of sources) {
+ const text = readFileSync(file, 'utf8');
+ for (const match of text.matchAll(/\bt(?:List)?\(\s*['"]([^'"]+)['"]/g)) used.add(match[1]);
+ for (const match of text.matchAll(/\bt(?:List)?\(\s*`([^`]+)`/g)) {
+ const source = match[1]
+ .split(/\$\{[^}]*\}/)
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+ .join('.+'); // a key built from a variable may itself contain dots
+ patterns.push(new RegExp(`^${source}$`));
+ }
+ }
+
+ const defined = new Set(Object.keys(JSON.parse(read(`locales/${site.defaultLocale}.json`)).ui || {}));
+ const isUsed = (key) => used.has(key) || patterns.some((re) => re.test(key));
+ const missing = [...used].filter((key) => !defined.has(key)).sort();
+ const unused = [...defined].filter((key) => !isUsed(key)).sort();
+
+ assert.deepEqual(missing, [],
+ `these keys are used but not defined in locales/${site.defaultLocale}.json — they would render as the key itself`);
+ assert.deepEqual(unused, [],
+ `these keys are defined but never used — remove them rather than have them translated`);
+ assert.ok(used.size > 0, 'the scan found no t() calls at all, which means it is not scanning anything');
+ assert.ok(patterns.length > 0, 'no template-literal keys were found — the dynamic-key scan is not working');
+}
+
+// Every locale must define exactly the same keys, or a language silently falls back to
+// English in the places its translator missed.
+for (const code of site.locales) {
+ const base = Object.keys(JSON.parse(read(`locales/${site.defaultLocale}.json`)).ui || {}).sort();
+ const theirs = Object.keys(JSON.parse(read(`locales/${code}.json`)).ui || {}).sort();
+ assert.deepEqual(theirs, base, `locales/${code}.json does not define the same ui keys as the default locale`);
+}
+
+// Most of what the generator does only becomes visible with a second locale, and a
+// half-translated locale is not something to add to the live site just to exercise
+// the code. So render a throwaway two-locale site into a temp directory instead.
+{
+ const tmp = mkdtempSync(path.join(tmpdir(), 'sb-i18n-'));
+ const localesDir = path.join(tmp, 'locales');
+ const outRoot = path.join(tmp, 'out');
+ mkdirSync(localesDir, { recursive: true });
+ mkdirSync(outRoot, { recursive: true });
+
+ const en = JSON.parse(read('locales/en.json'));
+ const xx = JSON.parse(JSON.stringify(en));
+ Object.assign(xx, { htmlLang: 'xx', ogLocale: 'xx_XX', nativeName: 'Test' });
+ xx.manifest = { name: 'XX name', short_name: 'XX', description: 'XX description' };
+ writeFileSync(path.join(localesDir, 'en.json'), JSON.stringify(en, null, 2));
+ writeFileSync(path.join(localesDir, 'xx.json'), JSON.stringify(xx, null, 2));
+ writeFileSync(
+ path.join(localesDir, 'site.json'),
+ JSON.stringify({ ...site, locales: ['en', 'xx'] }, null, 2)
+ );
+
+ execFileSync('node', [path.join(ROOT, 'scripts/build-i18n.js')], {
+ stdio: 'pipe',
+ env: { ...process.env, I18N_LOCALES_DIR: localesDir, I18N_OUT_ROOT: outRoot },
+ });
+
+ const at = (rel) => readFileSync(path.join(outRoot, rel), 'utf8');
+ const secondary = at('xx/index.html');
+ const primary = at('index.html');
+
+ // The secondary locale points at itself, not at the default one.
+ assert.ok(secondary.includes(''),
+ 'a secondary locale must be canonical to itself, or Google will not index it');
+ assert.match(secondary, //);
+
+ // hreflang has to be reciprocal: both pages list both locales plus x-default.
+ for (const page of [primary, secondary]) {
+ assert.ok(page.includes('hreflang="en" href="https://securebit.chat/"'));
+ assert.ok(page.includes('hreflang="xx" href="https://securebit.chat/xx/"'));
+ assert.ok(page.includes('hreflang="x-default" href="https://securebit.chat/"'));
+ }
+ assert.ok(secondary.includes(''));
+
+ // Nothing on a subdirectory page may use a relative asset path: "src/app.js" from
+ // /xx/ resolves to /xx/src/app.js and 404s. CSP forbids , so absolute is the
+ // only option available.
+ const relative = [...secondary.matchAll(/\b(?:src|href)="(?!https?:|data:|#|\/)([^"]+)"/g)];
+ assert.deepEqual(relative.map((m) => m[1]), [],
+ 'subdirectory pages must reference every asset with a root-absolute path');
+
+ // The locale needs its own manifest: the root one resolves "./" against itself.
+ assert.ok(secondary.includes(''));
+ const manifest = JSON.parse(at('xx/manifest.json'));
+ assert.equal(manifest.lang, 'xx');
+ assert.equal(manifest.start_url, '/xx/', 'an installed locale must launch into its own page');
+ assert.equal(manifest.scope, '/', 'scope must still cover the whole site');
+ assert.equal(manifest.name, 'XX name', 'manifest name should come from the locale file');
+ for (const icon of manifest.icons) {
+ assert.ok(icon.src.startsWith('/'), `manifest icon ${icon.src} would resolve under /xx/`);
+ }
+
+ // The Service Worker caches by exact path, so it needs the locale list stamped in;
+ // without it a localized page has no shell to fall back to when offline.
+ const sw = at('sw.js');
+ assert.ok(sw.includes("const SW_LOCALES = ['xx'];"),
+ 'scripts/build-i18n.js must stamp the secondary locales into sw.js');
+ assert.ok(sw.includes('LOCALE_SHELLS'), 'sw.js lost the localized-shell wiring');
+
+ // Both served configs must route deep links inside a locale to that locale's shell.
+ const nginx = at('deploy/nginx.conf');
+ assert.ok(nginx.includes('~^/xx/ /xx/index.html;'), 'nginx.conf lost its locale shell entry');
+ assert.ok(nginx.includes('try_files $uri $uri/ $sb_shell;'), 'nginx.conf must use the shell map');
+ const htaccess = at('.htaccess');
+ assert.ok(htaccess.includes('RewriteRule ^xx(/.*)?$ /xx/index.html [L]'), '.htaccess lost its locale rewrite');
+
+ // The sitemap must carry the alternates too, not just the URLs.
+ const sitemap = at('sitemap.xml');
+ assert.equal((sitemap.match(//g) || []).length, 2);
+ assert.ok(sitemap.includes(''));
+
+ rmSync(tmp, { recursive: true, force: true });
+}
+
+console.log('i18n-build.test.mjs: all assertions passed');
diff --git a/tests/i18n-runtime.test.mjs b/tests/i18n-runtime.test.mjs
new file mode 100644
index 0000000..500efce
--- /dev/null
+++ b/tests/i18n-runtime.test.mjs
@@ -0,0 +1,142 @@
+// 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 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');
diff --git a/tests/language-switcher.test.mjs b/tests/language-switcher.test.mjs
new file mode 100644
index 0000000..13fee57
--- /dev/null
+++ b/tests/language-switcher.test.mjs
@@ -0,0 +1,61 @@
+// Two decisions in the switcher are easy to undo by accident, and both are quiet:
+//
+// - Links, not buttons. Every locale is a separate document at its own URL. A control
+// that re-rendered strings in place would put all languages on one URL, which is
+// exactly the arrangement that cannot be indexed, shared, or opened in a new tab.
+// - Landing only. Switching locale navigates, and a navigation during a session drops
+// the peer connection. Offering it inside the chat invites someone to end their own
+// call by reaching for the language menu.
+
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8');
+const switcher = read('src/components/ui/LanguageSwitcher.jsx');
+const header = read('src/components/ui/Header.jsx');
+const boot = read('src/scripts/app-boot.js');
+
+// Links, not buttons.
+assert.match(switcher, /React\.createElement\('a',/,
+ 'the switcher must render anchors — a crawler cannot follow a click handler');
+// One button is allowed and only one: the control that opens the menu. Every entry that
+// selects a language must still be an anchor — a button there would leave all locales
+// sharing a single URL.
+{
+ const buttons = switcher.match(/React\.createElement\('button'/g) || [];
+ assert.equal(buttons.length, 1,
+ 'the only button may be the menu trigger; language entries must be links');
+ assert.match(switcher, /'aria-haspopup': 'menu'/, 'the trigger must announce that it opens a menu');
+ assert.equal(/React\.createElement\('button'[\s\S]{0,400}href:/.test(switcher), false,
+ 'no button may carry an href — that is an anchor pretending to be a button');
+}
+
+// The entries stay in the DOM when the menu is shut, so the links remain followable and
+// nothing depends on the menu having been opened.
+assert.match(switcher, /display: open \? 'block' : 'none'/,
+ 'the menu must be hidden visually, not removed from the document');
+assert.match(switcher, /href: link\.href/, 'each entry needs a real href');
+assert.match(switcher, /hrefLang: link\.hrefLang/, 'hreflang on the link tells crawlers what it points at');
+
+// The click must not be intercepted: the target locale is a different document.
+assert.equal(/preventDefault/.test(switcher), false,
+ 'intercepting the click would keep the visitor on the current document');
+
+// Accessibility: the current language is announced, not only styled.
+assert.match(switcher, /'aria-current': link\.isCurrent \? 'page' : undefined/);
+assert.match(switcher, /'aria-label': t\('language\.label'\)/, 'the nav needs a label');
+
+// It must disappear entirely while the site is single-locale.
+assert.match(switcher, /if \(SUPPORTED_LOCALES\.length < 2\) return null;/,
+ 'a one-language site must not show a language switcher');
+
+// Landing only.
+assert.match(header, /onLanding && React\.createElement\(LanguageSwitcher/,
+ 'the switcher must be gated on the landing page — navigating mid-session drops the connection');
+assert.match(header, /import \{ LanguageSwitcher \} from '\.\/LanguageSwitcher\.jsx';/,
+ 'import it directly rather than through window, so load order cannot matter');
+
+// And it has to actually reach the bundle.
+assert.match(boot, /import '\.\.\/components\/ui\/LanguageSwitcher\.jsx';/);
+
+console.log('language-switcher.test.mjs: all assertions passed');
diff --git a/uk/index.html b/uk/index.html
new file mode 100644
index 0000000..ca15312
--- /dev/null
+++ b/uk/index.html
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SecureBit.chat - приватний месенджер із наскрізним шифруванням
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/uk/manifest.json b/uk/manifest.json
new file mode 100644
index 0000000..2b875dc
--- /dev/null
+++ b/uk/manifest.json
@@ -0,0 +1,121 @@
+{
+ "name": "SecureBit.chat - приватний месенджер із наскрізним шифруванням",
+ "short_name": "SecureBit",
+ "description": "P2P-месенджер із захистом ECDH, DTLS і SAS та криптографією військового рівня",
+ "start_url": "/uk/",
+ "display": "standalone",
+ "background_color": "#1a1a1a",
+ "theme_color": "#ff6b35",
+ "orientation": "portrait-primary",
+ "scope": "/",
+ "lang": "uk",
+ "dir": "ltr",
+ "categories": [
+ "communication",
+ "security",
+ "productivity"
+ ],
+ "prefer_related_applications": false,
+ "icons": [
+ {
+ "src": "/logo/icon-72x72.png",
+ "sizes": "72x72",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-96x96.png",
+ "sizes": "96x96",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-128x128.png",
+ "sizes": "128x128",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-144x144.png",
+ "sizes": "144x144",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-152x152.png",
+ "sizes": "152x152",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-180x180.png",
+ "sizes": "180x180",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-256x256.png",
+ "sizes": "256x256",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-384x384.png",
+ "sizes": "384x384",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/logo/icon-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "shortcuts": [
+ {
+ "name": "Create Channel",
+ "short_name": "Create",
+ "description": "Create a new secure channel",
+ "url": "/uk/?action=create",
+ "icons": [
+ {
+ "src": "/logo/icon-96x96.png",
+ "sizes": "96x96",
+ "type": "image/png"
+ }
+ ]
+ },
+ {
+ "name": "Join Channel",
+ "short_name": "Join",
+ "description": "Join an existing secure channel",
+ "url": "/uk/?action=join",
+ "icons": [
+ {
+ "src": "/logo/icon-96x96.png",
+ "sizes": "96x96",
+ "type": "image/png"
+ }
+ ]
+ }
+ ]
+}
diff --git a/zh/index.html b/zh/index.html
new file mode 100644
index 0000000..faa262c
--- /dev/null
+++ b/zh/index.html
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SecureBit.chat - 私密的端到端加密通讯工具
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/zh/manifest.json b/zh/manifest.json
new file mode 100644
index 0000000..977189f
--- /dev/null
+++ b/zh/manifest.json
@@ -0,0 +1,121 @@
+{
+ "name": "SecureBit.chat - 私密的端到端加密通讯工具",
+ "short_name": "SecureBit",
+ "description": "采用 ECDH、DTLS 与 SAS 防护、军用级密码学的 P2P 通讯工具",
+ "start_url": "/zh/",
+ "display": "standalone",
+ "background_color": "#1a1a1a",
+ "theme_color": "#ff6b35",
+ "orientation": "portrait-primary",
+ "scope": "/",
+ "lang": "zh-Hans",
+ "dir": "ltr",
+ "categories": [
+ "communication",
+ "security",
+ "productivity"
+ ],
+ "prefer_related_applications": false,
+ "icons": [
+ {
+ "src": "/logo/icon-72x72.png",
+ "sizes": "72x72",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-96x96.png",
+ "sizes": "96x96",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-128x128.png",
+ "sizes": "128x128",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-144x144.png",
+ "sizes": "144x144",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-152x152.png",
+ "sizes": "152x152",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-180x180.png",
+ "sizes": "180x180",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-256x256.png",
+ "sizes": "256x256",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-384x384.png",
+ "sizes": "384x384",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo/icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/logo/icon-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "shortcuts": [
+ {
+ "name": "Create Channel",
+ "short_name": "Create",
+ "description": "Create a new secure channel",
+ "url": "/zh/?action=create",
+ "icons": [
+ {
+ "src": "/logo/icon-96x96.png",
+ "sizes": "96x96",
+ "type": "image/png"
+ }
+ ]
+ },
+ {
+ "name": "Join Channel",
+ "short_name": "Join",
+ "description": "Join an existing secure channel",
+ "url": "/zh/?action=join",
+ "icons": [
+ {
+ "src": "/logo/icon-96x96.png",
+ "sizes": "96x96",
+ "type": "image/png"
+ }
+ ]
+ }
+ ]
+}