diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e8752..64c8374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +### Changed — BREAKING + +- **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 + 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..b94f662 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 `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( + "editor.inline", + ({ 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..55af8aa --- /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), ["editor.inline"]); + + const root = await Deno.makeTempDir({ prefix: "dune_inline_edit_caps_" }); + try { + const storage = createStorage({ rootDir: root }); + const manager = provided["editor.inline"]({ + 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..d267fc0 --- /dev/null +++ b/tests/core_integration_test.ts @@ -0,0 +1,56 @@ +/** + * 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). + */ + +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 }); + 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("editor.inline"), 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 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 === "editor.inline"), { + name: "editor.inline", + kind: "slot", + provider: "inline-edit", + status: "active", + }); + const res = await handler(new Request("http://localhost/api/inline-edit/ws")); + assertEquals(res.status, 426); + }); + }, +);