Stability round two: no language links on POST-rendered pages, scoped observers, Vary only where it varies
CI / full-gate (push) Successful in 2m37s
CI / full-gate (push) Successful in 2m37s
This commit is contained in:
+2
-2
@@ -24,7 +24,7 @@ export const MOUNTED_LOCALES_DIR = join(rootDir, "locales");
|
||||
// A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts — with the script
|
||||
// subtag when the language needs one (sr-Latn-RS). Anything else in the folder is a mistake worth
|
||||
// stopping for.
|
||||
const LOCALE_FILE = /^([a-z]{2,3}(?:-[A-Z][a-z]{3})?-[A-Z]{2})\.ts$/;
|
||||
const LOCALE_FILE = /^([a-z]{2,3}(?:-[A-Z][a-z]{3})?-(?:[A-Z]{2}|[0-9]{3}))\.ts$/;
|
||||
|
||||
export interface LoadI18nOptions {
|
||||
localesDir?: string;
|
||||
@@ -90,7 +90,7 @@ async function readSet(dir: string, label: string, errors: string[]): Promise<Ma
|
||||
if (entry.isDirectory() || entry.name.startsWith(".")) continue;
|
||||
const locale = LOCALE_FILE.exec(entry.name)?.[1];
|
||||
if (locale === undefined) {
|
||||
errors.push(`${label}: "${entry.name}" is not a locale catalog — name it <language>-<REGION>.ts (e.g. sv-SE.ts)`);
|
||||
errors.push(`${label}: "${entry.name}" is not a locale catalog — name it <language>-<REGION>.ts (sv-SE.ts, es-419.ts, sr-Latn-RS.ts)`);
|
||||
continue;
|
||||
}
|
||||
let mod: { default?: unknown };
|
||||
|
||||
@@ -8,6 +8,7 @@ const request = (overrides: Partial<I18nRequest> = {}): I18nRequest => ({
|
||||
locale: "sv-SE",
|
||||
localeHref: (href) => href,
|
||||
locales: ["en-US", "sv-SE"],
|
||||
method: "GET",
|
||||
t: ENGLISH,
|
||||
url: new URL("http://localhost/admin/users?q=ada"),
|
||||
...overrides,
|
||||
@@ -30,3 +31,9 @@ test("dir follows the locale's script", () => {
|
||||
assert.equal(i18nLocals(request()).dir, "ltr");
|
||||
assert.equal(i18nLocals(request({ locale: "ar-EG" })).dir, "rtl");
|
||||
});
|
||||
|
||||
test("a page rendered from a POST offers no language links — that URL may have no GET at all", () => {
|
||||
// Following one would dead-end on a 405 (a POST-only route), or silently discard a re-rendered
|
||||
// form's input. The picker renders nothing below two choices, so an empty list hides it.
|
||||
assert.deepEqual(i18nLocals(request({ method: "POST" })).localeSwitch, []);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface I18nRequest {
|
||||
locale: string;
|
||||
localeHref: (href: string) => string;
|
||||
locales: string[];
|
||||
method: string; // a page rendered in response to a POST has no linkable URL — see localeSwitch
|
||||
t: Translate;
|
||||
url: URL;
|
||||
}
|
||||
@@ -50,6 +51,10 @@ export const ENGLISH_LOCALS: I18nLocals = {
|
||||
|
||||
export function i18nLocals(ctx: I18nRequest): I18nLocals {
|
||||
const here = `${ctx.url.pathname}${ctx.url.search}`;
|
||||
// The picker links to this same page in another language. After a POST that page's URL often has
|
||||
// no GET at all (the admin's recovery-code screen, say), so linking there would dead-end on a 405
|
||||
// — and on a re-rendered form it would silently discard what the user typed. Offer nothing.
|
||||
const linkable = ctx.method === "GET" || ctx.method === "HEAD";
|
||||
// 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;
|
||||
@@ -58,7 +63,7 @@ export function i18nLocals(ctx: I18nRequest): I18nLocals {
|
||||
locale: ctx.locale,
|
||||
localeHref: (href) => ctx.localeHref(href),
|
||||
localeParam: carried,
|
||||
localeSwitch: ctx.locales.map((tag) => ({ current: tag === ctx.locale, href: localeHref(here, tag), label: localeLabel(tag), tag })),
|
||||
localeSwitch: linkable ? ctx.locales.map((tag) => ({ current: tag === ctx.locale, href: localeHref(here, tag), label: localeLabel(tag), tag })) : [],
|
||||
locales: ctx.locales,
|
||||
t: ctx.t,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user