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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@

### Changed — BREAKING

- **Serves its own HTTP routes, so the bar works without an admin (#127).** `source`, `commit` and
`fields` are at `/api/inline-edit/content/<encoded path>/...` (core gates them: signed-in editor
holding `pages.update`, same-origin writes), with the TSX and page-ownership rules the admin's
routes enforced. The bar is injected for any such editor, including a site user on a site with no
admin, and no longer requires an `admin.prefix` provider. The bar no longer renders "Edit source"
or "Open in admin": the admin contributes those through `bar.actions`. `commit()` and
`patchFields()` stay on the manager object but are not part of core's `InlineEditManager`.
`onPageUpdate` carries the editor's name as `actor`.

- **The bar declares its own extension point, and reads what it needs per request (#127).**
`bar.actions` is declared here, as the typed key `BAR_ACTIONS`, 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 and `BAR_ACTIONS` from `@dune/plugin-inline-edit` (or recreates the key from
the name `bar.actions`). `transformResponse` reads the
contributions and the `admin.prefix` slot from `ResponseTransformContext.capabilities`
instead of `barActions` / `adminPrefix`.
- **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
68 changes: 68 additions & 0 deletions src/bar-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* 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 key and the types from
* `@dune/plugin-inline-edit` (or recreates the key from the name `bar.actions`):
*
* ```ts
* import { BAR_ACTIONS } from "@dune/plugin-inline-edit";
* api.capabilities.contribute(BAR_ACTIONS, ({ page }) =>
* page ? [{ id: "pdf:download", label: "PDF", href: `/pdf${page.route}` }] : []);
* ```
*
* @module
*/

import { type CapabilityKey, capabilityKey } from "@dune/core/plugin-api";

/**
* 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;
}

/**
* The bar's contribution point: each contributing plugin adds one function that
* returns its buttons for a response. Consumer: this plugin.
*/
export const BAR_ACTIONS: CapabilityKey<(ctx: BarActionContext) => BarAction[]> = capabilityKey("bar.actions");
99 changes: 12 additions & 87 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 All @@ -128,45 +128,34 @@ function renderAdminBarActions(actions: AdminBarAction[]): string {
export function buildAdminBarHtml(opts: {
sourcePath: string;
pageTitle: string | null;
adminPrefix: string;
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
* "Editing"/"Save" buttons are pure no-ops — clicking "Editing" enables
* hover handles that will never find a target, and there's nothing a
* field-level Save could ever commit — so both are omitted. "Edit source"
* stays: it always works, regardless of whether inline annotation found
* anything. Defaults to true (render normally) for callers that haven't
* field-level Save could ever commit — so both are omitted. Buttons other
* plugins contribute stay. Defaults to true (render normally) for callers that haven't
* computed it, e.g. call sites predating this option.
*/
hasEditableContent?: boolean;
}): string {
const {
sourcePath,
pageTitle,
adminPrefix,
userName,
pluginVersion,
actions = [],
hasEditableContent = true,
} = opts;
const encodedPath = encodeURIComponent(sourcePath);
// Was `${adminPrefix}/pages/${encodedPath}` — no such route exists
// (plugin-admin registers /admin/pages/edit?path=..., not a
// /admin/pages/<path> segment route); that 404'd. Fixed alongside adding
// the "Edit source" overlay below, which opens the same URL in an iframe.
const adminPageUrl = `${adminPrefix}/pages/edit?path=${encodedPath}`;
// The overlay iframe gets `embedded=1` (plugin-admin's _layout.tsx drops
// the sidebar/topbar chrome for it — redundant floated over a content
// page, nowhere to navigate to). The escape link stays un-embedded since
// that's a real full-page navigation, where the full admin shell belongs.
const adminPageUrlEmbedded = `${adminPageUrl}&embedded=1`;
const commitUrl = `${adminPrefix}/api/content/${encodedPath}/commit`;
const fieldsUrl = `${adminPrefix}/api/content/${encodedPath}/fields`;
// The routes below are this plugin's own (core gates them and hands the
// request to `handleRequest`), so the bar works with or without an admin.
const commitUrl = `/api/inline-edit/content/${encodedPath}/commit`;
const fieldsUrl = `/api/inline-edit/content/${encodedPath}/fields`;

return `
<style>
Expand Down Expand Up @@ -214,39 +203,13 @@ export function buildAdminBarHtml(opts: {
@media print {
#dune-admin-bar,
#dune-ao-edit-handle,
#dune-source-overlay,
.dune-bubble-menu,
.dune-ao-body-toolbar {
display: none !important;
}
body { padding-top: 0 !important; }
}

/* Source-editing overlay — full MDX/markdown source editor (the same
/admin/pages/edit view used from the admin panel) in an on-page modal,
so editing MDX-heavy pages (which inline body-editing can't handle,
since there's no single data-dune-body element to annotate) doesn't
require leaving the page. */
#dune-ab-edit-source { background: rgba(255,255,255,.12); color: #fff; }
#dune-source-overlay {
display: none; position: fixed; inset: 0; z-index: 100000;
background: rgba(0,0,0,.5);
}
#dune-source-overlay.dune-open { display: flex; flex-direction: column; }
#dune-source-overlay-bar {
flex: none; display: flex; align-items: center; justify-content: flex-end;
padding: 8px 16px; background: #1a1a2e;
}
#dune-source-overlay-close {
background: rgba(255,255,255,.15); color: #fff; border: none;
border-radius: 4px; padding: 4px 12px; font-size: 12px; cursor: pointer;
font-family: inherit;
}
#dune-source-overlay-close:hover { background: rgba(255,255,255,.25); }
#dune-source-overlay-frame {
flex: 1; width: 100%; border: none; background: #fff;
}

/* Marker hover indicators (active when edit mode is on).
Activation happens via the floating ✎ handle, never by clicking the
content itself, so links inside editable regions stay followable. */
Expand Down Expand Up @@ -339,17 +302,9 @@ export function buildAdminBarHtml(opts: {
<button id="dune-ab-save">Save</button>`
: ""
}
<button id="dune-ab-edit-source">✎ Edit source</button>
${renderAdminBarActions(actions)}
<a href="${adminPageUrl}" class="dune-ab-escape" title="Open full admin editor">Open in admin →</a>
${renderBarActions(actions)}
<span class="dune-ab-user">${escapeHtml(userName)}</span>
</div>
<div id="dune-source-overlay">
<div id="dune-source-overlay-bar">
<button id="dune-source-overlay-close" type="button">✕ Close</button>
</div>
<iframe id="dune-source-overlay-frame" title="Edit source"></iframe>
</div>
<script>
(function() {
'use strict';
Expand All @@ -364,35 +319,6 @@ export function buildAdminBarHtml(opts: {
};
window.__DUNE_USER_NAME__ = ${jsonStr(userName)};
window.__DUNE_EDIT_WS_PATH__ = "/api/inline-edit/ws";
window.__DUNE_ADMIN_PAGE_URL__ = ${jsonStr(adminPageUrlEmbedded)};

// ── Source-editing overlay ───────────────────────────────────────────────────
(function() {
var overlay = document.getElementById('dune-source-overlay');
var frame = document.getElementById('dune-source-overlay-frame');
var openBtn = document.getElementById('dune-ab-edit-source');
var closeBtn = document.getElementById('dune-source-overlay-close');
var loaded = false;

function open() {
if (!loaded) {
frame.src = window.__DUNE_ADMIN_PAGE_URL__;
loaded = true;
}
overlay.classList.add('dune-open');
}
function close() {
overlay.classList.remove('dune-open');
}
openBtn.addEventListener('click', open);
closeBtn.addEventListener('click', close);
overlay.addEventListener('keydown', function(e) {
if (e.key === 'Escape') close();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && overlay.classList.contains('dune-open')) close();
});
})();

var editMode = true;
document.body.classList.add('dune-edit-mode');
Expand Down Expand Up @@ -756,11 +682,10 @@ export async function injectAdminBar(
opts: {
sourcePath: string;
pageTitle: string | null;
adminPrefix: string;
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
4 changes: 2 additions & 2 deletions src/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export async function commitDoc(opts: {
// Write to disk.
await storage.write(filePath, committed);

hooks?.fire("onPageUpdate", { sourcePath }).catch((err) =>
hooks?.fire("onPageUpdate", { sourcePath, actor: author }).catch((err) =>
warnThrottled("hook", "onPageUpdate hook failed", err)
);
}
Expand Down Expand Up @@ -173,7 +173,7 @@ export async function patchFrontmatterFields(opts: {

await storage.write(filePath, patched);

hooks?.fire("onPageUpdate", { sourcePath }).catch((err) =>
hooks?.fire("onPageUpdate", { sourcePath, actor: author }).catch((err) =>
warnThrottled("hook", "onPageUpdate hook failed", err)
);
}
Loading
Loading