Files
securebit-chat/tests/language-switcher.test.mjs
T
lockbitchat 98d42ac2fb
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): nine languages, each at its own address; release v6.3.0
The site now speaks German, French, Spanish, Ukrainian, Russian, Chinese,
Korean and Hindi alongside English — 623 strings per language, 5,607
translations, covering the landing page, the key exchange, the chat, group
calls and every error along the way.

Each locale is a real page at a real URL (/de/, /fr/, …), generated at build
time from locales/*.json. That is the whole point: the app is client-rendered,
so a language that only swaps strings at runtime has no address for a crawler
to index and no link anyone can share. Each page carries its own canonical, a
reciprocal hreflang cluster and translated schema.org.

The URL decides the language, always. A stored preference only applies where
the address does not say, and nothing ever redirects on Accept-Language —
that is how sites become invisible to search engines outside one country.

robots.txt and sitemap.xml did not exist before; both are generated now.

Bugs found on the way, each with a test that would have caught it:
  - partner logos used page-relative paths and 404'd from any /xx/ page
  - post-build stamped ?v= only into the root shell, leaving locales behind
  - "Back online" appeared on every page load, not just after being offline
  - update timestamps were hard-coded to US format for every reader
  - t() threw on a partial window, taking whole components down with it
2026-08-29 12:46:15 -04:00

62 lines
3.3 KiB
JavaScript

// 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');