@schemastud/blockdoc
v0.1.1
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. - Ordering is not derived — only arity is. By default categories compile to
(a | b)*(unordered, unbounded repetition). Arity is now expressible via thechildConstraintsmanifest field (B1):childConstraints: { section: { required: true }, heading: { max: 1 } }compiles to a quantified sequence (heading? section+) —min/max/requiredbecome PM repetition operators (+,?,{n},{min,max}), and amax: 0category is dropped. Ordering — the realcontent_article's Beats draining outline then sections — is still not inferred; declaringchildConstraintspins theadmitsChildCategoriesorder as a side effect, but for order without arity use thecontentExpressionescape hatch (now allowed in profile manifests, not just the base set). A manifest with nochildConstraintscompiles exactly as before (no regression). - ~~Single-category unions are still just repetition.~~ Resolved by B1.
["list_item"]still derives(list_item)*by default, butchildConstraints: { list_item: { required: true } }now says "at least one" and{ min: 1, max: 1 }says "exactly one". 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.
Collaboration seams (reserved, no collab built — B4)
The package reserves five seams so a future collaboration build (y-prosemirror / presence transport) plugs in without reshaping the editor. All are reservation-only today:
DocSource(docSourceprop) — the editor reads its doc throughDocSource{get(); subscribe?()}, not only avalueprop; a collab-backed source pushes remote docs throughsubscribe.extraPlugins— raw PM plugins appended to the island's list (the transport plugs in here).- Swappable id-plugin (
idPluginprop) — defaults tonodeIdPlugin; a collab build replaces it with a CRDT-aware plugin. - Intent
origin— theFormIntentBusLikeintent payload carries an optionaloriginso an inline-AI / remote write is distinguishable from a local edit; the flush path stays pluggable (registerFlush), and AI writes ride the same transport (no side channel). - Annotation mark +
document_bindingsanchor —annotation-pluginkeeps the comment/binding anchor open; never a second CRDT.
The named debt (the crux invariant): stable node ids MUST survive a CRDT merge. The
document is a location index so one CRDT suffices (truth is SQL-authoritative — see the
document-is-a-location-index doctrine), but a merge could duplicate or drop ids.
NodeIdRematcher does not cover concurrency (it is single-user content-similarity
rematch). The collaboration effort owns id-merge-safety and a post-merge validity pass
(a concurrently-valid pair of edits can merge to an invalid tree). Presence/lock state lives
in the transport, never in the document (see lock-and-presence-state-lives-in-transport).
Development
npm install
npm test # vitest
npm run typecheck