yrby 0.6.1 → 0.7.1
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 +4 -4
- data/CHANGELOG.md +75 -28
- data/Cargo.lock +29 -29
- data/README.md +297 -98
- data/ext/yrby/Cargo.toml +3 -3
- data/ext/yrby/crates/html-core/Cargo.toml +5 -4
- data/ext/yrby/crates/html-core/src/lib.rs +3 -7
- data/ext/yrby/crates/lexical-html/Cargo.toml +5 -4
- data/ext/yrby/crates/lexical-html/src/lib.rs +6 -10
- data/ext/yrby/crates/prosemirror-html/Cargo.toml +5 -4
- data/ext/yrby/crates/prosemirror-html/src/lib.rs +114 -72
- data/ext/yrby/src/lib.rs +10 -10
- data/ext/yrby/src/protocol.rs +63 -0
- data/lib/generators/yrby/install/templates/document_channel.rb +1 -1
- data/lib/generators/yrby/tables/tables_generator.rb +3 -3
- data/lib/y/decoder.rb +64 -0
- data/lib/y/version.rb +1 -1
- data/lib/y.rb +2 -1
- metadata +2 -1
data/ext/yrby/Cargo.toml
CHANGED
|
@@ -14,9 +14,9 @@ magnus = "0.8"
|
|
|
14
14
|
rb-sys = "0.9"
|
|
15
15
|
yrs = { version = "0.27", features = ["sync"] }
|
|
16
16
|
serde_json = "1.0"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
yjs-html-core = { path = "crates/html-core" }
|
|
18
|
+
lexical-yjs-html = { path = "crates/lexical-html" }
|
|
19
|
+
prosemirror-yjs-html = { path = "crates/prosemirror-html" }
|
|
20
20
|
|
|
21
21
|
[dev-dependencies]
|
|
22
22
|
rb-sys-env = "0.1"
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
[package]
|
|
2
|
-
name = "
|
|
3
|
-
description = "Internal core for the
|
|
2
|
+
name = "yjs-html-core"
|
|
3
|
+
description = "Internal core for the lexical-yjs-html and prosemirror-yjs-html renderer crates; depend on those instead"
|
|
4
4
|
repository = "https://github.com/jpcamara/yrby"
|
|
5
5
|
readme = "README.md"
|
|
6
|
-
version = "0.1.
|
|
6
|
+
version = "0.1.1"
|
|
7
7
|
edition = "2024"
|
|
8
8
|
rust-version = "1.85"
|
|
9
9
|
authors = ["JP Camara <johnpcamara@gmail.com>"]
|
|
10
10
|
license = "MIT"
|
|
11
|
-
|
|
11
|
+
keywords = ["yjs", "crdt", "yrs", "html", "rendering"]
|
|
12
|
+
categories = ["text-processing", "web-programming"]
|
|
12
13
|
|
|
13
14
|
[dependencies]
|
|
14
15
|
yrs = { version = "0.27", features = ["sync"] }
|
|
@@ -196,11 +196,7 @@ pub fn resolve_parts<F: Fn(&str) -> Option<String>>(
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
|
-
if out.is_empty() {
|
|
200
|
-
None
|
|
201
|
-
} else {
|
|
202
|
-
Some(out)
|
|
203
|
-
}
|
|
199
|
+
if out.is_empty() { None } else { Some(out) }
|
|
204
200
|
}
|
|
205
201
|
|
|
206
202
|
/// An attribute reference on a node: rules say `:kind`; Lexical stores its own
|
|
@@ -384,7 +380,7 @@ fn parse_node_rule(name: &str, spec: &serde_json::Value) -> Result<NodeRule, Str
|
|
|
384
380
|
Some(other) => {
|
|
385
381
|
return Err(format!(
|
|
386
382
|
"rule for {name:?}: unknown content kind {other:?} (blocks|inline|none)"
|
|
387
|
-
))
|
|
383
|
+
));
|
|
388
384
|
}
|
|
389
385
|
};
|
|
390
386
|
if spec
|
|
@@ -431,7 +427,7 @@ fn parse_attrs(
|
|
|
431
427
|
Some(_) => {
|
|
432
428
|
return Err(format!(
|
|
433
429
|
"rule for {name:?}: attrs must be an array of [name, template] pairs"
|
|
434
|
-
))
|
|
430
|
+
));
|
|
435
431
|
}
|
|
436
432
|
};
|
|
437
433
|
for entry in entries {
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
[package]
|
|
2
|
-
name = "
|
|
2
|
+
name = "lexical-yjs-html"
|
|
3
3
|
description = "Render Lexical-shaped yrs documents to HTML, no browser or Node required"
|
|
4
4
|
repository = "https://github.com/jpcamara/yrby"
|
|
5
5
|
readme = "README.md"
|
|
6
|
-
version = "0.1.
|
|
6
|
+
version = "0.1.1"
|
|
7
7
|
edition = "2024"
|
|
8
8
|
rust-version = "1.85"
|
|
9
9
|
authors = ["JP Camara <johnpcamara@gmail.com>"]
|
|
10
10
|
license = "MIT"
|
|
11
|
-
|
|
11
|
+
keywords = ["yjs", "crdt", "lexical", "html", "yrs"]
|
|
12
|
+
categories = ["text-processing", "web-programming"]
|
|
12
13
|
|
|
13
14
|
[dependencies]
|
|
14
15
|
yrs = { version = "0.27", features = ["sync"] }
|
|
15
16
|
serde_json = "1.0"
|
|
16
|
-
|
|
17
|
+
yjs-html-core = { path = "../html-core", version = "0.1.1" }
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
//! with the unwrapped span. Case-transform format bits are never rendered
|
|
40
40
|
//! (their text-transform style is outside the sanitize whitelist).
|
|
41
41
|
//!
|
|
42
|
-
//! Custom nodes: rules are registered by `__type` (see `
|
|
42
|
+
//! Custom nodes: rules are registered by `__type` (see `yjs-html-core`) and
|
|
43
43
|
//! consulted before the built-in arms, so they extend the schema or override
|
|
44
44
|
//! a built-in. Declarative rules render here; callback rules emit
|
|
45
45
|
//! `Segment::Deferred` for the caller to fill in after the render.
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
mod readme_examples {}
|
|
51
51
|
|
|
52
52
|
// The full rules surface, re-exported: depend on this crate alone;
|
|
53
|
-
//
|
|
54
|
-
pub use
|
|
53
|
+
// yjs-html-core is an internal implementation crate.
|
|
54
|
+
pub use yjs_html_core::*;
|
|
55
55
|
use yrs::types::text::YChange;
|
|
56
56
|
use yrs::{
|
|
57
57
|
Any, GetString, Map, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef,
|
|
@@ -130,7 +130,7 @@ pub fn render_segments<T: ReadTxn>(
|
|
|
130
130
|
/// callback rules, segments always flatten.
|
|
131
131
|
pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
|
|
132
132
|
render_segments(txn, fragment, &Rules::empty()).map(|segs| {
|
|
133
|
-
|
|
133
|
+
yjs_html_core::flatten(segs)
|
|
134
134
|
.into_html()
|
|
135
135
|
.expect("no callback rules registered")
|
|
136
136
|
})
|
|
@@ -773,11 +773,7 @@ fn lexxy_style(style: &str) -> Option<String> {
|
|
|
773
773
|
css.push(';');
|
|
774
774
|
}
|
|
775
775
|
}
|
|
776
|
-
if css.is_empty() {
|
|
777
|
-
None
|
|
778
|
-
} else {
|
|
779
|
-
Some(css)
|
|
780
|
-
}
|
|
776
|
+
if css.is_empty() { None } else { Some(css) }
|
|
781
777
|
}
|
|
782
778
|
|
|
783
779
|
/// `link` / `autolink`: Lexxy's sanitize keeps only `href` and `title`
|
|
@@ -1301,7 +1297,7 @@ mod tests {
|
|
|
1301
1297
|
let txn = doc.transact();
|
|
1302
1298
|
let frag = txn.get_xml_fragment("root").unwrap();
|
|
1303
1299
|
let segs = render_segments(&txn, &frag, &rules).unwrap();
|
|
1304
|
-
let html =
|
|
1300
|
+
let html = yjs_html_core::flatten(segs).into_html().unwrap();
|
|
1305
1301
|
assert_eq!(
|
|
1306
1302
|
html,
|
|
1307
1303
|
"<p><a class=\"app-link\" href=\"https://x.example\">site</a>\
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
[package]
|
|
2
|
-
name = "
|
|
2
|
+
name = "prosemirror-yjs-html"
|
|
3
3
|
description = "Render ProseMirror-shaped yrs documents to HTML, no browser or Node required"
|
|
4
4
|
repository = "https://github.com/jpcamara/yrby"
|
|
5
5
|
readme = "README.md"
|
|
6
|
-
version = "0.1.
|
|
6
|
+
version = "0.1.2"
|
|
7
7
|
edition = "2024"
|
|
8
8
|
rust-version = "1.85"
|
|
9
9
|
authors = ["JP Camara <johnpcamara@gmail.com>"]
|
|
10
10
|
license = "MIT"
|
|
11
|
-
|
|
11
|
+
keywords = ["yjs", "crdt", "prosemirror", "tiptap", "html"]
|
|
12
|
+
categories = ["text-processing", "web-programming"]
|
|
12
13
|
|
|
13
14
|
[dependencies]
|
|
14
15
|
yrs = { version = "0.27", features = ["sync"] }
|
|
15
16
|
serde_json = "1.0"
|
|
16
|
-
|
|
17
|
+
yjs-html-core = { path = "../html-core", version = "0.1.1" }
|
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
mod readme_examples {}
|
|
43
43
|
|
|
44
44
|
// The full rules surface, re-exported: depend on this crate alone;
|
|
45
|
-
//
|
|
46
|
-
pub use
|
|
47
|
-
use yrs::types::text::YChange;
|
|
45
|
+
// yjs-html-core is an internal implementation crate.
|
|
46
|
+
pub use yjs_html_core::*;
|
|
48
47
|
use yrs::types::Attrs;
|
|
48
|
+
use yrs::types::text::YChange;
|
|
49
49
|
use yrs::{
|
|
50
50
|
Any, GetString, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef, XmlOut,
|
|
51
51
|
XmlTextRef,
|
|
@@ -105,7 +105,7 @@ pub fn render_segments<T: ReadTxn>(
|
|
|
105
105
|
/// callback rules, segments always flatten.
|
|
106
106
|
pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
|
|
107
107
|
render_segments(txn, fragment, &Rules::empty()).map(|segs| {
|
|
108
|
-
|
|
108
|
+
yjs_html_core::flatten(segs)
|
|
109
109
|
.into_html()
|
|
110
110
|
.expect("no callback rules registered")
|
|
111
111
|
})
|
|
@@ -605,102 +605,120 @@ fn render_rule_inline<T: ReadTxn>(
|
|
|
605
605
|
em.push('>');
|
|
606
606
|
}
|
|
607
607
|
|
|
608
|
-
/// Emit
|
|
608
|
+
/// Emit the formatted runs of a Y.XmlText, keeping marks shared by
|
|
609
|
+
/// adjacent runs open across them: `bold` then `bold italic` renders as
|
|
610
|
+
/// `<strong>a<em>b</em></strong>`, the way the editor's DOM serializer
|
|
611
|
+
/// keeps an active mark open into the next run, not as two sibling
|
|
612
|
+
/// `<strong>` wraps. Sharing is by identical opening tag, so a mark whose
|
|
613
|
+
/// attributes differ between runs (a different href, a different style)
|
|
614
|
+
/// never merges.
|
|
609
615
|
fn render_text_runs<T: ReadTxn>(txn: &T, t: &XmlTextRef, em: &mut Emitter, rules: &Rules) {
|
|
616
|
+
let mut open: Vec<(String, String)> = Vec::new();
|
|
610
617
|
for d in t.diff(txn, YChange::identity) {
|
|
611
618
|
if let Out::Any(Any::String(s)) = &d.insert {
|
|
612
|
-
|
|
619
|
+
let stack = run_stack(d.attributes.as_deref(), rules);
|
|
620
|
+
let shared = open
|
|
621
|
+
.iter()
|
|
622
|
+
.zip(stack.iter())
|
|
623
|
+
.take_while(|(a, b)| a == b)
|
|
624
|
+
.count();
|
|
625
|
+
for (_, close) in open.split_off(shared).into_iter().rev() {
|
|
626
|
+
em.push_str(&close);
|
|
627
|
+
}
|
|
628
|
+
for (open_tag, _) in &stack[shared..] {
|
|
629
|
+
em.push_str(open_tag);
|
|
630
|
+
}
|
|
631
|
+
open = stack;
|
|
632
|
+
em.push_str(&escape_text(s));
|
|
613
633
|
}
|
|
614
634
|
}
|
|
635
|
+
for (_, close) in open.into_iter().rev() {
|
|
636
|
+
em.push_str(&close);
|
|
637
|
+
}
|
|
615
638
|
}
|
|
616
639
|
|
|
617
|
-
///
|
|
618
|
-
///
|
|
619
|
-
///
|
|
620
|
-
///
|
|
621
|
-
///
|
|
622
|
-
|
|
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);
|
|
640
|
+
/// The run's mark wrappers as (open tag, close tag) pairs, outermost
|
|
641
|
+
/// first: registered custom marks (reverse-alphabetical, so the last name
|
|
642
|
+
/// alphabetically is outermost), link, the textStyle span, then bold,
|
|
643
|
+
/// italic, strike, underline, highlight, and innermost
|
|
644
|
+
/// subscript/superscript — or `code` alone in their place.
|
|
645
|
+
fn run_stack(marks: Option<&Attrs>, rules: &Rules) -> Vec<(String, String)> {
|
|
632
646
|
let Some(marks) = marks else {
|
|
633
|
-
return
|
|
647
|
+
return Vec::new();
|
|
634
648
|
};
|
|
649
|
+
// Innermost first, reversed at the end.
|
|
650
|
+
let mut stack: Vec<(String, String)> = Vec::new();
|
|
651
|
+
let tag = |t: &str| (format!("<{t}>"), format!("</{t}>"));
|
|
635
652
|
if has(marks, &["code"], rules) {
|
|
636
|
-
|
|
653
|
+
stack.push(tag("code"));
|
|
637
654
|
} else if !marks.contains_key("code") {
|
|
638
655
|
// (A claimed `code` lands here too — on the run, but skipped by the
|
|
639
|
-
// else-if: the rule replaces the wrap, via
|
|
656
|
+
// else-if: the rule replaces the wrap, via the custom marks below,
|
|
640
657
|
// and code's exclusivity over the other formatting marks holds.)
|
|
641
658
|
if has(marks, &["subscript", "sub"], rules) {
|
|
642
|
-
|
|
659
|
+
stack.push(tag("sub"));
|
|
643
660
|
} else if has(marks, &["superscript", "sup"], rules) {
|
|
644
|
-
|
|
661
|
+
stack.push(tag("sup"));
|
|
645
662
|
}
|
|
646
663
|
if has(marks, &["highlight"], rules) {
|
|
647
|
-
|
|
664
|
+
stack.push(tag("mark"));
|
|
648
665
|
}
|
|
649
666
|
if has(marks, &["underline", "u"], rules) {
|
|
650
|
-
|
|
667
|
+
stack.push(tag("u"));
|
|
651
668
|
}
|
|
652
669
|
if has(marks, &["strike", "s"], rules) {
|
|
653
|
-
|
|
670
|
+
stack.push(tag("s"));
|
|
654
671
|
}
|
|
655
672
|
if has(marks, &["italic", "em"], rules) {
|
|
656
|
-
|
|
673
|
+
stack.push(tag("em"));
|
|
657
674
|
}
|
|
658
675
|
if has(marks, &["bold", "strong"], rules) {
|
|
659
|
-
|
|
676
|
+
stack.push(tag("strong"));
|
|
660
677
|
}
|
|
661
678
|
if !rules.marks.contains_key("textStyle") {
|
|
662
679
|
if let Some(Any::Map(style)) = marks.get("textStyle") {
|
|
663
680
|
let css = text_style_css(style);
|
|
664
681
|
if !css.is_empty() {
|
|
665
|
-
|
|
682
|
+
stack.push((
|
|
683
|
+
format!("<span style=\"{}\">", escape_attr(&css)),
|
|
684
|
+
"</span>".to_string(),
|
|
685
|
+
));
|
|
666
686
|
}
|
|
667
687
|
}
|
|
668
688
|
}
|
|
669
689
|
}
|
|
670
690
|
if !rules.marks.contains_key("link") {
|
|
671
691
|
if let Some(Any::Map(link)) = marks.get("link") {
|
|
672
|
-
|
|
692
|
+
stack.push((link_open(link), "</a>".to_string()));
|
|
673
693
|
}
|
|
674
694
|
}
|
|
675
695
|
if !rules.marks.is_empty() {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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));
|
|
696
|
+
let code = marks.contains_key("code");
|
|
697
|
+
let mut names: Vec<&str> = rules
|
|
698
|
+
.marks
|
|
699
|
+
.keys()
|
|
700
|
+
.map(String::as_str)
|
|
701
|
+
.filter(|name| marks.contains_key(*name))
|
|
702
|
+
.filter(|name| !code || matches!(*name, "code" | "link"))
|
|
703
|
+
.collect();
|
|
704
|
+
names.sort_unstable();
|
|
705
|
+
for name in names {
|
|
706
|
+
let rule = &rules.marks[name];
|
|
707
|
+
stack.push((
|
|
708
|
+
custom_mark_open(rule, marks.get(name)),
|
|
709
|
+
format!("</{}>", rule.tag),
|
|
710
|
+
));
|
|
711
|
+
}
|
|
699
712
|
}
|
|
700
|
-
|
|
713
|
+
stack.reverse();
|
|
714
|
+
stack
|
|
701
715
|
}
|
|
702
716
|
|
|
703
|
-
|
|
717
|
+
/// The opening tag for a registered custom mark. Custom marks sit outside
|
|
718
|
+
/// everything; a code run keeps its exclusivity under overrides too: only
|
|
719
|
+
/// the `code` and `link` claims themselves may wrap it, matching what the
|
|
720
|
+
/// built-in wraps allow.
|
|
721
|
+
fn custom_mark_open(rule: &MarkRule, value: Option<&Any>) -> String {
|
|
704
722
|
let mut out = String::from("<");
|
|
705
723
|
out.push_str(&rule.tag);
|
|
706
724
|
for (attr, parts) in &rule.attrs {
|
|
@@ -709,14 +727,10 @@ fn wrap_custom_mark(inner: String, rule: &MarkRule, value: Option<&Any>) -> Stri
|
|
|
709
727
|
out.push_str(attr);
|
|
710
728
|
out.push_str("=\"");
|
|
711
729
|
out.push_str(&escape_attr(&v));
|
|
712
|
-
out.push('"');
|
|
730
|
+
out.push('\"');
|
|
713
731
|
}
|
|
714
732
|
}
|
|
715
733
|
out.push('>');
|
|
716
|
-
out.push_str(&inner);
|
|
717
|
-
out.push_str("</");
|
|
718
|
-
out.push_str(&rule.tag);
|
|
719
|
-
out.push('>');
|
|
720
734
|
out
|
|
721
735
|
}
|
|
722
736
|
|
|
@@ -790,7 +804,7 @@ fn hex_to_rgb(value: &str) -> Option<String> {
|
|
|
790
804
|
|
|
791
805
|
/// `<a>` with Tiptap's attribute order (target, rel, class, href, title),
|
|
792
806
|
/// skipping any that are absent or null.
|
|
793
|
-
fn
|
|
807
|
+
fn link_open(link: &std::collections::HashMap<String, Any>) -> String {
|
|
794
808
|
let mut out = String::from("<a");
|
|
795
809
|
for key in ["target", "rel", "class", "href", "title"] {
|
|
796
810
|
if let Some(Any::String(v)) = link.get(key) {
|
|
@@ -802,8 +816,6 @@ fn wrap_link(inner: String, link: &std::collections::HashMap<String, Any>) -> St
|
|
|
802
816
|
}
|
|
803
817
|
}
|
|
804
818
|
out.push('>');
|
|
805
|
-
out.push_str(&inner);
|
|
806
|
-
out.push_str("</a>");
|
|
807
819
|
out
|
|
808
820
|
}
|
|
809
821
|
|
|
@@ -822,10 +834,6 @@ fn render_image<T: ReadTxn>(txn: &T, e: &XmlElementRef, em: &mut Emitter) {
|
|
|
822
834
|
em.push('>');
|
|
823
835
|
}
|
|
824
836
|
|
|
825
|
-
fn wrap(inner: String, tag: &str) -> String {
|
|
826
|
-
format!("<{tag}>{inner}</{tag}>")
|
|
827
|
-
}
|
|
828
|
-
|
|
829
837
|
/// A mark is present and not claimed by a registered rule for that stored
|
|
830
838
|
/// name (a rule overrides the built-in wrap).
|
|
831
839
|
fn has(marks: &Attrs, keys: &[&str], rules: &Rules) -> bool {
|
|
@@ -903,10 +911,44 @@ mod tests {
|
|
|
903
911
|
a
|
|
904
912
|
}
|
|
905
913
|
|
|
914
|
+
/// Single-run rendering for the mark unit tests: the run's full
|
|
915
|
+
/// open/close stack around the escaped text.
|
|
916
|
+
fn render_run(text: &str, marks: Option<&Attrs>, rules: &Rules) -> String {
|
|
917
|
+
let stack = run_stack(marks, rules);
|
|
918
|
+
let mut html: String = stack.iter().map(|(open, _)| open.as_str()).collect();
|
|
919
|
+
html.push_str(&escape_text(text));
|
|
920
|
+
for (_, close) in stack.iter().rev() {
|
|
921
|
+
html.push_str(close);
|
|
922
|
+
}
|
|
923
|
+
html
|
|
924
|
+
}
|
|
925
|
+
|
|
906
926
|
fn run(text: &str, marks: Option<&Attrs>) -> String {
|
|
907
927
|
render_run(text, marks, &Rules::empty())
|
|
908
928
|
}
|
|
909
929
|
|
|
930
|
+
#[test]
|
|
931
|
+
fn adjacent_runs_share_their_common_outer_marks() {
|
|
932
|
+
use yrs::{Text, XmlFragment};
|
|
933
|
+
let doc = Doc::new();
|
|
934
|
+
let f = doc.get_or_insert_xml_fragment("default");
|
|
935
|
+
let mut txn = doc.transact_mut();
|
|
936
|
+
let p = f.insert(&mut txn, 0, XmlElementPrelim::empty("paragraph"));
|
|
937
|
+
let t = p.insert(&mut txn, 0, XmlTextPrelim::new(""));
|
|
938
|
+
let bold = marks(&["bold"]);
|
|
939
|
+
let bold_em = marks(&["bold", "italic"]);
|
|
940
|
+
t.insert_with_attributes(&mut txn, 0, "nested ", bold.clone());
|
|
941
|
+
t.insert_with_attributes(&mut txn, 7, "deep", bold_em);
|
|
942
|
+
t.insert_with_attributes(&mut txn, 11, " tail", bold);
|
|
943
|
+
drop(txn);
|
|
944
|
+
let txn = doc.transact();
|
|
945
|
+
assert_eq!(
|
|
946
|
+
render(&txn, &f).unwrap(),
|
|
947
|
+
"<p><strong>nested <em>deep</em> tail</strong></p>",
|
|
948
|
+
"shared outer marks stay open across adjacent runs"
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
|
|
910
952
|
/// Core rendering of the captured Tiptap document, pinned as a golden
|
|
911
953
|
/// (`.core.html`). The task list renders through the unknown-container
|
|
912
954
|
/// fallback here (unwrapped children); the external truth — byte parity
|
|
@@ -1236,7 +1278,7 @@ mod tests {
|
|
|
1236
1278
|
let txn = doc.transact();
|
|
1237
1279
|
let segs = render_segments(&txn, &frag, &rules).unwrap();
|
|
1238
1280
|
assert_eq!(
|
|
1239
|
-
|
|
1281
|
+
yjs_html_core::flatten(segs).into_html().unwrap(),
|
|
1240
1282
|
"<aside class=\"callout callout--warning\"><div class=\"para\">careful</div></aside>"
|
|
1241
1283
|
);
|
|
1242
1284
|
}
|
|
@@ -1294,7 +1336,7 @@ mod tests {
|
|
|
1294
1336
|
}
|
|
1295
1337
|
let txn = doc.transact();
|
|
1296
1338
|
let map = collect_node_types(&txn, &frag).unwrap();
|
|
1297
|
-
let json =
|
|
1339
|
+
let json = yjs_html_core::type_map_json(&map, |ty| {
|
|
1298
1340
|
if is_builtin(ty) {
|
|
1299
1341
|
Some("builtin")
|
|
1300
1342
|
} 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
|
|
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
|
///
|
|
@@ -297,13 +297,13 @@ impl RbDoc {
|
|
|
297
297
|
match msg {
|
|
298
298
|
Message::Sync(sync_msg) => match sync_msg {
|
|
299
299
|
SyncMessage::SyncStep1(sv) => {
|
|
300
|
-
// Respond with SyncStep2 carrying
|
|
301
|
-
// state
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
let update =
|
|
300
|
+
// Respond with SyncStep2 carrying the doc's full
|
|
301
|
+
// state, pending included, matching Y.js's
|
|
302
|
+
// encodeStateAsUpdate. A peer parks a pending
|
|
303
|
+
// struct exactly as this doc does and heals it
|
|
304
|
+
// when the missing dependency arrives.
|
|
305
|
+
let txn = doc.transact();
|
|
306
|
+
let update = txn.encode_state_as_update_v1(&sv);
|
|
307
307
|
let response = Message::Sync(SyncMessage::SyncStep2(update));
|
|
308
308
|
Ok((0, 0, response.encode_v1()))
|
|
309
309
|
}
|
data/ext/yrby/src/protocol.rs
CHANGED
|
@@ -135,6 +135,27 @@ pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool
|
|
|
135
135
|
let update = yrs::Update::decode_v1(update_bytes).map_err(|e| e.to_string())?;
|
|
136
136
|
let has_deletes = !update.delete_set().is_empty();
|
|
137
137
|
|
|
138
|
+
// A merged update can hide an internal gap behind a Skip block, and
|
|
139
|
+
// blocks past the gap can still integrate (yrs plants a Skip hole in the
|
|
140
|
+
// store) when their origins don't need the missing range. That moves
|
|
141
|
+
// neither the doc's public state vector (yrs caps it at the first hole)
|
|
142
|
+
// nor pending: invisible to every comparison below, so genuinely novel
|
|
143
|
+
// content would report "doesn't advance": applied, acked, but never
|
|
144
|
+
// recorded or broadcast. The update's own state_vector() caps at its
|
|
145
|
+
// first Skip the same way, so any insertion past a non-zero cap reveals
|
|
146
|
+
// such a gap: conservatively report it as advancing (record it; a
|
|
147
|
+
// duplicate in the log is harmless, dropped content is not). Standard
|
|
148
|
+
// Yjs providers never emit these frames (a client's own updates and
|
|
149
|
+
// diffs are gap-free), so this guards crafted or pathologically merged
|
|
150
|
+
// input, not a hot path.
|
|
151
|
+
let capped = update.state_vector();
|
|
152
|
+
for (client, ranges) in update.insertions(true).iter() {
|
|
153
|
+
let cap = capped.get(client);
|
|
154
|
+
if cap > 0 && ranges.iter().any(|range| range.end > cap) {
|
|
155
|
+
return Ok(true);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
138
159
|
// Fast path: blocks beyond the doc's state vector are content the doc
|
|
139
160
|
// lacks — the update advances, no probe needed. The common case (a novel
|
|
140
161
|
// edit) exits here; only retries and ambiguous diffs pay for the probe.
|
|
@@ -663,6 +684,48 @@ mod tests {
|
|
|
663
684
|
);
|
|
664
685
|
}
|
|
665
686
|
|
|
687
|
+
#[test]
|
|
688
|
+
fn a_gappy_update_with_novel_post_hole_content_advances() {
|
|
689
|
+
// A merged update hiding an internal gap behind a Skip, whose
|
|
690
|
+
// post-hole blocks integrate anyway (their origins don't need the
|
|
691
|
+
// missing range). Integration plants a Skip hole, so neither the
|
|
692
|
+
// public state vector nor pending moves, before the hidden-gap
|
|
693
|
+
// check, the probe comparison reported genuinely novel content as
|
|
694
|
+
// "doesn't advance": update_ready? accepted it, the doc applied it,
|
|
695
|
+
// but it was never recorded or broadcast. Unreachable through
|
|
696
|
+
// standard Yjs providers (a client's own updates and diffs are
|
|
697
|
+
// gap-free); this is a crafted/hostile frame shape.
|
|
698
|
+
let a = Doc::new();
|
|
699
|
+
let t1 = a.get_or_insert_text("one");
|
|
700
|
+
t1.insert(&mut a.transact_mut(), 0, "first");
|
|
701
|
+
let u1 = a
|
|
702
|
+
.transact()
|
|
703
|
+
.encode_state_as_update_v1(&yrs::StateVector::default());
|
|
704
|
+
t1.insert(&mut a.transact_mut(), 5, " second"); // the future hole
|
|
705
|
+
let sv2 = a.transact().state_vector();
|
|
706
|
+
// Novel content on a DIFFERENT root: no dependency on the hole.
|
|
707
|
+
let t3 = a.get_or_insert_text("two");
|
|
708
|
+
t3.insert(&mut a.transact_mut(), 0, "third");
|
|
709
|
+
let u3 = a.transact().encode_state_as_update_v1(&sv2);
|
|
710
|
+
|
|
711
|
+
let server = Doc::new();
|
|
712
|
+
server
|
|
713
|
+
.transact_mut()
|
|
714
|
+
.apply_update(yrs::Update::decode_v1(&u1).unwrap())
|
|
715
|
+
.unwrap();
|
|
716
|
+
// The crafted frame: u1 + u3 merged, u2's range an internal Skip.
|
|
717
|
+
let gappy = yrs::merge_updates_v1([&u1, &u3]).unwrap();
|
|
718
|
+
|
|
719
|
+
assert!(
|
|
720
|
+
update_is_ready(&server, &gappy).unwrap(),
|
|
721
|
+
"sanity: the sync flow would accept and apply this frame"
|
|
722
|
+
);
|
|
723
|
+
assert!(
|
|
724
|
+
update_advances_doc(&server, &gappy).unwrap(),
|
|
725
|
+
"novel content hiding behind a Skip must be recorded, not dropped"
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
666
729
|
// Build a causal gap: `first` inserts "a", `dependent` inserts "b" after it,
|
|
667
730
|
// so `dependent` alone parks as pending on a doc that lacks `first`.
|
|
668
731
|
fn gap_pair() -> (Vec<u8>, Vec<u8>) {
|
|
@@ -29,7 +29,7 @@ class DocumentChannel < ApplicationCable::Channel
|
|
|
29
29
|
# Everyone is denied until you fill this in. Wire it to your app's auth:
|
|
30
30
|
# identify current_user on the cable connection, then check they may read
|
|
31
31
|
# and write this document. Don't lean on on_change raising for access
|
|
32
|
-
# control
|
|
32
|
+
# control; that path exists for store failures.
|
|
33
33
|
def authorized?(_document_key)
|
|
34
34
|
false
|
|
35
35
|
end
|
|
@@ -5,15 +5,15 @@ require "rails/generators/active_record"
|
|
|
5
5
|
|
|
6
6
|
module Yrby
|
|
7
7
|
module Generators
|
|
8
|
-
# `bin/rails generate yrby:tables
|
|
8
|
+
# `bin/rails generate yrby:tables`: the migration for the gem-owned
|
|
9
9
|
# document models (Y::Document + Y::DocumentUpdate). Invoked by
|
|
10
10
|
# yrby:install, and by other gems building on the same storage.
|
|
11
11
|
#
|
|
12
12
|
# Template notes (kept here, not in the emitted migration): state is
|
|
13
|
-
# 4.gigabytes - 1 (longblob on MySQL
|
|
13
|
+
# 4.gigabytes - 1 (longblob on MySQL; a compacted snapshot is the whole
|
|
14
14
|
# document; a 16 MB cap would break compaction) and payload is
|
|
15
15
|
# 16.megabytes - 1 (one update can carry a big paste or a client's
|
|
16
|
-
# accumulated offline edits
|
|
16
|
+
# accumulated offline edits; the 64 KB default blob is too small).
|
|
17
17
|
# The partial unique index's WHERE only keeps
|
|
18
18
|
# key-only rows out of the index: uniqueness holds without it, since
|
|
19
19
|
# unique indexes treat NULLs as distinct on every supported database,
|