Skip to content

Write a page editor extension

Hussein Jarrar edited this page Sep 12, 2026 · 2 revisions

A page editor extension is a ```radd:<name> fence that renders as a live block, in the editor and in the read-only viewer. This page shows how to declare one, how its configuration form is generated, and how the editor turns the fence into a real node.

What an extension is

The wire format is a fenced code block whose language is radd:<name>, with an optional JSON object as its body:

```radd:toc
{"subpages": true, "depth": 3}
```

— web/src/lib/page-extensions.tsx

The body stays markdown in the database. FTS, the embedder, export and the public page surface all read it as an ordinary fenced code block — a fence, not a bespoke syntax, so nothing else has to be taught about it. Paste the page anywhere else and the fence shows as a labelled code block, not garbage.

The name is part of the wire format: it is stored in real page bodies, so renaming one is a data migration, not a rename. The first-party names are a StrEnum:

class PageExtensionName(StrEnum):
    """The fence suffix of each first-party extension (RADD-709).

    A member here is the wire format — it appears in page bodies in the
    database — so renaming one is a data migration, not a rename.
    """

    TOC = "toc"
    CHILDREN = "children"
    CALLOUT = "callout"
    BACKLINKS = "backlinks"
    INCLUDE = "include"
    LABEL_LIST = "label-list"
    NEW_FROM_TEMPLATE = "new-from-template"

— server/src/radd/modules/pages/types.py

Declaring an extension registers it. Rendering is a separate step, done entirely on the client. The server never renders a page body — a server-side renderer would be a second implementation of something nothing calls.

Declare an extension on the server

The kernel half is a PageExtensionSpec:

@dataclass(frozen=True)
class PageExtensionSpec:
    """An extension a page can embed as a fenced block — ```` ```radd:<name> ````.

    [docstring continues — see the file]
    """

    name: str  # the fence suffix: `toc` for ```radd:toc
    label: str  # insert-menu title
    description: str = ""  # one line, insert-menu subtitle
    params_schema: dict[str, Any] = field(default_factory=dict)
    icon: str = ""  # lucide icon name, for the insert menu

— server/src/radd/kernel/specs.py

A plugin lists its extensions on its manifest:

page_extensions: tuple[PageExtensionSpec, ...] = ()  # page fenced blocks (RADD-709)

— server/src/radd/kernel/plugin.py

A worked declaration, the callout extension:

PageExtensionSpec(
    name=PageExtensionName.CALLOUT,
    label="Callout",
    description="A tinted note: info, success, warning or danger.",
    icon="info",
    params_schema={
        "type": "object",
        "properties": {
            "kind": {
                "type": "string",
                "enum": ["info", "success", "warning", "danger"],
                "default": "info",
            },
            "title": {"type": "string", "description": "Optional bold first line."},
            "text": {
                "type": "string",
                "format": "markdown",
                "description": "Markdown body of the callout.",
            },
        },
    },
),

— server/src/radd/modules/pages/extensions.py

GET /pages/extensions reads the live kernel registry, not a constant, so a plugin's extension appears the moment it mounts and disappears when the plugin is disabled:

@router.get("/pages/extensions", response_model=list[PageExtensionRead])
async def list_page_extensions(session: Session, user: CurrentUser) -> list[PageExtensionRead]:
    if not await access.readable_spaces(session, user):
        return []
    sources = registries.page_extension_sources
    return [
        PageExtensionRead(
            name=spec.name,
            label=spec.label,
            description=spec.description,
            params_schema=spec.params_schema,
            icon=spec.icon,
            source=(source.plugin if (source := sources.get(spec.name)) else ""),
        )
        for spec in sorted(registries.page_extensions.values(), key=lambda s: s.label)
    ]

— server/src/radd/modules/pages/router.py

source is not self-declared. The kernel registry records which plugin mounted each extension, and the endpoint reads that record — a spec cannot claim a source it does not have:

for px in plugin.page_extensions:
    self.page_extensions[px.name] = px
    self.page_extension_sources[px.name] = ContributionSource(plugin=plugin.name)

— server/src/radd/kernel/registry.py

Unmount removes both entries in the same step, so a disabled plugin's extension leaves the insert menu immediately:

for px in plugin.page_extensions:
    self.page_extensions.pop(px.name, None)
    self.page_extension_sources.pop(px.name, None)

— server/src/radd/kernel/registry.py

Declaring a spec is only half the job. A plugin must also register a client-side renderer under the same name (see "Rendering", below) — a page holding a block whose name nothing renders shows an honest "Unknown extension" card, never raw JSON.

The schema generates the configuration form

params_schema is JSON Schema. web/src/components/editor/extension-schema.ts reads it and produces one form control per property:

function kindOf(property: RawProperty): FieldKindValue | null {
  if (Array.isArray(property.enum) && property.enum.length) return FieldKind.enum;
  if (property.type === "boolean") return FieldKind.boolean;
  if (property.type === "integer" || property.type === "number") return FieldKind.integer;
  if (property.type === "string") {
    return property.format === "markdown" ? FieldKind.markdown : FieldKind.text;
  }
  return null;
}

— web/src/components/editor/extension-schema.ts

Schema shape Control
enum (non-empty) select — options are exactly the schema's enum, in order
type: "boolean" checkbox
type: "integer" or "number" number field, with minimum/maximum
type: "string", format: "markdown" textarea
type: "string", no format one-line text field
anything else (array, object, unrecognised) dropped from the form

required marks a field mandatory in the form label. A property this build's schema cannot describe is not guessed at — the dialog falls back to a raw JSON editor for the whole block, which stays available behind an "Edit as JSON" toggle even when the form renders, so nothing is ever a dead end.

Saving folds the form's values back into the block with two rules that protect content nobody in this session touched:

export function mergeParams(
  existing: Record<string, unknown>,
  fields: SchemaField[],
  values: Record<string, unknown>,
): Record<string, unknown> {
  const merged: Record<string, unknown> = { ...existing };
  for (const field of fields) {
    const value = values[field.key];
    const wasPresent = Object.prototype.hasOwnProperty.call(existing, field.key);
    const isEmpty =
      value === undefined || value === null || (typeof value === "string" && value === "");
    const isDefault = field.default !== undefined && value === field.default;

    if (isEmpty && !field.required) {
      delete merged[field.key];
    } else if (isDefault && !wasPresent) {
      delete merged[field.key];
    } else {
      merged[field.key] = value;
    }
  }
  return merged;
}

— web/src/components/editor/extension-schema.ts

A parameter the current schema does not describe — because a plugin was upgraded, downgraded or disabled — is carried through untouched rather than dropped. A value equal to the schema's default is omitted unless the author already wrote it explicitly, so a block stays as short as its author left it instead of accreting "depth": 3 on every save.

Rendering: the editor and the read-only viewer are two engines

The two surfaces do not share one renderer. They share one registry:

export interface PageExtension {
  name: string;
  label: string;
  description: string;
  render: (params: Record<string, unknown>) => ReactNode;
}

const registry = new Map<string, PageExtension>();

export function registerPageExtension(extension: PageExtension): void {
  registry.set(extension.name, extension);
}

export const lookupPageExtension = (name: string) => registry.get(name);

— web/src/lib/page-extensions.tsx

A plugin's frontend bundle calls registerPageExtension with the same name its PageExtensionSpec declared. Whichever surface renders the block, it calls extension.render(params) from this one map.

In the editor, a radd:* fence is a real ProseMirror node, radd_extension. A $remark transform claims the fence at PARSE time, before the schema ever assigns it a type:

export const raddExtensionRemark = $remark(
  "raddExtension",
  () => () => (tree: UnistNode) => {
    visit(tree, "code", (node, index, parent: Parent | undefined) => {
      if (!parent || index === null || index === undefined) return;
      const code = node as UnistNode & { lang?: string | null; value?: string };
      const name = extensionNameOfInfo(code.lang ?? "");
      if (!name) return;
      const replacement: RaddExtensionMdast = {
        type: MDAST_TYPE,
        name,
        body: code.value ?? "",
      };
      parent.children[index] = replacement as never;
    });
  },
);

— web/src/components/editor/extension-node.ts

ExtensionNodeView is the React node view: it looks up the extension by name, renders it live, and adds a hover row with Configure and Remove buttons.

In the read-only viewer, the mechanism is different again. PageBody splits a page's raw markdown into prose runs and extension blocks before any of it reaches the Milkdown/ProseMirror renderer:

const segments = useMemo(() => splitExtensionBlocks(text), [text]);
// …
{segments.map((segment, index) =>
  segment.kind === "markdown" ? (
    <RichViewer key={`md-${index}`} text={segment.text} eager={eager} onReady={markReady} />
  ) : (
    <ExtensionBlock key={`ext-${index}`} name={segment.name} body={segment.body} />
  ),
)}

— web/src/components/pages/PageBody.tsx

Each prose run gets its own RichViewer instance — the same read-mode Milkdown engine the editor's code blocks and images use — and each extension block renders as plain React, through the same registry the editor uses. RichViewer itself never registers the radd_extension node or the remark transform, so it never sees a radd:* fence at all; by the time text reaches it, the fence is already gone.

Extensions are opt-in per editor surface, through a boolean prop on RichEditor:

// `radd:*` fences become a real node with a live React view (RADD-746).
// Registered only where extensions are offered: a comment has no page whose
// headings a `toc` could list, and turning its fences into rendered blocks
// there would change what a comment does, not just how it looks.
if (extensionsOn) {
  editor
    .use(raddExtensionRemark)
    .use(raddExtensionSchema)
    .use(raddExtensionConfigOnInsert)
    .use(
      $view(raddExtensionSchema.node, () =>
        nodeViewFactory({
          component: ExtensionNodeView,
          stopEvent: () => true,
        }),
      ),
    );
}

— web/src/components/editor/RichEditor.tsx

Only the page-body editor turns this on (<RichEditor extensions … /> in web/src/components/pages/PageView.tsx). A comment or an issue description never offers the insert menu, and a radd:toc fence typed into one stays a plain code block.

How a user inserts one

A toolbar button, "Insert extension" (the Blocks icon), opens ExtensionPicker, populated live from GET /pages/extensions and grouped by the contributing plugin — a heading appears only when there is more than one contributor:

export function insertExtensionBlock(view: EditorView, spec: PageExtensionSpec): void {
  const { state } = view;
  const params = defaultsFor(spec);
  const body = Object.keys(params).length ? JSON.stringify(params, null, 2) : "";
  const extension = state.schema.nodes[RADD_EXTENSION_NODE];
  const node = extension ? extension.create({ name: spec.name, body }) : /* … */ null;
  if (!node) return;
  const tr = state.tr.replaceSelectionWith(node).scrollIntoView();
  // …
  if (inserted >= 0) tr.setMeta(configOnInsertKey, inserted);
  view.dispatch(tr);
  view.focus();
}

— web/src/components/editor/ExtensionPicker.tsx

The inserted block is pre-filled from the schema's declared defaults (and, for a required property with no default, a placeholder), so it renders as a result immediately rather than as an empty shell. The insert also opens the block's generated configuration form at once, through a decoration keyed by node position (configOnInsertKey) — "what can this take" is answered at the point of asking, rather than by reading the JSON by eye.

ProseMirror rules to know

These three rules cost real debugging time while this feature was built. Know them before writing a node view of your own.

A node view resolves by first match. ProseMirror's someProp walks registered node views and uses the first one that answers for a node's type. This is exactly the collision an extension avoids: rather than compete with the code block's own node view for the same code type, the extension claims radd:* fences at parse time and gives them a distinct mdast type (raddExtension) that nothing else in the schema matches — so there is no race to lose.

**The correction worth recording.** Overriding the editor's `code_block` node
view to catch these does collide — ProseMirror's `someProp` resolves node
views by FIRST match, and the preset registers first — but the right move is
**not to be a code block at all**. A `$remark` transform claims the fence
during markdown PARSING and hands back a distinct mdast type, so the
code-block view never sees it and no registration race decides the outcome.

— web/src/components/editor/extension-node.ts (module docstring, abridged)

contentRef appends the adapter's content element; it does not become the element you put it on. @prosemirror-adapter/react defaults a node view's content element to a <div>. The radd_extension node is an atom with no editable content, so this does not bite it directly — but it is the rule behind the table node view sitting right next to it in the same file, and it will bite any node view you give real ProseMirror-editable content to:

// The content element must be a real <tbody> (RADD-759). The adapter
// defaults it to a <div>, which inside a table is not a row group at
// all: the rows fell into an anonymous shrink-to-fit table and every
// table rendered squished, cells at 20px under 213px columns.
contentAs: "tbody",

— web/src/components/editor/RichEditor.tsx

Pass contentAs whenever your node view's wrapping tag matters to how the browser lays out its children.

A node view whose body is a form needs stopEvent, or ProseMirror reads every keystroke and click aimed at it as an edit to the document. The extension node view sets it unconditionally:

$view(raddExtensionSchema.node, () =>
  nodeViewFactory({
    component: ExtensionNodeView,
    // The block is an atom whose body is interactive React — links,
    // buttons, a config dialog. ProseMirror must not treat a click
    // inside it as a click on the document.
    stopEvent: () => true,
  }),
),

— web/src/components/editor/RichEditor.tsx

This is safe here because the extension's interactive chrome — the Configure/Remove row, and the configuration dialog itself — is a portal into document.body, not real content inside the node's own DOM. Compare the image node view, which does have an editable form inside its own DOM (the "paste a URL" empty state) and so scopes stopEvent to that form alone, leaving a click on the image itself free to select the node:

// The empty state is a form (RADD-760) — a file button and a URL field.
// Without this ProseMirror reads every keystroke aimed at that field as a
// keystroke on the document. Scoped to our own chrome, so a click on the
// image itself still selects the node.
stopEvent: (event) =>
  event.target instanceof HTMLElement &&
  Boolean(event.target.closest("[data-image-chrome]")),

— web/src/components/editor/RichEditor.tsx

An unscoped stopEvent: () => true on a node view that also needs plain clicks to select it would make the node unselectable. Scope to your own chrome whenever the node view has content ProseMirror should still handle.

A complete worked example

radd:callout, end to end. The wire format, seeded by a proof script:

const SEED = [
  "# Heading one",
  "",
  "```radd:callout",
  '{"kind": "warning", "title": "Mind the gap", "text": "Body text."}',
  "```",
  // …
].join("\n");

— web/scripts/extension-node-proof.mjs

The server declares it (shown above, extensions.py). The client registers a renderer under the same name:

function Callout({ params }: { params: Record<string, unknown> }) {
  const kind = (String(params.kind ?? "info") as keyof typeof CALLOUTS) in CALLOUTS
    ? (String(params.kind ?? "info") as keyof typeof CALLOUTS)
    : "info";
  const meta = CALLOUTS[kind];
  const Icon = meta.icon;
  const title = typeof params.title === "string" ? params.title : "";
  const text = typeof params.text === "string" ? params.text : "";
  return (
    <div data-callout-kind={kind} className={`my-2 flex gap-2 rounded-lg border px-3 py-2 ${meta.cls}`}>
      <Icon size={15} aria-hidden className={`mt-0.5 shrink-0 ${meta.ink}`} />
      <div className="min-w-0 flex-1">
        {title && <p data-callout-title className={`text-[13px] font-semibold ${meta.ink}`}>{title}</p>}
        {text && <Markdown text={text} />}
      </div>
    </div>
  );
}

const EXTENSIONS: PageExtension[] = [
  // …
  {
    name: "callout",
    label: "Callout",
    description: "A tinted note: info, success, warning or danger.",
    render: (params) => <Callout params={params} />,
  },
  // …
];

for (const extension of EXTENSIONS) registerPageExtension(extension);

— web/src/components/pages/extensions.tsx

The schema's kind enum becomes a select with exactly info/success/ warning/danger; title becomes a text field; text, carrying "format": "markdown", becomes a textarea. Each callout kind is tinted with its own token pair — border-callout-<kind>-border / bg-callout-<kind>-fill / text-callout-<kind>-ink — computed to clear 4.5:1 text contrast and 3:1 border contrast in both themes, never a raw palette utility.

How to verify

Six scripts under web/scripts/ cover this feature end to end, driving a real headless Chromium session over the DevTools Protocol — building is not verifying, per CLAUDE.md.

Script Proves
seed-extension-proof-page.py Creates or refreshes a dev page exercising every first-party extension, plus a child page, for the other proofs to read.
page-extensions-proof.mjs The read-only viewer: every extension became a rendered element (not leaked JSON inside a code block); an ordinary code block and a fence-inside-documentation both still render as code; ToC anchors actually scroll; callout contrast clears 4.5:1/3:1 in both themes.
extension-node-proof.mjs The editor: the fence is a real node, not a code block; the block renders live while editing; hover chrome exists and is what is actually painted at its coordinates; the config dialog opens on the block's real parameters; an edit round-trips; an untouched block saves byte-identical.
extension-config-proof.mjs The generated form: every schema property gets a matching control, the enum control's options exactly match the schema's enum, an unknown parameter is preserved and named as carried-through, no default is written unasked, and an extension with no schema falls back to the JSON editor with an explanation.
extension-insert-proof.mjs The insert menu: it lists exactly what GET /pages/extensions declares, a real click (not element.click(), which cannot see a z-index bug) inserts a radd:callout fence pre-filled from the schema's defaults, and it survives a save.
extension-menu-proof.mjs The menu is a faithful projection of the registry: labels, icons and grouping-by-source all trace back to the server's response, and the whole list fits on screen at 1100px.

Run the seed once against a dev server, then any of the browser proofs — all live under web/scripts/, each printing its own usage line if called with no arguments:

cd web
python3 scripts/seed-extension-proof-page.py
node scripts/page-extensions-proof.mjs http://localhost:8000 ext-proof render-proof hussein@hjarrar.com radd-dev-1
node scripts/extension-node-proof.mjs http://localhost:8000 ext-proof hussein@hjarrar.com radd-dev-1
node scripts/extension-config-proof.mjs http://localhost:8000 ext-proof hussein@hjarrar.com radd-dev-1
node scripts/extension-menu-proof.mjs http://localhost:8000 ext-proof hussein@hjarrar.com radd-dev-1
node scripts/extension-insert-proof.mjs http://localhost:8000 ext-proof render-proof hussein@hjarrar.com radd-dev-1

TODO(verify): these commands are built from each script's own Usage: header comment and process.argv.slice(2) destructuring, not from an actual run against a live dev server — confirm before publishing if a dev server is available.

See also


Mirrored from project.radd-hq.com on 2026-09-12. Documentation is written there; this copy is regenerated by scripts/publish_wiki.py and hand edits do not survive it.

Clone this wiki locally