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 documents1695
fully covered, byte-identical HTML1317
surviving the round trip, covered or not1662
fully covered but differing (each one a bug worth fixing)0
threw0

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. code is 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 leading carveCaption node.

  • 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 carries carveHeadingRef, carveRef and carveRawRef, 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]: url definition 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 in degradedTypes().

  • 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 carrying class="mention", not as a carveMention node, 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.