Carry the language through sign-in; warn when switching leaves the page; product-review copy fixes
CI / full-gate (push) Successful in 2m40s

This commit is contained in:
2026-08-04 09:59:00 +02:00
parent 37b88b2fe6
commit bd76c981ee
18 changed files with 70 additions and 27 deletions
+6 -2
View File
@@ -54,8 +54,12 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18
const baseline = shipped.get(DEFAULT_LOCALE);
if (!baseline) errors.push(`core: no ${DEFAULT_LOCALE}.ts — it is the baseline every other locale is checked against`);
const core = new Map(shipped);
for (const [locale, catalog] of await readSet(mountedDir, "locales", errors)) core.set(locale, catalog);
checkSet(core, "core", baseline, errors);
const mounted = await readSet(mountedDir, "locales", errors);
for (const [locale, catalog] of mounted) core.set(locale, catalog);
// Checked under the folder they actually live in: telling an operator "core de-DE: missing key …"
// sends them to src/i18n/locales/, which holds no de-DE.ts at all.
checkSet(new Map([...core].filter(([locale]) => !mounted.has(locale))), "core", baseline, errors);
checkSet(mounted, "locales", baseline, errors);
const available = [...core.keys()].sort();
const plugins = new Map<string, Map<string, Catalog>>();
+9 -2
View File
@@ -76,13 +76,20 @@ export function localeHref(href: string, locale: string | null): string {
const directions = new Map<string, "ltr" | "rtl">();
const labels = new Map<string, string>();
// 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 <html dir>. Derived from the locale's script, so an RTL catalog flips
// the document the day it is added.
// The document direction for <html dir>, 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;
+2 -1
View File
@@ -61,7 +61,7 @@ const messages = {
"dashboard.starter.intro":
"This is the built-in <code>/dashboard</code> — 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 <code>docs/plugin-contract.md</code> (the landing-pages section) and the bundled <code>plugins/scheduling/</code> reference.",
"See the plugin contract in <code>README.md</code> (Building plugins → the landing pages) and the <code>examples/plugins/scheduling/</code> reference.",
"dashboard.starter.replace":
"Replace it from a plugin: export a <code>dashboard</code> 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 <code>ctx.chrome</code>.",
"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",
+7 -6
View File
@@ -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 <code>/dashboard</code> — 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 <code>docs/plugin-contract.md</code> (avsnittet om startsidorna) och referensen <code>plugins/scheduling/</code>.",
"Se plugin-kontraktet i <code>README.md</code> (Building plugins → startsidorna) och referensen <code>examples/plugins/scheduling/</code>.",
"dashboard.starter.replace":
"Ersätt den från ett plugin: exportera en <code>dashboard</code>-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 <code>ctx.chrome</code>.",
"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.",
+8 -4
View File
@@ -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,