yrby 0.7.0 → 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 +14 -0
- data/Cargo.lock +29 -29
- 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 +3 -3
- data/lib/y/version.rb +1 -1
- data/lib/y.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 80931cbbabc08011f5f8ac78dfcf92e648feaf39ef8338ba7abb77bd575bb231
|
|
4
|
+
data.tar.gz: 340e157cea8aeee6ab5013ca43571e74a8f01f514ee8172aad81e14c10cf55b6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe5ba9524856f360def8b53a942bc8142017cb87254344aaeab9b90d4006471d3f480db5c527e4563f031ebc25d9c504c3070d306308676e10f11127ac6695c7
|
|
7
|
+
data.tar.gz: ef2cf7df98eced8c39ad5059ff12d3dfbdd8fc6dbd22c949163cc074d1710add09b9c4adb9d4c4ca24fa957f1b5983578c9f38623a80b0a4172027da4505a3a9
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to this project are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims
|
|
5
5
|
to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.7.1] - 2026-08-19
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- `Y::ProseMirror` and `Y::Tiptap` keep marks shared by adjacent text
|
|
12
|
+
runs open across them, the way ProseMirror's own serializer does:
|
|
13
|
+
bold then bold-italic renders as `<strong>a<em>b</em></strong>`, not
|
|
14
|
+
as two sibling `<strong>` wraps. A mark whose attributes differ
|
|
15
|
+
between runs never merges. Found by the new cross-renderer
|
|
16
|
+
verification, which checks the renderers against a live Tiptap
|
|
17
|
+
editor, `@tiptap/html`'s static `generateHTML`, `tiptap-php`,
|
|
18
|
+
Lexical's `$generateHtmlFromNodes`, and Payload CMS's
|
|
19
|
+
`convertLexicalToHTML` on a shared corpus.
|
|
20
|
+
|
|
7
21
|
## [0.7.0] - 2026-08-11
|
|
8
22
|
|
|
9
23
|
### Added
|
data/Cargo.lock
CHANGED
|
@@ -227,6 +227,15 @@ version = "1.3.0"
|
|
|
227
227
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
228
228
|
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
|
229
229
|
|
|
230
|
+
[[package]]
|
|
231
|
+
name = "lexical-yjs-html"
|
|
232
|
+
version = "0.1.1"
|
|
233
|
+
dependencies = [
|
|
234
|
+
"serde_json",
|
|
235
|
+
"yjs-html-core",
|
|
236
|
+
"yrs",
|
|
237
|
+
]
|
|
238
|
+
|
|
230
239
|
[[package]]
|
|
231
240
|
name = "libc"
|
|
232
241
|
version = "0.2.180"
|
|
@@ -337,6 +346,15 @@ dependencies = [
|
|
|
337
346
|
"unicode-ident",
|
|
338
347
|
]
|
|
339
348
|
|
|
349
|
+
[[package]]
|
|
350
|
+
name = "prosemirror-yjs-html"
|
|
351
|
+
version = "0.1.2"
|
|
352
|
+
dependencies = [
|
|
353
|
+
"serde_json",
|
|
354
|
+
"yjs-html-core",
|
|
355
|
+
"yrs",
|
|
356
|
+
]
|
|
357
|
+
|
|
340
358
|
[[package]]
|
|
341
359
|
name = "quote"
|
|
342
360
|
version = "1.0.43"
|
|
@@ -608,18 +626,26 @@ version = "0.2.1"
|
|
|
608
626
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
609
627
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
|
610
628
|
|
|
629
|
+
[[package]]
|
|
630
|
+
name = "yjs-html-core"
|
|
631
|
+
version = "0.1.1"
|
|
632
|
+
dependencies = [
|
|
633
|
+
"serde_json",
|
|
634
|
+
"yrs",
|
|
635
|
+
]
|
|
636
|
+
|
|
611
637
|
[[package]]
|
|
612
638
|
name = "yrby"
|
|
613
639
|
version = "0.1.0"
|
|
614
640
|
dependencies = [
|
|
641
|
+
"lexical-yjs-html",
|
|
615
642
|
"magnus",
|
|
643
|
+
"prosemirror-yjs-html",
|
|
616
644
|
"rb-sys",
|
|
617
645
|
"rb-sys-env 0.1.2",
|
|
618
646
|
"serde_json",
|
|
647
|
+
"yjs-html-core",
|
|
619
648
|
"yrs",
|
|
620
|
-
"yrs-html-core",
|
|
621
|
-
"yrs-lexical-html",
|
|
622
|
-
"yrs-prosemirror-html",
|
|
623
649
|
]
|
|
624
650
|
|
|
625
651
|
[[package]]
|
|
@@ -640,32 +666,6 @@ dependencies = [
|
|
|
640
666
|
"thiserror",
|
|
641
667
|
]
|
|
642
668
|
|
|
643
|
-
[[package]]
|
|
644
|
-
name = "yrs-html-core"
|
|
645
|
-
version = "0.1.0"
|
|
646
|
-
dependencies = [
|
|
647
|
-
"serde_json",
|
|
648
|
-
"yrs",
|
|
649
|
-
]
|
|
650
|
-
|
|
651
|
-
[[package]]
|
|
652
|
-
name = "yrs-lexical-html"
|
|
653
|
-
version = "0.1.0"
|
|
654
|
-
dependencies = [
|
|
655
|
-
"serde_json",
|
|
656
|
-
"yrs",
|
|
657
|
-
"yrs-html-core",
|
|
658
|
-
]
|
|
659
|
-
|
|
660
|
-
[[package]]
|
|
661
|
-
name = "yrs-prosemirror-html"
|
|
662
|
-
version = "0.1.0"
|
|
663
|
-
dependencies = [
|
|
664
|
-
"serde_json",
|
|
665
|
-
"yrs",
|
|
666
|
-
"yrs-html-core",
|
|
667
|
-
]
|
|
668
|
-
|
|
669
669
|
[[package]]
|
|
670
670
|
name = "zmij"
|
|
671
671
|
version = "1.0.14"
|
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
|
///
|
data/lib/y/version.rb
CHANGED
data/lib/y.rb
CHANGED
|
@@ -20,5 +20,5 @@ require_relative "y/decoder"
|
|
|
20
20
|
module Y
|
|
21
21
|
# Doc, Error, and the protocol module functions are defined in the Rust
|
|
22
22
|
# extension. The ActionCable integration (Y::ActionCable::Sync) lives in the
|
|
23
|
-
#
|
|
23
|
+
# `yrby-rails` gem; require "y/action_cable".
|
|
24
24
|
end
|