yrby 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: addc2f9c08a109299b6c3db7cbf977b9b39194b7d784424b55876e8357897b29
4
- data.tar.gz: 586d689d080396b5e3c9cf528f49dfbccd5dca831e6e64bbaf6de35216f74143
3
+ metadata.gz: bb0de1a9048f2aa942aad3bafed3269a029ed28ea178a72cb0f532e163cbb5f5
4
+ data.tar.gz: 6949735cd0edca033e78ef060333b91c40af2e3f900a79da46885d6630e6bc32
5
5
  SHA512:
6
- metadata.gz: 3412853b540745a3a1db1134104c7cda42330d952b9d6eb08d8c20512069eae93f762aa77204cb6ecc3961da0c117df6bb66ef4b2aad5b0b81b05beedf3253b7
7
- data.tar.gz: 6d65ed5a61f737cd904800747c40e74d7b57fb0f64f77b12128533a0cfbf4c86058e13d2b2a33d9d005a7bfd015c31683fb25054236dbb3f28639f867d9a3a69
6
+ metadata.gz: 3f0a9aeea9e3efdd443f7da526eaf692226015a5a6be037527e1600b74cb2529e123fbcf0f03d91282ad4b60e96cfd5d81d51d149ec1f42b06f27664f4cf3ffb
7
+ data.tar.gz: 5532beea7978401cf18a2970eac4eefdc037c075bc6de2fb56b92978b3dc5e8b3c5759b9b6d9aabfb879ba92517e3792810ab14fabf9ef4461da30bfa2bd4c39
data/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-07-07
10
+
11
+ ### Added
12
+
13
+ - **`Y::ProseMirror` — render ProseMirror/Tiptap documents to HTML.**
14
+ `Y::ProseMirror.new(doc).to_html` turns a Tiptap document into HTML on the
15
+ server, with no Node process or headless editor. The output matches Tiptap's
16
+ own `getHTML()`; the tests check it byte-for-byte against a document captured
17
+ from a real editor. It follows `ueberdosis/tiptap-php` and reads both name
18
+ styles editors use — Tiptap's `bulletList`/`bold` and prosemirror-schema-basic's
19
+ `bullet_list`/`strong`. Covers paragraphs, headings, blockquotes,
20
+ bullet/ordered/task lists, code blocks, links, images, mentions, details,
21
+ hard breaks, horizontal rules, tables, text styles (color, font family), and
22
+ every text mark. A table renders as semantic `<table><tbody>` without the
23
+ editor's column-width styling. A root that isn't ProseMirror (a Lexical
24
+ document, say) returns `nil`.
25
+
9
26
  ## [0.3.1] - 2026-07-01
10
27
 
11
28
  Fixes from a full source review.
data/README.md CHANGED
@@ -175,6 +175,31 @@ guarantees keep serving safe:
175
175
  keeps its pending), while `encode_state_as_update` stays lossless so you can
176
176
  still preserve the raw pending bytes for recovery.
177
177
 
178
+ ### Rendering to HTML
179
+
180
+ `Y::ProseMirror` renders a ProseMirror or Tiptap document to HTML on the
181
+ server, with no Node process or headless editor:
182
+
183
+ ```ruby
184
+ prosemirror = Y::ProseMirror.new(doc)
185
+ prosemirror.to_html # the "default" fragment (Tiptap's default root)
186
+ prosemirror.to_html("content") # or another XML root
187
+ ```
188
+
189
+ The output matches Tiptap's own `getHTML()`, checked byte-for-byte in the tests
190
+ against a document captured from a real editor. So you can render, search, or
191
+ email a collaborative document without running a browser. It follows
192
+ [`tiptap-php`](https://github.com/ueberdosis/tiptap-php) and reads both name
193
+ styles editors use — Tiptap's `bulletList`/`bold` and prosemirror-schema-basic's
194
+ `bullet_list`/`strong`.
195
+
196
+ It covers paragraphs, headings, blockquotes, bullet/ordered/task lists, code
197
+ blocks, links, images, mentions, details, hard breaks, horizontal rules,
198
+ tables, text styles (color, font family), and every text mark. A table renders
199
+ as semantic `<table><tbody>`, without the column-width styling Tiptap's editor
200
+ view adds. A root that isn't ProseMirror — a Lexical document, say — returns
201
+ `nil`.
202
+
178
203
  ### Protocol codec (module functions)
179
204
 
180
205
  Classifying and unwrapping wire frames is stateless, so it's exposed as
@@ -242,21 +267,6 @@ servers:
242
267
  causal dependencies are already in the store (checked against `on_load`); a
243
268
  causally-incomplete update triggers a resync instead, so the log always
244
269
  rebuilds cleanly.
245
- - **An unhealable gap is dropped, not resynced forever.** A resync heals a gap
246
- whose missing dependency is still in flight. But a *permanently*-orphaned update
247
- (its dependency is gone for good) stays gappy through every resync, and a client
248
- retransmitting it would loop endlessly (server resyncs → client resends →
249
- repeat). After `gap_strike_limit` rejections of the same update on one
250
- connection (default 3, minimum 2), the channel settles it with
251
- `{ "ack" => id, "dropped" => true }` and drops it instead of resyncing again —
252
- breaking the loop while never dropping a *healable* gap (those heal within a
253
- resync or two, and healing frees the strike). The `dropped` flag lets the
254
- client surface the loss (`yrby-client` reports it via `onError`) instead of
255
- silently showing synced. Set `gap_strike_limit nil` to disable. Works on both
256
- transports: plain ActionCable keeps strikes on the channel instance; under
257
- AnyCable (fresh instance per RPC command) they persist through
258
- anycable-rails' `state_attr_accessor` (istate), declared automatically when
259
- anycable-rails is loaded.
260
270
  - **`on_change` is at-least-once, and the durable guarantee is that replaying the
261
271
  log reconstructs the document.** Every update triggers `on_change` before it's acked or
262
272
  broadcast (record-before-distribute). If exactly-once updates matter for you, **you
data/ext/yrby/src/lib.rs CHANGED
@@ -6,6 +6,7 @@ use yrs::updates::decoder::Decode;
6
6
  use yrs::updates::encoder::Encode;
7
7
  use yrs::{Doc, GetString, ReadTxn, Transact};
8
8
 
9
+ mod prosemirror_html;
9
10
  mod protocol;
10
11
  mod read;
11
12
  use protocol::{
@@ -30,6 +31,7 @@ struct RbDoc(Doc);
30
31
  fn assert_thread_safe() {
31
32
  fn is_send_sync<T: Send + Sync>() {}
32
33
  is_send_sync::<Doc>();
34
+ is_send_sync::<RbProseMirror>();
33
35
  }
34
36
 
35
37
  /// Run `f` with the GVL (Global VM Lock) released, so other Ruby threads,
@@ -329,6 +331,55 @@ impl RbDoc {
329
331
  }
330
332
  }
331
333
 
334
+ // ============================================================================
335
+ // Y::ProseMirror — schema-pinned rendering of ProseMirror/Tiptap documents
336
+ // ============================================================================
337
+
338
+ /// A ProseMirror/Tiptap view over a `Y::Doc`. The schema knowledge (node/mark
339
+ /// names, Tiptap's serializer semantics) lives here rather than on the
340
+ /// schema-agnostic `Doc`. Holds a cheap clone of the doc (yrs `Doc` is an Arc
341
+ /// handle), so it reads live state.
342
+ ///
343
+ /// Thread safety matches `Y::Doc`: every method opens its own transaction
344
+ /// inside `nogvl` and holds no lock across the GVL boundary.
345
+ #[magnus::wrap(class = "Y::ProseMirror", free_immediately, size)]
346
+ struct RbProseMirror(Doc);
347
+
348
+ impl RbProseMirror {
349
+ /// `Y::ProseMirror.new(doc)`
350
+ fn new(doc: &RbDoc) -> Self {
351
+ RbProseMirror(doc.0.clone())
352
+ }
353
+
354
+ /// Render an XML root (default `"default"`, the fragment name Tiptap's
355
+ /// Collaboration extension uses) to HTML. Output follows tiptap-php and
356
+ /// matches Tiptap's `getHTML()` on the reference fixture; see
357
+ /// `prosemirror_html.rs` for coverage and caveats. Returns nil when the
358
+ /// root is missing or not ProseMirror-shaped (e.g. a Lexical document).
359
+ fn to_html(&self, args: &[Value]) -> Result<Option<String>, Error> {
360
+ if args.len() > 1 {
361
+ let ruby = Ruby::get().unwrap();
362
+ return Err(Error::new(
363
+ ruby.exception_arg_error(),
364
+ format!(
365
+ "wrong number of arguments (given {}, expected 0..1)",
366
+ args.len()
367
+ ),
368
+ ));
369
+ }
370
+ let name: String = match args.first() {
371
+ Some(arg) => TryConvert::try_convert(*arg)?,
372
+ None => "default".to_string(),
373
+ };
374
+ let doc = &self.0;
375
+ Ok(nogvl(move || {
376
+ let txn = doc.transact();
377
+ let fragment = txn.get_xml_fragment(name.as_str())?;
378
+ prosemirror_html::render(&txn, &fragment)
379
+ }))
380
+ }
381
+ }
382
+
332
383
  // ============================================================================
333
384
  // Protocol codec (stateless), exposed as `Y` module functions
334
385
  // ============================================================================
@@ -409,6 +460,11 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
409
460
  "handle_sync_message",
410
461
  method!(RbDoc::handle_sync_message, 1),
411
462
  )?;
463
+ // Y::ProseMirror: schema-pinned ProseMirror/Tiptap rendering over a Doc.
464
+ let prosemirror_class = module.define_class("ProseMirror", ruby.class_object())?;
465
+ prosemirror_class.define_singleton_method("new", function!(RbProseMirror::new, 1))?;
466
+ prosemirror_class.define_method("to_html", method!(RbProseMirror::to_html, -1))?;
467
+
412
468
  // Stateless protocol codec, as Y module functions.
413
469
  module.define_module_function("wrap_update", function!(wrap_update, 1))?;
414
470
  module.define_module_function("message_kind", function!(message_kind, 1))?;
@@ -0,0 +1,896 @@
1
+ //! Native HTML rendering of ProseMirror/Tiptap documents from the yrs collab
2
+ //! structure — no Node process, no headless editor.
3
+ //!
4
+ //! The y-prosemirror binding stores a document in a Y.XmlFragment: block nodes
5
+ //! are Y.XmlElement (the tag is the node type, its attributes are the node
6
+ //! attrs), and text is Y.XmlText whose per-run formatting attributes are the
7
+ //! marks. Node and mark names come from the editor's schema, so this accepts
8
+ //! both spellings in use: Tiptap's camelCase (`bulletList`, `bold`) and the
9
+ //! prosemirror-schema-basic snake_case (`bullet_list`, `strong`).
10
+ //!
11
+ //! Output follows `ueberdosis/tiptap-php` (the maintained PHP renderer for
12
+ //! ProseMirror JSON) and matches Tiptap's own `getHTML()` byte for byte on the
13
+ //! captured fixtures — including mentions — with one deliberate exception: a
14
+ //! table renders as the semantic `<table><tbody>…`, without the
15
+ //! `<colgroup>`/`min-width` styling Tiptap's editor view injects (tiptap-php
16
+ //! drops it too). The details family follows tiptap-php's renderHTML, since
17
+ //! that Tiptap extension is Pro-only.
18
+ //!
19
+ //! Marks nest in a fixed order (outermost first): link, bold, italic, strike,
20
+ //! underline, highlight, then subscript/superscript. `code` excludes every
21
+ //! other mark, so a code run is just `<code>`.
22
+
23
+ use yrs::types::text::YChange;
24
+ use yrs::types::Attrs;
25
+ use yrs::{
26
+ Any, GetString, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef, XmlOut,
27
+ XmlTextRef,
28
+ };
29
+
30
+ // The block tree is walked on a heap stack, so depth costs memory, not native
31
+ // stack frames; this caps the work a pathological document can demand.
32
+ const MAX_DEPTH: usize = 1024;
33
+
34
+ /// A pending block on the traversal stack. `Open` renders a node (pushing its
35
+ /// block children as more work); `Close` emits a container's end tag once its
36
+ /// children are done. Container end tags are all fixed strings.
37
+ enum Work {
38
+ Open(XmlElementRef, usize),
39
+ Close(&'static str),
40
+ }
41
+
42
+ /// Render a ProseMirror/Tiptap-shaped XML root to HTML, or `None` when the root
43
+ /// isn't ProseMirror-shaped. ProseMirror blocks are plain Y.XmlElement tags; a
44
+ /// Lexical root (Y.XmlText children carrying a `__type`) is a different schema
45
+ /// and returns `None` instead of a garbled render.
46
+ pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
47
+ if !is_prosemirror_shaped(txn, fragment) {
48
+ return None;
49
+ }
50
+ let mut out = String::new();
51
+ for node in fragment.children(txn) {
52
+ match node {
53
+ XmlOut::Element(e) => render_block_tree(txn, &e, &mut out),
54
+ // y-prosemirror never writes a bare text run at the root, but a
55
+ // crafted doc can; render it rather than drop it.
56
+ XmlOut::Text(t) => render_text_runs(txn, &t, &mut out),
57
+ // Fragments can't nest as children in yrs, so this arm shouldn't
58
+ // be reachable; it exists because the match must be exhaustive,
59
+ // and escaping the text is the safe degradation.
60
+ XmlOut::Fragment(f) => out.push_str(&escape_text(&f.get_string(txn))),
61
+ }
62
+ }
63
+ Some(out)
64
+ }
65
+
66
+ /// A root is ProseMirror-shaped when it's empty or its first child is a block
67
+ /// element with no `__type` (Lexical stamps `__type` on every node; ProseMirror
68
+ /// uses the node type as the element tag).
69
+ fn is_prosemirror_shaped<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> bool {
70
+ match fragment.children(txn).next() {
71
+ Some(XmlOut::Element(e)) => e.get_attribute(txn, "__type").is_none(),
72
+ Some(_) => false, // Lexical stores blocks as XmlText
73
+ None => true, // empty document
74
+ }
75
+ }
76
+
77
+ /// Walk one top-level block and everything under it on a heap stack.
78
+ fn render_block_tree<T: ReadTxn>(txn: &T, root: &XmlElementRef, out: &mut String) {
79
+ let mut stack: Vec<Work> = vec![Work::Open(root.clone(), 0)];
80
+ while let Some(work) = stack.pop() {
81
+ match work {
82
+ Work::Close(tag) => out.push_str(tag),
83
+ Work::Open(node, depth) => open_block(txn, &node, depth, out, &mut stack),
84
+ }
85
+ }
86
+ }
87
+
88
+ /// Render one block. Text blocks (paragraph, heading, code) render in full on
89
+ /// the spot; container blocks emit their opening tag and defer their children
90
+ /// (and matching `Close`) to the stack.
91
+ fn open_block<T: ReadTxn>(
92
+ txn: &T,
93
+ e: &XmlElementRef,
94
+ depth: usize,
95
+ out: &mut String,
96
+ stack: &mut Vec<Work>,
97
+ ) {
98
+ match e.tag().as_ref() {
99
+ "paragraph" => {
100
+ out.push_str("<p>");
101
+ out.push_str(&render_inline(txn, e, 0));
102
+ out.push_str("</p>");
103
+ }
104
+ "heading" => {
105
+ let level = num_attr(txn, e, "level").unwrap_or(1).clamp(1, 6);
106
+ let tag = ['1', '2', '3', '4', '5', '6'][(level - 1) as usize];
107
+ out.push_str("<h");
108
+ out.push(tag);
109
+ out.push('>');
110
+ out.push_str(&render_inline(txn, e, 0));
111
+ out.push_str("</h");
112
+ out.push(tag);
113
+ out.push('>');
114
+ }
115
+ "codeBlock" | "code_block" => {
116
+ out.push_str("<pre><code");
117
+ if let Some(lang) = str_attr(txn, e, "language").filter(|l| !l.is_empty()) {
118
+ out.push_str(" class=\"language-");
119
+ out.push_str(&escape_attr(&lang));
120
+ out.push('"');
121
+ }
122
+ out.push('>');
123
+ out.push_str(&escape_text(&code_text(txn, e)));
124
+ out.push_str("</code></pre>");
125
+ }
126
+ "blockquote" => open_container(txn, e, depth, "<blockquote>", "</blockquote>", out, stack),
127
+ "bulletList" | "bullet_list" => open_container(txn, e, depth, "<ul>", "</ul>", out, stack),
128
+ "orderedList" | "ordered_list" => {
129
+ match num_attr(txn, e, "start") {
130
+ Some(start) if start != 1 => {
131
+ out.push_str("<ol start=\"");
132
+ out.push_str(&start.to_string());
133
+ out.push_str("\">");
134
+ }
135
+ _ => out.push_str("<ol>"),
136
+ }
137
+ push_block_children(txn, e, depth, "</ol>", out, stack);
138
+ }
139
+ "listItem" | "list_item" => open_container(txn, e, depth, "<li>", "</li>", out, stack),
140
+ "taskList" | "task_list" => open_container(
141
+ txn,
142
+ e,
143
+ depth,
144
+ "<ul data-type=\"taskList\">",
145
+ "</ul>",
146
+ out,
147
+ stack,
148
+ ),
149
+ "taskItem" | "task_item" => {
150
+ let checked = bool_attr(txn, e, "checked");
151
+ out.push_str("<li data-checked=\"");
152
+ out.push_str(if checked { "true" } else { "false" });
153
+ out.push_str("\" data-type=\"taskItem\"><label><input type=\"checkbox\"");
154
+ if checked {
155
+ out.push_str(" checked=\"checked\"");
156
+ }
157
+ out.push_str("><span></span></label><div>");
158
+ push_block_children(txn, e, depth, "</div></li>", out, stack);
159
+ }
160
+ "table" => {
161
+ out.push_str("<table><tbody>");
162
+ push_block_children(txn, e, depth, "</tbody></table>", out, stack);
163
+ }
164
+ "tableRow" | "table_row" => open_container(txn, e, depth, "<tr>", "</tr>", out, stack),
165
+ "tableHeader" | "table_header" => open_cell(txn, e, depth, "th", "</th>", out, stack),
166
+ "tableCell" | "table_cell" => open_cell(txn, e, depth, "td", "</td>", out, stack),
167
+ // The details family follows tiptap-php (the extension is Tiptap Pro,
168
+ // so there's no free getHTML() to capture against).
169
+ "details" => {
170
+ let open = if bool_attr(txn, e, "open") {
171
+ "<details open=\"open\">"
172
+ } else {
173
+ "<details>"
174
+ };
175
+ open_container(txn, e, depth, open, "</details>", out, stack);
176
+ }
177
+ "detailsSummary" | "details_summary" => {
178
+ out.push_str("<summary>");
179
+ out.push_str(&render_inline(txn, e, 0));
180
+ out.push_str("</summary>");
181
+ }
182
+ "detailsContent" | "details_content" => open_container(
183
+ txn,
184
+ e,
185
+ depth,
186
+ "<div data-type=\"detailsContent\">",
187
+ "</div>",
188
+ out,
189
+ stack,
190
+ ),
191
+ "horizontalRule" | "horizontal_rule" => out.push_str("<hr>"),
192
+ "image" => render_image(txn, e, out),
193
+ "hardBreak" | "hard_break" => out.push_str("<br>"),
194
+ // Unknown block: keep its content rather than dropping it. If it holds
195
+ // child blocks, render them with no wrapper; otherwise treat it as a
196
+ // text block.
197
+ _ => {
198
+ if has_element_child(txn, e) {
199
+ // Renders its direct text runs, then the children, with no
200
+ // invented wrapper tags.
201
+ push_block_children(txn, e, depth, "", out, stack);
202
+ } else {
203
+ let inline = render_inline(txn, e, 0);
204
+ if !inline.is_empty() {
205
+ out.push_str("<p>");
206
+ out.push_str(&inline);
207
+ out.push_str("</p>");
208
+ }
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ fn open_container<T: ReadTxn>(
215
+ txn: &T,
216
+ e: &XmlElementRef,
217
+ depth: usize,
218
+ open: &str,
219
+ close: &'static str,
220
+ out: &mut String,
221
+ stack: &mut Vec<Work>,
222
+ ) {
223
+ out.push_str(open);
224
+ push_block_children(txn, e, depth, close, out, stack);
225
+ }
226
+
227
+ /// A table cell: `<th>`/`<td>` carrying colspan/rowspan (default 1, always
228
+ /// emitted, matching Tiptap).
229
+ fn open_cell<T: ReadTxn>(
230
+ txn: &T,
231
+ e: &XmlElementRef,
232
+ depth: usize,
233
+ tag: &str,
234
+ close: &'static str,
235
+ out: &mut String,
236
+ stack: &mut Vec<Work>,
237
+ ) {
238
+ out.push('<');
239
+ out.push_str(tag);
240
+ out.push_str(" colspan=\"");
241
+ out.push_str(&num_attr(txn, e, "colspan").unwrap_or(1).to_string());
242
+ out.push_str("\" rowspan=\"");
243
+ out.push_str(&num_attr(txn, e, "rowspan").unwrap_or(1).to_string());
244
+ out.push_str("\">");
245
+ push_block_children(txn, e, depth, close, out, stack);
246
+ }
247
+
248
+ /// Defer a node's child *elements* onto the stack, closing tag below them, so
249
+ /// they render in order and the tag closes after. Any direct text runs render
250
+ /// to `out` first: schema-valid documents never put bare text in a container,
251
+ /// but a crafted one can, and dropping it would lose content. Past `MAX_DEPTH`
252
+ /// the children are dropped but the tag still closes, keeping the output well
253
+ /// formed.
254
+ fn push_block_children<T: ReadTxn>(
255
+ txn: &T,
256
+ e: &XmlElementRef,
257
+ depth: usize,
258
+ close: &'static str,
259
+ out: &mut String,
260
+ stack: &mut Vec<Work>,
261
+ ) {
262
+ for node in e.children(txn) {
263
+ if let XmlOut::Text(t) = node {
264
+ render_text_runs(txn, &t, out);
265
+ }
266
+ }
267
+ stack.push(Work::Close(close));
268
+ if depth >= MAX_DEPTH {
269
+ return;
270
+ }
271
+ let children: Vec<_> = e
272
+ .children(txn)
273
+ .filter_map(|c| match c {
274
+ XmlOut::Element(el) => Some(el),
275
+ _ => None,
276
+ })
277
+ .collect();
278
+ for child in children.into_iter().rev() {
279
+ stack.push(Work::Open(child, depth + 1));
280
+ }
281
+ }
282
+
283
+ /// Render a text block's inline content: text runs (with their marks) and
284
+ /// inline element nodes (hard breaks, mentions, inline images). An unknown
285
+ /// inline node keeps its text instead of vanishing; `depth` caps that
286
+ /// recursion on a crafted nest of unknowns.
287
+ fn render_inline<T: ReadTxn>(txn: &T, e: &XmlElementRef, depth: usize) -> String {
288
+ let mut out = String::new();
289
+ for node in e.children(txn) {
290
+ match node {
291
+ XmlOut::Text(t) => render_text_runs(txn, &t, &mut out),
292
+ XmlOut::Element(child) => match child.tag().as_ref() {
293
+ "hardBreak" | "hard_break" => out.push_str("<br>"),
294
+ "image" => render_image(txn, &child, &mut out),
295
+ "mention" => render_mention(txn, &child, &mut out),
296
+ _ => {
297
+ if depth < MAX_DEPTH {
298
+ out.push_str(&render_inline(txn, &child, depth + 1));
299
+ }
300
+ }
301
+ },
302
+ XmlOut::Fragment(_) => {}
303
+ }
304
+ }
305
+ out
306
+ }
307
+
308
+ /// A mention, as Tiptap's Mention extension serializes it (no app-configured
309
+ /// HTMLAttributes): `data-type`, `data-id`, `data-label` when present, the
310
+ /// suggestion char, and `@label` (falling back to `@id`) as the text.
311
+ fn render_mention<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
312
+ let id = str_attr(txn, e, "id");
313
+ let label = str_attr(txn, e, "label");
314
+ let char = str_attr(txn, e, "mentionSuggestionChar").unwrap_or_else(|| "@".to_string());
315
+ out.push_str("<span data-type=\"mention\"");
316
+ if let Some(id) = &id {
317
+ out.push_str(" data-id=\"");
318
+ out.push_str(&escape_attr(id));
319
+ out.push('"');
320
+ }
321
+ if let Some(label) = &label {
322
+ out.push_str(" data-label=\"");
323
+ out.push_str(&escape_attr(label));
324
+ out.push('"');
325
+ }
326
+ out.push_str(" data-mention-suggestion-char=\"");
327
+ out.push_str(&escape_attr(&char));
328
+ out.push_str("\">");
329
+ out.push_str(&escape_text(&char));
330
+ out.push_str(&escape_text(&label.or(id).unwrap_or_default()));
331
+ out.push_str("</span>");
332
+ }
333
+
334
+ /// Emit each formatted run of a Y.XmlText.
335
+ fn render_text_runs<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut String) {
336
+ for d in t.diff(txn, YChange::identity) {
337
+ if let Out::Any(Any::String(s)) = &d.insert {
338
+ out.push_str(&render_run(s, d.attributes.as_deref()));
339
+ }
340
+ }
341
+ }
342
+
343
+ /// Wrap one text run in its marks, nesting innermost-first:
344
+ /// subscript/superscript, highlight, underline, strike, italic, bold, a
345
+ /// textStyle span, then link on the outside. `code` renders alone among the
346
+ /// formatting marks (Tiptap's Code mark excludes them all), but a link still
347
+ /// wraps it — Tiptap can't produce code+link, prosemirror-schema-basic can,
348
+ /// and dropping the link would lose the href.
349
+ fn render_run(text: &str, marks: Option<&Attrs>) -> String {
350
+ let mut html = escape_text(text);
351
+ let Some(marks) = marks else {
352
+ return html;
353
+ };
354
+ if has(marks, &["code"]) {
355
+ html = wrap(html, "code");
356
+ } else {
357
+ if has(marks, &["subscript", "sub"]) {
358
+ html = wrap(html, "sub");
359
+ } else if has(marks, &["superscript", "sup"]) {
360
+ html = wrap(html, "sup");
361
+ }
362
+ if has(marks, &["highlight"]) {
363
+ html = wrap(html, "mark");
364
+ }
365
+ if has(marks, &["underline", "u"]) {
366
+ html = wrap(html, "u");
367
+ }
368
+ if has(marks, &["strike", "s"]) {
369
+ html = wrap(html, "s");
370
+ }
371
+ if has(marks, &["italic", "em"]) {
372
+ html = wrap(html, "em");
373
+ }
374
+ if has(marks, &["bold", "strong"]) {
375
+ html = wrap(html, "strong");
376
+ }
377
+ if let Some(Any::Map(style)) = marks.get("textStyle") {
378
+ let css = text_style_css(style);
379
+ if !css.is_empty() {
380
+ html = format!("<span style=\"{}\">{html}</span>", escape_attr(&css));
381
+ }
382
+ }
383
+ }
384
+ if let Some(Any::Map(link)) = marks.get("link") {
385
+ html = wrap_link(html, link);
386
+ }
387
+ html
388
+ }
389
+
390
+ /// The `style` string for a textStyle mark (Tiptap's Color/FontFamily/etc.
391
+ /// extensions all store their value as a textStyle attribute). Attributes are
392
+ /// camelCase CSS property names; unset ones sit in the map as explicit nulls.
393
+ /// Keys sort alphabetically, which is the order Tiptap serializes (color
394
+ /// before font-family). Hex colors convert to rgb() because that's how they
395
+ /// come back out of the browser's style attribute; other values pass through.
396
+ fn text_style_css(style: &std::collections::HashMap<String, Any>) -> String {
397
+ let mut pairs: Vec<_> = style
398
+ .iter()
399
+ .filter_map(|(k, v)| match v {
400
+ Any::String(s) => Some((k, s.as_ref())),
401
+ _ => None,
402
+ })
403
+ .collect();
404
+ pairs.sort_by(|a, b| a.0.cmp(b.0));
405
+ let mut css = String::new();
406
+ for (key, value) in pairs {
407
+ if !css.is_empty() {
408
+ css.push(' ');
409
+ }
410
+ // camelCase -> kebab-case: fontFamily -> font-family.
411
+ for ch in key.chars() {
412
+ if ch.is_ascii_uppercase() {
413
+ css.push('-');
414
+ css.push(ch.to_ascii_lowercase());
415
+ } else {
416
+ css.push(ch);
417
+ }
418
+ }
419
+ css.push_str(": ");
420
+ css.push_str(&hex_to_rgb(value).unwrap_or_else(|| value.to_string()));
421
+ css.push(';');
422
+ }
423
+ css
424
+ }
425
+
426
+ /// `#rgb`/`#rrggbb` -> `rgb(r, g, b)`, matching the browser's style-attribute
427
+ /// serialization. Anything else (named colors, rgb()/hsl(), fonts) is None.
428
+ fn hex_to_rgb(value: &str) -> Option<String> {
429
+ let hex = value.strip_prefix('#')?;
430
+ // len() and the slices below are byte-based; non-ASCII input would panic
431
+ // on a char boundary. Real hex never is, crafted input passes through.
432
+ if !hex.is_ascii() {
433
+ return None;
434
+ }
435
+ let (r, g, b) = match hex.len() {
436
+ 3 => {
437
+ let d = |i: usize| u8::from_str_radix(&hex[i..=i].repeat(2), 16);
438
+ (d(0).ok()?, d(1).ok()?, d(2).ok()?)
439
+ }
440
+ 6 => {
441
+ let d = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16);
442
+ (d(0).ok()?, d(2).ok()?, d(4).ok()?)
443
+ }
444
+ _ => return None,
445
+ };
446
+ Some(format!("rgb({r}, {g}, {b})"))
447
+ }
448
+
449
+ /// `<a>` with Tiptap's attribute order (target, rel, class, href, title),
450
+ /// skipping any that are absent or null.
451
+ fn wrap_link(inner: String, link: &std::collections::HashMap<String, Any>) -> String {
452
+ let mut out = String::from("<a");
453
+ for key in ["target", "rel", "class", "href", "title"] {
454
+ if let Some(Any::String(v)) = link.get(key) {
455
+ out.push(' ');
456
+ out.push_str(key);
457
+ out.push_str("=\"");
458
+ out.push_str(&escape_attr(v));
459
+ out.push('"');
460
+ }
461
+ }
462
+ out.push('>');
463
+ out.push_str(&inner);
464
+ out.push_str("</a>");
465
+ out
466
+ }
467
+
468
+ /// `<img>` with attribute order src, alt, title, skipping absent/null ones.
469
+ fn render_image<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
470
+ out.push_str("<img");
471
+ for (attr, html) in [("src", "src"), ("alt", "alt"), ("title", "title")] {
472
+ if let Some(v) = str_attr(txn, e, attr) {
473
+ out.push(' ');
474
+ out.push_str(html);
475
+ out.push_str("=\"");
476
+ out.push_str(&escape_attr(&v));
477
+ out.push('"');
478
+ }
479
+ }
480
+ out.push('>');
481
+ }
482
+
483
+ fn wrap(inner: String, tag: &str) -> String {
484
+ format!("<{tag}>{inner}</{tag}>")
485
+ }
486
+
487
+ fn has(marks: &Attrs, keys: &[&str]) -> bool {
488
+ keys.iter().any(|k| marks.contains_key(*k))
489
+ }
490
+
491
+ fn has_element_child<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> bool {
492
+ e.children(txn).any(|c| matches!(c, XmlOut::Element(_)))
493
+ }
494
+
495
+ /// The concatenated text of a code block (no marks — code is plain text).
496
+ fn code_text<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> String {
497
+ let mut s = String::new();
498
+ for node in e.children(txn) {
499
+ if let XmlOut::Text(t) = node {
500
+ for d in t.diff(txn, YChange::identity) {
501
+ if let Out::Any(Any::String(run)) = &d.insert {
502
+ s.push_str(run);
503
+ }
504
+ }
505
+ }
506
+ }
507
+ s
508
+ }
509
+
510
+ fn str_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<String> {
511
+ match e.get_attribute(txn, name) {
512
+ Some(Out::Any(Any::String(s))) => Some(s.to_string()),
513
+ _ => None,
514
+ }
515
+ }
516
+
517
+ fn num_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<i64> {
518
+ match e.get_attribute(txn, name) {
519
+ Some(Out::Any(Any::Number(n))) => Some(n as i64),
520
+ Some(Out::Any(Any::BigInt(n))) => Some(n),
521
+ _ => None,
522
+ }
523
+ }
524
+
525
+ fn bool_attr<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> bool {
526
+ matches!(e.get_attribute(txn, name), Some(Out::Any(Any::Bool(true))))
527
+ }
528
+
529
+ /// Text-content escaping, matching the browser serializer: `&`, `<`, `>`.
530
+ fn escape_text(s: &str) -> String {
531
+ s.replace('&', "&amp;")
532
+ .replace('<', "&lt;")
533
+ .replace('>', "&gt;")
534
+ }
535
+
536
+ /// Attribute-value escaping: text escaping plus `"`.
537
+ fn escape_attr(s: &str) -> String {
538
+ escape_text(s).replace('"', "&quot;")
539
+ }
540
+
541
+ #[cfg(test)]
542
+ mod tests {
543
+ use super::*;
544
+ use std::collections::HashMap;
545
+ use std::sync::Arc;
546
+ use yrs::updates::decoder::Decode;
547
+ use yrs::{Doc, Transact, Update, XmlElementPrelim, XmlTextPrelim};
548
+
549
+ fn doc_from(bytes: &[u8]) -> Doc {
550
+ let doc = Doc::new();
551
+ doc.transact_mut()
552
+ .apply_update(Update::decode_v1(bytes).unwrap())
553
+ .unwrap();
554
+ doc
555
+ }
556
+
557
+ fn marks(keys: &[&str]) -> Attrs {
558
+ let mut a = Attrs::new();
559
+ for k in keys {
560
+ a.insert((*k).into(), Any::Bool(true));
561
+ }
562
+ a
563
+ }
564
+
565
+ /// The core proof: a document captured from a real Tiptap editor renders to
566
+ /// exactly the editor's own `getHTML()`. The fixture covers headings,
567
+ /// every mark and combination, links, escaping, blockquote, nested bullet
568
+ /// and ordered lists (with a `start`), a task list, code blocks with and
569
+ /// without a language, a hard break, a horizontal rule, an image, and the
570
+ /// trailing empty paragraph Tiptap keeps.
571
+ #[test]
572
+ fn renders_the_captured_tiptap_document_byte_for_byte() {
573
+ let doc = doc_from(include_bytes!("fixtures/prosemirror_tiptap.bin"));
574
+ let txn = doc.transact();
575
+ let frag = txn.get_xml_fragment("default").unwrap();
576
+ assert_eq!(
577
+ render(&txn, &frag).unwrap(),
578
+ include_str!("fixtures/prosemirror_tiptap.html")
579
+ );
580
+ }
581
+
582
+ /// A table renders as tiptap-php's semantic form — `<table><tbody>` with
583
+ /// colspan/rowspan cells — dropping the `<colgroup>`/`min-width` styling
584
+ /// Tiptap's editor view adds (and which isn't in the CRDT).
585
+ #[test]
586
+ fn renders_a_table_as_semantic_html() {
587
+ let doc = doc_from(include_bytes!("fixtures/prosemirror_table.bin"));
588
+ let txn = doc.transact();
589
+ let frag = txn.get_xml_fragment("default").unwrap();
590
+ assert_eq!(
591
+ render(&txn, &frag).unwrap(),
592
+ include_str!("fixtures/prosemirror_table.html")
593
+ );
594
+ }
595
+
596
+ #[test]
597
+ fn marks_nest_in_tiptaps_serializer_order() {
598
+ assert_eq!(render_run("x", None), "x");
599
+ assert_eq!(
600
+ render_run("x", Some(&marks(&["bold"]))),
601
+ "<strong>x</strong>"
602
+ );
603
+ assert_eq!(render_run("x", Some(&marks(&["italic"]))), "<em>x</em>");
604
+ // bold wraps italic.
605
+ assert_eq!(
606
+ render_run("x", Some(&marks(&["italic", "bold"]))),
607
+ "<strong><em>x</em></strong>"
608
+ );
609
+ // code excludes every other mark.
610
+ assert_eq!(
611
+ render_run("x", Some(&marks(&["code", "bold"]))),
612
+ "<code>x</code>"
613
+ );
614
+ // Full compatible stack, innermost sub to outermost bold.
615
+ assert_eq!(
616
+ render_run(
617
+ "x",
618
+ Some(&marks(&[
619
+ "bold",
620
+ "italic",
621
+ "strike",
622
+ "underline",
623
+ "highlight",
624
+ "subscript"
625
+ ]))
626
+ ),
627
+ "<strong><em><s><u><mark><sub>x</sub></mark></u></s></em></strong>"
628
+ );
629
+ // Escaping happens before wrapping.
630
+ assert_eq!(render_run("<&>", None), "&lt;&amp;&gt;");
631
+ }
632
+
633
+ #[test]
634
+ fn renders_a_link_run_with_tiptaps_attribute_order() {
635
+ let mut link = HashMap::new();
636
+ link.insert(
637
+ "href".to_string(),
638
+ Any::String("https://e.com?a=1&b=2".into()),
639
+ );
640
+ link.insert("target".to_string(), Any::String("_blank".into()));
641
+ link.insert("rel".to_string(), Any::String("noopener".into()));
642
+ let mut a = Attrs::new();
643
+ a.insert("link".into(), Any::Map(Arc::new(link)));
644
+ assert_eq!(
645
+ render_run("site", Some(&a)),
646
+ "<a target=\"_blank\" rel=\"noopener\" href=\"https://e.com?a=1&amp;b=2\">site</a>"
647
+ );
648
+
649
+ // class and title (Link's remaining attrs) keep Tiptap's serialized
650
+ // order: target, rel, class, href, title. Null-valued attrs skip.
651
+ let mut link = HashMap::new();
652
+ link.insert("href".to_string(), Any::String("https://d.example".into()));
653
+ link.insert("class".to_string(), Any::String("doc-link".into()));
654
+ link.insert("title".to_string(), Any::String("A Doc".into()));
655
+ link.insert("target".to_string(), Any::Null);
656
+ let mut a = Attrs::new();
657
+ a.insert("link".into(), Any::Map(Arc::new(link)));
658
+ assert_eq!(
659
+ render_run("the doc", Some(&a)),
660
+ "<a class=\"doc-link\" href=\"https://d.example\" title=\"A Doc\">the doc</a>"
661
+ );
662
+ }
663
+
664
+ /// Mentions (captured from Tiptap's Mention extension): the fixture holds
665
+ /// one mention with a label, one with only an id, and a link carrying
666
+ /// class and title.
667
+ #[test]
668
+ fn renders_the_captured_mention_document_byte_for_byte() {
669
+ let doc = doc_from(include_bytes!("fixtures/prosemirror_mention.bin"));
670
+ let txn = doc.transact();
671
+ let frag = txn.get_xml_fragment("default").unwrap();
672
+ assert_eq!(
673
+ render(&txn, &frag).unwrap(),
674
+ include_str!("fixtures/prosemirror_mention.html")
675
+ );
676
+ }
677
+
678
+ /// textStyle (captured from Tiptap's Color/FontFamily extensions): hex
679
+ /// colors come back out of the browser as rgb(), rgb() strings pass
680
+ /// through, font-family joins the same span, and the span wraps outside
681
+ /// bold.
682
+ #[test]
683
+ fn renders_the_captured_textstyle_document_byte_for_byte() {
684
+ let doc = doc_from(include_bytes!("fixtures/prosemirror_textstyle.bin"));
685
+ let txn = doc.transact();
686
+ let frag = txn.get_xml_fragment("default").unwrap();
687
+ assert_eq!(
688
+ render(&txn, &frag).unwrap(),
689
+ include_str!("fixtures/prosemirror_textstyle.html")
690
+ );
691
+ }
692
+
693
+ #[test]
694
+ fn text_style_converts_hex_and_kebab_cases_keys() {
695
+ let mut style = HashMap::new();
696
+ style.insert("color".to_string(), Any::String("#ff0000".into()));
697
+ style.insert(
698
+ "fontFamily".to_string(),
699
+ Any::String("Georgia, serif".into()),
700
+ );
701
+ style.insert("fontSize".to_string(), Any::Null); // unset: skipped
702
+ assert_eq!(
703
+ text_style_css(&style),
704
+ "color: rgb(255, 0, 0); font-family: Georgia, serif;"
705
+ );
706
+
707
+ assert_eq!(hex_to_rgb("#0f8"), Some("rgb(0, 255, 136)".to_string()));
708
+ assert_eq!(hex_to_rgb("rebeccapurple"), None);
709
+ assert_eq!(hex_to_rgb("#12345"), None);
710
+ // Multibyte input must pass through, not panic on a byte-slice
711
+ // boundary ("日" is one char, three bytes — it enters the 3 arm).
712
+ assert_eq!(hex_to_rgb("#日"), None);
713
+ assert_eq!(hex_to_rgb("#日本"), None);
714
+ }
715
+
716
+ /// A known container with bare text jammed directly into it (schema-valid
717
+ /// documents never do this) keeps the text instead of dropping it.
718
+ #[test]
719
+ fn a_known_container_keeps_stray_direct_text() {
720
+ let doc = Doc::new();
721
+ let frag = doc.get_or_insert_xml_fragment("default");
722
+ {
723
+ let mut txn = doc.transact_mut();
724
+ let bq = frag.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
725
+ bq.push_back(&mut txn, XmlTextPrelim::new("stray"));
726
+ let p = bq.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
727
+ p.push_back(&mut txn, XmlTextPrelim::new("body"));
728
+ }
729
+ let txn = doc.transact();
730
+ assert_eq!(
731
+ render(&txn, &frag).unwrap(),
732
+ "<blockquote>stray<p>body</p></blockquote>"
733
+ );
734
+ }
735
+
736
+ /// The details family follows tiptap-php's renderHTML (the Tiptap
737
+ /// extension is Pro-only, so tiptap-php is the reference).
738
+ #[test]
739
+ fn renders_the_details_family_per_tiptap_php() {
740
+ let doc = Doc::new();
741
+ let frag = doc.get_or_insert_xml_fragment("default");
742
+ {
743
+ let mut txn = doc.transact_mut();
744
+ let details = frag.push_back(&mut txn, XmlElementPrelim::empty("details"));
745
+ details.insert_attribute(&mut txn, "open", true);
746
+ let summary = details.push_back(&mut txn, XmlElementPrelim::empty("detailsSummary"));
747
+ summary.push_back(&mut txn, XmlTextPrelim::new("More info"));
748
+ let content = details.push_back(&mut txn, XmlElementPrelim::empty("detailsContent"));
749
+ let p = content.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
750
+ p.push_back(&mut txn, XmlTextPrelim::new("The body."));
751
+
752
+ let closed = frag.push_back(&mut txn, XmlElementPrelim::empty("details"));
753
+ closed.push_back(&mut txn, XmlElementPrelim::empty("detailsSummary"));
754
+ }
755
+ let txn = doc.transact();
756
+ assert_eq!(
757
+ render(&txn, &frag).unwrap(),
758
+ "<details open=\"open\"><summary>More info</summary>\
759
+ <div data-type=\"detailsContent\"><p>The body.</p></div></details>\
760
+ <details><summary></summary></details>"
761
+ );
762
+ }
763
+
764
+ /// prosemirror-schema-basic's `code` mark has no excludes, so a code run
765
+ /// can also carry a link; the link must survive. (Tiptap's Code mark
766
+ /// excludes everything, so this shape only comes from schema-basic docs.)
767
+ #[test]
768
+ fn a_code_run_keeps_its_link() {
769
+ let mut link = HashMap::new();
770
+ link.insert("href".to_string(), Any::String("https://e.com".into()));
771
+ let mut a = Attrs::new();
772
+ a.insert("code".into(), Any::Map(Arc::new(HashMap::new())));
773
+ a.insert("link".into(), Any::Map(Arc::new(link)));
774
+ assert_eq!(
775
+ render_run("x", Some(&a)),
776
+ "<a href=\"https://e.com\"><code>x</code></a>"
777
+ );
778
+ }
779
+
780
+ /// An unknown block holding both text and child blocks keeps the text.
781
+ #[test]
782
+ fn an_unknown_block_with_mixed_content_keeps_its_text() {
783
+ let doc = Doc::new();
784
+ let frag = doc.get_or_insert_xml_fragment("default");
785
+ {
786
+ let mut txn = doc.transact_mut();
787
+ let callout = frag.push_back(&mut txn, XmlElementPrelim::empty("callout"));
788
+ callout.push_back(&mut txn, XmlTextPrelim::new("intro"));
789
+ let p = callout.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
790
+ p.push_back(&mut txn, XmlTextPrelim::new("body"));
791
+ }
792
+ let txn = doc.transact();
793
+ assert_eq!(render(&txn, &frag).unwrap(), "intro<p>body</p>");
794
+ }
795
+
796
+ /// An unknown inline node keeps its text instead of vanishing.
797
+ #[test]
798
+ fn an_unknown_inline_node_keeps_its_text() {
799
+ let doc = Doc::new();
800
+ let frag = doc.get_or_insert_xml_fragment("default");
801
+ {
802
+ let mut txn = doc.transact_mut();
803
+ let p = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
804
+ p.push_back(&mut txn, XmlTextPrelim::new("see "));
805
+ let custom = p.push_back(&mut txn, XmlElementPrelim::empty("customInline"));
806
+ custom.push_back(&mut txn, XmlTextPrelim::new("kept"));
807
+ p.push_back(&mut txn, XmlTextPrelim::new(" here"));
808
+ }
809
+ let txn = doc.transact();
810
+ assert_eq!(render(&txn, &frag).unwrap(), "<p>see kept here</p>");
811
+ }
812
+
813
+ /// The prosemirror-schema-basic spellings (snake_case nodes, `strong`/`em`
814
+ /// marks) render the same as Tiptap's camelCase.
815
+ #[test]
816
+ fn accepts_prosemirror_basic_schema_names() {
817
+ let doc = Doc::new();
818
+ let frag = doc.get_or_insert_xml_fragment("default");
819
+ {
820
+ let mut txn = doc.transact_mut();
821
+ let bq = frag.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
822
+ let p = bq.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
823
+ let t = p.push_back(&mut txn, XmlTextPrelim::new("hi bold it"));
824
+ t.format(&mut txn, 3, 4, marks(&["strong"]));
825
+ t.format(&mut txn, 8, 2, marks(&["em"]));
826
+ }
827
+ let txn = doc.transact();
828
+ assert_eq!(
829
+ render(&txn, &frag).unwrap(),
830
+ "<blockquote><p>hi <strong>bold</strong> <em>it</em></p></blockquote>"
831
+ );
832
+ }
833
+
834
+ #[test]
835
+ fn a_lexical_shaped_root_is_refused() {
836
+ // A Lexical doc stores blocks as XmlText carrying `__type`. That's a
837
+ // different schema; render must return None, not a garbled document.
838
+ let doc = Doc::new();
839
+ // Create both roots before opening the read transaction:
840
+ // get_or_insert_* opens its own write transaction, which would deadlock
841
+ // against a live read guard.
842
+ let frag = doc.get_or_insert_xml_fragment("root");
843
+ let empty = doc.get_or_insert_xml_fragment("empty");
844
+ {
845
+ let mut txn = doc.transact_mut();
846
+ let block = frag.push_back(&mut txn, XmlTextPrelim::new("hello"));
847
+ block.insert_attribute(&mut txn, "__type", "paragraph");
848
+ }
849
+ let txn = doc.transact();
850
+ assert_eq!(render(&txn, &frag), None);
851
+ // An empty root is fine.
852
+ assert_eq!(render(&txn, &empty).as_deref(), Some(""));
853
+ }
854
+
855
+ #[test]
856
+ fn deeply_nested_blocks_do_not_overflow_the_stack() {
857
+ // The walk is on the heap, so nesting that would blow a small native
858
+ // stack under recursion renders fine. Built and rendered on a 512 KiB
859
+ // thread so it can't pass just by having room to spare.
860
+ std::thread::Builder::new()
861
+ .stack_size(512 * 1024)
862
+ .spawn(|| {
863
+ let doc = Doc::new();
864
+ let frag = doc.get_or_insert_xml_fragment("default");
865
+ {
866
+ let mut txn = doc.transact_mut();
867
+ let mut cursor =
868
+ frag.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
869
+ for _ in 0..20_000 {
870
+ cursor = cursor.push_back(&mut txn, XmlElementPrelim::empty("blockquote"));
871
+ }
872
+ let p = cursor.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
873
+ p.push_back(&mut txn, XmlTextPrelim::new("deep"));
874
+ }
875
+ let txn = doc.transact();
876
+ let html = render(&txn, &frag).expect("prosemirror-shaped");
877
+ assert_eq!(
878
+ html.matches("<blockquote>").count(),
879
+ html.matches("</blockquote>").count()
880
+ );
881
+ assert!(html.contains("<p>deep</p>") || html.contains("<blockquote>"));
882
+ })
883
+ .unwrap()
884
+ .join()
885
+ .unwrap();
886
+ }
887
+
888
+ #[test]
889
+ fn escaping_matches_the_browser_serializer() {
890
+ assert_eq!(escape_text(r#"<a & "b">"#), r#"&lt;a &amp; "b"&gt;"#);
891
+ assert_eq!(
892
+ escape_attr(r#"<a & "b">"#),
893
+ r#"&lt;a &amp; &quot;b&quot;&gt;"#
894
+ );
895
+ }
896
+ }
data/lib/y/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Y
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yrby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara
@@ -84,6 +84,7 @@ files:
84
84
  - ext/yrby/Cargo.toml
85
85
  - ext/yrby/extconf.rb
86
86
  - ext/yrby/src/lib.rs
87
+ - ext/yrby/src/prosemirror_html.rs
87
88
  - ext/yrby/src/protocol.rs
88
89
  - ext/yrby/src/read.rs
89
90
  - lib/y.rb