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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
6 changes: 3 additions & 3 deletions src/bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Response> {
Expand Down
4 changes: 2 additions & 2 deletions src/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down
22 changes: 13 additions & 9 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
79 changes: 79 additions & 0 deletions tests/capabilities_test.ts
Original file line number Diff line number Diff line change
@@ -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<string, (ctx: unknown) => 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("<html><body><h1>Hi</h1></body></html>", {
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("<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: 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);
});
56 changes: 56 additions & 0 deletions tests/core_integration_test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof bootstrap>>, handler: (req: Request) => Promise<Response>) => Promise<void>,
) {
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);
});
},
);
Loading