From bd76c981ee89312f34210e934bea5bdd16522eef Mon Sep 17 00:00:00 2001 From: lilleman Date: Tue, 4 Aug 2026 09:59:00 +0200 Subject: [PATCH] Carry the language through sign-in; warn when switching leaves the page; product-review copy fixes --- README.md | 6 +++++- e2e-tests/language.spec.ts | 2 +- examples/plugins/admin/i18n/sv-SE.ts | 10 +++++----- public/css/styles.css | 1 + src/auth/routes.ts | 11 +++++++++-- src/http/app.test.ts | 4 ++-- src/i18n/load.ts | 8 ++++++-- src/i18n/locale.ts | 11 +++++++++-- src/i18n/locales/en-US.ts | 3 ++- src/i18n/locales/sv-SE.ts | 13 +++++++------ src/i18n/view-locals.ts | 12 ++++++++---- src/plugin-host/plugin-api.ts | 3 +++ views/403.ejs | 1 + views/404.ejs | 1 + views/500.ejs | 1 + views/503.ejs | 1 + views/error.ejs | 1 + views/partials/locale-switch.ejs | 8 +++++++- 18 files changed, 70 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 2166ae6..d10193a 100644 --- a/README.md +++ b/README.md @@ -948,7 +948,11 @@ language there therefore leaves the POST's own result behind — a re-rendered f one-time code — which is the accepted cost of having the picker everywhere. **Writing a catalog.** `en-US.ts` exports the object and its type; every other locale is written -against that type, so a missing or misspelled key is a type error before the app ever boots: +against that type, so a missing or misspelled key is a type error before the app ever boots. For a +language of your own: copy `src/i18n/locales/en-US.ts` into `locales/.ts`, type it +`CoreMessages` (from `#plugin-api`), and translate. The `as PluralMessage` cast below is required — +without it the inferred type pins the plural forms to English's two, and a locale that selects more +(Polish, Arabic) becomes unwritable: ```ts // plugins/shop/i18n/en-US.ts diff --git a/e2e-tests/language.spec.ts b/e2e-tests/language.spec.ts index 181e659..4efb682 100644 --- a/e2e-tests/language.spec.ts +++ b/e2e-tests/language.spec.ts @@ -37,7 +37,7 @@ test("the switcher changes language, and the choice survives clicking through th await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); await expect(page).toHaveURL(/locale=sv-SE/); await expect(page.getByRole("heading", { name: "Startpanel" })).toBeVisible(); // the starter dashboard, in Swedish - await expect(page.getByRole("link", { name: "Panel" })).toBeVisible(); // the menu too + await expect(page.getByRole("link", { name: "Översikt", exact: true })).toBeVisible(); // the menu too await mkdir(SHOTS, { recursive: true }); await page.screenshot({ fullPage: true, path: `${SHOTS}/live-05-swedish.png` }); diff --git a/examples/plugins/admin/i18n/sv-SE.ts b/examples/plugins/admin/i18n/sv-SE.ts index 350cf75..639b160 100644 --- a/examples/plugins/admin/i18n/sv-SE.ts +++ b/examples/plugins/admin/i18n/sv-SE.ts @@ -14,7 +14,7 @@ const messages: AdminMessages = { "admin.clients.consent.screen": "Visar godkännandesidan", "admin.clients.created": "Klienten är registrerad", "admin.clients.createdNotice": "Klienten är registrerad.", - "admin.clients.delete": "Ta bort klient", + "admin.clients.delete": "Radera klient", "admin.clients.deleteMessage": "Ta bort klienten {{name}}? Appar som använder den kan inte längre logga in via Plainpages.", "admin.clients.error.rejected": "Hydra nekade klienten — kontrollera omdirigerings-URI:erna och scopen.", "admin.clients.field.name": "Namn", @@ -36,7 +36,7 @@ const messages: AdminMessages = { "admin.clients.searchLabel": "Sök klienter", "admin.clients.searchPlaceholder": "Sök på namn eller klient-ID…", "admin.clients.secret": "Klienthemlighet", - "admin.clients.secretHint": "Kopiera nu — hemligheten kan inte visas igen. Spara dem där appen läser sina uppgifter.", + "admin.clients.secretHint": "Kopiera nu — hemligheten kan inte visas igen. Spara uppgifterna där appen läser dem.", "admin.clients.title": "OAuth2-klienter", "admin.clients.validation.name": "Ange ett namn för klienten.", "admin.clients.validation.redirectUri": "\"{{uri}}\" är inte en giltig omdirigerings-URI — använd en absolut URL som https://app.example.com/callback.", @@ -54,7 +54,7 @@ const messages: AdminMessages = { "admin.groups.column.members": "Medlemmar", "admin.groups.column.name": "Grupp", "admin.groups.create": "Skapa grupp", - "admin.groups.delete": "Ta bort grupp", + "admin.groups.delete": "Radera grupp", "admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det tar bort gruppen och alla dess medlemskap.", "admin.groups.field.name": "Gruppnamn", "admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.", @@ -90,7 +90,7 @@ const messages: AdminMessages = { "admin.permissions.column.members": "Medlemmar", "admin.permissions.column.name": "Behörighet", "admin.permissions.create": "Skapa behörighet", - "admin.permissions.delete": "Ta bort behörighet", + "admin.permissions.delete": "Radera behörighet", "admin.permissions.deleteMessage": "Ta bort behörigheten {{name}}? Den återkallas från alla den är tilldelad till.", "admin.permissions.error.adminUndeletable": "Behörigheten admin kan inte tas bort — det skulle ta bort all administratörsåtkomst.", "admin.permissions.error.selfRevoke": "Du kan inte återkalla din egen administratörsåtkomst.", @@ -120,7 +120,7 @@ const messages: AdminMessages = { "admin.users.confirm": "Bekräfta åtgärden", "admin.users.create": "Skapa användare", "admin.users.deactivate": "Inaktivera", - "admin.users.delete": "Ta bort användare", + "admin.users.delete": "Radera användare", "admin.users.deleteMessage": "Ta bort {{email}}? Kontot tas bort permanent och det går inte att ångra.", "admin.users.edit": "Redigera användare", "admin.users.error.create": "Användaren kunde inte skapas — kontrollera e-postadressen och försök igen.", diff --git a/public/css/styles.css b/public/css/styles.css index a937229..bbea499 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -500,6 +500,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */ } .menu-item-form { display: contents; } /* form wraps the Sign-out button without changing layout */ .menu-item:hover { background: var(--surface-2); } +.menu-item[aria-current] { font-weight: 600; color: var(--text); } /* the language you are in */ .menu-item.danger { color: var(--neg); } .menu-item .ico { color: var(--text-faint); } .menu-item.danger .ico { color: var(--neg); } diff --git a/src/auth/routes.ts b/src/auth/routes.ts index d09f0eb..5f98592 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -7,6 +7,7 @@ import { readFormBody } from "../http/body.ts"; import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts"; import type { RequestContext } from "../http/context.ts"; import { CSRF_FIELD } from "./csrf.ts"; +import { chosenLocale } from "../i18n/locale.ts"; import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts"; import { HydraError, type HydraAdmin } from "./hydra-admin.ts"; import type { KetoClient } from "./keto-client.ts"; @@ -60,10 +61,16 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole // as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com". const raw = ctx.url.searchParams.get("return_to"); const local = localPath(raw); + const chosen = chosenLocale(ctx); let returnTo: string | undefined; - if (local) { + if (local || chosen) { + // The flow's return target is the host's, not Kratos' — so the language the visitor picked + // on the sign-in page survives the round-trip through Kratos and lands on the page after + // it. Without this the most-travelled path in the product (pick Swedish → sign in) drops + // straight back to Accept-Language. const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`); - complete.searchParams.set("return_to", local); + if (local) complete.searchParams.set("return_to", ctx.localeHref(local)); + if (chosen) complete.searchParams.set("locale", chosen); returnTo = complete.toString(); } else if (raw) returnTo = raw; const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) }); diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 8e77f5b..21fe6cf 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -372,8 +372,8 @@ test("/error renders a themed sign-in error page (Kratos' flow error sink), not test("renders the 500 HTML page when a handler throws", async () => { const dir = mkdtempSync(join(tmpdir(), "pp-views-")); - writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // the dashboard view - cpSync(join(viewsDir, "500.ejs"), join(dir, "500.ejs")); + cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker + writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir }); try { await new Promise((resolve) => app.listen(0, resolve)); diff --git a/src/i18n/load.ts b/src/i18n/load.ts index 76be109..6dcf4c3 100644 --- a/src/i18n/load.ts +++ b/src/i18n/load.ts @@ -54,8 +54,12 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise !mounted.has(locale))), "core", baseline, errors); + checkSet(mounted, "locales", baseline, errors); const available = [...core.keys()].sort(); const plugins = new Map>(); diff --git a/src/i18n/locale.ts b/src/i18n/locale.ts index 7a60432..15f12b2 100644 --- a/src/i18n/locale.ts +++ b/src/i18n/locale.ts @@ -76,13 +76,20 @@ export function localeHref(href: string, locale: string | null): string { const directions = new Map(); const labels = new Map(); +// The locale this request explicitly asked for, or null. `localeHref` is a no-op unless one was +// chosen, so asking the function that decides keeps callers from re-deriving the rule. +export function chosenLocale(ctx: { locale: string; localeHref: (href: string) => string }): string | null { + return ctx.localeHref("/") === "/" ? null : ctx.locale; +} + interface TextInfoLocale { getTextInfo?: () => { direction?: string }; textInfo?: { direction?: string }; } -// The document direction for . Derived from the locale's script, so an RTL catalog flips -// the document the day it is added. +// The document direction for , from the locale's script. It states the direction — the +// shipped stylesheet still uses physical left/right properties, so an RTL locale also needs those +// moved to logical ones before it lays out correctly. export function textDirection(locale: string): "ltr" | "rtl" { const cached = directions.get(locale); if (cached !== undefined) return cached; diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 3353690..0fa49a1 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -61,7 +61,7 @@ const messages = { "dashboard.starter.intro": "This is the built-in /dashboard — the gated home shown to a signed-in user. It's a placeholder so a fresh clone has something here; it holds no real data.", "dashboard.starter.reference": - "See the plugin contract in docs/plugin-contract.md (the landing-pages section) and the bundled plugins/scheduling/ reference.", + "See the plugin contract in README.md (Building plugins → the landing pages) and the examples/plugins/scheduling/ reference.", "dashboard.starter.replace": "Replace it from a plugin: export a dashboard handler from your plugin's manifest and it owns this page, rendered against your own views with the native app shell (the same menu you see now) via ctx.chrome.", "dashboard.starter.title": "Starter dashboard", @@ -131,6 +131,7 @@ const messages = { "landing.title": "Operational web apps, without the boilerplate.", "locale.label": "Language", + "locale.leavesPage": "Switching leaves this page", "nav.dashboard": "Dashboard", diff --git a/src/i18n/locales/sv-SE.ts b/src/i18n/locales/sv-SE.ts index d6ea294..a2cf95e 100644 --- a/src/i18n/locales/sv-SE.ts +++ b/src/i18n/locales/sv-SE.ts @@ -32,7 +32,7 @@ const messages: CoreMessages = { "common.add": "Lägg till", "common.cancel": "Avbryt", - "common.delete": "Ta bort", + "common.delete": "Radera", "common.edit": "Redigera", "common.new": "Ny", "common.remove": "Ta bort", @@ -52,14 +52,14 @@ const messages: CoreMessages = { "dashboard.starter.intro": "Detta är den inbyggda /dashboard — den inloggade startsidan. Den är en platshållare så att en färsk klon har något här; den innehåller inga riktiga data.", "dashboard.starter.reference": - "Se plugin-kontraktet i docs/plugin-contract.md (avsnittet om startsidorna) och referensen plugins/scheduling/.", + "Se plugin-kontraktet i README.md (Building plugins → startsidorna) och referensen examples/plugins/scheduling/.", "dashboard.starter.replace": "Ersätt den från ett plugin: exportera en dashboard-hanterare från pluginets manifest så äger det den här sidan, renderad mot dina egna vyer med appens eget skal (samma meny du ser nu) via ctx.chrome.", "dashboard.starter.title": "Startpanel", - "dashboard.title": "Panel", + "dashboard.title": "Översikt", "error.403.body": "Du har inte behörighet att se det här (403).", - "error.403.docTitle": "Åtkomst nekad", + "error.403.docTitle": "Förbjudet", "error.403.title": "Åtkomst nekad", "error.404.body": "Vi hittade inte sidan (404).", "error.404.docTitle": "Sidan finns inte", @@ -114,11 +114,12 @@ const messages: CoreMessages = { "{{brand}} är en självhostad grund för administrativa och operativa gränssnitt — inloggning, en konfigurationsstyrd meny och ett serverrenderat designsystem utan JavaScript. Du lägger till de verksamhetsnära skärmarna genom att släppa in plugin-mappar.", "landing.register": "Skapa konto", "landing.signIn": "Logga in", - "landing.title": "Operativa webbappar, utan all pannplåt.", + "landing.title": "Operativa webbappar, utan allt grundarbete.", "locale.label": "Språk", + "locale.leavesPage": "Byter du språk lämnar du den här sidan", - "nav.dashboard": "Panel", + "nav.dashboard": "Översikt", "oauth.consentExpired": "Den här behörighetsbegäran har gått ut. Börja om från appen du skulle logga in i.", "oauth.loginExpired": "Den här inloggningsbegäran har gått ut. Börja om från appen du skulle logga in i.", diff --git a/src/i18n/view-locals.ts b/src/i18n/view-locals.ts index b669948..d5fd5c0 100644 --- a/src/i18n/view-locals.ts +++ b/src/i18n/view-locals.ts @@ -4,7 +4,7 @@ import { DEFAULT_LOCALE } from "./catalog.ts"; import { ENGLISH } from "./english.ts"; -import { localeHref, localeLabel, textDirection } from "./locale.ts"; +import { chosenLocale, localeHref, localeLabel, textDirection } from "./locale.ts"; import type { Translate } from "./translate.ts"; export interface LocaleChoice { @@ -16,6 +16,10 @@ export interface LocaleChoice { export interface I18nLocals { dir: "ltr" | "rtl"; + // True when switching language cannot stay on this page (its URL answers no GET, so the picker + // points elsewhere) — the page then says so, because what it leaves behind may be a one-time + // secret that cannot be shown again. + leavesPage: boolean; locale: string; localeHref: (href: string) => string; // The locale to carry as a hidden field, or null when the visitor never asked for one. A GET form @@ -43,6 +47,7 @@ export interface I18nRequest { // left-to-right, no language picker. export const ENGLISH_LOCALS: I18nLocals = { dir: "ltr", + leavesPage: false, locale: DEFAULT_LOCALE, localeHref: (href) => href, localeParam: null, @@ -52,11 +57,10 @@ export const ENGLISH_LOCALS: I18nLocals = { }; export function i18nLocals(ctx: I18nRequest): I18nLocals { - // ctx.localeHref is a no-op unless the URL asked for a locale, so it is also the honest answer to - // "did it?" — asking the function that decides keeps the two from drifting apart. - const carried = ctx.localeHref("/") === "/" ? null : ctx.locale; + const carried = chosenLocale(ctx); return { dir: textDirection(ctx.locale), + leavesPage: ctx.switchBase !== `${ctx.url.pathname}${ctx.url.search}`, locale: ctx.locale, localeHref: (href) => ctx.localeHref(href), localeParam: carried, diff --git a/src/plugin-host/plugin-api.ts b/src/plugin-host/plugin-api.ts index 5f2cc6a..e8024ca 100644 --- a/src/plugin-host/plugin-api.ts +++ b/src/plugin-host/plugin-api.ts @@ -22,6 +22,9 @@ export { englishTranslator } from "../i18n/english.ts"; export { localeLabel } from "../i18n/locale.ts"; export type { Translate, TranslateVars } from "../i18n/translate.ts"; export type { Catalog, PluralMessage } from "../i18n/catalog.ts"; +// The shape of the core catalog — what an operator's own locales/.ts is written against, so a +// missing key is a type error in the editor rather than a wall of boot errors. +export type { CoreMessages } from "../i18n/locales/en-US.ts"; export { parseListQuery } from "../ui/list-query.ts"; export { paginate } from "../ui/paginate.ts"; export type { PageModel } from "../ui/paginate.ts"; diff --git a/views/403.ejs b/views/403.ejs index 34b345e..1ae20bb 100644 --- a/views/403.ejs +++ b/views/403.ejs @@ -13,6 +13,7 @@

<%= t("error.403.title") %>

<%= t("error.403.body") %>

+ <%- include("partials/locale-switch", { up: false }) %>

"><%= t("error.backHome") %>

diff --git a/views/404.ejs b/views/404.ejs index bbb4eca..aacbf50 100644 --- a/views/404.ejs +++ b/views/404.ejs @@ -13,6 +13,7 @@

<%= t("error.404.title") %>

<%= t("error.404.body") %>

+ <%- include("partials/locale-switch", { up: false }) %>

"><%= t("error.backHome") %>

diff --git a/views/500.ejs b/views/500.ejs index 61e5a3f..6af6836 100644 --- a/views/500.ejs +++ b/views/500.ejs @@ -13,6 +13,7 @@

<%= t("error.500.title") %>

<%= t("error.500.body") %>

+ <%- include("partials/locale-switch", { up: false }) %>

"><%= t("error.backHome") %>

diff --git a/views/503.ejs b/views/503.ejs index 896670f..d645649 100644 --- a/views/503.ejs +++ b/views/503.ejs @@ -13,6 +13,7 @@

<%= t("error.503.title") %>

<%= t("error.503.body") %>

+ <%- include("partials/locale-switch", { up: false }) %>

"><%= t("error.tryAgain") %>

diff --git a/views/error.ejs b/views/error.ejs index e362741..72b2315 100644 --- a/views/error.ejs +++ b/views/error.ejs @@ -13,6 +13,7 @@

<%= t("error.flow.title") %>

<%= t("error.flow.body") %>

+ <%- include("partials/locale-switch", { up: false }) %>

"><%= t("error.backToSignIn") %>

<% if (locals.id) { %>

<%= t("error.reference", { id }) %>

<% } %>
diff --git a/views/partials/locale-switch.ejs b/views/partials/locale-switch.ejs index 358e5e1..cbe24c6 100644 --- a/views/partials/locale-switch.ejs +++ b/views/partials/locale-switch.ejs @@ -9,6 +9,12 @@ <%- include("menu", { up: locals.up !== false, trigger: { class: "btn icon-btn", icon: "i-globe", label: t("locale.label") }, - items: [{ head: t("locale.label") }, ...choices.map((c) => ({ current: c.current, href: c.href, hreflang: c.tag, label: c.label, ownLocale: true }))], + items: [ + { head: t("locale.label") }, + ...choices.map((c) => ({ current: c.current, href: c.href, hreflang: c.tag, label: c.label, ownLocale: true })), + // This page can't be re-rendered in another language (its URL answers no GET), so switching + // navigates away — and may leave a one-time secret behind. Say it before the click. + ...(locals.leavesPage ? [{ head: t("locale.leavesPage") }] : []), + ], }) %> <% } -%>