From e36ac5d697f229d7559d65fa26bda0639e3d8e67 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 18:07:50 +0100 Subject: [PATCH 1/6] feat!: the bar declares bar.actions and reads per request from ctx.capabilities (#127) BarAction and BarActionContext live here; the key is declared by declaration merging. transformResponse reads the contributions and the admin.prefix slot from ResponseTransformContext.capabilities. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 8 +++++ README.md | 5 +-- src/bar-actions.ts | 67 ++++++++++++++++++++++++++++++++++++++ src/bar.ts | 12 +++---- src/mod.ts | 13 ++++++-- tests/capabilities_test.ts | 51 ++++++++++++++++++++++++----- 6 files changed, 138 insertions(+), 18 deletions(-) create mode 100644 src/bar-actions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 64c8374..15dc740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ### Changed — BREAKING +- **The bar declares its own extension point, and reads what it needs per request (#127).** + `bar.actions` is declared here, by declaration merging, with its types `BarAction` and + `BarActionContext` (renamed from core's `AdminBarAction` / `AdminBarActionContext`, which + left core's contract; the context no longer carries `adminPrefix`). A plugin that adds a + button imports the types from `@dune/plugin-inline-edit`. `transformResponse` reads the + contributions and the `admin.prefix` slot from `ResponseTransformContext.capabilities` + instead of `barActions` / `adminPrefix`. With no `admin.prefix` provider no bar is injected, + as before (the bar's endpoints are still the admin's). - **Provides the `editor.inline` capability and reads `bar.actions`, instead of `adminServices` and `ctx.plugins` (duneorg/dune#97, `@dune/core` 0.36).** The manager is declared in `setup()` with `api.capabilities.provide("editor.inline", ...)`; the admin diff --git a/README.md b/README.md index 5e8a593..12512f8 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,9 @@ point in `transformResponse`: manager, and the admin's content endpoints use it for commits and presence. Any plugin can provide the same slot to replace it. - **Renders `bar.actions`** — buttons that other plugins contribute with - `api.capabilities.contribute("bar.actions", ({ page, adminPrefix }) => [...])` - show up in the admin bar; this plugin does not know what they do. + `api.capabilities.contribute("bar.actions", ({ page }) => [...])` show up in the + bar; this plugin does not know what they do. It declares the point, so import + `BarAction` / `BarActionContext` from `@dune/plugin-inline-edit`. Admin identity comes from whichever plugin provides `auth.session` (the admin plugin does); with none, no bar is injected. diff --git a/src/bar-actions.ts b/src/bar-actions.ts new file mode 100644 index 0000000..f65ebd1 --- /dev/null +++ b/src/bar-actions.ts @@ -0,0 +1,67 @@ +/** + * The bar's extension point. + * + * This plugin renders the editing bar and is the only reader of `bar.actions`, + * so it declares the key (and its types) itself instead of core's contract + * carrying it. A plugin that adds a button imports the types from + * `@dune/plugin-inline-edit`; importing them also makes the key known to + * TypeScript: + * + * ```ts + * api.capabilities.contribute("bar.actions", ({ page }) => + * page ? [{ id: "pdf:download", label: "PDF", href: `/pdf${page.route}` }] : []); + * ``` + * + * @module + */ + +/** + * Context passed to a `bar.actions` contribution: what belongs on the bar for + * this response. A plugin that links to its own pages builds the URLs itself + * (the admin knows its prefix); the bar passes none. + * + * @since 3.0.0 + */ +export interface BarActionContext { + /** Content page matching the current URL, or null for non-content routes. */ + page: { sourcePath: string; route: string; title: string | null; template: string } | null; +} + +/** + * A button contributed to the editing bar through the `bar.actions` + * contribution point. This plugin owns the bar; the contributing plugin controls + * only the button's appearance and behavior, not its position or styling. + * + * @since 3.0.0 + */ +export interface BarAction { + /** + * Unique id — namespace by plugin name to avoid collisions across + * plugins, e.g. `"pdf-export:download"`. + */ + id: string; + /** Button label. */ + label: string; + /** Optional emoji/icon prefix. */ + icon?: string; + /** + * Renders as a plain link (``) when set — the common case for + * downloads and navigation. Mutually exclusive with `onClick`; `href` + * takes precedence if both are set. + */ + href?: string; + /** + * Renders as a ` - ${renderAdminBarActions(actions)} + ${renderBarActions(actions)} Open in admin → ${escapeHtml(userName)} @@ -760,7 +760,7 @@ export async function injectAdminBar( userName: string; pluginVersion: string; /** Buttons contributed by other plugins through the `bar.actions` capability point. */ - actions?: AdminBarAction[]; + actions?: BarAction[]; }, ): Promise { if (!response.body) return response; diff --git a/src/mod.ts b/src/mod.ts index b94f662..e4ec178 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -38,6 +38,7 @@ import type { DunePlugin } from "@dune/core/plugins"; import { createInlineEditManager } from "./manager.ts"; import { injectAdminBar } from "./bar.ts"; +import "./bar-actions.ts"; const plugin: DunePlugin = { name: "inline-edit", @@ -63,7 +64,7 @@ const plugin: DunePlugin = { ); }, - transformResponse({ req, response, auth, page, adminPrefix, barActions }) { + transformResponse({ req, response, auth, page, capabilities }) { // Only inject for authenticated admins with edit rights on content pages. if (!auth || !auth.hasPermission("pages.update")) return response; if (!page) return response; @@ -72,13 +73,20 @@ const plugin: DunePlugin = { const contentType = response.headers.get("Content-Type") ?? ""; if (!contentType.includes("text/html")) return response; + // The bar's edit, save and source controls talk to the admin's endpoints + // for now, so it needs to know where the admin is mounted; without one + // there is nothing for them to call. Read per request: this module is + // shared between sites. + const adminPrefix = capabilities.get("admin.prefix")?.prefix; + if (!adminPrefix) return response; + // Never inject into admin paths. const url = new URL(req.url); if (url.pathname.startsWith(adminPrefix)) return response; // Buttons any plugin contributed to the `bar.actions` point. This bar // renders them without knowing what they do. - const actions = barActions.flatMap((contribute) => contribute({ page, adminPrefix })); + const actions = capabilities.list("bar.actions").flatMap((contribute) => contribute({ page })); return injectAdminBar(response, { sourcePath: page.sourcePath, @@ -94,6 +102,7 @@ const plugin: DunePlugin = { export default plugin; export { createInlineEditManager } from "./manager.ts"; +export type { BarAction, BarActionContext } from "./bar-actions.ts"; export type { InlineEditClient, InlineEditManagerOptions, diff --git a/tests/capabilities_test.ts b/tests/capabilities_test.ts index 55af8aa..3015ee2 100644 --- a/tests/capabilities_test.ts +++ b/tests/capabilities_test.ts @@ -1,7 +1,7 @@ /** * The plugin meets core through the capability registry: it provides the - * `inline-edit` slot in `setup()` and renders the `bar.actions` contributions - * that arrive as `ResponseTransformContext.barActions`. + * `editor.inline` slot in `setup()` and renders the `bar.actions` contributions + * it reads from `ResponseTransformContext.capabilities`. */ import { assertEquals, assertExists, assertStringIncludes } from "@std/assert"; @@ -40,7 +40,16 @@ Deno.test("setup() provides the inline-edit slot, built from the capability cont } }); -Deno.test("transformResponse renders the buttons delivered as barActions", async () => { +/** A reader holding the `admin.prefix` slot and the given `bar.actions` contributions. */ +function reader(barActions: unknown[], prefix: string | null = "/admin") { + return { + get: (name: string) => (name === "admin.prefix" && prefix ? { prefix } : undefined), + list: (point: string) => (point === "bar.actions" ? barActions : []), + providers: () => [], + }; +} + +Deno.test("transformResponse renders the buttons read from the bar.actions contributions", async () => { const response = new Response("

Hi

", { headers: { "Content-Type": "text/html; charset=utf-8" }, }); @@ -50,13 +59,12 @@ Deno.test("transformResponse renders the buttons delivered as barActions", async response, auth: { username: "alice", role: "admin", hasPermission: () => true }, page: { sourcePath: "content/about.md", route: "/about", title: "About", template: "post" }, - adminPrefix: "/admin", config: {}, - barActions: [ + capabilities: reader([ () => [{ id: "pdf:download", label: "PDF export", href: "/pdf/about" }], ({ page }: { page: { template: string } | null }) => page?.template === "post" ? [{ id: "x:post-only", label: "Post only", href: "/x" }] : [], - ], + ]), }) as Response; const html = await out.text(); assertStringIncludes(html, "PDF export"); @@ -71,9 +79,36 @@ Deno.test("transformResponse does nothing for visitors without edit rights", asy response, auth: null, page: { sourcePath: "content/about.md", route: "/about", title: "About", template: "post" }, - adminPrefix: "/admin", config: {}, - barActions: [() => [{ id: "a", label: "Never shown", href: "/a" }]], + capabilities: reader([() => [{ id: "a", label: "Never shown", href: "/a" }]]), + }) as Response; + assertEquals(out, response); +}); + +Deno.test("transformResponse injects nothing when no plugin provides admin.prefix", async () => { + const response = new Response("x", { headers: { "Content-Type": "text/html" } }); + // deno-lint-ignore no-explicit-any + const out = await (plugin.transformResponse as any)({ + req: new Request("http://localhost/about"), + response, + auth: { username: "alice", role: "admin", hasPermission: () => true }, + page: { sourcePath: "content/about.md", route: "/about", title: "About", template: "post" }, + config: {}, + capabilities: reader([], null), + }) as Response; + assertEquals(out, response); +}); + +Deno.test("transformResponse skips the admin's own pages, using the prefix the admin provides", async () => { + const response = new Response("x", { headers: { "Content-Type": "text/html" } }); + // deno-lint-ignore no-explicit-any + const out = await (plugin.transformResponse as any)({ + req: new Request("http://localhost/panel/pages"), + response, + auth: { username: "alice", role: "admin", hasPermission: () => true }, + page: { sourcePath: "content/about.md", route: "/about", title: "About", template: "post" }, + config: {}, + capabilities: reader([], "/panel"), }) as Response; assertEquals(out, response); }); From 5fa302ba4953fe2198862c22a7c4fe8c976b45d8 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 21:16:57 +0100 Subject: [PATCH 2/6] feat!: serve source/commit/fields at /api/inline-edit/content/*, with no admin needed (duneorg/dune#127) handleRequest implements the three routes that lived in the admin's /admin/api/content/*, with the TSX and page-ownership rules they enforced. Core decides who may edit; the bar is injected for any signed-in editor, with or without an admin, and no longer renders the admin's "Edit source" overlay or "Open in admin" link (the admin contributes those as bar actions). onPageUpdate carries the editor's name as actor. Co-Authored-By: Claude Sonnet 5.5 --- src/bar.ts | 87 ++-------------------- src/commit.ts | 4 +- src/http.ts | 125 ++++++++++++++++++++++++++++++++ src/manager.ts | 27 ++++++- src/mod.ts | 34 ++++----- src/types.ts | 2 + tests/bar_test.ts | 53 ++++---------- tests/capabilities_test.ts | 15 ++-- tests/commit_test.ts | 8 +-- tests/http_test.ts | 143 +++++++++++++++++++++++++++++++++++++ 10 files changed, 346 insertions(+), 152 deletions(-) create mode 100644 src/http.ts create mode 100644 tests/http_test.ts diff --git a/src/bar.ts b/src/bar.ts index ba6b139..8747772 100644 --- a/src/bar.ts +++ b/src/bar.ts @@ -128,7 +128,6 @@ function renderBarActions(actions: BarAction[]): string { export function buildAdminBarHtml(opts: { sourcePath: string; pageTitle: string | null; - adminPrefix: string; userName: string; pluginVersion: string; /** Buttons contributed by other plugins through the `bar.actions` capability point — see mod.ts. */ @@ -138,9 +137,8 @@ export function buildAdminBarHtml(opts: { * page (a title `

` or a `data-dune-body` element). When false, the * "Editing"/"Save" buttons are pure no-ops — clicking "Editing" enables * hover handles that will never find a target, and there's nothing a - * field-level Save could ever commit — so both are omitted. "Edit source" - * stays: it always works, regardless of whether inline annotation found - * anything. Defaults to true (render normally) for callers that haven't + * field-level Save could ever commit — so both are omitted. Buttons other + * plugins contribute stay. Defaults to true (render normally) for callers that haven't * computed it, e.g. call sites predating this option. */ hasEditableContent?: boolean; @@ -148,25 +146,16 @@ export function buildAdminBarHtml(opts: { const { sourcePath, pageTitle, - adminPrefix, userName, pluginVersion, actions = [], hasEditableContent = true, } = opts; const encodedPath = encodeURIComponent(sourcePath); - // Was `${adminPrefix}/pages/${encodedPath}` — no such route exists - // (plugin-admin registers /admin/pages/edit?path=..., not a - // /admin/pages/ segment route); that 404'd. Fixed alongside adding - // the "Edit source" overlay below, which opens the same URL in an iframe. - const adminPageUrl = `${adminPrefix}/pages/edit?path=${encodedPath}`; - // The overlay iframe gets `embedded=1` (plugin-admin's _layout.tsx drops - // the sidebar/topbar chrome for it — redundant floated over a content - // page, nowhere to navigate to). The escape link stays un-embedded since - // that's a real full-page navigation, where the full admin shell belongs. - const adminPageUrlEmbedded = `${adminPageUrl}&embedded=1`; - const commitUrl = `${adminPrefix}/api/content/${encodedPath}/commit`; - const fieldsUrl = `${adminPrefix}/api/content/${encodedPath}/fields`; + // The routes below are this plugin's own (core gates them and hands the + // request to `handleRequest`), so the bar works with or without an admin. + const commitUrl = `/api/inline-edit/content/${encodedPath}/commit`; + const fieldsUrl = `/api/inline-edit/content/${encodedPath}/fields`; return `