Y::ProseMirror: native ProseMirror/Tiptap HTML rendering - #44
Conversation
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>
|
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>
|
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>
|
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>
|
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>
|
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>
|
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>
|
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 ProseMirror sibling of
Y::Lexical(#41).Y::ProseMirror.new(doc).to_htmlrenders a ProseMirror or Tiptap document to HTML from the CRDT bytes — no Node process, no headless editor.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 theY.XmlText. The node and mark names come from the editor's schema, so this reads both styles in use:bulletList,codeBlock,bold,italicbullet_list,code_block,strong,emInspired 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 — withcodeexcluding 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-widthstyling 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
nogvl, compile-timeSend + Syncassertion, and a thread-safety hammer test (renders under concurrent writers).nil, never a garbled render.Testing
Independent of #41 — based on
main, small expected conflict inlib.rs/CHANGELOGwhen both land (each registers its class / adds a changelog bullet). A futureyrs-prosemirror-htmlcrate is the natural home if we extract these renderers.🤖 Generated with Claude Code