feat(i18n): Arabic, Hebrew, Persian and Urdu, and a layout that mirrors; release v6.4.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

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.
This commit is contained in:
lockbitchat
2026-08-29 18:05:20 -04:00
parent 98d42ac2fb
commit 5e32f547b9
68 changed files with 15147 additions and 500 deletions
+35 -1
View File
@@ -19,6 +19,10 @@ const ROOT = fileURLToPath(new URL('..', import.meta.url));
// locale page has to start with a slash.
const ROOT_DIRS = ['logo', 'assets', 'libs', 'dist', 'config', 'src'];
// Prose about a path is not a path. A comment quoting './sw.js' to explain why it is
// wrong must not itself be reported as the thing being wrong.
const isComment = (line) => /^\s*(\/\/|\/?\*)/.test(line);
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');
@@ -27,6 +31,7 @@ const offenders = [];
for (const file of files) {
const text = readFileSync(path.join(ROOT, file), 'utf8');
text.split('\n').forEach((line, i) => {
if (isComment(line)) return;
// 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
@@ -37,9 +42,38 @@ for (const file of files) {
});
}
// The check above only knows about root *directories*, so a root-level *file* walked
// straight past it: the Service Worker was registered as './sw.js', which asks /ar/ for
// /ar/sw.js and gets a 404. That left twelve of the thirteen pages with no worker at all
// — no offline shell, no update prompt — and it was invisible from the English page,
// which is the only one where the relative path happens to be right.
const ROOT_FILES = ['sw.js', 'manifest.json', 'meta.json', 'robots.txt', 'sitemap.xml', 'browserconfig.xml'];
for (const file of files) {
const text = readFileSync(path.join(ROOT, file), 'utf8');
text.split('\n').forEach((line, i) => {
if (isComment(line)) return;
for (const match of line.matchAll(new RegExp(`['"](?:\\./)?(?:${ROOT_FILES.join('|').replace(/\./g, '\\.')})['"]`, 'g'))) {
if (/\b(import|from|require)\b/.test(line)) continue;
offenders.push(`${file}:${i + 1} ${match[0]}`);
}
});
}
// A worker scoped to './' controls only the directory it was registered from, so an app
// installed at /ar/ would stop being covered the moment it navigated to the root. There
// is one worker for the whole site; its scope is the whole site.
for (const file of files) {
const text = readFileSync(path.join(ROOT, file), 'utf8');
if (!text.includes('serviceWorker.register')) continue;
text.split('\n').forEach((line, i) => {
if (isComment(line)) return;
if (/scope:\s*['"](?!\/['"])/.test(line)) offenders.push(`${file}:${i + 1} ${line.trim()}`);
});
}
assert.deepEqual(
offenders, [],
'these asset paths are relative and will 404 from a locale subdirectory — prefix them with "/":\n' +
'these paths are relative and change meaning inside a locale subdirectory — make them root-absolute:\n' +
offenders.join('\n')
);
+10 -3
View File
@@ -47,7 +47,7 @@ for (const code of site.locales) {
const html = read(pageFor(code));
const locale = localeFile(code);
assert.match(html, new RegExp(`<html lang="${locale.htmlLang}">`), `${code}: wrong <html lang>`);
assert.match(html, new RegExp(`<html lang="${locale.htmlLang}" dir="${locale.dir}">`), `${code}: wrong <html lang>/<html dir>`);
assert.ok(
html.includes(`<link rel="canonical" href="${urlFor(code)}">`),
`${code}: canonical must point at its own URL, not another locale's`
@@ -181,7 +181,10 @@ for (const code of site.locales) {
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' });
// Right-to-left on purpose: direction is carried from the locale file all the way
// into <html dir> and the per-locale manifest, and the throwaway site is where that
// plumbing can be exercised without a real RTL locale having to be the one under test.
Object.assign(xx, { htmlLang: 'xx', ogLocale: 'xx_XX', nativeName: 'Test', dir: 'rtl' });
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));
@@ -202,7 +205,10 @@ for (const code of site.locales) {
// The secondary locale points at itself, not at the default one.
assert.ok(secondary.includes('<link rel="canonical" href="https://securebit.chat/xx/">'),
'a secondary locale must be canonical to itself, or Google will not index it');
assert.match(secondary, /<html lang="xx">/);
assert.match(secondary, /<html lang="xx" dir="rtl">/,
'the locale\'s writing direction must be on <html>, not applied later by the app');
assert.match(primary, /<html lang="en" dir="ltr">/,
'a left-to-right locale must still say so explicitly');
// hreflang has to be reciprocal: both pages list both locales plus x-default.
for (const page of [primary, secondary]) {
@@ -223,6 +229,7 @@ for (const code of site.locales) {
assert.ok(secondary.includes('<link rel="manifest" href="/xx/manifest.json">'));
const manifest = JSON.parse(at('xx/manifest.json'));
assert.equal(manifest.lang, 'xx');
assert.equal(manifest.dir, 'rtl', 'an installed RTL locale must launch right-to-left');
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');
+37
View File
@@ -23,6 +23,43 @@ const ROOT = fileURLToPath(new URL('..', import.meta.url));
);
}
// 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')));
+188
View File
@@ -0,0 +1,188 @@
// Right-to-left is a layout property, not a translation. Arabic strings in a left-to-right
// page are not a localized app — the avatar still sits on the wrong side of the name, the
// drawer still flies in from the wrong edge, the chevron still points away from "next".
//
// So this test guards the mechanism rather than the wording: that direction reaches the
// document, that the source no longer hard-codes a physical side, and that the two things
// which must NOT mirror — the swipe maths and machine-readable text — are handled.
//
// It reads the source rather than rendering it because there is no DOM here that resolves
// logical properties: jsdom parses `margin-inline-start` but has no layout engine to
// mirror it, so an assertion about pixels would be an assertion about jsdom.
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
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'));
/** Every source file the UI is built from. */
function sources() {
const out = [];
const walk = (dir) => {
for (const entry of readdirSync(path.join(ROOT, dir))) {
const rel = `${dir}/${entry}`;
if (statSync(path.join(ROOT, rel)).isDirectory()) walk(rel);
else if (/\.(jsx?|css)$/.test(entry) && !rel.endsWith('i18n/generated.js')) out.push(rel);
}
};
walk('src');
return out;
}
// ── The direction reaches the page ───────────────────────────────────────────────────
// It has to be in the served HTML, not applied by the app: styling and first paint both
// happen long before React mounts, so a direction set in JS shows one frame of a mirrored
// layout to every RTL reader.
{
const rtl = site.locales.filter((code) => JSON.parse(read(`locales/${code}.json`)).dir === 'rtl');
assert.ok(rtl.length > 0, 'no RTL locale is registered — this test has nothing to protect');
for (const code of rtl) {
const locale = JSON.parse(read(`locales/${code}.json`));
const page = read(code === site.defaultLocale ? 'index.html' : `${code}/index.html`);
assert.match(page, new RegExp(`<html lang="${locale.htmlLang}" dir="rtl">`),
`${code}: the generated page does not declare its writing direction`);
assert.equal(JSON.parse(read(`${code}/manifest.json`)).dir, 'rtl',
`${code}: an installed PWA would launch left-to-right`);
}
assert.ok(read('templates/index.template.html').includes('src/styles/rtl.css'),
'the mirroring stylesheet is not linked from the template');
}
// ── The runtime exposes direction ────────────────────────────────────────────────────
{
const i18n = await import(pathToFileURL(path.join(ROOT, 'src/i18n/index.js')));
const rtl = site.locales.find((code) => JSON.parse(read(`locales/${code}.json`)).dir === 'rtl');
assert.equal(i18n.localeDir(site.defaultLocale), 'ltr');
assert.equal(i18n.localeDir(rtl), 'rtl');
assert.equal(i18n.isRTL(rtl), true);
assert.equal(i18n.isRTL(site.defaultLocale), false);
assert.equal(i18n.localeDir('nope'), 'ltr', 'an unknown locale must not throw or mirror');
// The sign is what call sites multiply an offset by, so it is the part that breaks
// silently: a drawer with the wrong sign slides off-screen instead of open.
assert.equal(i18n.direction(site.defaultLocale), 1);
assert.equal(i18n.direction(rtl), -1);
assert.equal(i18n.LTR_TEXT.dir, 'ltr');
assert.equal(i18n.LTR_TEXT.style.unicodeBidi, 'isolate',
'an LTR run inside RTL text must be isolated, or it drags the sentence around it');
}
// ── No physical sides left in the UI ─────────────────────────────────────────────────
// A single margin-left is enough to put an icon on the wrong side of its label, and it is
// invisible to anyone testing in English. Logical properties are the whole mechanism, so
// a regression here is a regression in RTL support.
{
// camelCase in React style objects, kebab-case in stylesheets and injected CSS strings.
const banned = [
[/\bmarginLeft\s*:/, 'marginLeft → marginInlineStart'],
[/\bmarginRight\s*:/, 'marginRight → marginInlineEnd'],
[/\bpaddingLeft\s*:/, 'paddingLeft → paddingInlineStart'],
[/\bpaddingRight\s*:/, 'paddingRight → paddingInlineEnd'],
[/\bborderLeft\s*:/, 'borderLeft → borderInlineStart'],
[/\bborderRight\s*:/, 'borderRight → borderInlineEnd'],
[/textAlign\s*:\s*['"](?:left|right)['"]/, "textAlign: 'left'/'right' → 'start'/'end'"],
[/(?<![-\w])margin-(?:left|right)\s*:/, 'margin-left/right → margin-inline-start/end'],
[/(?<![-\w])padding-(?:left|right)\s*:/, 'padding-left/right → padding-inline-start/end'],
[/(?<![-\w])border-(?:left|right)\s*:/, 'border-left/right → border-inline-start/end'],
[/text-align\s*:\s*(?:left|right)\b/, 'text-align: left/right → start/end'],
[/(?<![-\w])\bml-\d/, 'Tailwind ml-* → ms-*'],
[/(?<![-\w])\bmr-\d/, 'Tailwind mr-* → me-*'],
[/(?<![-\w])\bpl-\d/, 'Tailwind pl-* → ps-*'],
[/(?<![-\w])\bpr-\d/, 'Tailwind pr-* → pe-*'],
[/(?<![-\w])\btext-(?:left|right)\b/, 'Tailwind text-left/right → text-start/end'],
];
// rtl.css is where the exceptions live — it exists precisely to say "left" on purpose.
const files = sources().filter((f) => f !== 'src/styles/rtl.css');
// A notch is on the physical left of the handset whichever way the text runs, so
// safe-area insets are the one place where a physical side is the correct answer.
const physicalOnPurpose = (line) => line.includes('env(safe-area-inset-');
const offences = [];
for (const file of files) {
const lines = read(file).split('\n');
lines.forEach((line, i) => {
if (physicalOnPurpose(line)) return;
for (const [pattern, fix] of banned) {
if (pattern.test(line)) offences.push(`${file}:${i + 1} ${fix}`);
}
});
}
assert.deepEqual(offences, [], `physical directions left in the UI:\n ${offences.join('\n ')}`);
}
// ── The gesture mirrors with the layout ──────────────────────────────────────────────
// The drawer keeps one logical offset (0 open, -width closed) in both directions, and
// mirrors at exactly two points: the pixels it paints and the finger that drives it. Miss
// either and the panel tracks the wrong way under the thumb.
{
const app = read('src/app.jsx');
assert.match(app, /const DIR = direction\(\);/, 'app.jsx does not resolve the layout direction');
assert.match(app, /translate3d\(' \+ \(x \* DIR\)/, 'the drawer paints an unmirrored offset');
assert.match(app, /d\.base \+ \(e\.clientX - d\.x0\) \* DIR/, 'the drawer drag is not mirrored');
assert.ok(app.includes('d.vel.add(e.clientX * DIR'),
'the velocity tracker samples raw screen x, so a flick would settle the wrong way');
}
// ── Machine text is pinned left-to-right ─────────────────────────────────────────────
// Bidi reordering rewrites exactly the strings this app is made of. A session descriptor
// or a SAS code printed in the wrong order still looks like a key, so the reader compares
// it happily against their peer's screen and confirms a channel they never verified.
{
const app = read('src/app.jsx');
for (const field of ['value: sasInput', 'value: answerInput', 'value: offerInput']) {
const at = [...app.matchAll(new RegExp(field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))]
.map((m) => m.index);
assert.notEqual(at.length, 0, `${field} is gone — this guard now protects nothing`);
// Every one of them, not just the first: the app renders the SAS code in two
// places, and it was the one further up the file that got missed.
for (const i of at) {
assert.ok(app.slice(Math.max(0, i - 260), i).includes("dir: 'ltr'"),
`the field holding ${field} at offset ${i} must be dir="ltr" or bidi reorders what the user compares`);
}
}
// Displaying a code is as dangerous as typing one, and easier to miss. The safety
// code is drawn one character per tile in a flex row, and a flex row follows the
// writing direction — so in Arabic the first character lands on the right and the
// code reads backwards. It still looks like a code, which is the whole problem.
const cells = app.indexOf("key: 'cells'");
assert.notEqual(cells, -1, 'the safety-code tiles are gone — this guard now protects nothing');
assert.ok(app.slice(cells, cells + 60).includes("dir: 'ltr'"),
'the safety-code tiles sit in a flex row, so they must be pinned left-to-right');
// The group code, wherever it is put on screen. A plain `group.sasCode` also appears
// in guard conditions, which render nothing — so the window looks both ways and only
// the sites that are actually inside an element have to carry the direction.
const group = read('src/components/ui/GroupChat.jsx');
// `!group.sasCode` is an existence check that renders nothing; everything else puts
// the digits somewhere a person can read them.
const shown = [...group.matchAll(/(!?)group\.sasCode/g)]
.filter((m) => m[1] !== '!')
.map((m) => m.index);
assert.ok(shown.length >= 2, 'the group safety code is no longer rendered where expected');
let pinned = 0;
for (const i of shown) {
if (group.slice(Math.max(0, i - 420), i + 260).includes("dir: 'ltr'")) pinned += 1;
}
assert.equal(pinned, shown.length,
`${shown.length - pinned} of ${shown.length} group-safety-code sites are not pinned ` +
'left-to-right — everyone in the group compares those digits against each other');
const rtlCss = read('src/styles/rtl.css');
for (const selector of ['[dir="rtl"] code', '[dir="rtl"] pre', '[dir="rtl"] .sb-sc']) {
assert.ok(rtlCss.includes(selector), `rtl.css no longer isolates ${selector}`);
}
assert.match(rtlCss, /unicode-bidi:\s*isolate/, 'isolation is what stops a run dragging its neighbours');
}
console.log('rtl-layout.test.mjs: all assertions passed');