Skip to Content
GuidesExtending the Schema

Extending the Schema

Pass custom nodes and marks to stesuraSchema from @stesura/core/schema. It merges them over the core specs (a matching key replaces the core spec) and runs the global-attribute pass.

import { stesuraSchema } from "@stesura/core/schema"; const schema = stesuraSchema({ nodes: { panel: panelSpec }, marks: { commentHighlight: commentHighlightSpec }, });

Source: packages/core/src/schema/index.ts.

Pick names the core schema doesn’t use: the same key replaces the core node. The examples below use a made-up panel; the core schema already has a callout.

Adding a node

A node spec is a standard ProseMirror NodeSpec. Nothing Stesura-specific is required.

import type { NodeSpec } from "prosemirror-model"; const panelSpec: NodeSpec = { group: "block", content: "block+", attrs: { type: { default: "info" } }, toDOM(node) { return [ "div", { class: `panel panel-${node.attrs.type}`, "data-type": node.attrs.type }, 0, ]; }, parseDOM: [ { tag: "div.panel", getAttrs: (dom) => ({ type: dom.getAttribute("data-type") || "info" }), }, ], };

It needs no node view either: the editor renders its toDOM with the derived values (spacing) and the page-break lead pagination charges for it. A view of your own must keep both; see Your own block types.

section_content accepts the block group, so a group: "block" node needs no change to the section spec. For narrower content rules, pass your own section_content spec in nodes.

table is not in the block group (see Table placement), so content: "block+" excludes tables. To host them, use content: "(block | table)+". That gives paste-fitting a legal path to a table inside a note body or a nested table’s cell (body > wrapper > table), which the editor otherwise bans: the built-in guards flatten or refuse such a table, and the load-time heal flattens what gets through, but for an airtight ban keep tables out of wrappers that can appear inside note bodies.

Stesura spec keys and wrapper spacing

Stesura reads extra keys off the node spec, the standard ProseMirror extension pattern (cf. tableRole in prosemirror-tables). StesuraNodeSpec from @stesura/core/schema types them; use it instead of NodeSpec to get completion.

The main one is spacingRole. A wrapper node like the panel above (block node with block content) is pass-through by default: its children join the surrounding margin-collapse chain, so spacing.before/spacing.after crosses the wrapper’s edges and pagination sees consistent gaps. To isolate its children instead, like a table cell:

import type { StesuraNodeSpec } from "@stesura/core/schema"; const isolatedPanelSpec: StesuraNodeSpec = { group: "block", content: "block+", spacingRole: "boundary", // children get their own collapse chain // ...toDOM / parseDOM };

See Schema → Spacing Roles for the full role model.

The other keys:

KeyTypeEffect
spacingRole"passThrough" | "boundary"See above.
measureAs"atomic"Pagination measures a textblock as one unbreakable box instead of splitting it into lines. For nodes whose text is source code for a rendering (math_display, Mermaid diagrams). Without it, the rendering’s internal rects would be read as breakable lines.
keepTogetherbooleanA wrapper that fits a page is moved to the next page whole rather than split between its children. One taller than a page still splits. Used by callout.
defaultSpacing{ before?, after? } (px)Spacing for a spaced node that has neither its own spacing nor a style providing one. It collapses with neighbors like any other spacing. callout uses 8px each side.

measuresAsAtomic(type) and keepsTogether(type) from @stesura/core/schema read the first two.

An atomic node whose rendering arrives asynchronously must set data-render-pending on its root until the result (or error) lands, so PDF export and test settle gates wait for it. See Node Views.

Adding a mark

import type { MarkSpec } from "prosemirror-model"; const commentHighlightSpec: MarkSpec = { attrs: { color: { default: "#fef08a" }, author: { default: null }, }, inclusive: true, toDOM(mark) { return [ "span", { style: `background-color: ${mark.attrs.color}`, "data-author": mark.attrs.author }, 0, ]; }, parseDOM: [ { tag: "span[data-author]", getAttrs: (dom) => ({ color: dom.style.backgroundColor || "#fef08a", author: dom.getAttribute("data-author"), }), }, ], };

toDOM and untrusted attrs

Attrs are document data: they arrive from collab peers, imports and pastes, and a hostile document can set them to anything the attr accepts. toDOM is trusted code, so it must render safely whatever the attrs hold:

  • Run URLs through safeNormalizeUrl (@stesura/core/utils), or sanitizeImageSrc (@stesura/core/helpers) for image sources. They refuse javascript:, vbscript:, data: (raster images excepted) and file:.
  • Never spread node.attrs into the DOM attribute object. Name each attribute yourself, or a document can set onclick.
  • Never put document text inside raw-text elements (style, noembed, xmp, …).
  • Validate values interpolated into style (such as color above) if they don’t come with a validate on the attr: a free-form string can inject extra declarations.
import { safeNormalizeUrl } from "@stesura/core/utils"; const citationSpec: MarkSpec = { attrs: { source: { default: "" } }, toDOM: (mark) => ["a", { href: safeNormalizeUrl(mark.attrs.source) ?? "#" }, 0], };

Headers, footers and the cross-reference preview are rendered from toDOM, never from your node view, and painted outside the editor. There, the output is additionally sanitized with DOMPurify: event handlers, dangerous URLs and script/style/iframe-like elements are removed. Custom elements (my-widget) are kept. An attribute outside the standard HTML allowlist (other than data-* and aria-*) is stripped on those surfaces only; register it once at startup:

import { extendSerializedDomAllowlist } from "@stesura/core/helpers"; extendSerializedDomAllowlist({ attrs: ["x-citation-id"], tags: ["legacy-box"] });

Event handlers, srcdoc, formaction and the forbidden elements cannot be registered.

A command for the new node

import type { Command } from "prosemirror-state"; export const insertPanel = (type: "info" | "warning" | "error"): Command => (state, dispatch) => { const panelType = state.schema.nodes.panel; const paragraph = state.schema.nodes.paragraph; if (!panelType || !paragraph) return false; const panel = panelType.create({ type }, paragraph.create()); if (dispatch) dispatch(state.tr.replaceSelectionWith(panel).scrollIntoView()); return true; };

uniqueIdPlugin assigns the new nodes’ ids.

Global attributes

Shared attribute bundles (formatting, spacing, numbering, blockStyling, …) are configured through the globalAttributes option. See Global Attributes for the full reference.

Opt a custom node into core attributes with the nodes delta (true grants, false revokes, absent keeps the default):

const schema = stesuraSchema({ nodes: { panel: panelSpec }, globalAttributes: { nodes: { nodeComment: { panel: true }, spacing: { panel: true }, // panel becomes one spaced block, no longer pass-through numbering: { heading: false }, // revoking defaults works too }, }, });

Register your own global attribute under definitions and bind it with the same delta:

const schema = stesuraSchema({ nodes: { panel: panelSpec }, globalAttributes: { definitions: { audit: { attrs: { audit: { default: null } }, toDOM: (node) => (node.attrs.audit ? { "data-audit": node.attrs.audit } : {}), getAttrs: (dom) => ({ audit: dom.dataset.audit ?? null }), }, }, nodes: { audit: { paragraph: true, heading: true, panel: true } }, }, });

Guard rails:

  • A custom definition whose key collides with a core attribute is discarded (core wins).
  • uniqueId bindings cannot be changed. Every non-inline node, custom ones included, gets an id. A custom inline node that needs one declares id in its spec; uniqueIdPlugin then keeps it unique.
  • Typos in attribute keys or node names are ignored with a console diagnostic.
  • A node spec or definition that declares a reserved gate attribute (numbering, spacing, indent, …) throws at construction.

Excluding a node

excludeNodes leaves nodes out of the schema. A document holding one can then not be parsed, so content that arrives by paste, import or a collaborator’s step cannot bring it back:

const schema = stesuraSchema({ excludeNodes: ["image", "image_anchored"] });

Core nodes come out in families, listed in EXCLUDABLE_NODE_FAMILIES, and only whole: every feature that creates, edits or reads a family’s nodes steps aside without them. Today that is images (image, image_anchored). Nodes you added through nodes can always be excluded.

Grants in globalAttributes.nodes to an excluded node are dropped with it. stesuraSchema throws a RangeError on a name that is not a node, on a core node outside the families, on part of a family, and when a remaining node’s content names an excluded one.

Without images:

  • The Insert tab has no image button, and image commands return false.
  • A paste or drop of nothing but pictures is refused with an imagesNotAllowed toast. Text pasted with pictures still goes in, without them, and raises the same toast.
  • Picture watermarks are not offered, setWatermark refuses one, and one set anyway (by a collaborator or an import) is neither drawn nor exported. Text watermarks are unaffected.
  • DOCX import skips pictures without uploading them and reports them once, as an images_excluded warning.
  • Markdown import keeps an image’s alt text as a paragraph and drops one without alt text.

Narrowing content

For a constrained editor (a title, a caption, a fragment you will embed into full documents), keep every node and narrow what the containers accept instead of excluding nodes. Override the core specs through nodes:

import { nodes, stesuraSchema } from "@stesura/core/schema"; const singleParagraph = stesuraSchema({ nodes: { doc: { ...nodes.doc, content: "section footnotes endnotes?" }, section_content: { ...nodes.section_content, content: "paragraph" }, }, });

The node types, marks and attributes stay those of the full schema, and each narrowed expression accepts a subset of the full one, so every document of the narrowed schema is valid in the full schema. ProseMirror enforces the expression: no command can add a block it refuses (one may do nothing, or split the paragraph where the expression allows a second), and a collaborator’s step that would is refused. To allow some blocks, name them: "(paragraph | heading | callout)+".

Where no block may follow the caret’s:

  • Enter inserts a line break, as Shift+Enter does everywhere.
  • Pasted blocks, table cells included, become the paragraph’s inline content, joined by line breaks.

Build the full and the narrowed schema from the same globalAttributes and custom nodes: an attribute only one of them grants is dropped silently when content moves to the other.

Bypassing stesuraSchema

For full control (headless tooling, server-side import), build the Schema yourself from nodes and marks, both exported from @stesura/core/schema. Keep the global-attribute pass with withGlobalAttributes, which takes the same globalAttributes config:

import { Schema } from "prosemirror-model"; import { marks, nodes, withGlobalAttributes } from "@stesura/core/schema"; const schema = new Schema({ nodes: withGlobalAttributes(nodes), marks });

Skip withGlobalAttributes and nodes lose id, numbering, spacing, …, which most commands and plugins rely on. Also, getGlobalDOMAttributesMap only includes custom definitions for schemas built by stesuraSchema.

Next steps

Last updated on