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:
| Key | Type | Effect |
|---|---|---|
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. |
keepTogether | boolean | A 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), orsanitizeImageSrc(@stesura/core/helpers) for image sources. They refusejavascript:,vbscript:,data:(raster images excepted) andfile:. - Never spread
node.attrsinto the DOM attribute object. Name each attribute yourself, or a document can setonclick. - Never put document text inside raw-text elements (
style,noembed,xmp, …). - Validate values interpolated into
style(such ascolorabove) if they don’t come with avalidateon 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).
uniqueIdbindings cannot be changed. Every non-inline node, custom ones included, gets anid. A custom inline node that needs one declaresidin its spec;uniqueIdPluginthen 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
imagesNotAllowedtoast. Text pasted with pictures still goes in, without them, and raises the same toast. - Picture watermarks are not offered,
setWatermarkrefuses 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_excludedwarning. - 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
- Node Views: render a node with React.
- Global Attributes: what the global-attribute system contributes.
- Schema: built-in node and mark reference.