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
12 changed files with 28 additions and 26 deletions
Showing only changes of commit 9af29ffdb9 - Show all commits
+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
+7 -7
View File
@@ -9,19 +9,19 @@ at boot. Entries start at 0.3.0.
### 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; nothing else in a 0.4 plugin
needs to change.
`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
`buildPaginationModel(page, hrefFor, { label?, pageSizes? })` from `@plainpages/plugin-api` turns
`paginate()`'s result into what `partials/pagination` renders. `hrefFor(n)` is your list's URL on
page n, and the rows-per-page form carries the same list state as the links. It replaces the hand-written
mapping a list page needed.
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 a script, image or other file your own markup links in `assetHref("/public/<id>/…")` in a view,
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.
+2 -1
View File
@@ -1059,7 +1059,8 @@ const pagination = buildPaginationModel(page, (n) => listHref({ ...query, page:
```
`listHref` is yours: the list's URL with all its state. The rows-per-page form's hidden inputs are
read off it, so it carries the same filters, search and sort as the page links.
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
+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 -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
@@ -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),
};
+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, { 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, { assetHref: assets.href, 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", () => {
+2 -2
View File
@@ -23,8 +23,8 @@ 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`.
// 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).
+1 -2
View File
@@ -50,8 +50,7 @@ export function routePublic(restPath: string, publicDir: string, pluginsDir: str
return { dir: publicDir, subPath: restPath };
}
// 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
// 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.
+1 -1
View File
@@ -59,7 +59,7 @@ test("buildPaginationModel maps a page to the pagination partial's model, every
});
});
test("buildPaginationModel disables the step off either end, keeps ellipses, and omits what wasn't asked for", () => {
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 });
+6 -5
View File
@@ -1,7 +1,7 @@
// 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.
// `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;
@@ -47,8 +47,9 @@ 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")
// 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
}