Skip to content

Y::ProseMirror: native ProseMirror/Tiptap HTML rendering - #44

Merged
jpcamara merged 7 commits into
mainfrom
feat/prosemirror-html
Jul 8, 2026
Merged

Y::ProseMirror: native ProseMirror/Tiptap HTML rendering#44
jpcamara merged 7 commits into
mainfrom
feat/prosemirror-html

Conversation

@jpcamara

@jpcamara jpcamara commented Jul 6, 2026

Copy link
Copy Markdown
Owner

The ProseMirror sibling of Y::Lexical (#41). Y::ProseMirror.new(doc).to_html renders a ProseMirror or Tiptap document to HTML from the CRDT bytes — no Node process, no headless editor.

prosemirror = Y::ProseMirror.new(doc)
prosemirror.to_html            # the "default" fragment (Tiptap's default root)
prosemirror.to_html("content") # or another XML root

Handles both ProseMirror and Tiptap

y-prosemirror stores blocks as Y.XmlElement (tag = node type) and text marks as per-run Yjs formatting attributes on the Y.XmlText. The node and mark names come from the editor's schema, so this reads both styles in use:

  • Tiptap camelCase: bulletList, codeBlock, bold, italic
  • prosemirror-schema-basic snake_case: bullet_list, code_block, strong, em

Inspired by tiptap-php

Coverage and output follow ueberdosis/tiptap-php, the maintained PHP renderer for ProseMirror JSON: paragraphs, headings, blockquotes, bullet/ordered/task lists, code blocks (with language), links, images, hard breaks, horizontal rules, tables, and every text mark (bold, italic, strike, code, underline, highlight, sub/sup). Marks nest in Tiptap's serializer order — link, bold, italic, strike, underline, highlight, sub/sup — with code excluding all others.

Byte-for-byte, with one deliberate exception

The output matches Tiptap's own getHTML() byte-for-byte, tested against a document captured from a real Tiptap editor (headless capture, same method as the Lexical PR). The one place it diverges: tables render as tiptap-php's semantic <table><tbody>…, dropping the <colgroup>/min-width styling Tiptap's editor view injects — that styling isn't in the CRDT anyway, and tiptap-php drops it too. Tables are tested separately with a hand-authored semantic expected.

Same architecture as Y::Lexical

  • Block tree walked on an explicit heap stack (no native recursion), with a depth cap — the deep-nesting crash class the Lexical PR fixed can't happen here either (tested at 20k depth on a 512 KiB thread).
  • One transaction per call inside nogvl, compile-time Send + Sync assertion, and a thread-safety hammer test (renders under concurrent writers).
  • A root that isn't ProseMirror-shaped (a Lexical document) returns nil, never a garbled render.

Testing

  • 9 Rust tests: byte-for-byte main fixture, semantic table, mark-order units, link attribute order, both naming schemes, Lexical-refusal, deep-nesting no-overflow, escaping.
  • 6 Ruby tests through the public API: byte-for-byte, semantic table, live-state, per-node/mark probes, missing-root nil, arg guard.
  • Full suite green: 39 cargo, 105 Ruby, clippy/fmt/rubocop clean.

Independent of #41 — based on main, small expected conflict in lib.rs/CHANGELOG when both land (each registers its class / adds a changelog bullet). A future yrs-prosemirror-html crate is the natural home if we extract these renderers.

🤖 Generated with Claude Code

Y::ProseMirror.new(doc).to_html renders a ProseMirror or Tiptap document
to HTML from the CRDT bytes, with no Node process or headless editor.
It's the ProseMirror sibling of Y::Lexical.

The output matches Tiptap's getHTML() byte-for-byte, tested against a
document captured from a real Tiptap editor. It follows tiptap-php's
coverage and reads both name styles: Tiptap's camelCase (bulletList,
bold) and prosemirror-schema-basic's snake_case (bullet_list, strong).
Marks are per-run Yjs formatting attributes on the XmlText; blocks are
XmlElement tags. Marks nest in Tiptap's serializer order (link, bold,
italic, strike, underline, highlight, sub/sup), with code excluding all
others.

Tables render as tiptap-php's semantic <table><tbody> form, dropping the
<colgroup>/min-width styling Tiptap's editor view injects (which isn't
in the CRDT anyway). A root that isn't ProseMirror-shaped (a Lexical
document) returns nil.

Same architecture as Y::Lexical: block tree walked on a heap stack (no
native recursion) with a depth cap, one transaction per call inside
nogvl, compile-time Send+Sync assertion, and a thread-safety hammer
test. Ground-truth fixtures captured with a headless Tiptap editor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 6, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

Review findings against tiptap-php's coverage and fresh captures:

- Mentions were dropped entirely. Tiptap's Mention extension stores an
  inline <mention> element (id, label, mentionSuggestionChar); it now
  renders as Tiptap serializes it, checked byte-for-byte against a new
  captured fixture: <span data-type="mention" data-id data-label
  data-mention-suggestion-char>@Label</span>, falling back to @id when
  there's no label.

- Links dropped class and title. A capture shows Tiptap serializes link
  attrs as target, rel, class, href, title — wrap_link now emits all
  five, skipping absent/null ones.

- The details family (details/detailsSummary/detailsContent) is in
  tiptap-php but wasn't handled. Now renders per tiptap-php's
  renderHTML (that extension is Tiptap Pro, so tiptap-php is the
  reference): <details open="open">, <summary>, and
  <div data-type="detailsContent">.

- An unknown inline node now keeps its text instead of vanishing,
  matching the unknown-block fallback. The recursion this adds carries
  the same depth cap as the block walk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 6, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

Closes the last render gap against tiptap-php's mark set. Tiptap's
Color/FontFamily extensions store their values as textStyle mark
attributes; a run carrying them now renders <span style="...">,
checked byte-for-byte against a captured fixture.

Capture details the implementation follows: hex colors come back out of
the browser's style attribute as rgb() (stored #ff0000 serializes as
color: rgb(255, 0, 0)), so #rgb/#rrggbb convert; rgb()/named values
pass through. Unset attributes sit in the map as explicit nulls and
skip. Keys serialize alphabetically, camelCase to kebab-case, and the
span wraps outside bold but inside link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 6, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

Two content-loss edges outside what the captured fixtures pin:

- A code run also carrying a link lost the link. Tiptap's Code mark
  excludes all others so it can't produce the shape, but
  prosemirror-schema-basic's code mark has no excludes — and this
  renderer explicitly reads schema-basic names. code still renders
  alone among the formatting marks; the link now wraps it.

- An unknown block holding both text and child blocks dropped its
  direct text (the fallback rendered one or the other). The text runs
  now render before the deferred children. Not via render_inline,
  which would also render inline element children the stack is about
  to walk, duplicating them.

Fixture output is byte-for-byte unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 6, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

A real Tiptap editor runs headless under JSDOM, builds a document
covering every node and mark over the y-prosemirror collab binding, and
Y::ProseMirror must reproduce the editor's own getHTML() from the raw
doc bytes — piped through frontend/render_check.rb via the demo bundle.

The gem's fixture tests pin parity with the editor version they were
captured from; this catches serializer drift when @tiptap/* is bumped.
It already did its job once: the demo resolves Tiptap 2.27.2 (the
fixtures were captured on 2.11-era packages) and parity holds.

The one intended divergence stays explicit: the test strips the
<colgroup>/min-width sizing Tiptap's editor view injects into table
getHTML (it isn't in the CRDT; tiptap-php drops it too) and requires
everything else byte-for-byte. No server; runs as its own CI step
before the server suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

The script had case arms for both Y::ProseMirror and Y::Lexical so the
file could be identical across the two renderer branches, but that left
each branch referencing a class it doesn't define. Each branch's copy
now handles only its own renderer; the merged version reconciles to
both when the second PR lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

…eanup

From the line-by-line review walkthrough:

- hex_to_rgb sliced by byte index but len() is byte length, so a
  crafted textStyle color like "#日" (one char, three bytes — it
  enters the 3-digit arm) panicked on a char-boundary slice. Non-ASCII
  input now passes through like any other non-hex value; tests pin the
  3- and 6-byte shapes.

- Known containers dropped bare text jammed directly into them (a
  blockquote's direct text runs), while the unknown-block fallback kept
  it. push_block_children now renders direct text runs first for every
  container, and the unknown arm's hand-rolled version of the same loop
  is gone. Schema-valid documents never hit this; crafted ones keep
  their content.

- Comments on render()'s defensive arms state their reachability, the
  Arc import is gone (it existed for one optional type annotation), and
  paragraph's vestigial inline binding is inlined.

Fixture output is byte-for-byte unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 8, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@jpcamara
jpcamara merged commit a88f712 into main Jul 8, 2026
7 checks passed
@jpcamara
jpcamara deleted the feat/prosemirror-html branch July 8, 2026 03:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant