@wulperstd/schema
v1.5.0
Published
Framework-agnostic, DOM-free document schema and validation for the block editor.
Readme
@wulperstd/schema
Framework-agnostic, DOM-free document schema and validation for the block
editor's document JSON. It defines the node/mark specs and the ProseMirror
Schema built from them, plus the pure validation functions and shared
scheme-allowlist predicates that both authoring (@wulperstd/editor-core)
and rendering (@wulperstd/renderer-html) build on.
Install
pnpm add @wulperstd/schema @tiptap/pm@tiptap/pm is a required peer dependency, not a bundled one: this package's
blockSchema and validation are built on a ProseMirror Schema
(@tiptap/pm/model), and declaring it as a peer keeps a single shared
@tiptap/pm instance across your app instead of a second copy that would
break instanceof checks and plugin identity.
ESM-only: this package is published as "type": "module" with no
CommonJS entry in its exports map. It cannot be require()d from a
CommonJS-only consumer — import it from ESM (or an async import()) instead.
Public API
import {
NODE_TYPES,
MARK_TYPES,
nodeSpecs,
markSpecs,
blockSchema,
validate,
validateReport,
isSafeHref,
ALLOWED_HREF_SCHEMES,
EMBED_SRC_SCHEMES,
IMAGE_SRC_SCHEMES,
validateEmbedSrc,
isValidLinkTarget,
LINK_BLANK_REL,
ORDERED_LIST_START_MAX,
isValidOrderedListStart,
ORDERED_LIST_TYPES,
isValidOrderedListType,
MAX_COLSPAN,
MAX_ROWSPAN,
isTableHeaderRow,
isValidColspan,
isValidColwidth,
isValidRowspan,
parseCellSpan,
parseColwidth,
SCHEMA_VERSION,
documentJsonSchema,
} from '@wulperstd/schema';Type-only exports (NodeTypeName, MarkTypeName, BlockDocument, BlockNode,
BlockMark, ValidationError, ValidationResult, BlockStatus,
BlockReportEntry, ValidationReport, OrderedListType) are available from
the same entry point via import type.
validate(json: unknown): ValidationResult
Validates a ProseMirror JSON document against blockSchema. Never throws:
it returns { ok: true, doc } or { ok: false, errors }, where each error's
path is an RFC 6901 JSON pointer into the input document.
const result = validate({
type: 'doc',
attrs: { schemaVersion: SCHEMA_VERSION },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }],
});
if (!result.ok) {
console.error(result.errors);
}validateReport(json: unknown): ValidationReport
Per-block validation report: instead of an all-or-nothing result, returns a status for every node/mark, so a caller can render "valid" content and flag only the parts that failed.
blockSchema
The ProseMirror Schema instance built from nodeSpecs/markSpecs — the
same node/mark set @wulperstd/editor-core's Tiptap extensions implement
and @wulperstd/renderer-html's renderer targets, so all three packages
agree on one document shape.
isSafeHref, validateEmbedSrc, and the scheme constants
The scheme-allowlist predicates and constants (ALLOWED_HREF_SCHEMES,
EMBED_SRC_SCHEMES, IMAGE_SRC_SCHEMES) used by this package's own
link/embed/image attribute validation. @wulperstd/renderer-html
imports isSafeHref directly so write-time and render-time link-scheme
enforcement cannot drift apart.
SCHEMA_VERSION
The monotonic in-band document schema version, stored in every document's
attrs.schemaVersion. Deliberately independent of this package's own npm
version — bump it only when the document shape itself changes in a way
consumers must react to.
JSON Schema for LLM structured output
documentJsonSchema is a JSON Schema (draft 2020-12) describing the same
ProseMirror JSON document format validateReport()/validate() accept,
intended for an LLM's structured-output / tool input_schema so a backend can
have an LLM generate whole documents using any node and mark this editor
supports. It is built from this package's OWN constants and nodeSpecs
(enums, ranges, attribute defaults, group membership) — nothing is
hand-duplicated — has no runtime dependencies, and stays DOM-free like the
rest of this package. See src/json-schema.ts for the full implementation
and design notes.
You can consume it two ways:
// 1. As a TypeScript export (plain, JSON-serializable object):
import { documentJsonSchema } from '@wulperstd/schema';// 2. As a static JSON file, e.g. for a non-JS backend or an LLM tool-definition
// pipeline that reads a file path directly:
import documentJsonSchema from '@wulperstd/schema/document.schema.json' with { type: 'json' };
// or, in a non-bundler/non-Node context: resolve the package and read
// `dist/document.schema.json` directly.Both are produced from the exact same source (src/json-schema.ts); the JSON
file is emitted by scripts/emit-json-schema.mjs as part of pnpm build.
You MUST still run validateReport() and retry on failure
This schema is a best-effort, possibly LOOSER superset of validateReport().
It is designed to never REJECT a document the real validator accepts, but it
MAY ACCEPT some documents the real validator would reject. Constraining an
LLM's structured output with this schema is not a replacement for server-side
validation:
- Ask the LLM to produce a document conforming to
documentJsonSchema. - Run the result through
validateReport(json)— the authoritative validator. - If
ok: false, feedreport.issuesback to the LLM and ask it to fix exactly those problems, then retry from step 2. Each issue'spathis an RFC 6901 JSON pointer into the document (e.g./content/0/attrs,/content/2/content/0/marks/1), so you can point the LLM (or your own UI) directly at the offending location. - Only persist/render a document once
validateReport()returnsok: true.
Rules this JSON Schema does NOT enforce
These are real validateReport() rules the schema cannot (or deliberately
does not) express structurally — a document violating one of these is
accepted by the schema but rejected by validateReport():
- URL scheme safety (
link.href,cardLink.href,image.src,embed.src). The real check (isSafeHref) disallowsjavascript:, protocol-relative URLs, embedded control characters, and a wide Unicode whitespace-obfuscation set, and restricts the scheme to an allow-list that differs per field. The schema only types these fields asstringwith a prose description of the allow-list — a regex here would be brittle against the real normalization rules. - Color grammar (
highlight.color,textStyle.color,tableCell/tableHeader.backgroundColor). The real check (isValidColor) is a closed hex/rgb()/rgba()grammar; the schema types these asstring | nullwith a prose description only. tableCell/tableHeader.colwidthlength vs.colspan— not enforced byvalidateReport()either (no cross-field check), so this is exact parity, not a looseness gap.superscript/subscriptmutual exclusion on the same text run (and duplicate mark types on the same run) —validateReport()rejects both via ProseMirror's owndoc.check(); a JSON Schema has no practical way to express "this array must not contain two particular values together" or "no duplicatetype" over an open-endedmarksarray.- Extraneous top-level keys on a node are silently ignored by
validateReport(), but this schema usesadditionalProperties: falseon every node$def, so it is stricter than the real validator on this one dimension (a schema-valid document is never rejected byvalidateReport()for this reason — only the reverse direction is possible here). - An empty
marks: []on a block-level container node (e.g. aparagraphnested underblockquote) —validateReport()trivially accepts it, but block-level node$defshere don't declare amarksproperty at all, soadditionalProperties: falsewould reject it. Not expected to occur in practice.
Random document generator (generate:docs)
For fixtures, fuzz-testing, or demos, this package includes a seeded, schema-
driven random document generator (scripts/generate-docs.mjs) that walks
documentJsonSchema itself, so it keeps working as the schema grows. Every
generated document is checked against BOTH Ajv and validateReport() before
being emitted.
# from packages/schema, after `pnpm build` (or `pnpm --filter @wulperstd/schema build`):
pnpm run generate:docs -- --count 50 --seed 1 --stdout
# or, writing files:
pnpm run generate:docs -- --count 10 --seed 1 --out ./generatedFlags: --count N (default 10), --seed S (default 1, any string is
hashed to an integer), --max-depth D (default 4), --max-children C
(default 5), --out DIR (default ./generated), --stdout (print NDJSON to
stdout instead of writing files). All randomness is seeded (faker.seed()
plus an independent seeded PRNG for structural choices) — the same --seed
always reproduces byte-identical output.
test/support/random-document.ts re-exports the exact same generator for use
in this package's own vitest property-based test suite — there is only ONE
implementation.
Public API excludes view and plugin constructs
This package exports node/mark specs, AST types, and validation only. It never exports or requires any ProseMirror view or plugin construct, and never exports a mutator or registration function for the node/mark set — keeping it usable from any DOM-free environment (a server, a CLI, a worker).
