yrby 0.7.0 → 0.8.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/Cargo.lock +29 -29
  4. data/README.md +582 -380
  5. data/ext/yrby/Cargo.toml +3 -3
  6. data/ext/yrby/crates/html-core/Cargo.toml +5 -4
  7. data/ext/yrby/crates/html-core/src/lib.rs +16 -20
  8. data/ext/yrby/crates/lexical-html/Cargo.toml +5 -4
  9. data/ext/yrby/crates/lexical-html/src/lib.rs +57 -48
  10. data/ext/yrby/crates/prosemirror-html/Cargo.toml +5 -4
  11. data/ext/yrby/crates/prosemirror-html/src/lib.rs +151 -104
  12. data/ext/yrby/src/lib.rs +30 -15
  13. data/ext/yrby/src/protocol.rs +17 -17
  14. data/ext/yrby/src/read.rs +137 -26
  15. data/lib/generators/yrby/install/install_generator.rb +24 -13
  16. data/lib/generators/yrby/install/templates/document_channel.rb +4 -7
  17. data/lib/generators/yrby/tables/tables_generator.rb +7 -4
  18. data/lib/generators/yrby/tables/templates/create_y_tables.rb +1 -1
  19. data/lib/y/collaborative/attribute.rb +53 -0
  20. data/lib/y/collaborative/helper.rb +48 -0
  21. data/lib/y/collaborative.rb +98 -0
  22. data/lib/y/lexxy.rb +2 -2
  23. data/lib/y/rendering.rb +15 -15
  24. data/lib/y/tiptap.rb +3 -3
  25. data/lib/y/version.rb +1 -1
  26. data/lib/y.rb +1 -1
  27. metadata +4 -9
  28. data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/common.rs +0 -355
  29. data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/dynamic.rs +0 -276
  30. data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/macros.rs +0 -49
  31. data/ext/yrby/target/debug/build/rb-sys-4407948463231c4f/out/bindings-0.9.128-mri-arm64-darwin23-3.4.7.rs +0 -8934
  32. data/ext/yrby/target/debug/build/rb-sys-dbeea42737529c2d/out/bindings-0.9.128-mri-arm64-darwin23-3.4.7.rs +0 -8934
  33. data/ext/yrby/target/debug/build/serde-58ea0ee887cc2602/out/private.rs +0 -6
  34. data/ext/yrby/target/debug/build/serde_core-41f407c21c1f205e/out/private.rs +0 -5
  35. data/ext/yrby/target/debug/build/thiserror-0f1416a82ff26f22/out/private.rs +0 -5
@@ -1,40 +1,45 @@
1
- //! Native HTML rendering of ProseMirror/Tiptap documents from the yrs collab
2
- //! structure — no Node process, no headless editor.
1
+ //! HTML for ProseMirror and Tiptap documents, rendered from the Yjs
2
+ //! structure the editor syncs. No Node process and no headless editor.
3
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`).
4
+ //! The y-prosemirror binding stores a document in a `Y.XmlFragment`. Block
5
+ //! nodes are `Y.XmlElement`s: the tag is the node type and the attributes
6
+ //! are the node attrs. Text is `Y.XmlText`, and each run's formatting
7
+ //! attributes are its marks. Node and mark names come from the editor's
8
+ //! schema, so both spellings are accepted: Tiptap's camelCase (`bulletList`,
9
+ //! `bold`) and prosemirror-schema-basic's snake_case (`bullet_list`,
10
+ //! `strong`).
10
11
  //!
11
- //! This renders **core ProseMirror**: the prosemirror-schema-basic node set
12
- //! plus the prosemirror-tables family — paragraphs, headings, blockquotes,
13
- //! code blocks, bullet/ordered lists, images, hard breaks, horizontal rules,
14
- //! and tables (semantic `<table><tbody>…`, without the `<colgroup>`/
15
- //! `min-width` styling editor views inject). Tiptap's extension nodes — task
16
- //! lists, mentions, the details family — live in the Ruby layer as the
17
- //! `Y::Tiptap` renderer's rule set, built on the same extension API apps use
18
- //! (`Y::ProseMirror` is the core base class). Output follows
19
- //! `ueberdosis/tiptap-php`, and with `Y::Tiptap`'s rules it matches Tiptap's
20
- //! own `getHTML()` byte for byte on the captured fixtures — that guarantee
21
- //! is held at the Ruby layer; the native tests pin core output as goldens
22
- //! where a fixture contains Tiptap-only nodes.
12
+ //! This renders core ProseMirror: the prosemirror-schema-basic node set plus
13
+ //! the prosemirror-tables family. That is paragraphs, headings, blockquotes,
14
+ //! code blocks, bullet and ordered lists, images, hard breaks, horizontal
15
+ //! rules, and tables as semantic `<table><tbody>…`, without the
16
+ //! `<colgroup>` and `min-width` styling an editor view injects. Tiptap's
17
+ //! extension nodes (task lists, mentions, the details family) live in the
18
+ //! Ruby layer as the `Y::Tiptap` renderer's rule set, built on the same
19
+ //! extension API applications use. `Y::ProseMirror` is the core base class.
20
+ //! The output follows `ueberdosis/tiptap-php`. With `Y::Tiptap`'s rules it
21
+ //! matches Tiptap's own `getHTML()` byte for byte on the captured fixtures.
22
+ //! That guarantee is held at the Ruby layer. Where a fixture contains
23
+ //! Tiptap-only nodes, the tests here pin core output as goldens.
23
24
  //!
24
- //! Marks stay native on purpose: mark serialization is text-run machinery
25
- //! (nesting order, textStyle's CSS, code's exclusivity), which the rule
26
- //! system can't express. The built-in set covers schema-basic's marks and
27
- //! Tiptap's — nesting outermost-first: link, a textStyle span, bold, italic,
28
- //! strike, underline, highlight, then subscript/superscript. `code` excludes
29
- //! the other formatting marks, so a code run is `<code>` alone — though a
25
+ //! Marks stay native on purpose. Mark serialization is text-run machinery
26
+ //! (nesting order, textStyle's CSS, code's exclusivity) that the rule system
27
+ //! cannot express. The built-in set covers schema-basic's marks and
28
+ //! Tiptap's, nested outermost-first: link, a textStyle span, bold, italic,
29
+ //! strike, underline, highlight, then subscript and superscript. `code`
30
+ //! excludes the other formatting marks, so a code run is `<code>` alone. A
30
31
  //! link still wraps it (see `render_run`).
31
32
  //!
32
- //! Custom nodes and marks: apps register rules by node type and mark name
33
- //! (see `render_rules`). A node rule is consulted before the built-in arms, so
34
- //! it can extend the schema or override a built-in; a mark rule claims its
35
- //! mark from the built-in wraps and wraps outside everything, link included.
36
- //! Declarative rules render here; callback rules emit `Segment::Deferred` for
37
- //! the caller to fill in after the render.
33
+ //! A node type with no rule still renders its text and child blocks, just
34
+ //! unwrapped.
35
+ //!
36
+ //! Applications register custom nodes and marks as rules keyed by node type
37
+ //! and mark name (see `render_rules`). A node rule is consulted before the
38
+ //! built-in arms, so it can extend the schema or replace a built-in. A mark
39
+ //! rule claims its mark from the built-in wraps and wraps outside
40
+ //! everything, link included. Declarative rules render here. Callback rules
41
+ //! emit `Segment::Deferred` for the caller to fill in after the render
42
+ //! returns.
38
43
 
39
44
  // README examples are living code: compile-checked on every cargo test.
40
45
  #[cfg(doctest)]
@@ -42,10 +47,10 @@
42
47
  mod readme_examples {}
43
48
 
44
49
  // The full rules surface, re-exported: depend on this crate alone;
45
- // yrs-html-core is an internal implementation crate.
46
- pub use yrs_html_core::*;
47
- use yrs::types::text::YChange;
50
+ // yjs-html-core is an internal implementation crate.
51
+ pub use yjs_html_core::*;
48
52
  use yrs::types::Attrs;
53
+ use yrs::types::text::YChange;
49
54
  use yrs::{
50
55
  Any, GetString, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef, XmlOut,
51
56
  XmlTextRef,
@@ -105,7 +110,7 @@ pub fn render_segments<T: ReadTxn>(
105
110
  /// callback rules, segments always flatten.
106
111
  pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
107
112
  render_segments(txn, fragment, &Rules::empty()).map(|segs| {
108
- yrs_html_core::flatten(segs)
113
+ yjs_html_core::flatten(segs)
109
114
  .into_html()
110
115
  .expect("no callback rules registered")
111
116
  })
@@ -605,102 +610,120 @@ fn render_rule_inline<T: ReadTxn>(
605
610
  em.push('>');
606
611
  }
607
612
 
608
- /// Emit each formatted run of a Y.XmlText.
613
+ /// Emit the formatted runs of a Y.XmlText, keeping marks shared by
614
+ /// adjacent runs open across them: `bold` then `bold italic` renders as
615
+ /// `<strong>a<em>b</em></strong>`, the way the editor's DOM serializer
616
+ /// keeps an active mark open into the next run, not as two sibling
617
+ /// `<strong>` wraps. Sharing is by identical opening tag, so a mark whose
618
+ /// attributes differ between runs (a different href, a different style)
619
+ /// never merges.
609
620
  fn render_text_runs<T: ReadTxn>(txn: &T, t: &XmlTextRef, em: &mut Emitter, rules: &Rules) {
621
+ let mut open: Vec<(String, String)> = Vec::new();
610
622
  for d in t.diff(txn, YChange::identity) {
611
623
  if let Out::Any(Any::String(s)) = &d.insert {
612
- em.push_str(&render_run(s, d.attributes.as_deref(), rules));
624
+ let stack = run_stack(d.attributes.as_deref(), rules);
625
+ let shared = open
626
+ .iter()
627
+ .zip(stack.iter())
628
+ .take_while(|(a, b)| a == b)
629
+ .count();
630
+ for (_, close) in open.split_off(shared).into_iter().rev() {
631
+ em.push_str(&close);
632
+ }
633
+ for (open_tag, _) in &stack[shared..] {
634
+ em.push_str(open_tag);
635
+ }
636
+ open = stack;
637
+ em.push_str(&escape_text(s));
613
638
  }
614
639
  }
640
+ for (_, close) in open.into_iter().rev() {
641
+ em.push_str(&close);
642
+ }
615
643
  }
616
644
 
617
- /// Wrap one text run in its marks, nesting innermost-first:
618
- /// subscript/superscript, highlight, underline, strike, italic, bold, a
619
- /// textStyle span, then link on the outside. `code` renders alone among the
620
- /// formatting marks (Tiptap's Code mark excludes them all), but a link still
621
- /// wraps it — Tiptap can't produce code+link, prosemirror-schema-basic can,
622
- /// and dropping the link would lose the href.
623
- ///
624
- /// A registered mark rule claims its stored name from the built-in wraps
625
- /// (overriding it) and wraps outside everything; multiple custom marks nest
626
- /// alphabetically by name, so output is deterministic regardless of
627
- /// registration order. Overriding replaces only the markup: a claimed
628
- /// `code` still excludes the other formatting marks, and a claimed
629
- /// formatting mark stays excluded from a code run.
630
- fn render_run(text: &str, marks: Option<&Attrs>, rules: &Rules) -> String {
631
- let mut html = escape_text(text);
645
+ /// The run's mark wrappers as (open tag, close tag) pairs, outermost
646
+ /// first: registered custom marks (reverse-alphabetical, so the last name
647
+ /// alphabetically is outermost), link, the textStyle span, then bold,
648
+ /// italic, strike, underline, highlight, and innermost
649
+ /// subscript/superscript — or `code` alone in their place.
650
+ fn run_stack(marks: Option<&Attrs>, rules: &Rules) -> Vec<(String, String)> {
632
651
  let Some(marks) = marks else {
633
- return html;
652
+ return Vec::new();
634
653
  };
654
+ // Innermost first, reversed at the end.
655
+ let mut stack: Vec<(String, String)> = Vec::new();
656
+ let tag = |t: &str| (format!("<{t}>"), format!("</{t}>"));
635
657
  if has(marks, &["code"], rules) {
636
- html = wrap(html, "code");
658
+ stack.push(tag("code"));
637
659
  } else if !marks.contains_key("code") {
638
660
  // (A claimed `code` lands here too — on the run, but skipped by the
639
- // else-if: the rule replaces the wrap, via wrap_custom_marks below,
661
+ // else-if: the rule replaces the wrap, via the custom marks below,
640
662
  // and code's exclusivity over the other formatting marks holds.)
641
663
  if has(marks, &["subscript", "sub"], rules) {
642
- html = wrap(html, "sub");
664
+ stack.push(tag("sub"));
643
665
  } else if has(marks, &["superscript", "sup"], rules) {
644
- html = wrap(html, "sup");
666
+ stack.push(tag("sup"));
645
667
  }
646
668
  if has(marks, &["highlight"], rules) {
647
- html = wrap(html, "mark");
669
+ stack.push(tag("mark"));
648
670
  }
649
671
  if has(marks, &["underline", "u"], rules) {
650
- html = wrap(html, "u");
672
+ stack.push(tag("u"));
651
673
  }
652
674
  if has(marks, &["strike", "s"], rules) {
653
- html = wrap(html, "s");
675
+ stack.push(tag("s"));
654
676
  }
655
677
  if has(marks, &["italic", "em"], rules) {
656
- html = wrap(html, "em");
678
+ stack.push(tag("em"));
657
679
  }
658
680
  if has(marks, &["bold", "strong"], rules) {
659
- html = wrap(html, "strong");
681
+ stack.push(tag("strong"));
660
682
  }
661
683
  if !rules.marks.contains_key("textStyle") {
662
684
  if let Some(Any::Map(style)) = marks.get("textStyle") {
663
685
  let css = text_style_css(style);
664
686
  if !css.is_empty() {
665
- html = format!("<span style=\"{}\">{html}</span>", escape_attr(&css));
687
+ stack.push((
688
+ format!("<span style=\"{}\">", escape_attr(&css)),
689
+ "</span>".to_string(),
690
+ ));
666
691
  }
667
692
  }
668
693
  }
669
694
  }
670
695
  if !rules.marks.contains_key("link") {
671
696
  if let Some(Any::Map(link)) = marks.get("link") {
672
- html = wrap_link(html, link);
697
+ stack.push((link_open(link), "</a>".to_string()));
673
698
  }
674
699
  }
675
700
  if !rules.marks.is_empty() {
676
- html = wrap_custom_marks(html, marks, rules);
677
- }
678
- html
679
- }
680
-
681
- /// Apply the run's registered custom marks, outermost of everything. A code
682
- /// run keeps its exclusivity under overrides too: only the `code` and `link`
683
- /// claims themselves may wrap it, matching what the built-in wraps allow —
684
- /// otherwise overriding a formatting mark would change which marks render on
685
- /// a code run, not just their markup.
686
- fn wrap_custom_marks(html: String, marks: &Attrs, rules: &Rules) -> String {
687
- let code = marks.contains_key("code");
688
- let mut names: Vec<&str> = rules
689
- .marks
690
- .keys()
691
- .map(String::as_str)
692
- .filter(|name| marks.contains_key(*name))
693
- .filter(|name| !code || matches!(*name, "code" | "link"))
694
- .collect();
695
- names.sort_unstable();
696
- let mut html = html;
697
- for name in names {
698
- html = wrap_custom_mark(html, &rules.marks[name], marks.get(name));
701
+ let code = marks.contains_key("code");
702
+ let mut names: Vec<&str> = rules
703
+ .marks
704
+ .keys()
705
+ .map(String::as_str)
706
+ .filter(|name| marks.contains_key(*name))
707
+ .filter(|name| !code || matches!(*name, "code" | "link"))
708
+ .collect();
709
+ names.sort_unstable();
710
+ for name in names {
711
+ let rule = &rules.marks[name];
712
+ stack.push((
713
+ custom_mark_open(rule, marks.get(name)),
714
+ format!("</{}>", rule.tag),
715
+ ));
716
+ }
699
717
  }
700
- html
718
+ stack.reverse();
719
+ stack
701
720
  }
702
721
 
703
- fn wrap_custom_mark(inner: String, rule: &MarkRule, value: Option<&Any>) -> String {
722
+ /// The opening tag for a registered custom mark. Custom marks sit outside
723
+ /// everything; a code run keeps its exclusivity under overrides too: only
724
+ /// the `code` and `link` claims themselves may wrap it, matching what the
725
+ /// built-in wraps allow.
726
+ fn custom_mark_open(rule: &MarkRule, value: Option<&Any>) -> String {
704
727
  let mut out = String::from("<");
705
728
  out.push_str(&rule.tag);
706
729
  for (attr, parts) in &rule.attrs {
@@ -709,14 +732,10 @@ fn wrap_custom_mark(inner: String, rule: &MarkRule, value: Option<&Any>) -> Stri
709
732
  out.push_str(attr);
710
733
  out.push_str("=\"");
711
734
  out.push_str(&escape_attr(&v));
712
- out.push('"');
735
+ out.push('\"');
713
736
  }
714
737
  }
715
738
  out.push('>');
716
- out.push_str(&inner);
717
- out.push_str("</");
718
- out.push_str(&rule.tag);
719
- out.push('>');
720
739
  out
721
740
  }
722
741
 
@@ -790,7 +809,7 @@ fn hex_to_rgb(value: &str) -> Option<String> {
790
809
 
791
810
  /// `<a>` with Tiptap's attribute order (target, rel, class, href, title),
792
811
  /// skipping any that are absent or null.
793
- fn wrap_link(inner: String, link: &std::collections::HashMap<String, Any>) -> String {
812
+ fn link_open(link: &std::collections::HashMap<String, Any>) -> String {
794
813
  let mut out = String::from("<a");
795
814
  for key in ["target", "rel", "class", "href", "title"] {
796
815
  if let Some(Any::String(v)) = link.get(key) {
@@ -802,8 +821,6 @@ fn wrap_link(inner: String, link: &std::collections::HashMap<String, Any>) -> St
802
821
  }
803
822
  }
804
823
  out.push('>');
805
- out.push_str(&inner);
806
- out.push_str("</a>");
807
824
  out
808
825
  }
809
826
 
@@ -822,10 +839,6 @@ fn render_image<T: ReadTxn>(txn: &T, e: &XmlElementRef, em: &mut Emitter) {
822
839
  em.push('>');
823
840
  }
824
841
 
825
- fn wrap(inner: String, tag: &str) -> String {
826
- format!("<{tag}>{inner}</{tag}>")
827
- }
828
-
829
842
  /// A mark is present and not claimed by a registered rule for that stored
830
843
  /// name (a rule overrides the built-in wrap).
831
844
  fn has(marks: &Attrs, keys: &[&str], rules: &Rules) -> bool {
@@ -903,10 +916,44 @@ mod tests {
903
916
  a
904
917
  }
905
918
 
919
+ /// Single-run rendering for the mark unit tests: the run's full
920
+ /// open/close stack around the escaped text.
921
+ fn render_run(text: &str, marks: Option<&Attrs>, rules: &Rules) -> String {
922
+ let stack = run_stack(marks, rules);
923
+ let mut html: String = stack.iter().map(|(open, _)| open.as_str()).collect();
924
+ html.push_str(&escape_text(text));
925
+ for (_, close) in stack.iter().rev() {
926
+ html.push_str(close);
927
+ }
928
+ html
929
+ }
930
+
906
931
  fn run(text: &str, marks: Option<&Attrs>) -> String {
907
932
  render_run(text, marks, &Rules::empty())
908
933
  }
909
934
 
935
+ #[test]
936
+ fn adjacent_runs_share_their_common_outer_marks() {
937
+ use yrs::{Text, XmlFragment};
938
+ let doc = Doc::new();
939
+ let f = doc.get_or_insert_xml_fragment("default");
940
+ let mut txn = doc.transact_mut();
941
+ let p = f.insert(&mut txn, 0, XmlElementPrelim::empty("paragraph"));
942
+ let t = p.insert(&mut txn, 0, XmlTextPrelim::new(""));
943
+ let bold = marks(&["bold"]);
944
+ let bold_em = marks(&["bold", "italic"]);
945
+ t.insert_with_attributes(&mut txn, 0, "nested ", bold.clone());
946
+ t.insert_with_attributes(&mut txn, 7, "deep", bold_em);
947
+ t.insert_with_attributes(&mut txn, 11, " tail", bold);
948
+ drop(txn);
949
+ let txn = doc.transact();
950
+ assert_eq!(
951
+ render(&txn, &f).unwrap(),
952
+ "<p><strong>nested <em>deep</em> tail</strong></p>",
953
+ "shared outer marks stay open across adjacent runs"
954
+ );
955
+ }
956
+
910
957
  /// Core rendering of the captured Tiptap document, pinned as a golden
911
958
  /// (`.core.html`). The task list renders through the unknown-container
912
959
  /// fallback here (unwrapped children); the external truth — byte parity
@@ -1236,7 +1283,7 @@ mod tests {
1236
1283
  let txn = doc.transact();
1237
1284
  let segs = render_segments(&txn, &frag, &rules).unwrap();
1238
1285
  assert_eq!(
1239
- yrs_html_core::flatten(segs).into_html().unwrap(),
1286
+ yjs_html_core::flatten(segs).into_html().unwrap(),
1240
1287
  "<aside class=\"callout callout--warning\"><div class=\"para\">careful</div></aside>"
1241
1288
  );
1242
1289
  }
@@ -1294,7 +1341,7 @@ mod tests {
1294
1341
  }
1295
1342
  let txn = doc.transact();
1296
1343
  let map = collect_node_types(&txn, &frag).unwrap();
1297
- let json = yrs_html_core::type_map_json(&map, |ty| {
1344
+ let json = yjs_html_core::type_map_json(&map, |ty| {
1298
1345
  if is_builtin(ty) {
1299
1346
  Some("builtin")
1300
1347
  } else {
data/ext/yrby/src/lib.rs CHANGED
@@ -9,14 +9,14 @@ use yrs::{Doc, GetString, ReadTxn, Transact};
9
9
 
10
10
  mod protocol;
11
11
  mod read;
12
+ use lexical_yjs_html as lexical_html;
13
+ use prosemirror_yjs_html as prosemirror_html;
12
14
  use protocol::{
13
15
  classify_message, has_pending, integrated_update, merged_doc_update, update_advances_doc,
14
16
  update_is_ready,
15
17
  };
16
18
  use render_rules::{Rules, Segment};
17
- pub(crate) use yrs_html_core as render_rules;
18
- use yrs_lexical_html as lexical_html;
19
- use yrs_prosemirror_html as prosemirror_html;
19
+ pub(crate) use yjs_html_core as render_rules;
20
20
 
21
21
  /// Wrapper around yrs Doc.
22
22
  ///
@@ -168,7 +168,7 @@ impl RbDoc {
168
168
  let doc = &self.0;
169
169
  nogvl(move || {
170
170
  // Exactly ONE transaction per call. Opening a second while the
171
- // first is still held deadlocks against a waiting writer — and
171
+ // first is still held deadlocks against a waiting writer, and
172
172
  // inside nogvl that hang can't be interrupted.
173
173
  let txn = doc.transact();
174
174
  txn.get_text(name.as_str()).map(|t| t.get_string(&txn))
@@ -200,8 +200,22 @@ impl RbDoc {
200
200
  })
201
201
  }
202
202
 
203
+ /// A `Y.Array` root serialized to a JSON array string (values recursive, in
204
+ /// array order). The counterpart to read_map for documents whose root is an
205
+ /// array: a board's cards, a sheet's rows. Callers parse the JSON (e.g.
206
+ /// `JSON.parse(doc.read_array("cards"))`). The serialization lives in
207
+ /// `read::array_json` (pure, Rust-tested).
208
+ fn read_array(&self, name: String) -> Option<String> {
209
+ let doc = &self.0;
210
+ nogvl(move || {
211
+ let txn = doc.transact();
212
+ let array = txn.get_array(name.as_str())?;
213
+ Some(read::array_json(&txn, &array))
214
+ })
215
+ }
216
+
203
217
  /// True if the doc holds un-integrable pending structs or a pending delete
204
- /// set — content that couldn't integrate because a causally-prior update is
218
+ /// set: content that couldn't integrate because a causally-prior update is
205
219
  /// missing. Such content is a recovery buffer, not document state; it heals if
206
220
  /// the missing dependency later arrives. A pure read.
207
221
  fn pending(&self) -> bool {
@@ -211,7 +225,7 @@ impl RbDoc {
211
225
 
212
226
  /// Like `encode_state_as_update` (full state), but **gap-free**: it excludes
213
227
  /// any pending (un-integrable) structs and pending delete set. Use this when
214
- /// persisting or serving state that other peers will apply — serving pending
228
+ /// persisting or serving state that other peers will apply. Serving pending
215
229
  /// content poisons their sync. Non-destructive: this doc keeps its pending, so
216
230
  /// a genuine gap still heals if its dependency arrives. (`encode_state_as_update`
217
231
  /// stays lossless for raw-update recovery.)
@@ -337,12 +351,12 @@ impl RbDoc {
337
351
  }
338
352
 
339
353
  // ============================================================================
340
- // Y::Lexical — schema-pinned rendering of Lexical/Lexxy documents
354
+ // Y::Lexical: schema-pinned rendering of Lexical/Lexxy documents
341
355
  // ============================================================================
342
356
 
343
357
  /// A Lexical view over a `Y::Doc`. The schema knowledge lives here rather
344
358
  /// than on the schema-agnostic `Doc`: core Lexical natively, everything else
345
- /// through the render rules compiled at construction (see `render_rules` —
359
+ /// through the render rules compiled at construction (see `render_rules`;
346
360
  /// the `Y::Lexxy` facade's rule set arrives that way). Holds a cheap clone of
347
361
  /// the doc (yrs `Doc` is an Arc handle), so it reads live state.
348
362
  ///
@@ -357,7 +371,7 @@ struct RbLexical {
357
371
  }
358
372
 
359
373
  impl RbLexical {
360
- /// `Y::NativeLexical.new(doc, rules_json)` — the Y::Lexical facade
374
+ /// `Y::NativeLexical.new(doc, rules_json)`; the Y::Lexical facade
361
375
  /// compiles its `nodes:` config to the rules JSON.
362
376
  fn native_new(doc: &RbDoc, rules_json: String) -> Result<Self, Error> {
363
377
  Ok(RbLexical {
@@ -367,7 +381,7 @@ impl RbLexical {
367
381
  }
368
382
 
369
383
  /// Render the document's XML root (default `"root"`, Lexical's standard
370
- /// collab root name) natively — no Node process or headless editor. The
384
+ /// collab root name) natively, with no Node process or headless editor. The
371
385
  /// native side renders core Lexical plus whatever the rules cover; with
372
386
  /// the rule set `Y::Lexxy` passes, output matches Lexxy's own serializer
373
387
  /// byte-for-byte on the reference fixtures (see `lexical_html.rs`). Returns nil when the root is missing or not
@@ -386,7 +400,7 @@ impl RbLexical {
386
400
  segments_result(segments)
387
401
  }
388
402
 
389
- /// The document's node types as observed facts, JSON-encoded — the
403
+ /// The document's node types as observed facts, JSON-encoded, the
390
404
  /// native half of the facade's `node_types` discovery aid. Nil when the
391
405
  /// root is missing or not Lexical-shaped.
392
406
  fn node_types(&self, args: &[Value]) -> Result<Value, Error> {
@@ -415,13 +429,13 @@ impl RbLexical {
415
429
  }
416
430
 
417
431
  // ============================================================================
418
- // Y::ProseMirror — schema-pinned rendering of ProseMirror/Tiptap documents
432
+ // Y::ProseMirror: schema-pinned rendering of ProseMirror/Tiptap documents
419
433
  // ============================================================================
420
434
 
421
435
  /// A ProseMirror view over a `Y::Doc`. The schema knowledge lives here rather
422
436
  /// than on the schema-agnostic `Doc`: core ProseMirror natively, everything
423
437
  /// else through the render rules compiled at construction (see
424
- /// `render_rules` — the `Y::Tiptap` facade's rule set arrives that way).
438
+ /// `render_rules`; the `Y::Tiptap` facade's rule set arrives that way).
425
439
  /// Holds a cheap clone of the doc (yrs `Doc` is an Arc handle), so it reads
426
440
  /// live state.
427
441
  ///
@@ -436,7 +450,7 @@ struct RbProseMirror {
436
450
  }
437
451
 
438
452
  impl RbProseMirror {
439
- /// `Y::NativeProseMirror.new(doc, rules_json)` — the Y::ProseMirror facade
453
+ /// `Y::NativeProseMirror.new(doc, rules_json)`; the Y::ProseMirror facade
440
454
  /// compiles its `nodes:`/`marks:` config to the rules JSON.
441
455
  fn native_new(doc: &RbDoc, rules_json: String) -> Result<Self, Error> {
442
456
  Ok(RbProseMirror {
@@ -466,7 +480,7 @@ impl RbProseMirror {
466
480
  segments_result(segments)
467
481
  }
468
482
 
469
- /// The document's node types as observed facts, JSON-encoded — the
483
+ /// The document's node types as observed facts, JSON-encoded, the
470
484
  /// native half of the facade's `node_types` discovery aid. Nil when the
471
485
  /// root is missing or not ProseMirror-shaped.
472
486
  fn node_types(&self, args: &[Value]) -> Result<Value, Error> {
@@ -627,6 +641,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
627
641
  doc_class.define_method("read_text", method!(RbDoc::read_text, 1))?;
628
642
  doc_class.define_method("read_xml", method!(RbDoc::read_xml, 1))?;
629
643
  doc_class.define_method("read_map", method!(RbDoc::read_map, 1))?;
644
+ doc_class.define_method("read_array", method!(RbDoc::read_array, 1))?;
630
645
  doc_class.define_method("pending?", method!(RbDoc::pending, 0))?;
631
646
  doc_class.define_method(
632
647
  "compacted_state_update",