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",
|
"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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user