Add buildPaginationModel and ctx.assetHref, and bump the plugin contract to 0.5.0
CI / full-gate (push) Failing after 5s

This commit is contained in:
2026-11-08 14:32:05 +01:00
parent 01f368d663
commit 404f2584ac
20 changed files with 189 additions and 100 deletions
+16
View File
@@ -392,6 +392,22 @@ test("renders the 500 HTML page when a handler throws", async () => {
}
});
test("a plugin versions the /public/ files its own markup links, through the assetHref view local or ctx.assetHref", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
mkdirSync(join(dir, "drag", "views"), { recursive: true });
mkdirSync(join(dir, "drag", "public"), { recursive: true });
writeFileSync(join(dir, "drag", "views", "page.ejs"), `<script src="<%= assetHref("/public/drag/board.js") %>"></script><img src="<%= logo %>" alt="">`);
writeFileSync(join(dir, "drag", "public", "board.js"), "void 0;");
writeFileSync(join(dir, "drag", "public", "logo.svg"), "<svg/>");
t.after(() => rmSync(dir, { force: true, recursive: true }));
const drag: Plugin = { apiVersion: "1.0.0", id: "drag", routes: [{ handler: (ctx) => ({ data: { logo: ctx.assetHref("/public/drag/logo.svg") }, view: "page" }), method: "GET", path: "/page", public: true }] };
const url = await startApp(t, [drag], dir);
const html = await (await fetch(url + "/drag/page")).text();
assert.match(html, /src="\/public\/drag\/board\.js\?v=[\w-]+"/);
assert.match(html, /src="\/public\/drag\/logo\.svg\?v=[\w-]+"/);
});
// Every /public/ reference in the page, as written in the HTML.
const publicRefs = (html: string): string[] => [...html.matchAll(/(?:href|src)="(\/public\/[^"]*)"/g)].map((m) => m[1] ?? "");
+2 -2
View File
@@ -268,9 +268,9 @@ export function createApp(options: AppOptions = {}): Server {
// Base context (no route params), for the built-in routes. Every plugin-owned render — a
// landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead.
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
const ctx = buildContext(req, res, { assetHref: assets.href, chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
buildContext(req, res, { assetHref: assets.href, chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
renderPage = viewsFor(ctx);
// Plugin onRequest hooks run before routing and may short-circuit the request.
+6
View File
@@ -22,6 +22,10 @@ export interface User {
}
export interface RequestContext {
// A /public/ href with `?v=<content hash>`, so the browser caches the file for a year; any other
// href, or one already carrying a query or fragment, comes back unchanged. The same function is
// the `assetHref` view local, for hrefs a handler builds into `data`.
assetHref(href: string): string;
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
chrome: PageChrome;
@@ -66,6 +70,7 @@ export interface RequestContext {
}
export interface BuildContextOptions {
assetHref?: (href: string) => string;
// Lazy chrome factory: composing the global menu is only paid for if the handler actually reads
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
// The host's factory is memoised, so the menu composes at most once per request across contexts.
@@ -99,6 +104,7 @@ export function buildContext(
const buildChrome = options.chrome;
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
return {
assetHref: options.assetHref ?? ((href) => href),
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
declaredPermissions: options.declaredPermissions ?? [],
declaredSettings: options.declaredSettings ?? [],
+5 -6
View File
@@ -50,12 +50,11 @@ export function routePublic(restPath: string, publicDir: string, pluginsDir: str
return { dir: publicDir, subPath: restPath };
}
// Caching: the host renders every /public/ href through assetHref, which appends `?v=<hash of the
// file's content>`. A request whose `v` is the current hash is cached for a year as immutable, so a
// repeat page view downloads no asset; a changed file gets a new hash, and with it a new URL. Any other
// request — an href a plugin's own markup emits, a stale `v` mid-deploy — is cached for five minutes,
// then revalidated against the ETag (that same hash) or Last-Modified. A plugin has no way to version
// its own hrefs yet: `assetHref` is a reserved local but not contract, since offering it is a minor.
// Caching: every /public/ href rendered through assetHref (the host's, and a plugin's through the view
// local or ctx.assetHref) gets `?v=<hash of the file's content>`. A request whose `v` is the current
// hash is cached for a year as immutable, so a repeat page view downloads no asset; a changed file gets
// a new hash, and with it a new URL. Any other request — an unwrapped href, a stale `v` mid-deploy — is
// cached for five minutes, then revalidated against the ETag (that same hash) or Last-Modified.
const IMMUTABLE = "public, max-age=31536000, immutable";
const SHORT = "public, max-age=300";
+2 -2
View File
@@ -32,8 +32,8 @@ export type { Catalog, PluralMessage } from "../i18n/catalog.ts";
// 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";
export { buildPaginationModel, paginate } from "../ui/paginate.ts";
export type { PageModel, PaginationModel, PaginationModelOptions } from "../ui/paginate.ts";
export { readFormBody } from "../http/body.ts";
export { CSRF_FIELD } from "../auth/csrf.ts";
// System capabilities for a privileged/system plugin (ctx.system) — the Ory admin clients + the
+1 -1
View File
@@ -11,7 +11,7 @@ import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
import type { StorageCredentials } from "./storage.ts";
// The Plainpages release this contract ships in — see README → Contract versioning.
export const HOST_API_VERSION = "0.4.0";
export const HOST_API_VERSION = "0.5.0";
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
+37 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { paginate, type PageModel } from "./paginate.ts";
import { buildPaginationModel, paginate, type PageModel } from "./paginate.ts";
// Compact view of the page sequence: ellipsis → "…", current → "[n]", else the number.
const shape = (m: PageModel): (number | string | null)[] =>
@@ -41,3 +41,39 @@ test("paginate windows the sequence: single gaps fill, wider gaps ellipsize, sib
assert.deepEqual(shape(paginate(200, 10, 10, { siblings: 2 })), [1, "…", 8, 9, "[10]", 11, 12, "…", 20]);
assert.deepEqual(shape(paginate(200, 10, 10, { boundaries: 2 })), [1, 2, "…", 9, "[10]", 11, "…", 19, 20]);
});
test("buildPaginationModel maps a page to the pagination partial's model, every href from hrefFor", () => {
const hrefFor = (n: number): string => `/users?q=ann&sort=-name${n > 1 ? `&page=${n}` : ""}`;
assert.deepEqual(buildPaginationModel(paginate(100, 2, 25), hrefFor, { label: "User pages", pageSizes: [25, 50] }), {
label: "User pages",
next: { href: "/users?q=ann&sort=-name&page=3" },
pages: [
{ href: "/users?q=ann&sort=-name", label: "1" },
{ current: true, label: "2" },
{ href: "/users?q=ann&sort=-name&page=3", label: "3" },
{ href: "/users?q=ann&sort=-name&page=4", label: "4" },
],
prev: { href: "/users?q=ann&sort=-name" },
rows: { hidden: [{ name: "q", value: "ann" }, { name: "sort", value: "-name" }], name: "pageSize", options: [25, 50], value: 25 },
summary: { from: 26, to: 50, total: 100 },
});
});
test("buildPaginationModel disables the step off either end, keeps ellipses, and omits what wasn't asked for", () => {
const m = buildPaginationModel(paginate(100, 1, 10), (n) => `/l?page=${n}`);
assert.deepEqual(m.prev, {});
assert.deepEqual(m.pages[m.pages.length - 2], { ellipsis: true });
assert.equal("label" in m, false);
assert.equal("rows" in m, false);
assert.deepEqual(buildPaginationModel(paginate(100, 10, 10), (n) => `/l?page=${n}`).next, {});
});
test("buildPaginationModel's rows form carries the links' list state, minus page, page size and locale", () => {
const m = buildPaginationModel(paginate(10, 1, 50), () => "/l?status=active&status=locked&page=2&pageSize=50&locale=sv-SE", { pageSizes: [50] });
assert.deepEqual(m.rows?.hidden, [{ name: "status", value: "active" }, { name: "status", value: "locked" }]);
});
test("buildPaginationModel follows renamed page and page-size params, as parseListQuery takes them", () => {
const m = buildPaginationModel(paginate(10, 1, 5), () => "/l?q=x&p=2&size=5", { pageParam: "p", pageSizeParam: "size", pageSizes: [5] });
assert.deepEqual(m.rows, { hidden: [{ name: "q", value: "x" }], name: "size", options: [5], value: 5 });
});
+46 -4
View File
@@ -1,7 +1,7 @@
// paginate: pagination math → the model pagination.ejs renders. Pure and
// URL-free (README signature `paginate(total, page, pageSize)`); the caller maps each
// page number to an href. Inputs are clamped/guarded so it never produces a broken model:
// page is pinned to [1, pageCount], total/pageSize coerced to sane integers.
// paginate: pagination math → a PageModel. Pure and URL-free (README signature
// `paginate(total, page, pageSize)`); buildPaginationModel then maps it, with the caller's
// page → href function, to the model pagination.ejs renders. Inputs are clamped/guarded so it never
// produces a broken model: page is pinned to [1, pageCount], total/pageSize coerced to sane integers.
export interface PageItem {
current: boolean;
@@ -45,6 +45,48 @@ export function paginate(total: number, page: number, pageSize: number, options:
};
}
export interface PaginationModelOptions {
label?: string; // the nav's aria-label; the partial defaults it
pageParam?: string; // as parseListQuery's options (default "page")
pageSizeParam?: string; // as parseListQuery's options (default "pageSize")
pageSizes?: number[]; // the rows-per-page choices; omitted ⇒ no rows-per-page form
}
export interface PaginationModel {
label?: string;
next: { href?: string };
pages: ({ ellipsis: true } | { current: true; label: string } | { href: string; label: string })[];
prev: { href?: string };
rows?: { hidden: { name: string; value: string }[]; name: string; options: number[]; value: number };
summary: { from: number; to: number; total: number };
}
// `hrefFor(n)` is the list's URL on page n, carrying all its other state. The rows-per-page form is a
// GET, which replaces the whole query string, so its hidden inputs are read off that same URL: the
// form then carries exactly the state the links do. `locale` is left to the partial, which adds it.
export function buildPaginationModel(page: PageModel, hrefFor: (page: number) => string, options: PaginationModelOptions = {}): PaginationModel {
const step = (n: number | null): { href?: string } => (n === null ? {} : { href: hrefFor(n) });
const model: PaginationModel = {
next: step(page.next),
pages: page.pages.map((p) =>
p.page === null ? { ellipsis: true as const }
: p.current ? { current: true as const, label: String(p.page) }
: { href: hrefFor(p.page), label: String(p.page) }),
prev: step(page.prev),
summary: { from: page.from, to: page.to, total: page.total },
};
if (options.label !== undefined) model.label = options.label;
if (options.pageSizes) {
const name = options.pageSizeParam ?? "pageSize";
const omit = new Set([options.pageParam ?? "page", name, "locale"]);
const hidden = [...new URL(hrefFor(page.page), "http://x").searchParams]
.filter(([key]) => !omit.has(key))
.map(([key, value]) => ({ name: key, value }));
model.rows = { hidden, name, options: options.pageSizes, value: page.pageSize };
}
return model;
}
const item = (page: number, current = false): PageItem => ({ current, ellipsis: false, page });
const gap = (): PageItem => ({ current: false, ellipsis: true, page: null });