From a15035da2fc72d89ed3968ba982e1c646d0d1d16 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 12:04:35 +0100 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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 {