feat(i18n): nl at root URLs + keep devtools in the docker demo

Serve the source locale (nl) at / instead of /nl/: angular.json sourceLocale is now
{ code: 'nl', subPath: '' } → nl output at browser/ root (base href /), en stays /en/.
Rework localeLinks (nl → bare path, en → /en/…) + spec, and serve-i18n.mjs (nl assets at
root, en under /en/, / serves the nl index). And build the docker demo (+ serve:i18n) with
`--configuration development --localize` so isDevMode() stays true and the dev `⚙ state`
panel + role/scenario switchers render in the localized compose demo (they were correctly
gated off in the previous production build). Verified: nl base href /, en /en/, ngDevMode present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-23 19:24:33 +02:00
co-authored by Claude Opus 4.8
parent deb5d77e04
commit c00e607b8f
6 changed files with 35 additions and 31 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
"sourceRoot": "src", "sourceRoot": "src",
"prefix": "app", "prefix": "app",
"i18n": { "i18n": {
"sourceLocale": "nl", "sourceLocale": { "code": "nl", "subPath": "" },
"locales": { "locales": {
"en": { "en": {
"translation": "src/locale/messages.en.xlf" "translation": "src/locale/messages.en.xlf"
+6 -5
View File
@@ -1,8 +1,9 @@
# ponytail: dev-server images (not multi-stage prod builds) — this is a demo. # ponytail: dev-server images (not multi-stage prod builds) — this is a demo.
# `docker compose up` → app at http://localhost:4200 (LOCALIZED: /nl/ + /en/, the header # `docker compose up` → app at http://localhost:4200 (LOCALIZED: nl at /, en at /en/, the header
# language switcher works), Swagger at http://localhost:5000/swagger. The web container does a # language switcher works, and the dev `⚙ state` panel stays visible), Swagger at :5000/swagger.
# one-time `ng build --localize` then serves both locale bundles statically (with /api proxied); # The web container does a one-time `ng build --configuration development --localize` (development
# for a fast HMR loop use `npm start` locally instead (nl-only at /). # config keeps isDevMode()=true so the dev tools render) then serves both locale bundles
# statically (with /api proxied); for a fast HMR loop use `npm start` locally instead (nl at /).
services: services:
api: api:
image: mcr.microsoft.com/dotnet/sdk:10.0 image: mcr.microsoft.com/dotnet/sdk:10.0
@@ -37,7 +38,7 @@ services:
# once (`ng build --localize`) then serves them statically via scripts/serve-i18n.mjs # once (`ng build --localize`) then serves them statically via scripts/serve-i18n.mjs
# (per-locale SPA fallback + /api reverse-proxy → the api container), so the language # (per-locale SPA fallback + /api reverse-proxy → the api container), so the language
# switcher actually switches. ponytail: `--no-fund --loglevel=error` silences npm 11 noise. # switcher actually switches. ponytail: `--no-fund --loglevel=error` silences npm 11 noise.
command: sh -c "npm ci --no-fund --loglevel=error && npx ng build --localize && node scripts/serve-i18n.mjs" command: sh -c "npm ci --no-fund --loglevel=error && npx ng build --configuration development --localize && node scripts/serve-i18n.mjs"
environment: environment:
- PORT=4200 - PORT=4200
- API_PROXY_TARGET=http://api:5000 - API_PROXY_TARGET=http://api:5000
+1 -1
View File
@@ -23,7 +23,7 @@
"gen": "plop", "gen": "plop",
"gen:value-object": "plop value-object", "gen:value-object": "plop value-object",
"gen:form-machine": "plop form-machine", "gen:form-machine": "plop form-machine",
"serve:i18n": "ng build --localize && node scripts/serve-i18n.mjs", "serve:i18n": "ng build --configuration development --localize && node scripts/serve-i18n.mjs",
"ci": "bash scripts/ci-local.sh", "ci": "bash scripts/ci-local.sh",
"e2e": "playwright test", "e2e": "playwright test",
"extract-i18n": "ng extract-i18n --output-path src/locale" "extract-i18n": "ng extract-i18n --output-path src/locale"
+7 -9
View File
@@ -52,25 +52,23 @@ createServer(async (req, res) => {
return; return;
} }
const url = decodeURIComponent(rawUrl.split('?')[0]); const url = decodeURIComponent(rawUrl.split('?')[0]);
// Landing at / has no locale bundle — redirect to Dutch.
if (url === '/') {
res.writeHead(302, { location: '/nl/' });
return res.end();
}
const rel = normalize(url).replace(/^(\.\.[/\\])+/, ''); // no path traversal const rel = normalize(url).replace(/^(\.\.[/\\])+/, ''); // no path traversal
const locale = url.startsWith('/en/') ? 'en' : 'nl'; // nl (source) is served at the ROOT (subPath ''); en lives under /en/.
const isEn = url === '/en' || url.startsWith('/en/');
const indexPath = isEn ? join(ROOT, 'en', 'index.html') : join(ROOT, 'index.html');
try { try {
// A real asset (nl at root, en under /en/) — otherwise fall through to the SPA index.
if (url === '/' || url === '/en' || url === '/en/') throw new Error('serve index');
const file = await readFile(join(ROOT, rel)); const file = await readFile(join(ROOT, rel));
send(res, 200, file, MIME[extname(rel)] ?? 'application/octet-stream'); send(res, 200, file, MIME[extname(rel)] ?? 'application/octet-stream');
} catch { } catch {
// SPA fallback to the requested locale's index.html. // SPA fallback to the requested locale's index.html.
try { try {
const index = await readFile(join(ROOT, locale, 'index.html')); send(res, 200, await readFile(indexPath), 'text/html');
send(res, 200, index, 'text/html');
} catch { } catch {
send(res, 404, 'Not found', 'text/plain'); send(res, 404, 'Not found', 'text/plain');
} }
} }
}).listen(PORT, () => { }).listen(PORT, () => {
console.log(`Serving ${ROOT} at http://localhost:${PORT}/ (→ /nl/, /en/)`); console.log(`Serving ${ROOT} at http://localhost:${PORT}/ (nl at /, en at /en/)`);
}); });
@@ -1,27 +1,30 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { localeLinks } from './locale-links'; import { localeLinks } from './locale-links';
describe('localeLinks', () => { describe('localeLinks (nl at root, en under /en/)', () => {
it('preserves the route when already under a locale prefix, marks the active one', () => { it('an nl route (no prefix) links nl to the bare path, en under /en, marks active', () => {
const links = localeLinks('/nl/dashboard', 'nl'); const links = localeLinks('/dashboard', 'nl');
expect(links.map((l) => [l.locale, l.href, l.active])).toEqual([ expect(links.map((l) => [l.locale, l.href, l.active])).toEqual([
['nl', '/nl/dashboard', true], ['nl', '/dashboard', true],
['en', '/en/dashboard', false], ['en', '/en/dashboard', false],
]); ]);
}); });
it('swaps the prefix and keeps a deep path (en active)', () => { it('an en route strips the /en prefix for the nl target (deep path, en active)', () => {
const links = localeLinks('/en/beheer/audit', 'en'); const links = localeLinks('/en/beheer/audit', 'en');
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/nl/beheer/audit'); expect(links.find((l) => l.locale === 'nl')!.href).toBe('/beheer/audit');
expect(links.find((l) => l.locale === 'en')!.active).toBe(true); expect(links.find((l) => l.locale === 'en')!.active).toBe(true);
}); });
it('handles an unprefixed dev path (served at /), keeps query + hash', () => { it('keeps query + hash on both targets', () => {
const links = localeLinks('/registreren', 'nl', '?scenario=slow', '#top'); const links = localeLinks('/registreren', 'nl', '?scenario=slow', '#top');
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/registreren?scenario=slow#top');
expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/registreren?scenario=slow#top'); expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/registreren?scenario=slow#top');
}); });
it('a bare locale root maps to the sibling root', () => { it('the root maps nl → / and en → /en/', () => {
expect(localeLinks('/nl', 'nl').find((l) => l.locale === 'en')!.href).toBe('/en/'); const links = localeLinks('/', 'nl');
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/');
expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/');
}); });
}); });
@@ -16,10 +16,11 @@ const LOCALES: readonly { locale: Locale; label: string }[] = [
]; ];
/** /**
* Build the two language links for the switcher. Compile-time i18n serves each locale as its * Build the two language links for the switcher. Compile-time i18n serves the source locale
* own bundle under `/<locale>/`, so switching is a full navigation to the sibling bundle at the * (nl) at the ROOT (`subPath: ''`) and en under `/en/`, so switching is a full navigation to the
* same route. Strips any leading `/nl` or `/en` from the current path and re-prefixes the target * sibling bundle at the same route. Strips a leading `/en` from the current path, then targets nl
* locale, keeping query + hash. Pure — no DOM (the component passes `location.*` in). * at the bare path and en under `/en`. Keeps query + hash. Pure — no DOM (the component passes
* `location.*` in).
*/ */
export function localeLinks( export function localeLinks(
pathname: string, pathname: string,
@@ -27,11 +28,12 @@ export function localeLinks(
search = '', search = '',
hash = '', hash = '',
): LocaleLink[] { ): LocaleLink[] {
const rest = pathname.replace(/^\/(nl|en)(?=\/|$)/, '') || '/'; const rest = pathname.replace(/^\/en(?=\/|$)/, '') || '/';
const href = (locale: Locale) => `${locale === 'en' ? `/en${rest}` : rest}${search}${hash}`;
return LOCALES.map(({ locale, label }) => ({ return LOCALES.map(({ locale, label }) => ({
locale, locale,
label, label,
href: `/${locale}${rest}${search}${hash}`, href: href(locale),
active: locale === active, active: locale === active,
})); }));
} }