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
47 lines
2.2 KiB
JavaScript
47 lines
2.2 KiB
JavaScript
// 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 <base href> 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`);
|