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); });