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:
+1
-1
@@ -18,7 +18,7 @@
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"i18n": {
|
||||
"sourceLocale": "nl",
|
||||
"sourceLocale": { "code": "nl", "subPath": "" },
|
||||
"locales": {
|
||||
"en": {
|
||||
"translation": "src/locale/messages.en.xlf"
|
||||
|
||||
+6
-5
@@ -1,8 +1,9 @@
|
||||
# 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
|
||||
# language switcher works), Swagger at http://localhost:5000/swagger. The web container does a
|
||||
# one-time `ng build --localize` then serves both locale bundles statically (with /api proxied);
|
||||
# for a fast HMR loop use `npm start` locally instead (nl-only at /).
|
||||
# `docker compose up` → app at http://localhost:4200 (LOCALIZED: nl at /, en at /en/, the header
|
||||
# language switcher works, and the dev `⚙ state` panel stays visible), Swagger at :5000/swagger.
|
||||
# The web container does a one-time `ng build --configuration development --localize` (development
|
||||
# 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:
|
||||
api:
|
||||
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
|
||||
# (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.
|
||||
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:
|
||||
- PORT=4200
|
||||
- API_PROXY_TARGET=http://api:5000
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
"gen": "plop",
|
||||
"gen:value-object": "plop value-object",
|
||||
"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",
|
||||
"e2e": "playwright test",
|
||||
"extract-i18n": "ng extract-i18n --output-path src/locale"
|
||||
|
||||
@@ -52,25 +52,23 @@ createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
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 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 {
|
||||
// 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));
|
||||
send(res, 200, file, MIME[extname(rel)] ?? 'application/octet-stream');
|
||||
} catch {
|
||||
// SPA fallback to the requested locale's index.html.
|
||||
try {
|
||||
const index = await readFile(join(ROOT, locale, 'index.html'));
|
||||
send(res, 200, index, 'text/html');
|
||||
send(res, 200, await readFile(indexPath), 'text/html');
|
||||
} catch {
|
||||
send(res, 404, 'Not found', 'text/plain');
|
||||
}
|
||||
}
|
||||
}).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 { localeLinks } from './locale-links';
|
||||
|
||||
describe('localeLinks', () => {
|
||||
it('preserves the route when already under a locale prefix, marks the active one', () => {
|
||||
const links = localeLinks('/nl/dashboard', 'nl');
|
||||
describe('localeLinks (nl at root, en under /en/)', () => {
|
||||
it('an nl route (no prefix) links nl to the bare path, en under /en, marks active', () => {
|
||||
const links = localeLinks('/dashboard', 'nl');
|
||||
expect(links.map((l) => [l.locale, l.href, l.active])).toEqual([
|
||||
['nl', '/nl/dashboard', true],
|
||||
['nl', '/dashboard', true],
|
||||
['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');
|
||||
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);
|
||||
});
|
||||
|
||||
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');
|
||||
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');
|
||||
});
|
||||
|
||||
it('a bare locale root maps to the sibling root', () => {
|
||||
expect(localeLinks('/nl', 'nl').find((l) => l.locale === 'en')!.href).toBe('/en/');
|
||||
it('the root maps nl → / and en → /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
|
||||
* own bundle under `/<locale>/`, so switching is a full navigation to the sibling bundle at the
|
||||
* same route. Strips any leading `/nl` or `/en` from the current path and re-prefixes the target
|
||||
* locale, keeping query + hash. Pure — no DOM (the component passes `location.*` in).
|
||||
* Build the two language links for the switcher. Compile-time i18n serves the source locale
|
||||
* (nl) at the ROOT (`subPath: ''`) and en under `/en/`, so switching is a full navigation to the
|
||||
* sibling bundle at the same route. Strips a leading `/en` from the current path, then targets nl
|
||||
* at the bare path and en under `/en`. Keeps query + hash. Pure — no DOM (the component passes
|
||||
* `location.*` in).
|
||||
*/
|
||||
export function localeLinks(
|
||||
pathname: string,
|
||||
@@ -27,11 +28,12 @@ export function localeLinks(
|
||||
search = '',
|
||||
hash = '',
|
||||
): 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 }) => ({
|
||||
locale,
|
||||
label,
|
||||
href: `/${locale}${rest}${search}${hash}`,
|
||||
href: href(locale),
|
||||
active: locale === active,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user