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:
@@ -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