diff --git a/angular.json b/angular.json index c2f9e9e..954bdf6 100644 --- a/angular.json +++ b/angular.json @@ -18,7 +18,7 @@ "sourceRoot": "src", "prefix": "app", "i18n": { - "sourceLocale": "nl", + "sourceLocale": { "code": "nl", "subPath": "" }, "locales": { "en": { "translation": "src/locale/messages.en.xlf" diff --git a/docker-compose.yml b/docker-compose.yml index 9ecb25d..8fa0e4e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/package.json b/package.json index 9be12b7..f195cae 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/serve-i18n.mjs b/scripts/serve-i18n.mjs index f851ca6..8efda0d 100644 --- a/scripts/serve-i18n.mjs +++ b/scripts/serve-i18n.mjs @@ -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/)`); }); diff --git a/src/app/shared/layout/language-switcher/locale-links.spec.ts b/src/app/shared/layout/language-switcher/locale-links.spec.ts index 903f838..f9480d2 100644 --- a/src/app/shared/layout/language-switcher/locale-links.spec.ts +++ b/src/app/shared/layout/language-switcher/locale-links.spec.ts @@ -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/'); }); }); diff --git a/src/app/shared/layout/language-switcher/locale-links.ts b/src/app/shared/layout/language-switcher/locale-links.ts index 1eb68bd..997089f 100644 --- a/src/app/shared/layout/language-switcher/locale-links.ts +++ b/src/app/shared/layout/language-switcher/locale-links.ts @@ -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 `//`, 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, })); }