Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
67 changes: 67 additions & 0 deletions src/bar-actions.ts
Original file line number Diff line number Diff line change
@@ -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 (`<a href>`) 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 `<button>` running this literal JavaScript on click, when
* `href` is not set. Trusted content from a loaded plugin, not user
* input — no sanitization is applied, the same trust level as any other
* server-rendered admin-only script.
*/
onClick?: string;
}

declare module "@dune/core/plugin-api" {

Check failure on line 62 in src/bar-actions.ts

View workflow job for this annotation

GitHub Actions / check

found an ambient module, which is a global augmentation, which are not unsupported
interface Contributions {
/** Buttons for the editing bar. Consumer: `@dune/plugin-inline-edit`. */
"bar.actions": (ctx: BarActionContext) => BarAction[];
}
}
12 changes: 6 additions & 6 deletions src/bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
* lazy-imported only when a body edit starts, so browsing costs nothing.
*/

import type { AdminBarAction } from "@dune/core/plugins";
import type { BarAction } from "./bar-actions.ts";

// ── HTML annotation pass ──────────────────────────────────────────────────────

Expand Down Expand Up @@ -100,9 +100,9 @@ function jsonStr(s: string): string {
* 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.
* see {@link BarAction.onClick}'s own doc for why that's the right call.
*/
function renderAdminBarActions(actions: AdminBarAction[]): string {
function renderBarActions(actions: BarAction[]): string {
return actions.map((a) => {
const label = `${a.icon ? escapeHtml(a.icon) + " " : ""}${
escapeHtml(a.label)
Expand Down Expand Up @@ -132,7 +132,7 @@ export function buildAdminBarHtml(opts: {
userName: string;
pluginVersion: string;
/** Buttons contributed by other plugins through the `bar.actions` capability point — see mod.ts. */
actions?: AdminBarAction[];
actions?: BarAction[];
/**
* Whether `annotateEditableElements()` found anything to annotate on this
* page (a title `<h1>` or a `data-dune-body` element). When false, the
Expand Down Expand Up @@ -340,7 +340,7 @@ export function buildAdminBarHtml(opts: {
: ""
}
<button id="dune-ab-edit-source">✎ Edit source</button>
${renderAdminBarActions(actions)}
${renderBarActions(actions)}
<a href="${adminPageUrl}" class="dune-ab-escape" title="Open full admin editor">Open in admin →</a>
<span class="dune-ab-user">${escapeHtml(userName)}</span>
</div>
Expand Down Expand Up @@ -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<Response> {
if (!response.body) return response;
Expand Down
13 changes: 11 additions & 2 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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,
Expand Down
51 changes: 43 additions & 8 deletions tests/capabilities_test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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("<html><body><h1>Hi</h1></body></html>", {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
Expand All @@ -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");
Expand All @@ -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("<html><body>x</body></html>", { 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("<html><body>x</body></html>", { 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);
});
Loading