@schemastud/blockdoc
v0.1.0
Published
Manifest-driven rich-content documents on Tiptap: TypeScript types for the blockdoc node-manifest format, assemblePMSchema (manifest → ProseMirror Schema via category-derived content expressions, the conformance oracle), createManifestExtensions (the same
Readme
@schemastud/blockdoc
Manifest-driven ProseMirror documents. The /core subpath (React-free) ships:
- TypeScript types for the blockdoc node-manifest format (
BlockdocManifest,NodeManifestEntry,MarkManifestEntry,DocManifest), assemblePMSchema(manifest | manifest[])— compiles one or more manifests into a ProseMirrorSchema(via@tiptap/pm/model),- the exported derivation pieces the Tiptap extension generator shares (
collectManifestEntries,allBlockCategories,contentExpressionFor,attrsFromSchema,groupsFor), - id conventions:
generateNodeId()(UUIDv7, matching the server'sStr::uuid7()) andNODE_ID_ATTR('id').
Nothing under src/core imports React or RJSF. The editing half ships as two more subpaths:
/react— theBlockdocEditorisland on Tiptap (v3, ADR-0072 as amended by splicewire-editor issue 11).createManifestExtensions(manifests, { docAdmits?, nodeViews? })GENERATES the TiptapNode/Markextensions from the manifests at runtime — never hand-authored per node type — reusing the exact derivation piecessrc/core/assemble.tsexports (collectManifestEntries,allBlockCategories,contentExpressionFor,attrsFromSchema,groupsFor), soassemblePMSchemastays the conformance oracle (tests/schema-parity.test.tspins both compilations to the same accept/reject behavior). The generated extensions carry the editing DOM: semantic tags for the base prose set (p,h{level},blockquote,ul/ol/li,pre>code,hr,br) withdata-node-id, genericdiv[data-node-type]for typed blocks, and marks asstrong/em/code/a[href]/span[data-annotation-id]. The island (useEditor/EditorContent) commitsdoc.toJSON()throughonChangeon trailing debounce (default 400ms), blur, andflushCommits()(ref orcommitBus); a last-committed guard absorbs the RJSF onChange echo while a genuinely external value rebuilds the document via a commit-suppressed, history-freesetContent(selection remapped by node id, undo history effectively fresh) without firing a commit. A free-tierBubbleMenuoffers bold/italic/code and prompt-free link set/unset for whichever of those marks the manifests declare. NodeView registry:registerNodeView(name, Component)overrides; unregistered non-base-prose nodes get the generic NodeView (labeled chrome + aSchemaFormover the node's attrs — the drill-down seam); components keep theNodeViewComponentPropscontract and rideReactNodeViewRenderer/NodeViewWrapperthrough thetiptapNodeViewadapter. Collab seams prepared but empty:extraPlugins({ schema })and theDocSourceabstraction (defaultvalueDocSource).Guardrails (enforced by
tests/no-pro-imports.test.ts): MIT core + free extensions only — no@tiptap-pro/cloud modules (collab is Reverb + y-prosemirror later; comments are our annotation marks; AI is the intent bus). Our integrity plugins (node-id UUIDv7 uniqueness, annotation-mark id integrity) stay RAW ProseMirror plugins registered throughaddProseMirrorPlugins— one-layer portability in both directions — and every ProseMirror import rides@tiptap/pm/*so exactly one PM instance exists./rjsf—createRichContentWidget(nodeViewRegistry, defaults?)producing a component that mounts as an RJSF field (object values) or widget: readsmanifest/manifestRef(resolved viaformContext.schemaFetcher, falling back todefaults.schemaFetcher),palette, andcommitfromui:options; assembles[defaults.baseManifest, profile]; runs advisory client-side validation of the field schema at commit boundaries (server stays authoritative).
import { assemblePMSchema, generateNodeId } from '@schemastud/blockdoc/core';
const schema = assemblePMSchema([baseManifest, contentManifest]);
const doc = schema.nodeFromJSON(json);
doc.check(); // containment enforcement — throws on category violationsManifest format (v1, frozen 2026-07-10)
Both sides build against this exact JSON shape: the server export command
(block-schema:export-manifest) emits it per profile; the client assemblePMSchema
consumes it. The base prose set is NOT emitted per profile — it is the hand-authored
base manifest vendored client-side (see tests/fixtures/base.manifest.json).
{
"profile": "content", // profile key, or "base" for the vendored base set
"version": 1, // manifest format version
"doc": {
// What the document node admits, same semantics as node admitsChildCategories.
"admitsChildCategories": ["section"]
},
"nodes": [
{
"name": "contentSection", // PM node type name == server #[NodeType] name
"description": "…", // optional
"group": "block", // from #[NodeType]->group; 'block' | 'inline'
"category": "section", // Block::category(); becomes the PM group; null allowed
"admitsChildCategories": ["prose"], // null = unconstrained; [] = leaf; list = category union
"admitsText": false, // true → textblock (content 'inline*') when admitsChildCategories is null
"contentExpression": null, // explicit escape hatch (base set only); overrides derivation
"attrsSchema": { // JSON Schema object over node attrs; id ALWAYS present
"type": "object",
"properties": {
"id": { "type": ["string", "null"] },
"heading": { "type": "string" }
}
}
}
],
"marks": [
{ "name": "strong" },
{ "name": "em" },
{ "name": "code" },
{ "name": "link", "attrsSchema": { "type": "object", "properties": { "href": { "type": "string" } } } },
{
"name": "annotation",
"attrsSchema": { "type": "object", "properties": { "id": { "type": ["string", "null"] } } },
"excludes": "" // PM excludes; "" allows overlapping same-type marks
}
]
}Derivation rules (assemblePMSchema)
contentExpressionset → used verbatim.- else
admitsChildCategoriesis a list → content(catA | catB)*(PM group union); empty list → leaf (no content). - else (
null) →admitsText: true→ content'inline*'; otherwise UNCONSTRAINED: the union of every block category the composed manifests declare,(cat1 | cat2 | …)*(degrading to a leaf only when no categories exist). - PM
groupper node = itscategory(nodes are addressed in content expressions by category). Nodes with manifestgroup: 'inline'additionally join the PMinlinegroup (and getinline: true) so'inline*'reaches them. - Node attrs = keys of
attrsSchema.properties, each{ default: schema.default ?? null };idis force-present with defaultnull. - The
docnode's content derives fromdoc.admitsChildCategoriesby rules 2/3 (a doc never admits raw text). - The
textnode is implicit (PM groupinline); manifests never declare it — declaringtextordocthrows. - Marks: attrs from
attrsSchema.propertiessame as nodes (no forcedid);excludespassed through when present (including'').
Merging (array argument, e.g. [base, profile]): nodes and marks concatenate in
order; a later manifest may not redeclare an existing node name — that throws.
(Mark redeclaration also throws; that is this implementation's choice, the frozen
format only mandates it for nodes.) The last manifest with a non-null doc wins;
if no manifest carries a doc, assembly throws.
Known limits of the category → content-expression derivation
Prototyped against the real content profile vocabulary
(ContentArticleBlock / ContentOutlineBlock / ContentSectionBlock in
splicewire-app, mirrored by hand in tests/fixtures/content-article.manifest.json).
These are the semantics that do not compile cleanly:
- Null-category nodes are untargetable. A node whose
categoryisnulljoins no PM group, so no category-derived expression can ever admit it — it can only be the (mapped-away) root or dead vocabulary. This is real, not hypothetical:ContentArticleBlockdoes not overrideBlock::category(), so the article root has no category. The fixture keepscontentArticlefaithful (categorynull) and instead gives the doc node the article's role (doc.admitsChildCategories: ["section"]); the export command must decide whether root blocks map todocor get a category. - Only set-union repetition is expressible. Categories compile to
(a | b)*— sets with unordered, unbounded repetition. PM content expressions can express sequences, counts and required children (outline sections+,heading paragraph*), but categories cannot. The real grammar's ordering (content_article's Beats drain outline then sections, in declaration order) is lost: the derived(outline | section)*admits an outline anywhere, repeatedly, or never. Ordering / arity constraints need thecontentExpressionescape hatch (or a richer manifest field later). - Single-category unions are still just repetition.
["list_item"]derives(list_item)*— you cannot say "exactly one" or "at least one" child. null("unconstrained") has no exact PM equivalent. Server-side, anulladmitsChildCategoriesmeans "admits anything" (Block::withContentenforces nothing). PM has no "any" wildcard, so the derivation compilesnullto the enumerated union of every block category the composed manifests declare — faithful to the server for every category that exists at assembly time, but a node whose category no manifest declares remains unplaceable. (admitsText: truestill takes precedence and compiles to'inline*'.) The realContentSectionBlockalso carries duplicate prose as a flatbodystring attr; whether that attr yields to child prose is a per-block server decision the client cannot infer.- Category
$ids are not PM-safe tokens. Real categories are URL$ids (e.g.https://app.splicewire.com/json-schemas/block-category/content-section). PM's content-expression tokenizer only accepts word characters, so raw$ids cannot be PM group names. The fixtures use short tokens (section,outline,prose); the export command must emit a stable PM-safe slug per category$id, not the raw id. - Name casing. Server
#[NodeType]names are snake_case (content_section) — valid PM names — while the hand-authored fixtures follow PM's camelCase convention (contentSection, matchingbullet_list/list_itemin the base set). The export command must pick one convention; the client treats names as opaque.
Fixtures
tests/fixtures/base.manifest.json— the vendored base prose vocabulary (paragraph, heading, blockquote, lists, code_block, horizontal_rule, hard_break; marks strong/em/code/link/annotation). Carriesdoc: null— the profile manifest owns the doc.tests/fixtures/content-article.manifest.json— thecontentprofile vocabulary mirrored by hand from the server blocks, with the adaptations documented above.
Development
npm install
npm test # vitest
npm run typecheck