Skip to Content
SchemaExtending 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: { callout: calloutSpec }, marks: { commentHighlight: commentHighlightSpec }, });

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

Adding a node

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

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

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 table cell or note body (cell > wrapper > table), which the editor otherwise bans: the built-in guards refuse the deliberate UI paths and the load-time heal flattens what gets through, but for an airtight ban keep tables out of wrappers that can appear inside table cells.

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 callout 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.

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"), }), }, ], };

A command for the new node

import type { Command } from "prosemirror-state"; export const insertCallout = (type: "info" | "warning" | "error"): Command => (state, dispatch) => { const calloutType = state.schema.nodes.callout; const paragraph = state.schema.nodes.paragraph; if (!calloutType || !paragraph) return false; const callout = calloutType.create({ type }, paragraph.create()); if (dispatch) dispatch(state.tr.replaceSelectionWith(callout).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: { callout: calloutSpec }, globalAttributes: { nodes: { nodeComment: { callout: true }, spacing: { callout: true }, // callout 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: { callout: calloutSpec }, 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, callout: 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.

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