Add buildPaginationModel and ctx.assetHref, and bump the plugin contract to 0.5.0 #215

Merged
lilleman merged 9 commits from item-23 into main 2026-11-08 14:49:38 +01:00
26 changed files with 222 additions and 109 deletions
+1 -2
View File
@@ -246,8 +246,7 @@ Revisit only if the stated reason stops holding.
- **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list
page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`,
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
`data` so a collision loses the key instead of breaking the shell — as is `assetHref`
(`src/http/static.ts`), which stays undocumented for plugins because calling it is not yet contract.
`data` so a collision loses the key instead of breaking the shell — as is `assetHref`.
- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` requires — and a
mismatch stops startup. A plugin may ship fewer locales than the host (its strings fall back to
+19
View File
@@ -6,6 +6,25 @@ at boot. Entries start at 0.3.0.
## Unreleased
### Plugin contract 0.5: set `apiVersion` to `"0.5.0"`
The host's contract version is now 0.5.0, so discovery refuses a plugin whose `apiVersion` is still
`0.4.x`. Change the literal in each plugin's manifest when you upgrade, and add `assetHref` to any
`RequestContext` your tests build by hand: `assetHref: (href) => href`.
### `buildPaginationModel` builds the pagination partial's model
Call `buildPaginationModel(page, hrefFor, { label?, pageSizes?, pageParam?, pageSizeParam? })` from
`@plainpages/plugin-api` in place of mapping `paginate()`'s result to `partials/pagination` by hand.
`hrefFor(n)` is your list's URL on page n, and the rows-per-page form carries the same list state as
the links. A list that renamed its params in `parseListQuery` passes the same names here.
### A plugin can version its own `/public/` files
Wrap the href of a script, image or other file your own markup links in `assetHref("/public/<id>/…")` in a view,
or `ctx.assetHref(…)` in a handler. The href gets the file's content hash, and the browser caches the
file for a year instead of revalidating it every five minutes.
### Discovery warns when a gate names a permission no plugin declares
A route or nav node that gates on a permission no installed plugin declares now logs a boot warning
+28 -9
View File
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({
apiVersion: "0.4.0",
apiVersion: "0.5.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
@@ -437,7 +437,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { listThings, createThings } from "./handlers.ts";
export default definePlugin({
apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning)
apiVersion: "0.5.0", // semver string of the host contract this plugin was built against (see Versioning)
// Nav fragment, merged into the global menu and gate-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
@@ -521,8 +521,10 @@ export async function listThings(ctx: RequestContext) {
`include()` the core building-block partials and its own. To load the plugin's own CSS, pass its
`/public/<id>/x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`.
The host adds the file's content hash to every `/public/` href the shell renders, so browsers and
any cache in front of Plainpages keep the file for a year; a `/public/` URL your own markup writes
is kept for five minutes, then revalidated.
any cache in front of Plainpages keep the file for a year. Get the same for a script or image your
own markup links by wrapping its href: `<script src="<%= assetHref("/public/<id>/x.js") %>" defer>`
in a view, `ctx.assetHref(href)` in a handler. An unwrapped `/public/` URL is kept for five
minutes, then revalidated.
- **Finer authorization than the route `permission`** uses the guards from `@plainpages/plugin-api`:
`requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and
`check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw
@@ -561,7 +563,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { landing, board } from "./pages.ts";
export default definePlugin({
apiVersion: "0.4.0",
apiVersion: "0.5.0",
home: landing, // owns "/" — the public front page
dashboard: board, // owns "/dashboard" — the post-login app home
});
@@ -593,6 +595,7 @@ interface RequestContext {
locale: string; // the locale being served, e.g. "sv-SE"
locales: string[]; // every installed locale, sorted
localeHref(href): string; // carry an explicitly chosen locale onto a link this page renders
assetHref(href): string; // version a /public/ href so it is cached for a year; any other href unchanged
query: URLSearchParams; // alias of url.searchParams
req: IncomingMessage;
res: ServerResponse;
@@ -850,7 +853,7 @@ camel humps both becoming underscores — so `upstream` on the `scheduling` plug
```ts
export default definePlugin({
apiVersion: "0.4.0",
apiVersion: "0.5.0",
settings: [
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
{ key: "pageSize", type: "number", default: 25 },
@@ -904,7 +907,7 @@ import { definePlugin } from "@plainpages/plugin-api";
let sql: ReturnType<typeof postgres>;
export default definePlugin({
apiVersion: "0.4.0",
apiVersion: "0.5.0",
storage: true,
hooks: {
onBoot: async (boot) => {
@@ -1039,10 +1042,26 @@ fully styled and zero-JS:
- **Partials:** app shell, nav tree, filter bar, data table (sort / select / row actions),
pagination, form fields, badges, menus, auth cards.
- **Helpers:** `composeNav` (menu from config), `parseListQuery`
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math),
`buildPaginationModel` (page math → the pagination partial, sketched below), and the
auth guards (`src/auth/guards.ts`): `requireSession`, `can(permission)` (coarse JWT-claim check,
zero I/O), `check(relation, object)` (the one live Keto call).
A list page's pagination, from URL to partial (`examples/plugins/admin/admin-users.ts` is the full
page):
```ts
const query = parseListQuery(ctx.url); // { q, filters, sort, page, pageSize }
const page = paginate(rows.length, query.page, query.pageSize); // or an upstream's total
const visible = rows.slice(page.from - 1, page.to);
const pagination = buildPaginationModel(page, (n) => listHref({ ...query, page: n }), { pageSizes: [25, 50, 100] });
// view: <%- include("partials/pagination", pagination) %>
```
`listHref` is yours: the list's URL with all its state. The rows-per-page form's hidden inputs are
read off it, so the form carries the same filters, search and sort as the page links. A list that
renamed its params in `parseListQuery` passes the same `pageParam`/`pageSizeParam` here.
## Interactivity: zero-JS spine
The core and all building blocks **work with zero JavaScript** — theme switching and filtering are
@@ -1188,7 +1207,7 @@ Rules worth knowing:
edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through
to them. Keep your catalog for your domain words.
- **Reserved view locals:** `t`, `locale`, `locales`, `localeHref`, `localeParam`, `localeSwitch`,
`dir`, and the host-internal `assetHref`. They are merged after your `data`, so a colliding key of yours is ignored rather than
`dir`, and `assetHref`. They are merged after your `data`, so a colliding key of yours is ignored rather than
breaking the shell. `locale` is likewise reserved in `parseListQuery`, never returned as a filter.
**Kratos writes the auth flow's own text** (field labels, validation errors), tagging each string
+25
View File
@@ -315,3 +315,28 @@ Inherited architect, decided by:
- Navigation: `src/test-tooling/stylesheet-floor.ts:1` `floorViolations`: the folder is named in AGENTS.md and the README file map
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: one loop split into `parenGroups`, `Group` and `keepGroup`
- Self-sufficiency: `src/auth/routes.ts:117` `codeFlowSubmit`: a comment says which `flowFailure` branch can fire there
## 2026-11-08T13:44:57Z, PR #215 at 3a3bd77, against 01f368d
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | better | better | same | better |
| Inherited architect | better | better | better | better | better |
Mid A, decided by:
- Locality, Shape: `src/ui/paginate.ts:68` `buildPaginationModel`: the rows form's hidden inputs are read off hrefFor, so form and links can't drift, where each page used to list them by hand
- Shape, Navigation: `examples/plugins/admin/admin-users.ts:140` `buildUsersListModel`: three near-identical listPagination copies become one call each to a named helper
- Locality: `src/http/app.ts:271` `shared`: ctx and contextFor spread one option object instead of spelling the same list twice
- Shape: `src/ui/paginate.ts:1` header: names both steps truthfully, and PaginationModel types the partial's input
- Navigation: `src/http/context.ts:28` `RequestContext.assetHref`: versioning a plugin's asset is a documented contract member, not a host-internal local
Inherited architect, decided by:
- Shape, Locality, Navigation: `src/ui/paginate.ts:68` `buildPaginationModel`: one typed function in the module owning the page math replaces three hand-written copies in the example plugins
- Locality: `examples/plugins/admin/admin-users.ts:188` `listPagination`: the older version built the hidden inputs field by field, apart from listHref; the round trip through new URL is a little clever, paid for by its comment
- Locality, Shape: `src/http/app.ts:271` `shared`: the two buildContext calls differ only in the translator and params
- Self-sufficiency: `src/http/context.ts:28` `RequestContext.assetHref`: the contract field says on the spot what it does and how it differs from the view local
- Self-sufficiency: `README.md:1050` pagination sketch: URL to partial in five lines, with the param-renaming rule
+17
View File
@@ -337,3 +337,20 @@ Read most (opens and searches):
- `CHANGELOG.md` 1
- `examples/plugins/admin/i18n/sv-SE.ts` 1
- `src/auth/routes.ts` 1
## Reading cost, chunk b94ae3839c24, finished 2026-11-08T13:27:14Z
87251 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 9
- `README.md` 7
- `src/plugin-host/plugin.test.ts` 6
- `src/plugin-host/plugin.ts` 5
- `src/test-tooling/stylesheet-floor.ts` 4
- `AGENTS.md` 3
- `src/test-tooling/app-harness.ts` 3
- `src/plugin-host/discovery.ts` 2
- `src/test-tooling/stylesheet-floor.test.ts` 2
- `.dockerignore` 1
+2 -18
View File
@@ -5,7 +5,7 @@
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
import { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { buildPaginationModel, can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
@@ -122,7 +122,7 @@ export function buildClientsListModel(opts: {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t),
pagination: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.clients.pagination"), pageSizes: PAGE_SIZES }),
table: listTable(rows, t),
title: t("admin.clients.title"),
};
@@ -158,22 +158,6 @@ function listFilterBar(state: ListState, t: Translate) {
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
return {
label: t("admin.clients.pagination"),
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
// ---- register form + detail view models ----
export function buildClientFormModel(opts: {
+2 -19
View File
@@ -6,7 +6,7 @@
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult.
import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
import { buildPaginationModel, can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
@@ -143,7 +143,7 @@ export function buildGroupsListModel(opts: {
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t),
pagination: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.groups.pagination"), pageSizes: PAGE_SIZES }),
table: listTable(rows, state, sort, t),
title: t("admin.groups.title"),
};
@@ -179,23 +179,6 @@ function listFilterBar(state: ListState, t: Translate) {
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
if (state.sort) hidden.push({ name: "sort", value: state.sort });
return {
label: t("admin.groups.pagination"),
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
// ---- create form + detail view models ----
export function buildGroupFormModel(opts: {
+1 -1
View File
@@ -19,7 +19,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET";
return {
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
assetHref: (href) => href, chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
verifyCsrf: opts.verifyCsrf ?? (() => true),
};
+2 -20
View File
@@ -3,7 +3,7 @@
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
// a shared `withUser` gate.
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { buildPaginationModel, can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
@@ -137,7 +137,7 @@ export function buildUsersListModel(opts: {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, all.length, t),
pagination: listPagination(state, page, t),
pagination: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.users.pagination"), pageSizes: PAGE_SIZES }),
table: listTable(rows, state, sort, t, opts.canWrite !== false),
title: t("admin.users.title"),
};
@@ -185,24 +185,6 @@ function listFilterBar(state: ListState, total: number, t: Translate) {
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
if (state.sort) hidden.push({ name: "sort", value: state.sort });
return {
label: t("admin.users.pagination"),
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
export interface FieldConfig {
autocomplete?: string;
hint?: string;
+1 -1
View File
@@ -28,7 +28,7 @@ const clients = on("oauth2-clients");
const pluginSettings = on("plugin-settings");
export default definePlugin({
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
apiVersion: "0.5.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV],
+2 -1
View File
@@ -10,7 +10,8 @@ What it demonstrates:
service and renders the rows with the core building blocks (`shifts.ejs` → app shell, filter-bar,
data-table). Search round-trips the URL; zero-JS. (It fetches **all** rows for brevity — for a
large list, parse `page`/`pageSize` from `parseListQuery`, forward them upstream as a `?limit`/
`?offset`, and render `pagination.ejs` with `paginate()`, exactly as the admin example plugin does.)
`?offset`, and render `pagination.ejs` with `paginate()` and `buildPaginationModel()`, as the admin example plugin
does.)
- **A form that forwards a write upstream** — `GET /scheduling/shifts/new` renders the form,
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
+1 -1
View File
@@ -12,7 +12,7 @@ let upstreamUrl = "";
const upstream = createUpstream(() => upstreamUrl);
export default definePlugin({
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
apiVersion: "0.5.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
+1 -1
View File
@@ -18,7 +18,7 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; us
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return {
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
assetHref: (href) => href, chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
verifyCsrf: opts.verifyCsrf ?? (() => true),
};
+1 -1
View File
@@ -12,7 +12,7 @@ test("readHostApiVersion pulls the constant out of the real source, and returns
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
// makes an accidental edit fail here rather than at release time.
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.4.0");
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.5.0");
});
test("every author-facing apiVersion sample matches the shipped contract", () => {
+1 -1
View File
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({
apiVersion: "0.4.0",
apiVersion: "0.5.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
+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] ?? "");
+3 -3
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 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 } : {}) });
const shared = { assetHref: assets.href, chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) };
const ctx = buildContext(req, res, { ...shared, ...i18nFor() });
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext => buildContext(req, res, { ...shared, ...i18nFor(pluginId), ...(params ? { params } : {}) });
renderPage = viewsFor(ctx);
// Plugin onRequest hooks run before routing and may short-circuit the request.
+1
View File
@@ -25,6 +25,7 @@ test("buildContext parses the URL, exposes query, and defaults to an anonymous u
assert.equal(ctx.user, null);
assert.deepEqual(ctx.permissions, []);
assert.deepEqual(ctx.params, {});
assert.equal(ctx.assetHref("/public/x.css"), "/public/x.css");
});
test("buildContext threads path params supplied by the router", () => {
+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. Views get the same
// function as the `assetHref` local; this one is 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 ?? [],
+4 -6
View File
@@ -50,12 +50,10 @@ 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 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 leaves prev/next without an href on the first/last page, 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 });
});
+47 -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 maps it to pagination.ejs's model. 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,49 @@ export function paginate(total: number, page: number, pageSize: number, options:
};
}
export interface PaginationModelOptions {
label?: string; // the nav's aria-label; the partial defaults it
// The two query params, named as parseListQuery was given them; pageSizeParam names the rows field.
pageParam?: string; // default "page"
pageSizeParam?: string; // 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 });
-16
View File
@@ -12,8 +12,6 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 23 | 0.5.0 | | | **Give list pages one view-model helper from query to pagination, instead of ~40 hand-written lines each.** | 3 | 1 | 2 | 4 | 5 | 1 by 4, 9 by 2, code health by 3 | 7.9 |
| 43 | 0.5.0 | | | **Let a plugin version the scripts and other `/public/` files its own markup links, so they are cached as immutable too.** | 2 | 2 | 1 | 3 | 3 | 8 by 3, 7 by 2 | 5.0 |
| 57 | 0.5.0 | | owner | **Put the caller's IP, user agent and host on every request's span and `request` log line, under OpenTelemetry's HTTP semantic conventions, reading the IP through a proxy only where configuration names it trusted.** | 2 | 2 | 2 | 4 | 3 | 1 by 3 | 3.3 |
| 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
| 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
@@ -32,20 +30,6 @@
## Details
### 23. Give list pages one view-model helper from query to pagination, instead of ~40 hand-written lines each.
Every list screen bridges `parseListQuery`/`paginate` to the partials by hand. At minimum a
`buildPaginationModel(page, hrefFor)`. Exported through the barrel, so it is contract surface.
### 43. Let a plugin version the scripts and other `/public/` files its own markup links, so they are cached as immutable too.
The host versions every `/public/` href it renders (`src/http/static.ts`): the shell's stylesheets,
favicon and logo, and a plugin's `styles`. A file a plugin's own markup links, such as fastjira's
`board-drag.js`, keeps an unversioned URL and is revalidated every five minutes. Offering a way —
`assetHref` documented as a view local, a `ctx.assetHref`, or a `scripts` slot in the shell — adds to
the plugin API, hence a minor. Pick one with the plugin authors' panel, then document it in README →
Reserved view locals.
### 57. Put the caller's IP, user agent and host on every request's span and `request` log line, under OpenTelemetry's HTTP semantic conventions, reading the IP through a proxy only where configuration names it trusted.
Today the `request` line and span in `src/http/app.ts` carry only method, path, status and `ms`, so
+1 -1
View File
@@ -12,7 +12,7 @@
<p>${t("dashboard.starter.intro")}</p>
<p>${t("dashboard.starter.replace")}</p>
<pre class="code-block"><code>export default definePlugin({
apiVersion: "0.4.0",
apiVersion: "0.5.0",
// view names plugins/&lt;id&gt;/views/&lt;view&gt;.ejs, rendered in this same shell
dashboard: (ctx) =&gt; ({ view: "dashboard", data: { /* … */ } }),
});</code></pre>