From 9af29ffdb9c6c55322183babb071e42d20a657e4 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 8 Nov 2026 14:41:19 +0100 Subject: [PATCH] =?UTF-8?q?Answer=20stability=20round=201:=201=20?= =?UTF-8?q?=F0=9F=94=B4,=204=20=F0=9F=9F=A1=20(0=20misread);=20assetHref?= =?UTF-8?q?=20in=20the=20test=20fakes,=20one=20shared=20buildContext=20opt?= =?UTF-8?q?ion=20set,=20default=20pinned,=20upgrade=20note=20and=20AGENTS.?= =?UTF-8?q?md=20corrected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 +-- CHANGELOG.md | 14 +++++++------- README.md | 3 ++- examples/plugins/admin/admin-shared.test.ts | 2 +- examples/plugins/scheduling/README.md | 3 ++- examples/plugins/scheduling/shifts.test.ts | 2 +- src/http/app.ts | 6 +++--- src/http/context.test.ts | 1 + src/http/context.ts | 4 ++-- src/http/static.ts | 3 +-- src/ui/paginate.test.ts | 2 +- src/ui/paginate.ts | 11 ++++++----- 12 files changed, 28 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f9ad06f..72f5132 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a95a20..d6b0595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//…")` in a view, +Wrap the href of a script, image or other file your own markup links in `assetHref("/public//…")` 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. diff --git a/README.md b/README.md index 6ce3e22..3880909 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/examples/plugins/admin/admin-shared.test.ts b/examples/plugins/admin/admin-shared.test.ts index b1ba5e4..dd34595 100644 --- a/examples/plugins/admin/admin-shared.test.ts +++ b/examples/plugins/admin/admin-shared.test.ts @@ -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), }; diff --git a/examples/plugins/scheduling/README.md b/examples/plugins/scheduling/README.md index 70f64e3..423082e 100644 --- a/examples/plugins/scheduling/README.md +++ b/examples/plugins/scheduling/README.md @@ -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`, diff --git a/examples/plugins/scheduling/shifts.test.ts b/examples/plugins/scheduling/shifts.test.ts index 41742bb..57f9f02 100644 --- a/examples/plugins/scheduling/shifts.test.ts +++ b/examples/plugins/scheduling/shifts.test.ts @@ -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), }; diff --git a/src/http/app.ts b/src/http/app.ts index 5bb0c9f..f7b2c77 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -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): 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): RequestContext => buildContext(req, res, { ...shared, ...i18nFor(pluginId), ...(params ? { params } : {}) }); renderPage = viewsFor(ctx); // Plugin onRequest hooks run before routing and may short-circuit the request. diff --git a/src/http/context.test.ts b/src/http/context.test.ts index 78e8452..07fb734 100644 --- a/src/http/context.test.ts +++ b/src/http/context.test.ts @@ -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", () => { diff --git a/src/http/context.ts b/src/http/context.ts index 54f1c63..19c4e45 100644 --- a/src/http/context.ts +++ b/src/http/context.ts @@ -23,8 +23,8 @@ export interface User { export interface RequestContext { // A /public/ href with `?v=`, 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). diff --git a/src/http/static.ts b/src/http/static.ts index a408c1a..f135b7a 100644 --- a/src/http/static.ts +++ b/src/http/static.ts @@ -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=`. A request whose `v` is the current +// Caching: every /public/ href rendered through assetHref gets `?v=`. 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. diff --git a/src/ui/paginate.test.ts b/src/ui/paginate.test.ts index fbb89ad..95cff3a 100644 --- a/src/ui/paginate.test.ts +++ b/src/ui/paginate.test.ts @@ -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 }); diff --git a/src/ui/paginate.ts b/src/ui/paginate.ts index 0370362..947e553 100644 --- a/src/ui/paginate.ts +++ b/src/ui/paginate.ts @@ -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 }