#179: negotiate the SPA shell's lang attribute in nginx #315

Merged
opus-5 merged 1 commits from issue-179-spa-lang into main 2026-08-01 20:24:27 +02:00
Contributor

Implements #179.

apps/web/index.html carries a hard lang="en". The app corrects it at runtime (#163), but nginx answers every SPA route with that same file — /public/... on prod is exactly that — so a crawler or a no-JS visit saw en for German content, permanently. WCAG 3.1.1 is about the delivered document, not the one JavaScript later fixes.

nginx-only, as the issue sketched: a map on Accept-Language plus a sub_filter in the index.html path. Only the FIRST tag decides — that is what "the browser's preferred language" means and it mirrors #163's semantics: de-CH counts as German, en-US,de does not.

Verified against a real nginx 1.27

The stage's own image (nginxinc/nginx-unprivileged:1.27-alpine) with this config mounted as-is:

Accept-Language delivered
de-DE,de;q=0.9,en;q=0.8 lang="de"
de lang="de"
en-US,en;q=0.9 lang="en"
en-US,en;q=0.9,de;q=0.8 lang="en"
fr-FR,fr lang="en"
(no header) lang="en"

An SPA route (/public/teich/seite) negotiates the same way; the gzipped response is rewritten too; a JavaScript asset comes through byte-identical.

Header regression

CSP, Cache-Control: no-cache and nosniff are unchanged — checked on the live response, not just in the diff.

One header is new: Vary: Accept-Language. The response now genuinely depends on a request header, and without it a shared cache could hand one language's copy to the other. The location is no-cache anyway, so this costs nothing and states the dependency correctly. Flagging it explicitly because the issue asked for the other headers to stay unchanged.

Known limit, documented in the config

nginx cannot read instance.defaultLocale, so an absent or unlisted Accept-Language yields en even on a German instance. For public content that is not the authoritative rendering — the api's server shell (/api/v1/public/...) already renders those with the instance locale.

Spot-check on test after deploy still to do (curl -H 'Accept-Language: de' against both a / and a /public/... URL).

Closes #179

Implements #179. `apps/web/index.html` carries a hard `lang="en"`. The app corrects it at runtime (#163), but nginx answers every SPA route with that same file — `/public/...` on prod is exactly that — so a crawler or a no-JS visit saw `en` for German content, permanently. WCAG 3.1.1 is about the delivered document, not the one JavaScript later fixes. nginx-only, as the issue sketched: a `map` on `Accept-Language` plus a `sub_filter` in the index.html path. Only the FIRST tag decides — that is what "the browser's preferred language" means and it mirrors #163's semantics: `de-CH` counts as German, `en-US,de` does not. ## Verified against a real nginx 1.27 The stage's own image (`nginxinc/nginx-unprivileged:1.27-alpine`) with this config mounted as-is: | Accept-Language | delivered | |---|---| | `de-DE,de;q=0.9,en;q=0.8` | `lang="de"` | | `de` | `lang="de"` | | `en-US,en;q=0.9` | `lang="en"` | | `en-US,en;q=0.9,de;q=0.8` | `lang="en"` | | `fr-FR,fr` | `lang="en"` | | (no header) | `lang="en"` | An SPA route (`/public/teich/seite`) negotiates the same way; the gzipped response is rewritten too; a JavaScript asset comes through byte-identical. ## Header regression CSP, `Cache-Control: no-cache` and `nosniff` are unchanged — checked on the live response, not just in the diff. **One header is new: `Vary: Accept-Language`.** The response now genuinely depends on a request header, and without it a shared cache could hand one language's copy to the other. The location is `no-cache` anyway, so this costs nothing and states the dependency correctly. Flagging it explicitly because the issue asked for the other headers to stay unchanged. ## Known limit, documented in the config nginx cannot read `instance.defaultLocale`, so an absent or unlisted Accept-Language yields `en` even on a German instance. For public content that is not the authoritative rendering — the api's server shell (`/api/v1/public/...`) already renders those with the instance locale. Spot-check on test after deploy still to do (`curl -H 'Accept-Language: de'` against both a `/` and a `/public/...` URL). Closes #179
opus-5 added 1 commit 2026-08-01 19:18:42 +02:00
#179: negotiate the SPA shell's lang attribute in nginx
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m44s
CI / Build container images (pull_request) Successful in 1m22s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CI / Auth e2e pack (pull_request) Successful in 9m32s
80dbe325f5
`apps/web/index.html` carries a hard `lang="en"`. The app corrects it at
runtime (#163), but nginx answers every SPA route with that same file, so a
crawler or a no-JS visit — `/public/...` on prod is exactly that — saw `en`
for German content, permanently. WCAG 3.1.1 is about the delivered document,
not the one JavaScript later fixes.

nginx-only, no backend involved: a `map` on `Accept-Language` and a
`sub_filter` in the index.html path. Only the FIRST tag decides, which is
what "the browser's preferred language" means and mirrors #163 — `de-CH`
counts as German, `en-US,de` does not.

`Vary: Accept-Language` is new. The response now genuinely depends on a
request header, and without it a shared cache could hand one language's copy
to the other. Everything else in the location is untouched: same CSP, same
`Cache-Control: no-cache`, same `nosniff`.

The known limit is documented in the config rather than worked around: nginx
cannot read `instance.defaultLocale`, so an unlisted or absent
Accept-Language yields `en` even on a German instance. For public content
that is not the authoritative rendering anyway — the api's server shell
(`/api/v1/public/...`) already renders those with the instance locale.

Verified against a real nginx 1.27 (the image the stage runs) with the
config mounted as-is: `de-DE,de;q=0.9,en;q=0.8` and `de` yield `lang="de"`;
`en-US,en;q=0.9`, `en-US,en;q=0.9,de;q=0.8`, `fr-FR,fr` and a request with no
header yield `lang="en"`; an SPA route (`/public/teich/seite`) negotiates the
same way; the gzipped response is rewritten too, and a JavaScript asset comes
through byte-identical.
fable-5 force-pushed issue-179-spa-lang from 80dbe325f5 to 9fbaeb559b 2026-08-01 19:31:03 +02:00 Compare
fable-5 force-pushed issue-179-spa-lang from 9fbaeb559b to 8752cf0c5a 2026-08-01 20:01:56 +02:00 Compare
opus-5 merged commit 8752cf0c5a into main 2026-08-01 20:24:27 +02:00
Sign in to join this conversation.
No description provided.