Show the language picker on every page, targeting the nearest page that answers GET
CI / full-gate (push) Successful in 2m39s
CI / full-gate (push) Successful in 2m39s
This commit is contained in:
@@ -1503,3 +1503,33 @@ test("an error page renders without composing the menu — it exists for when th
|
||||
assert.match(await res.text(), /Page not found/);
|
||||
assert.equal(built, 0);
|
||||
});
|
||||
|
||||
test("a POST-rendered page still offers the language picker, pointed at a page that answers GET", async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-post-lang-"));
|
||||
mkdirSync(join(dir, "demo", "views"), { recursive: true });
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
// The view renders the picker exactly as the shell does.
|
||||
writeFileSync(join(dir, "demo", "views", "page.ejs"), `<%- include("partials/locale-switch") %>`);
|
||||
const demo: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
id: "demo",
|
||||
routes: [
|
||||
{ handler: () => ({ view: "page" }), method: "GET", path: "/thing" },
|
||||
{ handler: () => ({ view: "page" }), method: "POST", path: "/thing" },
|
||||
{ handler: () => ({ view: "page" }), method: "POST", path: "/thing/act" }, // POST-only: no GET sibling
|
||||
],
|
||||
};
|
||||
const app = createApp({ i18n: createI18n(await loadI18n()), plugins: [demo], pluginsDir: dir });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const post = (path: string, headers: Record<string, string> = {}) => fetch(url + path, { headers, method: "POST" });
|
||||
|
||||
// A POST whose path also answers GET → the picker points at that page.
|
||||
assert.match(await (await post("/demo/thing?locale=sv-SE")).text(), /href="\/demo\/thing\?locale=en-US"/);
|
||||
// A POST-only path → the page the form was submitted from, so the link can't dead-end on a 405.
|
||||
const fromForm = await post("/demo/thing/act?locale=sv-SE", { referer: `${url}/demo/thing?locale=sv-SE` });
|
||||
assert.match(await fromForm.text(), /href="\/demo\/thing\?locale=en-US"/);
|
||||
// …and with no referer to fall back on, the front page.
|
||||
assert.match(await (await post("/demo/thing/act?locale=sv-SE")).text(), /href="\/\?locale=en-US"/);
|
||||
});
|
||||
|
||||
+28
-1
@@ -30,6 +30,7 @@ import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||
import { securityHeaders } from "./security-headers.ts";
|
||||
import { localPath } from "./safe-url.ts";
|
||||
import { routePublic, serveStatic } from "./static.ts";
|
||||
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
||||
|
||||
@@ -120,6 +121,18 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// it. A plugin's context carries that plugin's translator, so its own catalog wins in its own views.
|
||||
// They are merged LAST: these names are reserved (README → Building plugins), and a handler that
|
||||
// happens to use one loses that key rather than breaking the shell that renders around it.
|
||||
// Where the language picker on this page should point. Normally the page itself; after a POST
|
||||
// that URL may answer no GET (POST /admin/users/:id/delete has no GET sibling), so fall back to
|
||||
// the page the form was submitted from, then to the front page — the picker is on every page, so
|
||||
// every one of its links has to land somewhere real.
|
||||
const switchBase = (req: IncomingMessage, url: URL): string => {
|
||||
const method = (req.method ?? "GET").toUpperCase();
|
||||
if (method === "GET" || method === "HEAD") return `${url.pathname}${url.search}`;
|
||||
const answersGet = matchRoute(plugins, "GET", url.pathname) !== null
|
||||
|| matchBuiltinRoute(builtinRoutes, "GET", url.pathname) !== undefined;
|
||||
return answersGet ? url.pathname : (sameOriginPath(req) ?? "/");
|
||||
};
|
||||
|
||||
// Named field by field on purpose: spreading the context would trigger its lazy `chrome` getter,
|
||||
// composing the menu for every render — including the standalone error pages, which exist to
|
||||
// render when the shell's own data is what failed.
|
||||
@@ -127,7 +140,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
locale: ctx.locale,
|
||||
localeHref: ctx.localeHref,
|
||||
locales: ctx.locales,
|
||||
method: ctx.req.method ?? "GET",
|
||||
switchBase: switchBase(ctx.req, ctx.url),
|
||||
t: ctx.t,
|
||||
url: ctx.url,
|
||||
});
|
||||
@@ -403,6 +416,20 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
});
|
||||
}
|
||||
|
||||
// The Referer as a host-relative path, when it is one of ours — the page a form was submitted
|
||||
// from. Anything off-origin or malformed is discarded rather than trusted into a link.
|
||||
function sameOriginPath(req: IncomingMessage): string | null {
|
||||
const referer = req.headers.referer;
|
||||
if (typeof referer !== "string") return null;
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
if (req.headers.host !== undefined && url.host !== req.headers.host) return null;
|
||||
return localPath(`${url.pathname}${url.search}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type ViewRenderer = (view: string, data: Record<string, unknown>) => Promise<string>;
|
||||
|
||||
// Turn a handler's RouteResult into the HTTP response. `null` = the handler took over `ctx.res`
|
||||
|
||||
Reference in New Issue
Block a user