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:
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user