From a15035da2fc72d89ed3968ba982e1c646d0d1d16 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 12:04:35 +0100 Subject: [PATCH 01/11] feat!: provide the inline-edit capability and render bar.actions (duneorg/dune#97) setup() declares the inline-edit slot with api.capabilities.provide instead of adminServices(); transformResponse collects the admin bar's buttons from ResponseTransformContext.barActions instead of ctx.plugins[].adminBarActions. Needs @dune/core 0.36; major version bump (3.0.0) at release. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 11 +++++ README.md | 17 +++++++- src/bar.ts | 6 +-- src/commit.ts | 4 +- src/mod.ts | 22 ++++++---- src/types.ts | 6 +-- tests/capabilities_test.ts | 79 ++++++++++++++++++++++++++++++++++ tests/core_integration_test.ts | 56 ++++++++++++++++++++++++ 8 files changed, 183 insertions(+), 18 deletions(-) create mode 100644 tests/capabilities_test.ts create mode 100644 tests/core_integration_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e8752..041aabe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +### Changed — BREAKING + +- **Provides the `inline-edit` 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("inline-edit", ...)`; the admin + bar collects its buttons from `ResponseTransformContext.barActions`, which carries what + plugins contributed with `capabilities.contribute("bar.actions", ...)`. Core 0.36 + removed `DunePlugin.adminServices`, `adminBarActions` and + `ResponseTransformContext.plugins`, so this release needs `@dune/core` 0.36 or newer + and a major version bump (3.0.0) at release. + ### Fixed - **Failed saves and dropped frames are no longer silent (#3).** A failed write of the diff --git a/README.md b/README.md index 1529ed4..5e8a593 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,24 @@ Core defines the service interface and hosts the authenticated admin endpoints; this plugin supplies the implementation. Without it, Dune's inline-edit endpoints respond 501 and no edit chrome is injected. +## How it plugs into core + +The plugin declares one capability in `setup()` and consumes one contribution +point in `transformResponse`: + +- **Provides `inline-edit`** — core's `/api/inline-edit/ws` upgrades through this + 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. + +Admin identity comes from whichever plugin provides `auth.session` (the admin +plugin does); with none, no bar is injected. + ## Installation -Requires `@dune/core` ≥ 0.19. +Requires `@dune/core` ≥ 0.36 (the capability registry; earlier cores used `DunePlugin.adminServices`, which this plugin no longer implements). Add the plugin to your site's `site.yaml`: diff --git a/src/bar.ts b/src/bar.ts index 809de5b..0a59df7 100644 --- a/src/bar.ts +++ b/src/bar.ts @@ -97,7 +97,7 @@ function jsonStr(s: string): string { } /** - * Render buttons contributed by other plugins via `DunePlugin.adminBarActions` + * Render buttons contributed by other plugins through the `bar.actions` capability point * (see mod.ts, which collects them and passes the result in here). `label`/ * `icon`/`href` are escaped as untrusted display content; `onClick` is not — * see {@link AdminBarAction.onClick}'s own doc for why that's the right call. @@ -131,7 +131,7 @@ export function buildAdminBarHtml(opts: { adminPrefix: string; userName: string; pluginVersion: string; - /** Buttons contributed by other plugins via `DunePlugin.adminBarActions` — see mod.ts. */ + /** Buttons contributed by other plugins through the `bar.actions` capability point — see mod.ts. */ actions?: AdminBarAction[]; /** * Whether `annotateEditableElements()` found anything to annotate on this @@ -759,7 +759,7 @@ export async function injectAdminBar( adminPrefix: string; userName: string; pluginVersion: string; - /** Buttons contributed by other plugins via `DunePlugin.adminBarActions`. */ + /** Buttons contributed by other plugins through the `bar.actions` capability point. */ actions?: AdminBarAction[]; }, ): Promise { diff --git a/src/commit.ts b/src/commit.ts index 2d0fc37..20a9453 100644 --- a/src/commit.ts +++ b/src/commit.ts @@ -77,8 +77,8 @@ export async function commitDoc(opts: { * Fires `onPageUpdate` after a successful write, matching plugin-admin's * page-save route — without this, plugins that react to that hook (e.g. * regenerating content derived from the edited page) never see inline - * edits, only edits made through the admin CRUD routes. Optional so - * callers that predate AdminServicesContext.hooks still work. + * edits, only edits made through the admin CRUD routes. Optional so a + * caller that omits it (a test, a script) still works. */ hooks?: HookRegistry; }): Promise { diff --git a/src/mod.ts b/src/mod.ts index ca9e561..a8cef86 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -52,13 +52,18 @@ const plugin: DunePlugin = { editor: import.meta.resolve("./client/editor.ts"), }, - adminServices({ storage, history, dataDir, contentDir, hooks }) { - return { - inlineEdit: createInlineEditManager({ storage, history, dataDir, contentDir, hooks }), - }; + // The `inline-edit` capability: core's `/api/inline-edit/ws` upgrades through + // this manager, and the admin's content endpoints use it for commits and + // presence. Core builds it once the history engine and storage exist. + setup(api) { + api.capabilities.provide( + "inline-edit", + ({ storage, history, dataDir, contentDir, hooks }) => + createInlineEditManager({ storage, history, dataDir, contentDir, hooks }), + ); }, - transformResponse({ req, response, auth, page, adminPrefix, plugins }) { + transformResponse({ req, response, auth, page, adminPrefix, barActions }) { // Only inject for authenticated admins with edit rights on content pages. if (!auth || !auth.hasPermission("pages.update")) return response; if (!page) return response; @@ -71,10 +76,9 @@ const plugin: DunePlugin = { const url = new URL(req.url); if (url.pathname.startsWith(adminPrefix)) return response; - // Collect adminBarActions from every plugin (including this one — it - // declares none) — see DunePlugin.adminBarActions for why this bar - // renders other plugins' buttons without knowing what they do. - const actions = plugins.flatMap((p) => p.adminBarActions?.({ page, adminPrefix }) ?? []); + // 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 })); return injectAdminBar(response, { sourcePath: page.sourcePath, diff --git a/src/types.ts b/src/types.ts index 5d2b7be..00a7b2d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,9 +48,9 @@ export interface InlineEditManagerOptions { autoFlushMs?: number; /** * Fires `onPageUpdate` after each commit/patch, matching plugin-admin's - * page-save routes. Optional so callers on a dune-core version that - * predates AdminServicesContext.hooks still work (edits still save — - * they just don't fire the hook). + * page-save routes. Optional so a caller that builds a manager directly + * (a test, a script) can omit it: edits still save, they just don't fire + * the hook. Core passes it through the capability context. */ hooks?: HookRegistry; } diff --git a/tests/capabilities_test.ts b/tests/capabilities_test.ts new file mode 100644 index 0000000..f2c57d0 --- /dev/null +++ b/tests/capabilities_test.ts @@ -0,0 +1,79 @@ +/** + * 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`. + */ + +import { assertEquals, assertExists, assertStringIncludes } from "@std/assert"; +import { createHistoryEngine, createStorage } from "@dune/core"; +import plugin from "../src/mod.ts"; + +Deno.test("setup() provides the inline-edit slot, built from the capability context", async () => { + const provided: Record unknown> = {}; + // deno-lint-ignore no-explicit-any + (plugin.setup as any)({ + capabilities: { + provide: (name: string, factory: (ctx: unknown) => unknown) => { + provided[name] = factory; + }, + contribute: () => {}, + }, + }); + assertEquals(Object.keys(provided), ["inline-edit"]); + + const root = await Deno.makeTempDir({ prefix: "dune_inline_edit_caps_" }); + try { + const storage = createStorage({ rootDir: root }); + const manager = provided["inline-edit"]({ + storage, + history: createHistoryEngine({ storage, maxRevisions: 5, dataDir: ".dune/history" } as never), + dataDir: ".dune/admin", + contentDir: "content", + hooks: undefined, + }) as { handleUpgrade?: unknown; commit?: unknown; getPresence?: unknown }; + assertExists(manager); + assertEquals(typeof manager.handleUpgrade, "function"); + assertEquals(typeof manager.commit, "function"); + assertEquals(typeof manager.getPresence, "function"); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +}); + +Deno.test("transformResponse renders the buttons delivered as barActions", async () => { + const response = new Response("

Hi

", { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + // 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" }, + adminPrefix: "/admin", + config: {}, + barActions: [ + () => [{ 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"); + assertStringIncludes(html, "Post only"); +}); + +Deno.test("transformResponse does nothing for visitors without edit rights", 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: null, + page: { sourcePath: "content/about.md", route: "/about", title: "About", template: "post" }, + adminPrefix: "/admin", + config: {}, + barActions: [() => [{ id: "a", label: "Never shown", href: "/a" }]], + }) as Response; + assertEquals(out, response); +}); diff --git a/tests/core_integration_test.ts b/tests/core_integration_test.ts new file mode 100644 index 0000000..91d2be8 --- /dev/null +++ b/tests/core_integration_test.ts @@ -0,0 +1,56 @@ +/** + * Against a real core: the plugin's `inline-edit` slot is what enables core's + * `/api/inline-edit/ws` route (501 without it, WebSocket checks with it). + */ + +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import plugin from "../src/mod.ts"; + +async function withSite( + plugins: unknown[], + fn: (ctx: Awaited>, handler: (req: Request) => Promise) => Promise, +) { + const root = await Deno.makeTempDir({ prefix: "dune_inline_edit_core_" }); + try { + await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); + await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n\nHello\n"); + // deno-lint-ignore no-explicit-any + const ctx = await bootstrap(root, { plugins: plugins as any, configOverrides: { admin: { enabled: false } as any } }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + await fn(ctx, app.handler()); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +Deno.test( + "without the plugin the inline-edit WebSocket route is not enabled", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withSite([], async (ctx, handler) => { + assertEquals(ctx.capabilities.get("inline-edit"), undefined); + const res = await handler(new Request("http://localhost/api/inline-edit/ws")); + assertEquals(res.status, 501); + }); + }, +); + +Deno.test( + "with the plugin core resolves the inline-edit slot and the route proceeds to the WebSocket checks", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withSite([plugin], async (ctx, handler) => { + assertEquals(ctx.capabilities.providers().find((p) => p.name === "inline-edit"), { + name: "inline-edit", + kind: "slot", + provider: "inline-edit", + status: "active", + }); + const res = await handler(new Request("http://localhost/api/inline-edit/ws")); + assertEquals(res.status, 426); + }); + }, +); From 1ac8097201e8860dec8ce1998e941b6736e4b43e Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 23:13:20 +0100 Subject: [PATCH 02/11] feat!: provide the editor.inline slot (renamed from inline-edit) (duneorg/dune#122) Core 0.36 names the slot editor.inline, matching auth.session and editor.content. No alias: a core without the rename will not find the manager. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 ++-- src/mod.ts | 4 ++-- tests/capabilities_test.ts | 4 ++-- tests/core_integration_test.ts | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 041aabe..64c8374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ ### Changed — BREAKING -- **Provides the `inline-edit` capability and reads `bar.actions`, instead of +- **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("inline-edit", ...)`; the admin + is declared in `setup()` with `api.capabilities.provide("editor.inline", ...)`; the admin bar collects its buttons from `ResponseTransformContext.barActions`, which carries what plugins contributed with `capabilities.contribute("bar.actions", ...)`. Core 0.36 removed `DunePlugin.adminServices`, `adminBarActions` and diff --git a/src/mod.ts b/src/mod.ts index a8cef86..b94f662 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -52,12 +52,12 @@ const plugin: DunePlugin = { editor: import.meta.resolve("./client/editor.ts"), }, - // The `inline-edit` capability: core's `/api/inline-edit/ws` upgrades through + // The `editor.inline` capability: core's `/api/inline-edit/ws` upgrades through // this manager, and the admin's content endpoints use it for commits and // presence. Core builds it once the history engine and storage exist. setup(api) { api.capabilities.provide( - "inline-edit", + "editor.inline", ({ storage, history, dataDir, contentDir, hooks }) => createInlineEditManager({ storage, history, dataDir, contentDir, hooks }), ); diff --git a/tests/capabilities_test.ts b/tests/capabilities_test.ts index f2c57d0..55af8aa 100644 --- a/tests/capabilities_test.ts +++ b/tests/capabilities_test.ts @@ -19,12 +19,12 @@ Deno.test("setup() provides the inline-edit slot, built from the capability cont contribute: () => {}, }, }); - assertEquals(Object.keys(provided), ["inline-edit"]); + assertEquals(Object.keys(provided), ["editor.inline"]); const root = await Deno.makeTempDir({ prefix: "dune_inline_edit_caps_" }); try { const storage = createStorage({ rootDir: root }); - const manager = provided["inline-edit"]({ + const manager = provided["editor.inline"]({ storage, history: createHistoryEngine({ storage, maxRevisions: 5, dataDir: ".dune/history" } as never), dataDir: ".dune/admin", diff --git a/tests/core_integration_test.ts b/tests/core_integration_test.ts index 91d2be8..f42c799 100644 --- a/tests/core_integration_test.ts +++ b/tests/core_integration_test.ts @@ -31,7 +31,7 @@ Deno.test( { sanitizeOps: false, sanitizeResources: false }, async () => { await withSite([], async (ctx, handler) => { - assertEquals(ctx.capabilities.get("inline-edit"), undefined); + assertEquals(ctx.capabilities.get("editor.inline"), undefined); const res = await handler(new Request("http://localhost/api/inline-edit/ws")); assertEquals(res.status, 501); }); @@ -39,12 +39,12 @@ Deno.test( ); Deno.test( - "with the plugin core resolves the inline-edit slot and the route proceeds to the WebSocket checks", + "with the plugin core resolves the editor.inline slot and the route proceeds to the WebSocket checks", { sanitizeOps: false, sanitizeResources: false }, async () => { await withSite([plugin], async (ctx, handler) => { - assertEquals(ctx.capabilities.providers().find((p) => p.name === "inline-edit"), { - name: "inline-edit", + assertEquals(ctx.capabilities.providers().find((p) => p.name === "editor.inline"), { + name: "editor.inline", kind: "slot", provider: "inline-edit", status: "active", From c579d7b045cf9adacf57f3faba68bec14f0a33a5 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 19:37:30 +0100 Subject: [PATCH 03/11] test: core no longer loads an admin by itself, so the integration test needs no admin.enabled override Co-Authored-By: Claude Sonnet 5.5 --- tests/core_integration_test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core_integration_test.ts b/tests/core_integration_test.ts index f42c799..d267fc0 100644 --- a/tests/core_integration_test.ts +++ b/tests/core_integration_test.ts @@ -1,5 +1,5 @@ /** - * Against a real core: the plugin's `inline-edit` slot is what enables core's + * Against a real core: the plugin's `editor.inline` slot is what enables core's * `/api/inline-edit/ws` route (501 without it, WebSocket checks with it). */ @@ -18,7 +18,7 @@ async function withSite( await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n\nHello\n"); // deno-lint-ignore no-explicit-any - const ctx = await bootstrap(root, { plugins: plugins as any, configOverrides: { admin: { enabled: false } as any } }); + const ctx = await bootstrap(root, { plugins: plugins as any }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); await fn(ctx, app.handler()); } finally { From e36ac5d697f229d7559d65fa26bda0639e3d8e67 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 18:07:50 +0100 Subject: [PATCH 04/11] 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 05/11] 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 `