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.
This commit is contained in:
@@ -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')
|
||||
);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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')));
|
||||
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user