Add buildPaginationModel and ctx.assetHref, and bump the plugin contract to 0.5.0
CI / full-gate (push) Failing after 5s
CI / full-gate (push) Failing after 5s
This commit is contained in:
@@ -6,6 +6,25 @@ at boot. Entries start at 0.3.0.
|
|||||||
|
|
||||||
## Unreleased
|
## 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; nothing else in a 0.4 plugin
|
||||||
|
needs to change.
|
||||||
|
|
||||||
|
### `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.
|
||||||
|
|
||||||
|
### 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,
|
||||||
|
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
|
### 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
|
A route or nav node that gates on a permission no installed plugin declares now logs a boot warning
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
|
|||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.5.0",
|
||||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||||
@@ -437,7 +437,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
import { listThings, createThings } from "./handlers.ts";
|
import { listThings, createThings } from "./handlers.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
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.
|
// 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).
|
// `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
|
`include()` the core building-block partials and its own. To load the plugin's own CSS, pass its
|
||||||
`/public/<id>/x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`.
|
`/public/<id>/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
|
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
|
any cache in front of Plainpages keep the file for a year. Get the same for a script or image your
|
||||||
is kept for five minutes, then revalidated.
|
own markup links by wrapping its href: `<script src="<%= assetHref("/public/<id>/x.js") %>" defer>`
|
||||||
|
in a view, `ctx.assetHref(href)` in a handler. An unwrapped `/public/` URL is kept for five
|
||||||
|
minutes, then revalidated.
|
||||||
- **Finer authorization than the route `permission`** uses the guards from `@plainpages/plugin-api`:
|
- **Finer authorization than the route `permission`** uses the guards from `@plainpages/plugin-api`:
|
||||||
`requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and
|
`requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and
|
||||||
`check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw
|
`check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw
|
||||||
@@ -561,7 +563,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
import { landing, board } from "./pages.ts";
|
import { landing, board } from "./pages.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.5.0",
|
||||||
home: landing, // owns "/" — the public front page
|
home: landing, // owns "/" — the public front page
|
||||||
dashboard: board, // owns "/dashboard" — the post-login app home
|
dashboard: board, // owns "/dashboard" — the post-login app home
|
||||||
});
|
});
|
||||||
@@ -593,6 +595,7 @@ interface RequestContext {
|
|||||||
locale: string; // the locale being served, e.g. "sv-SE"
|
locale: string; // the locale being served, e.g. "sv-SE"
|
||||||
locales: string[]; // every installed locale, sorted
|
locales: string[]; // every installed locale, sorted
|
||||||
localeHref(href): string; // carry an explicitly chosen locale onto a link this page renders
|
localeHref(href): string; // carry an explicitly chosen locale onto a link this page renders
|
||||||
|
assetHref(href): string; // version a /public/ href so it is cached for a year; any other href unchanged
|
||||||
query: URLSearchParams; // alias of url.searchParams
|
query: URLSearchParams; // alias of url.searchParams
|
||||||
req: IncomingMessage;
|
req: IncomingMessage;
|
||||||
res: ServerResponse;
|
res: ServerResponse;
|
||||||
@@ -850,7 +853,7 @@ camel humps both becoming underscores — so `upstream` on the `scheduling` plug
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.5.0",
|
||||||
settings: [
|
settings: [
|
||||||
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
|
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
|
||||||
{ key: "pageSize", type: "number", default: 25 },
|
{ key: "pageSize", type: "number", default: 25 },
|
||||||
@@ -904,7 +907,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
let sql: ReturnType<typeof postgres>;
|
let sql: ReturnType<typeof postgres>;
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.5.0",
|
||||||
storage: true,
|
storage: true,
|
||||||
hooks: {
|
hooks: {
|
||||||
onBoot: async (boot) => {
|
onBoot: async (boot) => {
|
||||||
@@ -1039,10 +1042,25 @@ fully styled and zero-JS:
|
|||||||
- **Partials:** app shell, nav tree, filter bar, data table (sort / select / row actions),
|
- **Partials:** app shell, nav tree, filter bar, data table (sort / select / row actions),
|
||||||
pagination, form fields, badges, menus, auth cards.
|
pagination, form fields, badges, menus, auth cards.
|
||||||
- **Helpers:** `composeNav` (menu from config), `parseListQuery`
|
- **Helpers:** `composeNav` (menu from config), `parseListQuery`
|
||||||
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the
|
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math),
|
||||||
|
`buildPaginationModel` (page math → the pagination partial, sketched below), and the
|
||||||
auth guards (`src/auth/guards.ts`): `requireSession`, `can(permission)` (coarse JWT-claim check,
|
auth guards (`src/auth/guards.ts`): `requireSession`, `can(permission)` (coarse JWT-claim check,
|
||||||
zero I/O), `check(relation, object)` (the one live Keto call).
|
zero I/O), `check(relation, object)` (the one live Keto call).
|
||||||
|
|
||||||
|
A list page's pagination, from URL to partial (`examples/plugins/admin/admin-users.ts` is the full
|
||||||
|
page):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const query = parseListQuery(ctx.url); // { q, filters, sort, page, pageSize }
|
||||||
|
const page = paginate(rows.length, query.page, query.pageSize); // or an upstream's total
|
||||||
|
const visible = rows.slice(page.from - 1, page.to);
|
||||||
|
const pagination = buildPaginationModel(page, (n) => listHref({ ...query, page: n }), { pageSizes: [25, 50, 100] });
|
||||||
|
// view: <%- include("partials/pagination", pagination) %>
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
## Interactivity: zero-JS spine
|
## Interactivity: zero-JS spine
|
||||||
|
|
||||||
The core and all building blocks **work with zero JavaScript** — theme switching and filtering are
|
The core and all building blocks **work with zero JavaScript** — theme switching and filtering are
|
||||||
@@ -1188,7 +1206,7 @@ Rules worth knowing:
|
|||||||
edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through
|
edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through
|
||||||
to them. Keep your catalog for your domain words.
|
to them. Keep your catalog for your domain words.
|
||||||
- **Reserved view locals:** `t`, `locale`, `locales`, `localeHref`, `localeParam`, `localeSwitch`,
|
- **Reserved view locals:** `t`, `locale`, `locales`, `localeHref`, `localeParam`, `localeSwitch`,
|
||||||
`dir`, and the host-internal `assetHref`. They are merged after your `data`, so a colliding key of yours is ignored rather than
|
`dir`, and `assetHref`. They are merged after your `data`, so a colliding key of yours is ignored rather than
|
||||||
breaking the shell. `locale` is likewise reserved in `parseListQuery`, never returned as a filter.
|
breaking the shell. `locale` is likewise reserved in `parseListQuery`, never returned as a filter.
|
||||||
|
|
||||||
**Kratos writes the auth flow's own text** (field labels, validation errors), tagging each string
|
**Kratos writes the auth flow's own text** (field labels, validation errors), tagging each string
|
||||||
|
|||||||
@@ -337,3 +337,20 @@ Read most (opens and searches):
|
|||||||
- `CHANGELOG.md` 1
|
- `CHANGELOG.md` 1
|
||||||
- `examples/plugins/admin/i18n/sv-SE.ts` 1
|
- `examples/plugins/admin/i18n/sv-SE.ts` 1
|
||||||
- `src/auth/routes.ts` 1
|
- `src/auth/routes.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk b94ae3839c24, finished 2026-11-08T13:27:14Z
|
||||||
|
|
||||||
|
87251 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 9
|
||||||
|
- `README.md` 7
|
||||||
|
- `src/plugin-host/plugin.test.ts` 6
|
||||||
|
- `src/plugin-host/plugin.ts` 5
|
||||||
|
- `src/test-tooling/stylesheet-floor.ts` 4
|
||||||
|
- `AGENTS.md` 3
|
||||||
|
- `src/test-tooling/app-harness.ts` 3
|
||||||
|
- `src/plugin-host/discovery.ts` 2
|
||||||
|
- `src/test-tooling/stylesheet-floor.test.ts` 2
|
||||||
|
- `.dockerignore` 1
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
|
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
|
||||||
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
|
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
|
||||||
|
|
||||||
import { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
import { buildPaginationModel, can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
||||||
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ export function buildClientsListModel(opts: {
|
|||||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }],
|
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }],
|
||||||
canWrite: opts.canWrite !== false,
|
canWrite: opts.canWrite !== false,
|
||||||
filterBar: listFilterBar(state, t),
|
filterBar: listFilterBar(state, t),
|
||||||
pagination: listPagination(state, page, t),
|
pagination: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.clients.pagination"), pageSizes: PAGE_SIZES }),
|
||||||
table: listTable(rows, t),
|
table: listTable(rows, t),
|
||||||
title: t("admin.clients.title"),
|
title: t("admin.clients.title"),
|
||||||
};
|
};
|
||||||
@@ -158,21 +158,6 @@ function listFilterBar(state: ListState, t: Translate) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
|
|
||||||
const hidden: { name: string; value: string }[] = [];
|
|
||||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
|
||||||
return {
|
|
||||||
label: t("admin.clients.pagination"),
|
|
||||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
|
||||||
pages: page.pages.map((p) =>
|
|
||||||
p.ellipsis ? { ellipsis: true }
|
|
||||||
: p.current ? { current: true, label: String(p.page) }
|
|
||||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
|
||||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
|
||||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
|
||||||
summary: { from: page.from, to: page.to, total: page.total },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- register form + detail view models ----
|
// ---- register form + detail view models ----
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
||||||
// each returning a RouteResult.
|
// each returning a RouteResult.
|
||||||
|
|
||||||
import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
|
import { buildPaginationModel, can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
|
||||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||||
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
@@ -143,7 +143,7 @@ export function buildGroupsListModel(opts: {
|
|||||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
|
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
|
||||||
canWrite: opts.canWrite !== false,
|
canWrite: opts.canWrite !== false,
|
||||||
filterBar: listFilterBar(state, t),
|
filterBar: listFilterBar(state, t),
|
||||||
pagination: listPagination(state, page, t),
|
pagination: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.groups.pagination"), pageSizes: PAGE_SIZES }),
|
||||||
table: listTable(rows, state, sort, t),
|
table: listTable(rows, state, sort, t),
|
||||||
title: t("admin.groups.title"),
|
title: t("admin.groups.title"),
|
||||||
};
|
};
|
||||||
@@ -179,22 +179,6 @@ function listFilterBar(state: ListState, t: Translate) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
|
|
||||||
const hidden: { name: string; value: string }[] = [];
|
|
||||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
|
||||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
|
||||||
return {
|
|
||||||
label: t("admin.groups.pagination"),
|
|
||||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
|
||||||
pages: page.pages.map((p) =>
|
|
||||||
p.ellipsis ? { ellipsis: true }
|
|
||||||
: p.current ? { current: true, label: String(p.page) }
|
|
||||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
|
||||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
|
||||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
|
||||||
summary: { from: page.from, to: page.to, total: page.total },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- create form + detail view models ----
|
// ---- create form + detail view models ----
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
|
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
|
||||||
// a shared `withUser` gate.
|
// a shared `withUser` gate.
|
||||||
|
|
||||||
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
import { buildPaginationModel, can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
||||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||||
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ export function buildUsersListModel(opts: {
|
|||||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
|
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
|
||||||
canWrite: opts.canWrite !== false,
|
canWrite: opts.canWrite !== false,
|
||||||
filterBar: listFilterBar(state, all.length, t),
|
filterBar: listFilterBar(state, all.length, t),
|
||||||
pagination: listPagination(state, page, t),
|
pagination: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.users.pagination"), pageSizes: PAGE_SIZES }),
|
||||||
table: listTable(rows, state, sort, t, opts.canWrite !== false),
|
table: listTable(rows, state, sort, t, opts.canWrite !== false),
|
||||||
title: t("admin.users.title"),
|
title: t("admin.users.title"),
|
||||||
};
|
};
|
||||||
@@ -185,23 +185,6 @@ function listFilterBar(state: ListState, total: number, t: Translate) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
|
|
||||||
const hidden: { name: string; value: string }[] = [];
|
|
||||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
|
||||||
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
|
|
||||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
|
||||||
return {
|
|
||||||
label: t("admin.users.pagination"),
|
|
||||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
|
||||||
pages: page.pages.map((p) =>
|
|
||||||
p.ellipsis ? { ellipsis: true }
|
|
||||||
: p.current ? { current: true, label: String(p.page) }
|
|
||||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
|
||||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
|
||||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
|
||||||
summary: { from: page.from, to: page.to, total: page.total },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FieldConfig {
|
export interface FieldConfig {
|
||||||
autocomplete?: string;
|
autocomplete?: string;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const clients = on("oauth2-clients");
|
|||||||
const pluginSettings = on("plugin-settings");
|
const pluginSettings = on("plugin-settings");
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "0.5.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||||
|
|
||||||
nav: [ADMIN_NAV],
|
nav: [ADMIN_NAV],
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ let upstreamUrl = "";
|
|||||||
const upstream = createUpstream(() => upstreamUrl);
|
const upstream = createUpstream(() => upstreamUrl);
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "0.5.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||||
|
|
||||||
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
|
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
|
||||||
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test("readHostApiVersion pulls the constant out of the real source, and returns
|
|||||||
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
|
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
|
||||||
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
|
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
|
||||||
// makes an accidental edit fail here rather than at release time.
|
// makes an accidental edit fail here rather than at release time.
|
||||||
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.4.0");
|
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.5.0");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("every author-facing apiVersion sample matches the shipped contract", () => {
|
test("every author-facing apiVersion sample matches the shipped contract", () => {
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
|
|||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.5.0",
|
||||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||||
|
|||||||
@@ -392,6 +392,22 @@ test("renders the 500 HTML page when a handler throws", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a plugin versions the /public/ files its own markup links, through the assetHref view local or ctx.assetHref", async (t) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
||||||
|
mkdirSync(join(dir, "drag", "views"), { recursive: true });
|
||||||
|
mkdirSync(join(dir, "drag", "public"), { recursive: true });
|
||||||
|
writeFileSync(join(dir, "drag", "views", "page.ejs"), `<script src="<%= assetHref("/public/drag/board.js") %>"></script><img src="<%= logo %>" alt="">`);
|
||||||
|
writeFileSync(join(dir, "drag", "public", "board.js"), "void 0;");
|
||||||
|
writeFileSync(join(dir, "drag", "public", "logo.svg"), "<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.
|
// 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] ?? "");
|
const publicRefs = (html: string): string[] => [...html.matchAll(/(?:href|src)="(\/public\/[^"]*)"/g)].map((m) => m[1] ?? "");
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
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 =>
|
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
||||||
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
buildContext(req, res, { assetHref: assets.href, chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
||||||
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.
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ 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
|
||||||
|
// 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`.
|
||||||
|
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).
|
||||||
chrome: PageChrome;
|
chrome: PageChrome;
|
||||||
@@ -66,6 +70,7 @@ export interface RequestContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildContextOptions {
|
export interface BuildContextOptions {
|
||||||
|
assetHref?: (href: string) => string;
|
||||||
// Lazy chrome factory: composing the global menu is only paid for if the handler actually reads
|
// 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).
|
// 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.
|
// 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;
|
const buildChrome = options.chrome;
|
||||||
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
||||||
return {
|
return {
|
||||||
|
assetHref: options.assetHref ?? ((href) => href),
|
||||||
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
||||||
declaredPermissions: options.declaredPermissions ?? [],
|
declaredPermissions: options.declaredPermissions ?? [],
|
||||||
declaredSettings: options.declaredSettings ?? [],
|
declaredSettings: options.declaredSettings ?? [],
|
||||||
|
|||||||
+5
-6
@@ -50,12 +50,11 @@ export function routePublic(restPath: string, publicDir: string, pluginsDir: str
|
|||||||
return { dir: publicDir, subPath: restPath };
|
return { dir: publicDir, subPath: restPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caching: the host renders every /public/ href through assetHref, which appends `?v=<hash of the
|
// Caching: every /public/ href rendered through assetHref (the host's, and a plugin's through the view
|
||||||
// file's content>`. A request whose `v` is the current hash is cached for a year as immutable, so a
|
// local or ctx.assetHref) gets `?v=<hash of the file's content>`. A request whose `v` is the current
|
||||||
// repeat page view downloads no asset; a changed file gets a new hash, and with it a new URL. Any other
|
// hash is cached for a year as immutable, so a repeat page view downloads no asset; a changed file gets
|
||||||
// request — an href a plugin's own markup emits, a stale `v` mid-deploy — is cached for five minutes,
|
// a new hash, and with it a new URL. Any other request — an unwrapped href, a stale `v` mid-deploy — is
|
||||||
// then revalidated against the ETag (that same hash) or Last-Modified. A plugin has no way to version
|
// cached for five minutes, then revalidated against the ETag (that same hash) or Last-Modified.
|
||||||
// its own hrefs yet: `assetHref` is a reserved local but not contract, since offering it is a minor.
|
|
||||||
const IMMUTABLE = "public, max-age=31536000, immutable";
|
const IMMUTABLE = "public, max-age=31536000, immutable";
|
||||||
const SHORT = "public, max-age=300";
|
const SHORT = "public, max-age=300";
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// 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 type { CoreMessages } from "../i18n/locales/en-US.ts";
|
||||||
export { parseListQuery } from "../ui/list-query.ts";
|
export { parseListQuery } from "../ui/list-query.ts";
|
||||||
export { paginate } from "../ui/paginate.ts";
|
export { buildPaginationModel, paginate } from "../ui/paginate.ts";
|
||||||
export type { PageModel } from "../ui/paginate.ts";
|
export type { PageModel, PaginationModel, PaginationModelOptions } from "../ui/paginate.ts";
|
||||||
export { readFormBody } from "../http/body.ts";
|
export { readFormBody } from "../http/body.ts";
|
||||||
export { CSRF_FIELD } from "../auth/csrf.ts";
|
export { CSRF_FIELD } from "../auth/csrf.ts";
|
||||||
// System capabilities for a privileged/system plugin (ctx.system) — the Ory admin clients + the
|
// System capabilities for a privileged/system plugin (ctx.system) — the Ory admin clients + the
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
|
|||||||
import type { StorageCredentials } from "./storage.ts";
|
import type { StorageCredentials } from "./storage.ts";
|
||||||
|
|
||||||
// The Plainpages release this contract ships in — see README → Contract versioning.
|
// 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";
|
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
||||||
|
|
||||||
|
|||||||
+37
-1
@@ -1,6 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
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.
|
// Compact view of the page sequence: ellipsis → "…", current → "[n]", else the number.
|
||||||
const shape = (m: PageModel): (number | string | null)[] =>
|
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, { 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]);
|
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 disables the step off either end, 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 });
|
||||||
|
});
|
||||||
|
|||||||
+46
-4
@@ -1,7 +1,7 @@
|
|||||||
// paginate: pagination math → the model pagination.ejs renders. Pure and
|
// paginate: pagination math → a PageModel. Pure and URL-free (README signature
|
||||||
// URL-free (README signature `paginate(total, page, pageSize)`); the caller maps each
|
// `paginate(total, page, pageSize)`); buildPaginationModel then maps it, with the caller's
|
||||||
// page number to an href. Inputs are clamped/guarded so it never produces a broken model:
|
// page → href function, to the model pagination.ejs renders. Inputs are clamped/guarded so it never
|
||||||
// page is pinned to [1, pageCount], total/pageSize coerced to sane integers.
|
// produces a broken model: page is pinned to [1, pageCount], total/pageSize coerced to sane integers.
|
||||||
|
|
||||||
export interface PageItem {
|
export interface PageItem {
|
||||||
current: boolean;
|
current: boolean;
|
||||||
@@ -45,6 +45,48 @@ 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")
|
||||||
|
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 item = (page: number, current = false): PageItem => ({ current, ellipsis: false, page });
|
||||||
const gap = (): PageItem => ({ current: false, ellipsis: true, page: null });
|
const gap = (): PageItem => ({ current: false, ellipsis: true, page: null });
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,6 @@
|
|||||||
|
|
||||||
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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.
|
### 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
|
Today the `request` line and span in `src/http/app.ts` carry only method, path, status and `ms`, so
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
<p>${t("dashboard.starter.intro")}</p>
|
<p>${t("dashboard.starter.intro")}</p>
|
||||||
<p>${t("dashboard.starter.replace")}</p>
|
<p>${t("dashboard.starter.replace")}</p>
|
||||||
<pre class="code-block"><code>export default definePlugin({
|
<pre class="code-block"><code>export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.5.0",
|
||||||
// view names plugins/<id>/views/<view>.ejs, rendered in this same shell
|
// view names plugins/<id>/views/<view>.ejs, rendered in this same shell
|
||||||
dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
|
dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
|
||||||
});</code></pre>
|
});</code></pre>
|
||||||
|
|||||||
Reference in New Issue
Block a user