Answer stability round 1: 1 🔴, 4 🟡 (0 misread); assetHref in the test fakes, one shared buildContext option set, default pinned, upgrade note and AGENTS.md corrected

This commit is contained in:
2026-11-08 14:41:19 +01:00
parent 80070762e7
commit 9af29ffdb9
12 changed files with 28 additions and 26 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 - **`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`, 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 `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` `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.
- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US` - **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 — 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 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"` ### 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 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 `0.4.x`. Change the literal in each plugin's manifest when you upgrade, and add `assetHref` to any
needs to change. `RequestContext` your tests build by hand: `assetHref: (href) => href`.
### `buildPaginationModel` builds the pagination partial's model ### `buildPaginationModel` builds the pagination partial's model
`buildPaginationModel(page, hrefFor, { label?, pageSizes? })` from `@plainpages/plugin-api` turns Call `buildPaginationModel(page, hrefFor, { label?, pageSizes?, pageParam?, pageSizeParam? })` from
`paginate()`'s result into what `partials/pagination` renders. `hrefFor(n)` is your list's URL on `@plainpages/plugin-api` in place of mapping `paginate()`'s result to `partials/pagination` by hand.
page n, and the rows-per-page form carries the same list state as the links. It replaces the hand-written `hrefFor(n)` is your list's URL on page n, and the rows-per-page form carries the same list state as
mapping a list page needed. the links. A list that renamed its params in `parseListQuery` passes the same names here.
### A plugin can version its own `/public/` files ### 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 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. 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 `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 ## 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; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET"; req.method = opts.method ?? "GET";
return { 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, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
verifyCsrf: opts.verifyCsrf ?? (() => true), 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, 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 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`/ 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, - **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, `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`, 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 url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return { 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, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
verifyCsrf: opts.verifyCsrf ?? (() => true), 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 // 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. // 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 shared = { assetHref: assets.href, chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) };
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext => const ctx = buildContext(req, res, { ...shared, ...i18nFor() });
buildContext(req, res, { assetHref: assets.href, chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) }); const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext => buildContext(req, res, { ...shared, ...i18nFor(pluginId), ...(params ? { params } : {}) });
renderPage = viewsFor(ctx); renderPage = viewsFor(ctx);
// Plugin onRequest hooks run before routing and may short-circuit the request. // 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.equal(ctx.user, null);
assert.deepEqual(ctx.permissions, []); assert.deepEqual(ctx.permissions, []);
assert.deepEqual(ctx.params, {}); assert.deepEqual(ctx.params, {});
assert.equal(ctx.assetHref("/public/x.css"), "/public/x.css");
}); });
test("buildContext threads path params supplied by the router", () => { test("buildContext threads path params supplied by the router", () => {
+2 -2
View File
@@ -23,8 +23,8 @@ export interface User {
export interface RequestContext { export interface RequestContext {
// A /public/ href with `?v=<content hash>`, so the browser caches the file for a year; any other // 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 // href, or one already carrying a query or fragment, comes back unchanged. Views get the same
// the `assetHref` view local, for hrefs a handler builds into `data`. // function as the `assetHref` local; this one is for hrefs a handler builds into `data`.
assetHref(href: string): string; assetHref(href: string): string;
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its // 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). // 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 }; return { dir: publicDir, subPath: restPath };
} }
// Caching: every /public/ href rendered through assetHref (the host's, and a plugin's through the view // Caching: every /public/ href rendered through assetHref gets `?v=<hash of the file's content>`. A request whose `v` is the current
// 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 // 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 // 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. // 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}`); const m = buildPaginationModel(paginate(100, 1, 10), (n) => `/l?page=${n}`);
assert.deepEqual(m.prev, {}); assert.deepEqual(m.prev, {});
assert.deepEqual(m.pages[m.pages.length - 2], { ellipsis: true }); 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: pagination math → a PageModel. Pure and URL-free (README signature
// `paginate(total, page, pageSize)`); buildPaginationModel then maps it, with the caller's // `paginate(total, page, pageSize)`); buildPaginationModel maps it to pagination.ejs's model. Inputs
// page → href function, to the model pagination.ejs renders. Inputs are clamped/guarded so it never // are clamped/guarded so it never produces a broken model: page is pinned to [1, pageCount],
// produces a broken model: page is pinned to [1, pageCount], total/pageSize coerced to sane integers. // total/pageSize coerced to sane integers.
export interface PageItem { export interface PageItem {
current: boolean; current: boolean;
@@ -47,8 +47,9 @@ export function paginate(total: number, page: number, pageSize: number, options:
export interface PaginationModelOptions { export interface PaginationModelOptions {
label?: string; // the nav's aria-label; the partial defaults it label?: string; // the nav's aria-label; the partial defaults it
pageParam?: string; // as parseListQuery's options (default "page") // The two query params, named as parseListQuery was given them; pageSizeParam names the rows field.
pageSizeParam?: string; // as parseListQuery's options (default "pageSize") pageParam?: string; // default "page"
pageSizeParam?: string; // default "pageSize"
pageSizes?: number[]; // the rows-per-page choices; omitted ⇒ no rows-per-page form pageSizes?: number[]; // the rows-per-page choices; omitted ⇒ no rows-per-page form
} }