Keep the reader in the language they chose, and publish faster

**The picker was not the bug; the navigation was.** Every sidebar link on
a German page was built against the English path, so choosing German
lasted exactly one click and then put you back in English. The sidebar
and the Handbook link now follow the page's own language.

**And the choice survives arriving somewhere else.** It is remembered, so
a bookmark, a link from somebody else or a fresh visit lands in the
language you picked rather than resetting to English every time. The
redirect cannot loop — it lands where the page's language already equals
the remembered one — and it cannot strand anybody, because choosing
English is what sets the memory to English. The landing page is exempt:
following the memory there turned every visit to the site into a redirect
into the handbook, which is a redirect away from the page somebody asked
for rather than a language preference.

tests/e2e/site.spec.ts covers all of it — the two bugs you found by
clicking, plus the three ways the fix could go wrong. It is opt-in behind
E2E_SITE (`npm run test:e2e:site`) and does not run in CI: it needs a PHP
build and eight browser pages, and what it guards is a static site whose
failure is visible the moment anybody opens it.

**Publishing is faster.** The Pages job ran composer.json's auto-scripts
on every docs change — cache:clear, assets:install and importmap:install,
which is a Symfony kernel boot and a set of CDN downloads for JavaScript,
to render Markdown. --no-scripts, plus a download cache. The wiki job
needs no Composer at all and never did: bin/mirror-wiki.php has no
dependencies, which is now said out loud so nobody adds one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-05 22:54:41 +02:00
1 parent 4d3c1266ad
commit e6d0e9ca5d
7 files changed
+258 -19

No files matched your search

+19 -3
View File
@@ -49,10 +49,26 @@ jobs:
coverage: none
tools: composer:v2
# The parser is a require-dev dependency, so --no-dev would omit the
# one package this job exists to use.
# Warm cache first: the install below is almost entirely download and
# extract, and this job runs on every docs change.
- name: Cache Composer downloads
uses: actions/cache@v4
with:
path: ~/.cache/composer/files
key: composer-site-${{ hashFiles('composer.lock') }}
restore-keys: composer-site-
# --no-scripts is the difference between a fast job and a slow one.
# composer.json's auto-scripts run cache:clear, assets:install and
# importmap:install on every install — a Symfony kernel boot and a
# set of CDN downloads for JavaScript, none of which a Markdown
# renderer has any use for.
#
# --no-dev is NOT possible: league/commonmark is a require-dev
# dependency precisely because the application never renders
# Markdown, and this job is the only thing that does.
- name: Install PHP dependencies
run: composer install --no-interaction --prefer-dist --no-progress
run: composer install --no-interaction --prefer-dist --no-progress --no-scripts
- name: Build the site
run: php bin/build-site.php build/site
+6
View File
@@ -38,6 +38,12 @@ jobs:
steps:
- uses: actions/checkout@v4
# No Composer step anywhere in this job, deliberately:
# bin/mirror-wiki.php requires no autoloader and no package. It
# reads Markdown, rewrites links with preg_replace_callback and
# writes files — which is why it is the one piece of doc tooling
# that stayed dependency-free, and why this workflow finishes in
# seconds.
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
+1
View File
@@ -461,6 +461,7 @@ foreach ($languages as $language) {
languages: $languages,
locale: $code,
fallback: $isFallback,
prefix: $prefix,
),
);
+7 -2
View File
@@ -32,6 +32,7 @@ return static function (
array $languages = [],
string $locale = 'en',
bool $fallback = false,
string $prefix = 'docs/',
): string {
$escape = static fn (string $value): string => htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
@@ -44,9 +45,13 @@ return static function (
$sidebar .= sprintf('<p class="sidebar-heading">%s</p><ul>', $escape($section['title']));
foreach ($section['pages'] as $page) {
// $prefix, not a literal 'docs/'. Built against the English
// path, every link in the German sidebar pointed at the English
// page — so choosing German lasted exactly one click, and the
// language picker looked broken when the navigation was.
$sidebar .= sprintf(
'<li><a href="%s%s"%s>%s</a></li>',
$escape($root . 'docs/'),
$escape($root . $prefix),
$escape($page['href']),
$page['href'] === $here ? ' aria-current="page"' : '',
$escape($page['title']),
@@ -183,7 +188,7 @@ return static function (
<div class="topbar">
<a class="brand" href="{$root}index.html">plMail</a>
<div class="topbar-links">
<a href="{$root}docs/">{$escape($t['handbook'])}</a>
<a href="{$root}{$prefix}">{$escape($t['handbook'])}</a>
<a href="https://github.com/karatektus/pl_mail">GitHub</a>
</div>
<div class="search" role="search">
+40 -14
View File
@@ -54,29 +54,55 @@ if (null !== languagePicker) {
languagePicker.value = current;
languagePicker.addEventListener("change", () => {
const wanted = languagePicker.value;
/** Where a given page lives in another language. */
const inLanguage = (wanted) => {
// The landing page is README.md and has no translation, so there is no
// German counterpart of it to go to — and its URL carries no /docs/
// segment to rewrite, which meant switching language on the front page
// silently did nothing at all. The handbook's index in that language is
// the nearest honest destination, and it is where somebody changing the
// language on a landing page was going anyway.
// counterpart of it to go to — and its URL carries no /docs/ segment to
// rewrite, which is why switching language there once did nothing at
// all. The handbook's index is the nearest honest destination.
if (false === path.includes("/docs/")) {
window.location.href = "en" === wanted ? `${root}docs/` : `${root}docs/${wanted}/`;
return;
return "en" === wanted ? `${root}docs/` : `${root}docs/${wanted}/`;
}
// English is the tree's root; every other language is a directory
// inside it, which is why this is two rules rather than one.
const stripped = "en" === current ? path : path.replace(`/docs/${current}/`, "/docs/");
window.location.href = "en" === wanted
? stripped
: stripped.replace("/docs/", `/docs/${wanted}/`);
return "en" === wanted ? stripped : stripped.replace("/docs/", `/docs/${wanted}/`);
};
languagePicker.addEventListener("change", () => {
// Remembered, because the language has to survive more than the click.
// The sidebar keeps a reader inside their language on its own — those
// links are built per page — but arriving from the landing page, from a
// bookmark or from somebody else's link lands on English every time,
// and being bounced back to English on every fresh visit is the whole
// complaint the picker was meant to answer.
localStorage.setItem("plmail-language", languagePicker.value);
window.location.href = inLanguage(languagePicker.value);
});
// Landed on a page that is not in the remembered language: go to the one
// that is. This cannot loop — the redirect lands where `current` equals the
// remembered language, so the condition is false on arrival — and it cannot
// strand anybody in German, because choosing English is what sets the
// memory to English.
//
// `replace` rather than `href`, so Back leaves the site instead of bouncing
// between the two languages.
const remembered = localStorage.getItem("plmail-language");
// Not on the landing page. It has no translation and is the front door, so
// following the memory there threw somebody who once chose German straight
// into the handbook whenever they opened the site — which is a redirect
// away from the page they asked for, not a language preference.
if (null !== remembered
&& remembered !== current
&& true === known.includes(remembered)
&& true === path.includes("/docs/")
) {
window.location.replace(inLanguage(remembered));
}
}
// ── Wide tables ──────────────────────────────────────────────────────────────
+1
View File
@@ -13,6 +13,7 @@
"test:e2e:docker:headed": "npm run test:env:up && E2E_DOCKER=1 playwright test --headed",
"test:e2e:docker:trace": "npm run test:env:up && E2E_DOCKER=1 playwright test --trace on --video on",
"test:e2e": "playwright test",
"test:e2e:site": "php bin/build-site.php && E2E_SITE=1 playwright test site.spec.ts",
"test:e2e:screenshots": "E2E_SCREENSHOTS=1 playwright test screenshots.spec.ts",
"test:e2e:coverage": "rm -rf var/js-coverage && E2E_DOCKER=1 E2E_JS_COVERAGE=1 playwright test && node bin/js-coverage.mjs",
"test:e2e:ui": "playwright test --ui",
+184
View File
@@ -0,0 +1,184 @@
import { test, expect } from "@playwright/test";
import { execSync } from "node:child_process";
import { createServer } from "node:http";
import { readFileSync, statSync } from "node:fs";
import { extname, join, normalize } from "node:path";
/**
* The published site: the language picker, the theme picker, and the navigation
* that has to agree with them.
*
* Not part of the app suite — it imports `test` from @playwright/test rather
* than from ./support/test, because it needs no database, no fixture user and
* no signed-in session. It serves `build/site` and clicks it.
*
* It exists because two site bugs in a row were found by a person clicking
* rather than by anything here: switching language on the landing page silently
* did nothing, and every sidebar link on a German page pointed back at the
* English one — so choosing German lasted exactly one click. Both were invisible
* to the render check, which asks whether a link RESOLVES and never whether it
* resolves somewhere sensible.
*
* Serves over HTTP rather than opening file:// URLs: localStorage is per-origin
* and file:// gives every page an opaque one, so the remembered language — the
* thing most worth testing — cannot work there at all.
*/
// Opt-in, like screenshots.spec.ts, and for the same kind of reason rather
// than the same one. It is not flaky and it needs no fixtures — it is simply
// not worth a PHP build and eight browser pages on every push, when what it
// guards is a static site whose failure is visible the moment anybody opens it.
// Run it when the site tooling changes:
//
// npm run test:e2e:site
test.skip(
undefined === process.env.E2E_SITE,
'Site build required — run "npm run test:e2e:site".',
);
const SITE = join(process.cwd(), "build", "site");
const PORT = 8149;
const BASE = `http://127.0.0.1:${PORT}`;
const TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".css": "text/css",
".js": "text/javascript",
".json": "application/json",
".png": "image/png",
};
let server: ReturnType<typeof createServer>;
test.beforeAll(async () => {
// Built here rather than assumed: this spec is about the OUTPUT, and a
// stale build would test the previous commit's site and pass.
execSync("php bin/build-site.php", { stdio: "inherit" });
server = createServer((request, response) => {
const asked = decodeURIComponent((request.url ?? "/").split("?")[0]);
// normalize collapses any "..", so a crafted path cannot escape the
// directory being served.
let path = join(SITE, normalize(asked));
try {
if (statSync(path).isDirectory()) {
path = join(path, "index.html");
}
response.writeHead(200, { "content-type": TYPES[extname(path)] ?? "text/plain" });
response.end(readFileSync(path));
} catch {
response.writeHead(404).end("not found");
}
});
await new Promise<void>((ready) => server.listen(PORT, "127.0.0.1", ready));
});
test.afterAll(async () => {
await new Promise<void>((closed) => server.close(() => closed()));
});
test("the handbook is reachable in both languages", async ({ page }) => {
await page.goto(`${BASE}/docs/`);
await expect(page.locator("html")).toHaveAttribute("lang", "en");
await page.goto(`${BASE}/docs/de/`);
await expect(page.locator("html")).toHaveAttribute("lang", "de");
});
/**
* The bug that made the picker look broken when the navigation was: the sidebar
* was built against the English path on every page, so a German page linked
* exclusively to English ones.
*/
test("a German page's navigation stays in German", async ({ page }) => {
await page.goto(`${BASE}/docs/de/features/mail.html`);
const links = await page.locator(".sidebar a").evaluateAll(
(anchors) => anchors.map((anchor) => anchor.getAttribute("href") ?? ""),
);
expect(links.length).toBeGreaterThan(20);
expect(links.filter((href) => false === href.includes("/docs/de/"))).toEqual([]);
});
test("switching language keeps you on the same page", async ({ page }) => {
await page.goto(`${BASE}/docs/features/calendar.html`);
await page.selectOption("#language", "de");
await expect(page).toHaveURL(`${BASE}/docs/de/features/calendar.html`);
await page.selectOption("#language", "en");
await expect(page).toHaveURL(`${BASE}/docs/features/calendar.html`);
});
/**
* The landing page is README.md and has no translation, so there is no
* counterpart to send anybody to — and its URL carries no `/docs/` segment to
* rewrite, which is why switching there once did nothing whatsoever.
*/
test("switching language on the landing page opens that language's handbook", async ({ page }) => {
await page.goto(`${BASE}/index.html`);
await page.selectOption("#language", "de");
await expect(page).toHaveURL(`${BASE}/docs/de/`);
});
test("the chosen language survives arriving somewhere else", async ({ page }) => {
await page.goto(`${BASE}/docs/features/mail.html`);
await page.selectOption("#language", "de");
await expect(page).toHaveURL(/\/docs\/de\//);
// A fresh English URL — a bookmark, or somebody else's link.
await page.goto(`${BASE}/docs/install/docker.html`);
await expect(page).toHaveURL(`${BASE}/docs/de/install/docker.html`);
});
/** And choosing English back is not overridden by the memory that got you here. */
test("choosing English again sticks", async ({ page }) => {
await page.goto(`${BASE}/docs/features/mail.html`);
await page.selectOption("#language", "de");
await expect(page).toHaveURL(/\/docs\/de\//);
await page.selectOption("#language", "en");
await expect(page).toHaveURL(`${BASE}/docs/features/mail.html`);
await page.goto(`${BASE}/docs/install/docker.html`);
await expect(page).toHaveURL(`${BASE}/docs/install/docker.html`);
});
/**
* The front page must stay the front page. Following the remembered language
* there turned every visit to the site into a redirect into the handbook.
*/
test("the landing page is not hijacked by a remembered language", async ({ page }) => {
await page.goto(`${BASE}/docs/features/mail.html`);
await page.selectOption("#language", "de");
await expect(page).toHaveURL(/\/docs\/de\//);
await page.goto(`${BASE}/index.html`);
await expect(page).toHaveURL(`${BASE}/index.html`);
});
test("the theme picker offers plMail's own themes and remembers one", async ({ page }) => {
await page.goto(`${BASE}/docs/`);
const themes = await page.locator("#theme option").evaluateAll(
(options) => options.map((option) => (option as HTMLOptionElement).value),
);
// Generated from Theme::swatch(), so this is the app's list or the build is
// wrong. Solar is the default and must be selected before anything is picked.
expect(themes).toEqual(["system", "light", "paper", "dark", "nord", "dusk", "solar"]);
await expect(page.locator("#theme")).toHaveValue("solar");
await page.selectOption("#theme", "nord");
await expect(page.locator("html")).toHaveAttribute("data-theme", "nord");
await page.goto(`${BASE}/docs/features/mail.html`);
await expect(page.locator("html")).toHaveAttribute("data-theme", "nord");
});