ProseMirror / Tiptap bridge
September 25, 2026 · View on GitHub
Carve AST to a ProseMirror document and back, without a Node runtime.
use MarkupCarve\Carve\CarveConverter;
use MarkupCarve\Carve\ProseMirror\ProseMirrorRenderer;
use MarkupCarve\Carve\ProseMirror\ProseMirrorToCarve;
$document = (new CarveConverter())->parse($source);
$renderer = new ProseMirrorRenderer();
$json = $renderer->renderJson($document); // hand this to a Tiptap editor
$back = (new ProseMirrorToCarve())->convertJson($json);
(new CarveConverter())->render($back); // HTML
CarveConverter::carve()->getRenderer()->render($back); // Carve source
The point of the pair: an editor in the browser, and PHP rendering the stored document to HTML, PDF or DOCX in queue workers and CLI commands where no Node runtime exists.
This is the import route; the HTML path is the fallback
The other way into an editor is to render Carve to HTML and let Tiptap's
parseHTML re-derive the document. That works and is worth keeping - it is
how you load content an extension understands but this engine has no node
for. It is not the recommended route, for one
reason: fidelity there is bounded by what each extension's parseHTML claims,
so an attribute no extension declares is dropped silently. For a stored
document format, silent attribute loss on load is the failure you cannot
recover from, because nothing records that it happened.
The bridge builds from the AST instead, and reports what it could not carry -
droppedTypes() for content that is gone, degradedTypes() for a node type
that is gone while its text survives. Prefer it for anything you intend to
store and read back.
Abbreviation and citation definitions are block nodes in the editor document.
carveAbbreviationDefinition keeps each authored line in place, including
shadowed definitions. carveCitationDefinition keeps its key, attributes and
inline entry content. The bridge still reads older documents that carry
abbreviation definitions only in the doc attributes. When both forms are
present, the nodes determine the definitions so edits in the editor survive.
New documents carry definitions only as nodes, so deleting the last node also
deletes its definition.
The wire shape, not only the names
resources/prosemirror-wire-fixtures.json is a copy of the fixture set
carve-grammars publishes: one Carve source per construct and the exact
ProseMirror document a bridge must produce for it, attribute names included.
WireFixturesTest asserts both directions against it.
The map alone was not enough. It named the node a Carve type becomes and left
the attributes to each implementation, so this bridge wrote carveRef and
tight where carve-grammars wrote ref and nothing at all - each perfectly
round-tripping on its own, and neither able to read the other's documents
without loss. Refresh the fixtures the way the map is refreshed: copy them, and
bump the commit in _provenance.
Where the names come from
Node and mark names are not defined here. They come from
resources/prosemirror-schema-map.json, a copy of the map published by
carve-grammars, which owns the
CarveKit schema and the serializer. Restating the mapping in each engine is how
implementations drift - carve-php once emitted citation-group while everything
else spelled it with underscores.
Refresh the copy from upstream and bump the commit in its _provenance block.
ProseMirrorCorpusTest fails if this engine grows a node type the map has no
decision for.
Adding an entry by hand is a divergence, and has to be declared
That corpus assertion is satisfied by a local entry, which is what let six
decisions accumulate here while _provenance.commit named a file holding none of
them. So a second check asks the other question:
php scripts/check-schema-map.php --grammars <a carve-grammars checkout>
It compares every decision this copy states - kind, pm, accepts, whether a
type is unmapped, and the carrier node names - against the map at
_provenance.commit, and the pinned map against carve-grammars main. Any
difference has to be named in _provenance.divergences with a reason, and an
entry there that no longer differs is refused, so the list cannot only grow.
The decision is the subject, not the commit distance: carve-grammars merges continuously, so a gate on distance would be red from any open pull request over there. The distance is printed as a number, along with the entries whose prose differs from the pin without their decision differing.
A new editor node still belongs in carve-grammars first. A declaration is how a type this engine already produces gets a decision in the meantime.
What the editor model cannot hold
Roughly a third of Carve's node types have no ProseMirror equivalent. The bridge never guesses; it reports, in two categories:
$renderer->droppedTypes(); // ['comment' => 'comments are not represented …']
$renderer->degradedTypes(); // ['soft_break' => 'a soft break is whitespace …']
- Dropped - the content is gone: smart typography, and a caption NUMBER, which is a resolution artifact rather than editor content. Comments, figures with captions, frontmatter, cross-references, line blocks, inline footnotes and raw passthrough are all carried.
- Degraded - the node type is gone but the text survives: a soft break becomes a space, a smart quote becomes its glyph, an escaped character becomes the character. Dropping these instead would run words together or lose a character.
Ask the renderer rather than trusting this list: which types land where is a property of one bridge at one version, and both directions of that list move.
An application storing documents should assert both are empty rather than trust them:
$json = $renderer->renderJson($document);
if ($renderer->droppedTypes() !== []) {
throw new RuntimeException('editor cannot hold: ' . implode(', ', array_keys($renderer->droppedTypes())));
}
Going the other way, an unknown ProseMirror name is an error, not a skip: an editor that grew a node nobody mapped is exactly where silent loss is worst. What the payload carried but the Carve source cannot is reported in the same two categories, keyed by attribute rather than by node type:
$converter->droppedAttributes(); // ['data-team' => 'a mention has no Carve spelling …']
$converter->degradedAttributes(); // ['id' => 'the name has no Carve mention spelling …']
carve-rs and carve-grammars key and word each row the same way, so an application can compare the reports of two engines (markup-carve/carve-php#2167).
Fidelity
ProseMirrorCorpusTest sweeps the whole spec corpus. The strict gate is narrow on
purpose: a document whose types the editor model fully covers, with nothing
dropped and nothing degraded, must come back as byte-identical canonical
Carve. Documents that lose something are allowed to differ, because they must.
Measured at the pinned spec:
| count | |
|---|---|
| corpus documents | 1695 |
| fully covered, byte-identical HTML | 1317 |
| surviving the round trip, covered or not | 1662 |
| fully covered but differing (each one a bug worth fixing) | 0 |
| threw | 0 |
The test fails when a count moves the wrong way. The population must equal the
number of ::: compare blocks in the pinned spec's examples, so a partial
checkout cannot pass on a smaller corpus. The fully-covered count has the floor
MINIMUM_LOSSLESS (809) and the surviving count has MINIMUM_SURVIVING (1024).
The surviving count is the one that guards fidelity: a document leaves the
fully-covered row when the renderer starts reporting a loss it used to hide,
even though its round trip did not change. The differing row has the ceiling
MAXIMUM_COVERED_BUT_DIFFERING (0), and no document may throw. Both floors sit
several hundred documents below the measurement, so they catch a large
regression rather than a single document.
Application node types
An application's own editor node survives as an attributed container - no library change needed:
{#calc-1 .calculation data-label="Wärmebedarf" data-unit=kWh}
::: calculation
42
:::
becomes a carveDiv carrying data-label and data-unit in its attrs, and comes
back with them intact. Nodes that subclass Node instead can be registered with
AstCodec::register() for the JSON codec; the bridge itself needs the upstream map
to know the name, so a genuinely new editor node belongs in carve-grammars first.
Shape differences worth knowing
-
Marks. Carve nests emphasis as elements (
Strong > Text); ProseMirror hangs marks off the text node. Coming back, adjacent runs of the same mark are merged, or*bold with /italic/ inside*would return as three<strong>elements. -
Content-bearing nodes.
codeis a mark in ProseMirror but a node holding its text in Carve; a code block likewise. Both are translated explicitly. -
Tables. ProseMirror marks header cells; Carve also flags the row, which is what puts it in
<thead>. A table caption is state on the table, not a child, so it travels as a leadingcarveCaptionnode. -
Lists. Looseness is content, not styling: without carrying
tight, a loose list comes back tight and its items lose their paragraphs. -
Reference links. A link mark carries
href, which is what a link renders by rather than what it was written as. For a collapsed reference that reaches a heading the mark also carriescarveHeadingRef,carveRefandcarveRawRef, so this:# *bold* heading [*bold* heading][]comes back spelled as it was written instead of as
[*bold* heading](#bold-heading), which would bake a generated id into the source on every pass. The spelling is re-derived on the way back, not trusted: a reference resolves by the heading's rendered text, so an editor that retypes the visible text gets its edit kept and the link falls back to the inline form. A[text][label]reference is not carried, because it resolves against a[label]: urldefinition the editor model does not hold - written back without one it would be literal text, not a link - so it is written inline and reported indegradedTypes(). -
Mention labels. Tiptap's mention extension stores a display name, and a mention name is ASCII with interior dots and nothing else - so a label an editor produced (
Mark Scherer,o'brien) is written as the link form,[Mark Scherer](/u/42){.mention}, which keeps the label, the href and the class. Attributes and markup inside the label take the same route, for the same reason. It reads back as a link carryingclass="mention", not as acarveMentionnode, and nothing is reported dropped or degraded: an editor that builds its mentions from a node type needs a parse rule on the class, while one that styles by class already matches.