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 c3427ee..d6b0595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//…")` 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 diff --git a/README.md b/README.md index 5e59558..3880909 100644 --- a/README.md +++ b/README.md @@ -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: "

Hello from my plugin

" }) }, @@ -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//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: ``); + writeFileSync(join(dir, "drag", "public", "board.js"), "void 0;"); + writeFileSync(join(dir, "drag", "public", "logo.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] ?? ""); diff --git a/src/http/app.ts b/src/http/app.ts index 4f9a629..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, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) }); - const contextFor = (pluginId: string, params?: Record): 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): 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 62c988a..19c4e45 100644 --- a/src/http/context.ts +++ b/src/http/context.ts @@ -22,6 +22,10 @@ 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. 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 ?? [], diff --git a/src/http/static.ts b/src/http/static.ts index 1f69005..f135b7a 100644 --- a/src/http/static.ts +++ b/src/http/static.ts @@ -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=`. 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=`. 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"; diff --git a/src/plugin-host/plugin-api.ts b/src/plugin-host/plugin-api.ts index e3adaca..e13c673 100644 --- a/src/plugin-host/plugin-api.ts +++ b/src/plugin-host/plugin-api.ts @@ -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 diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index 20767ca..98a3dea 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -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"; diff --git a/src/ui/paginate.test.ts b/src/ui/paginate.test.ts index 34c7f36..95cff3a 100644 --- a/src/ui/paginate.test.ts +++ b/src/ui/paginate.test.ts @@ -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 }); +}); diff --git a/src/ui/paginate.ts b/src/ui/paginate.ts index 859e90b..947e553 100644 --- a/src/ui/paginate.ts +++ b/src/ui/paginate.ts @@ -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 }); diff --git a/todo.md b/todo.md index 2c2bf7c..b7fcbbb 100644 --- a/todo.md +++ b/todo.md @@ -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 diff --git a/views/index.ejs b/views/index.ejs index 7e4f14f..2a9b8ff 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -12,7 +12,7 @@

${t("dashboard.starter.intro")}

${t("dashboard.starter.replace")}

export default definePlugin({
-  apiVersion: "0.4.0",
+  apiVersion: "0.5.0",
   // view names plugins/<id>/views/<view>.ejs, rendered in this same shell
   dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
 });