yrby 0.4.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +42 -6
- data/ext/yrby/src/lexical_html.rs +993 -0
- data/ext/yrby/src/lib.rs +56 -0
- data/ext/yrby/src/read.rs +71 -4
- data/lib/y/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c53c83185ba046ddbcb6b232953af9aa2f52f883120aff8bb2fed8867f247df
|
|
4
|
+
data.tar.gz: 6a09a5541cca754d6ff838feb82838ce881d8fea144fed692031f7c957c9850b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d44fb5bb2a723b6a0dcf7ac6fd3bc248bd58f1401fe2e62009a214148f4a7b1ef1586d0d25585b39d291667eff04d6c66e911c7cdb5dec4968a0f3192b70a85c
|
|
7
|
+
data.tar.gz: 74da78ebc395e44cbdc9f32dcc51a239b7ea312de24f5ac64ec47db41bc784b47a53425afc83b948d36c9bf1b11922a3f9f3aa6615dcbac72a77759abe046ccc
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,24 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.5.0] - 2026-07-08
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`Y::Lexical` — render Lexical/Lexxy documents to HTML.**
|
|
14
|
+
`Y::Lexical.new(doc).to_html` turns a Lexxy document into HTML on the server,
|
|
15
|
+
with no Node process or headless editor. The output is identical to the HTML
|
|
16
|
+
a `lexxy-editor` submits to Rails; the tests check it byte-for-byte against a
|
|
17
|
+
document captured from a real editor. It covers the whole Lexxy 0.9.x node
|
|
18
|
+
set: headings, every text format, links, bullet/numbered/check/nested lists,
|
|
19
|
+
quotes, code blocks, horizontal rules, tables with header cells, image galleries, and
|
|
20
|
+
ActionText attachments. Unknown nodes fall back to a plain paragraph, and a
|
|
21
|
+
root that isn't Lexical (a ProseMirror document, say) returns `nil`. This is
|
|
22
|
+
what `tiptap-php` does for ProseMirror JSON, applied to the Yjs structure.
|
|
23
|
+
- `read_xml` now pulls text out of attachments too: a mention's text goes
|
|
24
|
+
inline, and an upload adds its caption, alt text, or filename. Before, both
|
|
25
|
+
were dropped.
|
|
26
|
+
|
|
9
27
|
## [0.4.0] - 2026-07-07
|
|
10
28
|
|
|
11
29
|
### Added
|
data/README.md
CHANGED
|
@@ -157,6 +157,17 @@ doc.handle_sync_message(data) # => [msg_type, sync_type, response]; answers
|
|
|
157
157
|
# SyncStep2 (never serves pending structs)
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
+
### Reading document contents
|
|
161
|
+
|
|
162
|
+
Reconstruct a document server-side — search, exports, emails, SSR — with no
|
|
163
|
+
Node process:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
doc.read_text("prosemirror") # => plain text of a Y.Text root, or nil
|
|
167
|
+
doc.read_xml("root") # => text of an XML root, one block per line
|
|
168
|
+
doc.read_map("state") # => a Y.Map root as a JSON string; JSON.parse it
|
|
169
|
+
```
|
|
170
|
+
|
|
160
171
|
### Pending structs and gap-free state
|
|
161
172
|
|
|
162
173
|
If a doc applies an update whose causally-prior update is missing (a "gappy"
|
|
@@ -177,8 +188,13 @@ guarantees keep serving safe:
|
|
|
177
188
|
|
|
178
189
|
### Rendering to HTML
|
|
179
190
|
|
|
180
|
-
|
|
181
|
-
server, with no Node process or headless editor:
|
|
191
|
+
Two schema-pinned renderers turn a collaborative document into HTML on the
|
|
192
|
+
server, with no Node process or headless editor: `Y::ProseMirror` for
|
|
193
|
+
ProseMirror/Tiptap documents and `Y::Lexical` for
|
|
194
|
+
[Lexxy](https://github.com/basecamp/lexxy) (Lexical) documents. Each returns
|
|
195
|
+
`nil` for a root that belongs to the other schema.
|
|
196
|
+
|
|
197
|
+
#### `Y::ProseMirror`
|
|
182
198
|
|
|
183
199
|
```ruby
|
|
184
200
|
prosemirror = Y::ProseMirror.new(doc)
|
|
@@ -187,8 +203,7 @@ prosemirror.to_html("content") # or another XML root
|
|
|
187
203
|
```
|
|
188
204
|
|
|
189
205
|
The output matches Tiptap's own `getHTML()`, checked byte-for-byte in the tests
|
|
190
|
-
against a document captured from a real editor.
|
|
191
|
-
email a collaborative document without running a browser. It follows
|
|
206
|
+
against a document captured from a real editor. It follows
|
|
192
207
|
[`tiptap-php`](https://github.com/ueberdosis/tiptap-php) and reads both name
|
|
193
208
|
styles editors use — Tiptap's `bulletList`/`bold` and prosemirror-schema-basic's
|
|
194
209
|
`bullet_list`/`strong`.
|
|
@@ -197,8 +212,29 @@ It covers paragraphs, headings, blockquotes, bullet/ordered/task lists, code
|
|
|
197
212
|
blocks, links, images, mentions, details, hard breaks, horizontal rules,
|
|
198
213
|
tables, text styles (color, font family), and every text mark. A table renders
|
|
199
214
|
as semantic `<table><tbody>`, without the column-width styling Tiptap's editor
|
|
200
|
-
view adds.
|
|
201
|
-
|
|
215
|
+
view adds.
|
|
216
|
+
|
|
217
|
+
#### `Y::Lexical`
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
lexical = Y::Lexical.new(doc)
|
|
221
|
+
lexical.to_html # the "root" fragment (Lexical's default root name)
|
|
222
|
+
lexical.to_html("notepad") # or another XML root
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The HTML is identical to what a `lexxy-editor` submits to Rails (its `value`).
|
|
226
|
+
The tests check this byte-for-byte against a document captured from a real
|
|
227
|
+
editor.
|
|
228
|
+
|
|
229
|
+
It handles the whole Lexxy 0.9.x node set: paragraphs, headings, every text
|
|
230
|
+
format and their combinations, links, the four list types and nesting,
|
|
231
|
+
blockquotes, code blocks, tabs and soft breaks, horizontal rules, tables with
|
|
232
|
+
header cells, image galleries, and ActionText attachments (uploads and
|
|
233
|
+
mentions both emit `<action-text-attachment>` elements that ActionText can
|
|
234
|
+
re-render).
|
|
235
|
+
|
|
236
|
+
In both renderers an unknown node keeps its content — text falls back to a
|
|
237
|
+
plain paragraph rather than disappearing.
|
|
202
238
|
|
|
203
239
|
### Protocol codec (module functions)
|
|
204
240
|
|
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
//! Native HTML rendering of Lexical/Lexxy documents from the yrs collab
|
|
2
|
+
//! structure — no Node process, no headless editor.
|
|
3
|
+
//!
|
|
4
|
+
//! Pinned to Lexxy (37signals' Rails editor, 0.9.x). The output matches what
|
|
5
|
+
//! `lexxy-editor` produces as its `value`: Lexical's `$generateHtmlFromNodes`,
|
|
6
|
+
//! run through Lexxy's text export and DOMPurify. The tests check it byte for
|
|
7
|
+
//! byte against a captured fixture. That's the HTML a Lexxy form submits to
|
|
8
|
+
//! Rails, so you can store it as ActionText content straight from the CRDT.
|
|
9
|
+
//!
|
|
10
|
+
//! Prior art: `ueberdosis/tiptap-php` renders ProseMirror JSON to HTML in pure
|
|
11
|
+
//! PHP — a schema-pinned renderer outside the JS runtime. This works from the
|
|
12
|
+
//! collab (Yjs) structure rather than JSON.
|
|
13
|
+
//!
|
|
14
|
+
//! Storage model (verified against bytes captured from a live Lexxy editor):
|
|
15
|
+
//! - Blocks are `Y.XmlText` with a `__type` attribute (`paragraph`, `heading`
|
|
16
|
+
//! (+`__tag`), `quote`, `list`/`listitem`, `early_escape_code` (+`__language`),
|
|
17
|
+
//! `wrapped_table_node`/`tablerow`/`tablecell`, `link`/`autolink` (inline)).
|
|
18
|
+
//! - Text runs are preceded by an embedded `Y.Map` carrying per-run metadata
|
|
19
|
+
//! (`__type: "text" | "code-highlight" | "tab"`, `__format` bitmask).
|
|
20
|
+
//! - `linebreak` is a bare metadata map; `tab` is a map followed by a "\t" run.
|
|
21
|
+
//! - Decorator nodes are `Y.XmlElement`s: `horizontal_divider`,
|
|
22
|
+
//! `action_text_attachment` (uploads), `custom_action_text_attachment`
|
|
23
|
+
//! (mentions/embeds), with their fields as plain attributes.
|
|
24
|
+
//!
|
|
25
|
+
//! Text-format rendering replicates Lexxy's `exportTextNodeDOM` exactly:
|
|
26
|
+
//! inner tag `strong` (bold) / `em` (italic, when not bold); outer tag `code` /
|
|
27
|
+
//! `mark` / `sub` / `sup`; an `<i>` wrap only when bold+italic combine (the
|
|
28
|
+
//! `em` slot is taken); `<s>` / `<u>` wraps always; `<span>`s are unwrapped, so
|
|
29
|
+
//! unformatted text is bare. A run's `__style` (Lexxy's highlight colors)
|
|
30
|
+
//! survives on the createDOM tag, filtered to color/background-color the way
|
|
31
|
+
//! Lexxy's sanitize allows; on a plain or s/u-only run it dies with the
|
|
32
|
+
//! unwrapped span. Case-transform format bits are never rendered (their
|
|
33
|
+
//! text-transform style is outside the sanitize whitelist).
|
|
34
|
+
|
|
35
|
+
use yrs::types::text::YChange;
|
|
36
|
+
use yrs::{
|
|
37
|
+
Any, GetString, Map, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef,
|
|
38
|
+
XmlOut, XmlTextRef,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Lexical text format bitmask (lexical 0.44).
|
|
42
|
+
const FMT_BOLD: u32 = 1;
|
|
43
|
+
const FMT_ITALIC: u32 = 1 << 1;
|
|
44
|
+
const FMT_STRIKETHROUGH: u32 = 1 << 2;
|
|
45
|
+
const FMT_UNDERLINE: u32 = 1 << 3;
|
|
46
|
+
const FMT_CODE: u32 = 1 << 4;
|
|
47
|
+
const FMT_SUBSCRIPT: u32 = 1 << 5;
|
|
48
|
+
const FMT_SUPERSCRIPT: u32 = 1 << 6;
|
|
49
|
+
const FMT_HIGHLIGHT: u32 = 1 << 7;
|
|
50
|
+
|
|
51
|
+
// Nesting caps. The block tree is walked on an explicit heap stack (no native
|
|
52
|
+
// recursion), so these don't prevent a stack overflow — they just bound how
|
|
53
|
+
// deep the renderer descends. Real docs nest a handful of levels deep (the
|
|
54
|
+
// torture fixture peaks at ~8); past 1024 a subtree is dropped, but its
|
|
55
|
+
// enclosing tags still close. Inline links are still walked recursively (their
|
|
56
|
+
// body is inline content, never blocks), so they carry the same cap.
|
|
57
|
+
const MAX_BLOCK_DEPTH: usize = 1024;
|
|
58
|
+
const MAX_INLINE_DEPTH: usize = 1024;
|
|
59
|
+
|
|
60
|
+
/// A unit of pending block work on the explicit traversal stack. `Open` renders
|
|
61
|
+
/// a block node (pushing its own children as more work); `Close` emits a
|
|
62
|
+
/// literal end tag once a container's children have all been processed. Every
|
|
63
|
+
/// container's closing tag is a fixed string, so `Close` can borrow `'static`.
|
|
64
|
+
enum Work {
|
|
65
|
+
Open(XmlTextRef, usize),
|
|
66
|
+
Close(&'static str),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Render a Lexical/Lexxy-shaped XML root to HTML, or `None` when the root
|
|
70
|
+
/// isn't Lexical-shaped. Lexical marks every node with a `__type` attribute; a
|
|
71
|
+
/// root whose children carry none — a ProseMirror document, say, whose blocks
|
|
72
|
+
/// are plain `<paragraph>` elements — is a foreign schema, and render returns
|
|
73
|
+
/// `None` for it rather than a lossy guess.
|
|
74
|
+
///
|
|
75
|
+
/// A `__type` the renderer doesn't recognize is handled differently: the node
|
|
76
|
+
/// renders its text in a `<p>`, so a Lexxy release that adds one stays readable.
|
|
77
|
+
pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
|
|
78
|
+
if !is_lexical_shaped(txn, fragment) {
|
|
79
|
+
return None;
|
|
80
|
+
}
|
|
81
|
+
let mut out = String::new();
|
|
82
|
+
for node in fragment.children(txn) {
|
|
83
|
+
match node {
|
|
84
|
+
XmlOut::Text(t) => render_block_tree(txn, &t, &mut out),
|
|
85
|
+
XmlOut::Element(e) => render_decorator(txn, &e, &mut out),
|
|
86
|
+
XmlOut::Fragment(f) => out.push_str(&escape_text(&f.get_string(txn))),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
Some(out)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// A root is Lexical-shaped when it is empty or at least one child carries the
|
|
93
|
+
/// `__type` attribute Lexical stamps on every node.
|
|
94
|
+
fn is_lexical_shaped<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> bool {
|
|
95
|
+
let mut any_child = false;
|
|
96
|
+
for node in fragment.children(txn) {
|
|
97
|
+
any_child = true;
|
|
98
|
+
let typed = match &node {
|
|
99
|
+
XmlOut::Text(t) => t.get_attribute(txn, "__type").is_some(),
|
|
100
|
+
XmlOut::Element(e) => e.get_attribute(txn, "__type").is_some(),
|
|
101
|
+
XmlOut::Fragment(_) => false,
|
|
102
|
+
};
|
|
103
|
+
if typed {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
!any_child // an empty document renders to an empty string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// The `__type` attribute of a block/inline `Y.XmlText`.
|
|
111
|
+
fn node_type<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> String {
|
|
112
|
+
match t.get_attribute(txn, "__type") {
|
|
113
|
+
Some(Out::Any(Any::String(s))) => s.to_string(),
|
|
114
|
+
_ => String::new(),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// A string attribute of a block (e.g. `__tag`, `__language`, `__url`).
|
|
119
|
+
fn str_attr<T: ReadTxn>(txn: &T, t: &XmlTextRef, name: &str) -> Option<String> {
|
|
120
|
+
match t.get_attribute(txn, name) {
|
|
121
|
+
Some(Out::Any(Any::String(s))) => Some(s.to_string()),
|
|
122
|
+
_ => None,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Walk a top-level block and everything under it on an explicit heap stack,
|
|
127
|
+
/// appending HTML to `out`. Container blocks (lists, tables, cells, list
|
|
128
|
+
/// items) push their children back as more work plus a `Close` for their end
|
|
129
|
+
/// tag; leaf blocks (paragraphs, headings, quotes, code) render in full on the
|
|
130
|
+
/// spot. The stack lives on the heap, so nesting depth can't overflow the
|
|
131
|
+
/// native call stack.
|
|
132
|
+
fn render_block_tree<T: ReadTxn>(txn: &T, root: &XmlTextRef, out: &mut String) {
|
|
133
|
+
let mut stack: Vec<Work> = vec![Work::Open(root.clone(), 0)];
|
|
134
|
+
while let Some(work) = stack.pop() {
|
|
135
|
+
match work {
|
|
136
|
+
Work::Close(tag) => out.push_str(tag),
|
|
137
|
+
Work::Open(node, depth) => open_block(txn, &node, depth, out, &mut stack),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// Render one block. A container emits its opening tag now and defers its
|
|
143
|
+
/// children (and matching `Close`) to the stack; a leaf renders completely.
|
|
144
|
+
fn open_block<T: ReadTxn>(
|
|
145
|
+
txn: &T,
|
|
146
|
+
t: &XmlTextRef,
|
|
147
|
+
depth: usize,
|
|
148
|
+
out: &mut String,
|
|
149
|
+
stack: &mut Vec<Work>,
|
|
150
|
+
) {
|
|
151
|
+
let ty = node_type(txn, t);
|
|
152
|
+
match ty.as_str() {
|
|
153
|
+
"paragraph" | "provisonal_paragraph" => {
|
|
154
|
+
// (sic: "provisonal" is Lexxy's spelling.) A provisional paragraph
|
|
155
|
+
// is a cursor-placement placeholder; empty ones export to nothing.
|
|
156
|
+
let inline = render_inline(txn, t, 0);
|
|
157
|
+
if inline.is_empty() {
|
|
158
|
+
if ty == "paragraph" {
|
|
159
|
+
out.push_str("<p><br></p>");
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
out.push_str("<p>");
|
|
163
|
+
out.push_str(&inline);
|
|
164
|
+
out.push_str("</p>");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
"heading" => {
|
|
168
|
+
let tag = match str_attr(txn, t, "__tag").as_deref() {
|
|
169
|
+
Some(tag @ ("h1" | "h2" | "h3" | "h4" | "h5" | "h6")) => tag.to_string(),
|
|
170
|
+
_ => "h1".to_string(),
|
|
171
|
+
};
|
|
172
|
+
out.push('<');
|
|
173
|
+
out.push_str(&tag);
|
|
174
|
+
out.push('>');
|
|
175
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
176
|
+
out.push_str("</");
|
|
177
|
+
out.push_str(&tag);
|
|
178
|
+
out.push('>');
|
|
179
|
+
}
|
|
180
|
+
"quote" => {
|
|
181
|
+
out.push_str("<blockquote>");
|
|
182
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
183
|
+
out.push_str("</blockquote>");
|
|
184
|
+
}
|
|
185
|
+
"code" | "early_escape_code" => {
|
|
186
|
+
// Lexxy replaces Lexical's CodeNode with its own type; both shapes
|
|
187
|
+
// are accepted. Code highlighting is derived state: token runs
|
|
188
|
+
// flatten to plain text; linebreaks are <br>, tabs a wrapped \t.
|
|
189
|
+
out.push_str("<pre");
|
|
190
|
+
if let Some(lang) = str_attr(txn, t, "__language").filter(|l| !l.is_empty()) {
|
|
191
|
+
out.push_str(" data-language=\"");
|
|
192
|
+
out.push_str(&escape_attr(&lang));
|
|
193
|
+
out.push('"');
|
|
194
|
+
}
|
|
195
|
+
out.push('>');
|
|
196
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
197
|
+
out.push_str("</pre>");
|
|
198
|
+
}
|
|
199
|
+
"list" => {
|
|
200
|
+
let tag = match str_attr(txn, t, "__tag").as_deref() {
|
|
201
|
+
Some("ol") => "ol",
|
|
202
|
+
_ => "ul",
|
|
203
|
+
};
|
|
204
|
+
out.push('<');
|
|
205
|
+
out.push_str(tag);
|
|
206
|
+
out.push('>');
|
|
207
|
+
// Direct inline content is crafted-only (Lexxy puts none here);
|
|
208
|
+
// keep it rather than drop it. Safe from double-render: this skips
|
|
209
|
+
// block children, and push_block_children skips inline content.
|
|
210
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
211
|
+
let close = if tag == "ol" { "</ol>" } else { "</ul>" };
|
|
212
|
+
push_block_children(txn, t, depth, close, false, stack);
|
|
213
|
+
}
|
|
214
|
+
"listitem" => open_listitem(txn, t, depth, out, stack),
|
|
215
|
+
"image_gallery" => {
|
|
216
|
+
// Adjacent previewable images grouped by Lexxy's gallery node.
|
|
217
|
+
// The class carries the image count (ActionText's convention).
|
|
218
|
+
let count = t
|
|
219
|
+
.diff(txn, YChange::identity)
|
|
220
|
+
.into_iter()
|
|
221
|
+
.filter(|d| matches!(d.insert, Out::YXmlElement(_)))
|
|
222
|
+
.count();
|
|
223
|
+
out.push_str("<div class=\"attachment-gallery attachment-gallery--");
|
|
224
|
+
out.push_str(&count.to_string());
|
|
225
|
+
out.push_str("\">");
|
|
226
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
227
|
+
out.push_str("</div>");
|
|
228
|
+
}
|
|
229
|
+
"table" | "wrapped_table_node" => {
|
|
230
|
+
out.push_str("<figure class=\"lexxy-content__table-wrapper\"><table><tbody>");
|
|
231
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
232
|
+
push_block_children(txn, t, depth, "</tbody></table></figure>", false, stack);
|
|
233
|
+
}
|
|
234
|
+
"tablerow" => {
|
|
235
|
+
out.push_str("<tr>");
|
|
236
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
237
|
+
push_block_children(txn, t, depth, "</tr>", false, stack);
|
|
238
|
+
}
|
|
239
|
+
"tablecell" => {
|
|
240
|
+
let header = matches!(
|
|
241
|
+
t.get_attribute(txn, "__headerState"),
|
|
242
|
+
Some(Out::Any(Any::Number(n))) if n > 0.0
|
|
243
|
+
) || matches!(
|
|
244
|
+
t.get_attribute(txn, "__headerState"),
|
|
245
|
+
Some(Out::Any(Any::BigInt(n))) if n > 0
|
|
246
|
+
);
|
|
247
|
+
let close = if header {
|
|
248
|
+
// Class + background match Lexxy's own header-cell export.
|
|
249
|
+
out.push_str(
|
|
250
|
+
"<th class=\"lexxy-content__table-cell--header\" \
|
|
251
|
+
style=\"background-color: rgb(242, 243, 245);\">",
|
|
252
|
+
);
|
|
253
|
+
"</th>"
|
|
254
|
+
} else {
|
|
255
|
+
out.push_str("<td>");
|
|
256
|
+
"</td>"
|
|
257
|
+
};
|
|
258
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
259
|
+
push_block_children(txn, t, depth, close, false, stack);
|
|
260
|
+
}
|
|
261
|
+
// A block type this renderer doesn't know: degrade to a readable
|
|
262
|
+
// paragraph instead of dropping content.
|
|
263
|
+
_ => {
|
|
264
|
+
let inline = render_inline(txn, t, 0);
|
|
265
|
+
if !inline.is_empty() {
|
|
266
|
+
out.push_str("<p>");
|
|
267
|
+
out.push_str(&inline);
|
|
268
|
+
out.push_str("</p>");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/// Defer a container's block children onto the stack, with its closing tag
|
|
275
|
+
/// below them, so the children render in order and the tag closes after. Past
|
|
276
|
+
/// `MAX_BLOCK_DEPTH` the children are dropped (the container still closes, so
|
|
277
|
+
/// the output stays well formed). `only_lists` keeps just nested-list children,
|
|
278
|
+
/// for list items whose inline content the caller has already emitted.
|
|
279
|
+
fn push_block_children<T: ReadTxn>(
|
|
280
|
+
txn: &T,
|
|
281
|
+
t: &XmlTextRef,
|
|
282
|
+
depth: usize,
|
|
283
|
+
close: &'static str,
|
|
284
|
+
only_lists: bool,
|
|
285
|
+
stack: &mut Vec<Work>,
|
|
286
|
+
) {
|
|
287
|
+
stack.push(Work::Close(close));
|
|
288
|
+
if depth >= MAX_BLOCK_DEPTH {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// Reversed: the stack is LIFO, so the last pushed child is rendered first.
|
|
292
|
+
for child in block_children(txn, t).into_iter().rev() {
|
|
293
|
+
if only_lists && node_type(txn, &child) != "list" {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
stack.push(Work::Open(child, depth + 1));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/// `<li>`: attribute order follows Lexxy's export — checked items put
|
|
301
|
+
/// `aria-checked` before `value`; items holding a nested list append the
|
|
302
|
+
/// `lexxy-nested-listitem` class after `value`. Inline content renders now;
|
|
303
|
+
/// the nested list (if any) is deferred to the stack.
|
|
304
|
+
fn open_listitem<T: ReadTxn>(
|
|
305
|
+
txn: &T,
|
|
306
|
+
t: &XmlTextRef,
|
|
307
|
+
depth: usize,
|
|
308
|
+
out: &mut String,
|
|
309
|
+
stack: &mut Vec<Work>,
|
|
310
|
+
) {
|
|
311
|
+
let value = match t.get_attribute(txn, "__value") {
|
|
312
|
+
Some(Out::Any(Any::Number(n))) => n as i64,
|
|
313
|
+
Some(Out::Any(Any::BigInt(n))) => n,
|
|
314
|
+
_ => 1,
|
|
315
|
+
};
|
|
316
|
+
let checked = match t.get_attribute(txn, "__checked") {
|
|
317
|
+
Some(Out::Any(Any::Bool(b))) => Some(b),
|
|
318
|
+
_ => None,
|
|
319
|
+
};
|
|
320
|
+
let has_nested_list = block_children(txn, t)
|
|
321
|
+
.iter()
|
|
322
|
+
.any(|c| node_type(txn, c) == "list");
|
|
323
|
+
|
|
324
|
+
out.push_str("<li");
|
|
325
|
+
if let Some(c) = checked {
|
|
326
|
+
out.push_str(" aria-checked=\"");
|
|
327
|
+
out.push_str(if c { "true" } else { "false" });
|
|
328
|
+
out.push('"');
|
|
329
|
+
}
|
|
330
|
+
out.push_str(" value=\"");
|
|
331
|
+
out.push_str(&value.to_string());
|
|
332
|
+
out.push('"');
|
|
333
|
+
if has_nested_list {
|
|
334
|
+
out.push_str(" class=\"lexxy-nested-listitem\"");
|
|
335
|
+
}
|
|
336
|
+
out.push('>');
|
|
337
|
+
// Inline content first, then any nested list blocks (Lexical stores the
|
|
338
|
+
// nested list as a child of the item).
|
|
339
|
+
out.push_str(&render_inline(txn, t, 0));
|
|
340
|
+
push_block_children(txn, t, depth, "</li>", true, stack);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/// The `Y.XmlText` children of a block that are themselves blocks (list items,
|
|
344
|
+
/// nested lists, table rows/cells, cell paragraphs) — inline types excluded.
|
|
345
|
+
fn block_children<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> Vec<XmlTextRef> {
|
|
346
|
+
let mut out = Vec::new();
|
|
347
|
+
for d in t.diff(txn, YChange::identity) {
|
|
348
|
+
if let Out::YXmlText(child) = d.insert {
|
|
349
|
+
if !is_inline_type(&node_type(txn, &child)) {
|
|
350
|
+
out.push(child);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
out
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
fn is_inline_type(ty: &str) -> bool {
|
|
358
|
+
matches!(ty, "link" | "autolink")
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/// Render a block's inline content: formatted text runs, linebreaks, tabs,
|
|
362
|
+
/// links, and inline decorators. Nested block children are NOT rendered here
|
|
363
|
+
/// (the block renderers handle them), so a list item's text doesn't duplicate
|
|
364
|
+
/// its nested list. `depth` counts inline-link nesting only (a link's body is
|
|
365
|
+
/// itself inline content); past `MAX_INLINE_DEPTH` a link renders as flat text,
|
|
366
|
+
/// capping the recursion.
|
|
367
|
+
fn render_inline<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize) -> String {
|
|
368
|
+
let mut out = String::new();
|
|
369
|
+
// Format carries from the metadata Map that precedes each run. Lexxy always
|
|
370
|
+
// emits one Map immediately before its run, so this is exact; a run with no
|
|
371
|
+
// preceding Map would inherit the previous run's format (wrong, but only
|
|
372
|
+
// reachable from a doc Lexxy didn't produce).
|
|
373
|
+
let mut format: u32 = 0;
|
|
374
|
+
let mut style = String::new();
|
|
375
|
+
let mut is_tab = false;
|
|
376
|
+
for d in t.diff(txn, YChange::identity) {
|
|
377
|
+
match d.insert {
|
|
378
|
+
Out::YMap(m) => {
|
|
379
|
+
let ty = match m.get(txn, "__type") {
|
|
380
|
+
Some(Out::Any(Any::String(s))) => s.to_string(),
|
|
381
|
+
_ => String::new(),
|
|
382
|
+
};
|
|
383
|
+
match ty.as_str() {
|
|
384
|
+
"linebreak" => out.push_str("<br>"),
|
|
385
|
+
"tab" => {
|
|
386
|
+
is_tab = true;
|
|
387
|
+
format = 0;
|
|
388
|
+
style.clear();
|
|
389
|
+
}
|
|
390
|
+
// "text", "code-highlight", and anything metadata-shaped:
|
|
391
|
+
// read the format bits and style for the run that follows.
|
|
392
|
+
_ => {
|
|
393
|
+
is_tab = false;
|
|
394
|
+
format = match m.get(txn, "__format") {
|
|
395
|
+
Some(Out::Any(Any::Number(n))) => n as u32,
|
|
396
|
+
Some(Out::Any(Any::BigInt(n))) => n as u32,
|
|
397
|
+
_ => 0,
|
|
398
|
+
};
|
|
399
|
+
style = match m.get(txn, "__style") {
|
|
400
|
+
Some(Out::Any(Any::String(s))) => s.to_string(),
|
|
401
|
+
_ => String::new(),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
Out::Any(Any::String(s)) => {
|
|
407
|
+
if is_tab {
|
|
408
|
+
// Lexxy exports a tab as a literal \t in a span.
|
|
409
|
+
out.push_str("<span>\t</span>");
|
|
410
|
+
is_tab = false;
|
|
411
|
+
} else {
|
|
412
|
+
out.push_str(&render_run(&s, format, &style));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
Out::YXmlText(child) => {
|
|
416
|
+
let ty = node_type(txn, &child);
|
|
417
|
+
if is_inline_type(&ty) {
|
|
418
|
+
render_link(txn, &child, depth, &mut out);
|
|
419
|
+
}
|
|
420
|
+
// Nested blocks are rendered by their parent block's renderer.
|
|
421
|
+
}
|
|
422
|
+
Out::YXmlElement(e) => render_inline_decorator(txn, &e, &mut out),
|
|
423
|
+
_ => {}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
out
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/// One text run with Lexical's format bitmask, as Lexxy's exporter wraps it.
|
|
430
|
+
fn render_run(text: &str, format: u32, style: &str) -> String {
|
|
431
|
+
let mut html = escape_text(text);
|
|
432
|
+
// A run's __style survives export only on a real createDOM tag (colors
|
|
433
|
+
// from Lexxy's highlight dropdown). The style attribute rides the OUTER
|
|
434
|
+
// tag when one exists, else the inner tag; a plain or s/u-only run's span
|
|
435
|
+
// is unwrapped, and the style dies with it — all captured behavior.
|
|
436
|
+
let css = lexxy_style(style);
|
|
437
|
+
let outer_tag = if format & FMT_CODE != 0 {
|
|
438
|
+
Some("code")
|
|
439
|
+
} else if format & FMT_HIGHLIGHT != 0 {
|
|
440
|
+
Some("mark")
|
|
441
|
+
} else if format & FMT_SUBSCRIPT != 0 {
|
|
442
|
+
Some("sub")
|
|
443
|
+
} else if format & FMT_SUPERSCRIPT != 0 {
|
|
444
|
+
Some("sup")
|
|
445
|
+
} else {
|
|
446
|
+
None
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// Inner semantic tag (createDOM): strong for bold, em for italic-only.
|
|
450
|
+
// A plain run's span is unwrapped, leaving bare text.
|
|
451
|
+
let inner_style = if outer_tag.is_none() {
|
|
452
|
+
css.as_deref()
|
|
453
|
+
} else {
|
|
454
|
+
None
|
|
455
|
+
};
|
|
456
|
+
if format & FMT_BOLD != 0 {
|
|
457
|
+
html = format_wrap(html, "strong", inner_style);
|
|
458
|
+
} else if format & FMT_ITALIC != 0 {
|
|
459
|
+
html = format_wrap(html, "em", inner_style);
|
|
460
|
+
}
|
|
461
|
+
// Outer semantic tag (createDOM): first match wins.
|
|
462
|
+
if let Some(tag) = outer_tag {
|
|
463
|
+
html = format_wrap(html, tag, css.as_deref());
|
|
464
|
+
}
|
|
465
|
+
// Lexxy's wrap pass: <i> only when italic couldn't claim the inner tag
|
|
466
|
+
// (bold took it); <b> never fires (bold always claims strong); <s>/<u>
|
|
467
|
+
// always wrap.
|
|
468
|
+
if format & FMT_ITALIC != 0 && format & FMT_BOLD != 0 {
|
|
469
|
+
html = format_wrap(html, "i", None);
|
|
470
|
+
}
|
|
471
|
+
if format & FMT_STRIKETHROUGH != 0 {
|
|
472
|
+
html = format_wrap(html, "s", None);
|
|
473
|
+
}
|
|
474
|
+
if format & FMT_UNDERLINE != 0 {
|
|
475
|
+
html = format_wrap(html, "u", None);
|
|
476
|
+
}
|
|
477
|
+
html
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
fn format_wrap(inner: String, tag: &str, style: Option<&str>) -> String {
|
|
481
|
+
match style {
|
|
482
|
+
Some(css) => format!("<{tag} style=\"{}\">{inner}</{tag}>", escape_attr(css)),
|
|
483
|
+
None => format!("<{tag}>{inner}</{tag}>"),
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/// Filter a run's `__style` to what Lexxy's sanitize lets through — `color`
|
|
488
|
+
/// and `background-color` — keeping source order, serialized the way the
|
|
489
|
+
/// sanitize hook rebuilds it: `prop: value;` with no separator between
|
|
490
|
+
/// properties. Everything else (text-transform, white-space, smuggled
|
|
491
|
+
/// properties) is stripped, matching the captured value output.
|
|
492
|
+
fn lexxy_style(style: &str) -> Option<String> {
|
|
493
|
+
let mut css = String::new();
|
|
494
|
+
for decl in style.split(';') {
|
|
495
|
+
let Some((prop, value)) = decl.split_once(':') else {
|
|
496
|
+
continue;
|
|
497
|
+
};
|
|
498
|
+
let (prop, value) = (prop.trim(), value.trim());
|
|
499
|
+
if (prop == "color" || prop == "background-color") && !value.is_empty() {
|
|
500
|
+
css.push_str(prop);
|
|
501
|
+
css.push_str(": ");
|
|
502
|
+
css.push_str(value);
|
|
503
|
+
css.push(';');
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if css.is_empty() {
|
|
507
|
+
None
|
|
508
|
+
} else {
|
|
509
|
+
Some(css)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/// `link` / `autolink`: Lexxy's sanitize keeps only `href` and `title`
|
|
514
|
+
/// (`target`/`rel` are stored in the doc but stripped from exported HTML).
|
|
515
|
+
fn render_link<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize, out: &mut String) {
|
|
516
|
+
out.push_str("<a");
|
|
517
|
+
if let Some(url) = str_attr(txn, t, "__url") {
|
|
518
|
+
out.push_str(" href=\"");
|
|
519
|
+
out.push_str(&escape_attr(&url));
|
|
520
|
+
out.push('"');
|
|
521
|
+
}
|
|
522
|
+
if let Some(title) = str_attr(txn, t, "__title").filter(|s| !s.is_empty()) {
|
|
523
|
+
out.push_str(" title=\"");
|
|
524
|
+
out.push_str(&escape_attr(&title));
|
|
525
|
+
out.push('"');
|
|
526
|
+
}
|
|
527
|
+
out.push('>');
|
|
528
|
+
if depth < MAX_INLINE_DEPTH {
|
|
529
|
+
out.push_str(&render_inline(txn, t, depth + 1));
|
|
530
|
+
} else {
|
|
531
|
+
// A link chain nested past the cap (only crafted input reaches here):
|
|
532
|
+
// keep its text, drop any further link structure.
|
|
533
|
+
out.push_str(&escape_text(&t.get_string(txn)));
|
|
534
|
+
}
|
|
535
|
+
out.push_str("</a>");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/// Root-level decorators: horizontal rule and upload attachments.
|
|
539
|
+
fn render_decorator<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
|
|
540
|
+
match elem_type(txn, e).as_str() {
|
|
541
|
+
"horizontal_divider" => out.push_str("<hr>"),
|
|
542
|
+
"action_text_attachment" => render_upload_attachment(txn, e, out),
|
|
543
|
+
"custom_action_text_attachment" => render_custom_attachment(txn, e, out),
|
|
544
|
+
_ => {} // unknown decorator: nothing extractable
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/// Inline decorators (inside a paragraph): mention/embed attachments.
|
|
549
|
+
fn render_inline_decorator<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
|
|
550
|
+
match elem_type(txn, e).as_str() {
|
|
551
|
+
"custom_action_text_attachment" => render_custom_attachment(txn, e, out),
|
|
552
|
+
"action_text_attachment" => render_upload_attachment(txn, e, out),
|
|
553
|
+
"horizontal_divider" => out.push_str("<hr>"),
|
|
554
|
+
_ => {}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
fn elem_type<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> String {
|
|
559
|
+
match e.get_attribute(txn, "__type") {
|
|
560
|
+
Some(Out::Any(Any::String(s))) => s.to_string(),
|
|
561
|
+
_ => String::new(),
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
fn elem_str<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<String> {
|
|
566
|
+
match e.get_attribute(txn, name) {
|
|
567
|
+
Some(Out::Any(Any::String(s))) => Some(s.to_string()),
|
|
568
|
+
_ => None,
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/// A numeric attribute rendered the way JavaScript would print it (integers
|
|
573
|
+
/// without a trailing `.0`). Assumes normal-magnitude, finite values — the
|
|
574
|
+
/// only numbers here are file sizes and pixel dimensions. A value past i64 or
|
|
575
|
+
/// a non-finite one would format differently from `String(n)` (e.g. `1e21`,
|
|
576
|
+
/// `Infinity`), but no real attachment carries one.
|
|
577
|
+
fn elem_num<T: ReadTxn>(txn: &T, e: &XmlElementRef, name: &str) -> Option<String> {
|
|
578
|
+
match e.get_attribute(txn, name) {
|
|
579
|
+
Some(Out::Any(Any::Number(n))) => {
|
|
580
|
+
if n.fract() == 0.0 {
|
|
581
|
+
Some(format!("{}", n as i64))
|
|
582
|
+
} else {
|
|
583
|
+
Some(format!("{n}"))
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
Some(Out::Any(Any::BigInt(n))) => Some(format!("{n}")),
|
|
587
|
+
_ => None,
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
fn push_attr(out: &mut String, name: &str, value: &str) {
|
|
592
|
+
out.push(' ');
|
|
593
|
+
out.push_str(name);
|
|
594
|
+
out.push_str("=\"");
|
|
595
|
+
out.push_str(&escape_attr(value));
|
|
596
|
+
out.push('"');
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/// An upload attachment, in the exact shape ActionText round-trips: attribute
|
|
600
|
+
/// order and presence mirror Lexxy's `exportDOM` (nulls omitted, `previewable`
|
|
601
|
+
/// only when true, `presentation="gallery"` always).
|
|
602
|
+
fn render_upload_attachment<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
|
|
603
|
+
out.push_str("<action-text-attachment");
|
|
604
|
+
if let Some(v) = elem_str(txn, e, "sgid") {
|
|
605
|
+
push_attr(out, "sgid", &v);
|
|
606
|
+
}
|
|
607
|
+
if matches!(
|
|
608
|
+
e.get_attribute(txn, "previewable"),
|
|
609
|
+
Some(Out::Any(Any::Bool(true)))
|
|
610
|
+
) {
|
|
611
|
+
push_attr(out, "previewable", "true");
|
|
612
|
+
}
|
|
613
|
+
if let Some(v) = elem_str(txn, e, "src") {
|
|
614
|
+
push_attr(out, "url", &v);
|
|
615
|
+
}
|
|
616
|
+
if let Some(v) = elem_str(txn, e, "altText") {
|
|
617
|
+
push_attr(out, "alt", &v);
|
|
618
|
+
}
|
|
619
|
+
if let Some(v) = elem_str(txn, e, "caption") {
|
|
620
|
+
push_attr(out, "caption", &v);
|
|
621
|
+
}
|
|
622
|
+
if let Some(v) = elem_str(txn, e, "contentType") {
|
|
623
|
+
push_attr(out, "content-type", &v);
|
|
624
|
+
}
|
|
625
|
+
if let Some(v) = elem_str(txn, e, "fileName") {
|
|
626
|
+
push_attr(out, "filename", &v);
|
|
627
|
+
}
|
|
628
|
+
if let Some(v) = elem_num(txn, e, "fileSize") {
|
|
629
|
+
push_attr(out, "filesize", &v);
|
|
630
|
+
}
|
|
631
|
+
if let Some(v) = elem_num(txn, e, "width") {
|
|
632
|
+
push_attr(out, "width", &v);
|
|
633
|
+
}
|
|
634
|
+
if let Some(v) = elem_num(txn, e, "height") {
|
|
635
|
+
push_attr(out, "height", &v);
|
|
636
|
+
}
|
|
637
|
+
push_attr(out, "presentation", "gallery");
|
|
638
|
+
out.push_str("></action-text-attachment>");
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/// A content attachment (mention, embed): `content` carries the escaped inner
|
|
642
|
+
/// HTML; `plainText` is not exported.
|
|
643
|
+
fn render_custom_attachment<T: ReadTxn>(txn: &T, e: &XmlElementRef, out: &mut String) {
|
|
644
|
+
out.push_str("<action-text-attachment");
|
|
645
|
+
if let Some(v) = elem_str(txn, e, "sgid") {
|
|
646
|
+
push_attr(out, "sgid", &v);
|
|
647
|
+
}
|
|
648
|
+
if let Some(v) = elem_str(txn, e, "innerHtml") {
|
|
649
|
+
push_attr(out, "content", &v);
|
|
650
|
+
}
|
|
651
|
+
if let Some(v) = elem_str(txn, e, "contentType") {
|
|
652
|
+
push_attr(out, "content-type", &v);
|
|
653
|
+
}
|
|
654
|
+
out.push_str("></action-text-attachment>");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/// Text-content escaping, matching what the browser's serializer emits:
|
|
658
|
+
/// `&`, `<`, `>` escaped; quotes left alone in text.
|
|
659
|
+
fn escape_text(s: &str) -> String {
|
|
660
|
+
s.replace('&', "&")
|
|
661
|
+
.replace('<', "<")
|
|
662
|
+
.replace('>', ">")
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/// Attribute-value escaping: text escaping plus `"`.
|
|
666
|
+
fn escape_attr(s: &str) -> String {
|
|
667
|
+
escape_text(s).replace('"', """)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
#[cfg(test)]
|
|
671
|
+
mod tests {
|
|
672
|
+
use super::*;
|
|
673
|
+
use yrs::updates::decoder::Decode;
|
|
674
|
+
use yrs::{Doc, Transact, Update};
|
|
675
|
+
|
|
676
|
+
/// Rendering the captured full-schema document must match Lexxy's own
|
|
677
|
+
/// serializer byte for byte. The pair was captured together from one live
|
|
678
|
+
/// editor session: `lexxy_full.bin` is the synced Yjs state, `lexxy_full.html`
|
|
679
|
+
/// is `lexxy-editor.value` for the same document. It covers every block and
|
|
680
|
+
/// format the schema map handles.
|
|
681
|
+
#[test]
|
|
682
|
+
fn renders_the_captured_lexxy_document_byte_for_byte() {
|
|
683
|
+
let bytes = include_bytes!("fixtures/lexxy_full.bin");
|
|
684
|
+
let expected = include_str!("fixtures/lexxy_full.html");
|
|
685
|
+
let doc = Doc::new();
|
|
686
|
+
doc.transact_mut()
|
|
687
|
+
.apply_update(Update::decode_v1(bytes).unwrap())
|
|
688
|
+
.unwrap();
|
|
689
|
+
let txn = doc.transact();
|
|
690
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
691
|
+
assert_eq!(render(&txn, &frag).unwrap(), expected.trim_end());
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/// A second live-Lexxy pair that stresses nesting: blocks inside table
|
|
695
|
+
/// cells, five-level mixed lists, formatted links in headings, the full
|
|
696
|
+
/// format stack, unicode and escaping edge cases, whitespace-only
|
|
697
|
+
/// paragraphs. The load-bearing structures are probed by name below.
|
|
698
|
+
/// One non-obvious parity fact worth stating: Lexxy's sanitized export
|
|
699
|
+
/// drops colSpan/rowSpan, so matching it byte-for-byte means not emitting
|
|
700
|
+
/// them either.
|
|
701
|
+
#[test]
|
|
702
|
+
fn renders_the_captured_torture_document_byte_for_byte() {
|
|
703
|
+
let bytes = include_bytes!("fixtures/lexxy_torture.bin");
|
|
704
|
+
let expected = include_str!("fixtures/lexxy_torture.html");
|
|
705
|
+
let doc = Doc::new();
|
|
706
|
+
doc.transact_mut()
|
|
707
|
+
.apply_update(Update::decode_v1(bytes).unwrap())
|
|
708
|
+
.unwrap();
|
|
709
|
+
let txn = doc.transact();
|
|
710
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
711
|
+
let html = render(&txn, &frag).unwrap();
|
|
712
|
+
assert_eq!(html, expected.trim_end());
|
|
713
|
+
|
|
714
|
+
// Byte-for-byte already proves these; name the load-bearing ones so
|
|
715
|
+
// a regression fails with the nested structure it broke.
|
|
716
|
+
for (what, probe) in [
|
|
717
|
+
("list nested in a table cell", "<td><ul><li"),
|
|
718
|
+
("quote in a table cell", "<td><blockquote>"),
|
|
719
|
+
(
|
|
720
|
+
"code block in a table cell",
|
|
721
|
+
"<td><pre data-language=\"javascript\">cell();</pre></td>",
|
|
722
|
+
),
|
|
723
|
+
("mention inside a header cell", "sgid=\"SGID_M_TABLE\""),
|
|
724
|
+
(
|
|
725
|
+
"five-level mixed list nesting",
|
|
726
|
+
"<ol><li value=\"1\"><s><i><strong>Level five</strong></i></s></li></ol>",
|
|
727
|
+
),
|
|
728
|
+
(
|
|
729
|
+
"the full format stack on one run",
|
|
730
|
+
"<u><s><i><code><strong>g</strong></code></i></s></u>",
|
|
731
|
+
),
|
|
732
|
+
(
|
|
733
|
+
"a titled link in a heading wrapping formatted runs",
|
|
734
|
+
"<a href=\"https://spec.example.com\" title=\"The Spec\">v<i><strong>2</strong></i><code>.final</code></a>",
|
|
735
|
+
),
|
|
736
|
+
(
|
|
737
|
+
"already-escaped-looking text escapes again",
|
|
738
|
+
"<strong>a && b < c > d \"q\" '&amp;'</strong>",
|
|
739
|
+
),
|
|
740
|
+
("emoji, CJK and RTL text", "🚀🎉 你好世界 العربية café"),
|
|
741
|
+
(
|
|
742
|
+
"an all-null upload keeps its normalized empty attrs",
|
|
743
|
+
"sgid=\"SGID_UP_MIN\" url=\"http://localhost:4111/files/min.bin\" alt=\"\" caption=\"\"",
|
|
744
|
+
),
|
|
745
|
+
(
|
|
746
|
+
"whitespace-only paragraphs",
|
|
747
|
+
"<p><span>\t</span></p><p><br></p><p><br></p>",
|
|
748
|
+
),
|
|
749
|
+
] {
|
|
750
|
+
assert!(html.contains(probe), "{what}: missing {probe}");
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/// Highlight colors (captured live): a run's __style survives on the
|
|
755
|
+
/// createDOM tag — the outer tag when present, else the inner — filtered
|
|
756
|
+
/// to color/background-color; plain and s/u-only runs lose it with their
|
|
757
|
+
/// unwrapped span. The fixture's byte-for-byte match pins both the keeps
|
|
758
|
+
/// and the drops.
|
|
759
|
+
#[test]
|
|
760
|
+
fn renders_the_captured_styles_document_byte_for_byte() {
|
|
761
|
+
let bytes = include_bytes!("fixtures/lexxy_styles.bin");
|
|
762
|
+
let expected = include_str!("fixtures/lexxy_styles.html");
|
|
763
|
+
let doc = Doc::new();
|
|
764
|
+
doc.transact_mut()
|
|
765
|
+
.apply_update(Update::decode_v1(bytes).unwrap())
|
|
766
|
+
.unwrap();
|
|
767
|
+
let txn = doc.transact();
|
|
768
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
769
|
+
assert_eq!(render(&txn, &frag).unwrap(), expected.trim_end());
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
#[test]
|
|
773
|
+
fn run_styles_ride_the_createdom_tag() {
|
|
774
|
+
// Outer tag takes the style over the inner one.
|
|
775
|
+
assert_eq!(
|
|
776
|
+
render_run("x", 128, "background-color: var(--highlight-bg-2);"),
|
|
777
|
+
"<mark style=\"background-color: var(--highlight-bg-2);\">x</mark>"
|
|
778
|
+
);
|
|
779
|
+
assert_eq!(
|
|
780
|
+
render_run("x", 1 | 128, "background-color: red;"),
|
|
781
|
+
"<mark style=\"background-color: red;\"><strong>x</strong></mark>"
|
|
782
|
+
);
|
|
783
|
+
// No outer tag: the inner takes it.
|
|
784
|
+
assert_eq!(
|
|
785
|
+
render_run("x", 1, "color: red;"),
|
|
786
|
+
"<strong style=\"color: red;\">x</strong>"
|
|
787
|
+
);
|
|
788
|
+
// Plain and s/u-only runs lose the style with their unwrapped span.
|
|
789
|
+
assert_eq!(render_run("x", 0, "color: red;"), "x");
|
|
790
|
+
assert_eq!(render_run("x", 4, "color: red;"), "<s>x</s>");
|
|
791
|
+
// Two properties: source order, no separator between them.
|
|
792
|
+
assert_eq!(
|
|
793
|
+
render_run("x", 128, "color: red; background-color: blue;"),
|
|
794
|
+
"<mark style=\"color: red;background-color: blue;\">x</mark>"
|
|
795
|
+
);
|
|
796
|
+
// Disallowed properties are stripped; quotes in values escape.
|
|
797
|
+
assert_eq!(
|
|
798
|
+
render_run("x", 128, "font-size: 40px; color: r\"ed;"),
|
|
799
|
+
"<mark style=\"color: r"ed;\">x</mark>"
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/// A known container with inline content jammed directly into it
|
|
804
|
+
/// (Lexxy never does this) keeps the content instead of dropping it.
|
|
805
|
+
#[test]
|
|
806
|
+
fn a_known_container_keeps_stray_inline_content() {
|
|
807
|
+
use yrs::{XmlFragment, XmlTextPrelim};
|
|
808
|
+
let doc = Doc::new();
|
|
809
|
+
let frag = doc.get_or_insert_xml_fragment("root");
|
|
810
|
+
{
|
|
811
|
+
let mut txn = doc.transact_mut();
|
|
812
|
+
let list = frag.push_back(&mut txn, XmlTextPrelim::new("stray"));
|
|
813
|
+
list.insert_attribute(&mut txn, "__type", "list");
|
|
814
|
+
list.insert_attribute(&mut txn, "__tag", "ul");
|
|
815
|
+
let li = list.insert_embed(&mut txn, 5, XmlTextPrelim::new("item"));
|
|
816
|
+
li.insert_attribute(&mut txn, "__type", "listitem");
|
|
817
|
+
}
|
|
818
|
+
let txn = doc.transact();
|
|
819
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
820
|
+
let html = render(&txn, &frag).unwrap();
|
|
821
|
+
|
|
822
|
+
assert!(html.contains("stray"), "stray inline text kept: {html}");
|
|
823
|
+
assert!(html.contains("<li value=\"1\">item</li>"), "{html}");
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/// An image gallery (adjacent previewable images grouped by Lexxy's
|
|
827
|
+
/// gallery node) renders as ActionText's classed div. Captured live, like
|
|
828
|
+
/// the other pairs: three image attachments between two paragraphs.
|
|
829
|
+
#[test]
|
|
830
|
+
fn renders_the_captured_gallery_document_byte_for_byte() {
|
|
831
|
+
let bytes = include_bytes!("fixtures/lexxy_gallery.bin");
|
|
832
|
+
let expected = include_str!("fixtures/lexxy_gallery.html");
|
|
833
|
+
let doc = Doc::new();
|
|
834
|
+
doc.transact_mut()
|
|
835
|
+
.apply_update(Update::decode_v1(bytes).unwrap())
|
|
836
|
+
.unwrap();
|
|
837
|
+
let txn = doc.transact();
|
|
838
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
839
|
+
let html = render(&txn, &frag).unwrap();
|
|
840
|
+
assert_eq!(html, expected.trim_end());
|
|
841
|
+
assert!(html.contains("<div class=\"attachment-gallery attachment-gallery--3\">"));
|
|
842
|
+
assert_eq!(html.matches("<action-text-attachment").count(), 3);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
#[test]
|
|
846
|
+
fn format_runs_match_lexxys_export_algorithm() {
|
|
847
|
+
// Singles take their semantic tag; the span for plain text unwraps.
|
|
848
|
+
assert_eq!(render_run("x", 0, ""), "x");
|
|
849
|
+
assert_eq!(render_run("x", 1, ""), "<strong>x</strong>");
|
|
850
|
+
assert_eq!(render_run("x", 2, ""), "<em>x</em>");
|
|
851
|
+
assert_eq!(render_run("x", 4, ""), "<s>x</s>");
|
|
852
|
+
assert_eq!(render_run("x", 8, ""), "<u>x</u>");
|
|
853
|
+
assert_eq!(render_run("x", 16, ""), "<code>x</code>");
|
|
854
|
+
assert_eq!(render_run("x", 32, ""), "<sub>x</sub>");
|
|
855
|
+
assert_eq!(render_run("x", 64, ""), "<sup>x</sup>");
|
|
856
|
+
assert_eq!(render_run("x", 128, ""), "<mark>x</mark>");
|
|
857
|
+
// bold+italic: bold claims the inner tag, italic falls back to <i>.
|
|
858
|
+
assert_eq!(render_run("x", 3, ""), "<i><strong>x</strong></i>");
|
|
859
|
+
// Wrap order: u outside s outside the semantic core.
|
|
860
|
+
assert_eq!(render_run("x", 4 | 8, ""), "<u><s>x</s></u>");
|
|
861
|
+
assert_eq!(render_run("x", 1 | 8, ""), "<u><strong>x</strong></u>");
|
|
862
|
+
// Outer tag composes with the inner one.
|
|
863
|
+
assert_eq!(
|
|
864
|
+
render_run("x", 1 | 16, ""),
|
|
865
|
+
"<code><strong>x</strong></code>"
|
|
866
|
+
);
|
|
867
|
+
assert_eq!(render_run("x", 2 | 128, ""), "<mark><em>x</em></mark>");
|
|
868
|
+
// Everything at once: u(s(i(outer(inner)))).
|
|
869
|
+
assert_eq!(
|
|
870
|
+
render_run("x", 1 | 2 | 4 | 8 | 16, ""),
|
|
871
|
+
"<u><s><i><code><strong>x</strong></code></i></s></u>"
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
#[test]
|
|
876
|
+
fn a_prosemirror_shaped_root_is_refused_not_mangled() {
|
|
877
|
+
// ProseMirror blocks are plain XmlElements with no __type — a
|
|
878
|
+
// different schema. render() must return None, never a lossy or empty
|
|
879
|
+
// rendering that looks like success.
|
|
880
|
+
use yrs::{XmlElementPrelim, XmlFragment, XmlTextPrelim};
|
|
881
|
+
let doc = Doc::new();
|
|
882
|
+
// Create BOTH roots before opening the read transaction:
|
|
883
|
+
// get_or_insert_* opens a write transaction internally and would
|
|
884
|
+
// deadlock against a live read guard (the read_text lesson).
|
|
885
|
+
let frag = doc.get_or_insert_xml_fragment("pm");
|
|
886
|
+
let empty = doc.get_or_insert_xml_fragment("empty");
|
|
887
|
+
{
|
|
888
|
+
let mut txn = doc.transact_mut();
|
|
889
|
+
let p = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
|
|
890
|
+
p.push_back(&mut txn, XmlTextPrelim::new("Body"));
|
|
891
|
+
}
|
|
892
|
+
let txn = doc.transact();
|
|
893
|
+
assert_eq!(render(&txn, &frag), None);
|
|
894
|
+
|
|
895
|
+
// An empty root is fine: an empty document, not a foreign schema.
|
|
896
|
+
assert_eq!(render(&txn, &empty).as_deref(), Some(""));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/// Build a chain of `depth` nested lists: list > listitem > list > … each
|
|
900
|
+
/// list holding one item whose only child is the next list down. Mirrors
|
|
901
|
+
/// the real storage model (block children are XmlText embedded in XmlText).
|
|
902
|
+
fn nested_list_doc(depth: usize) -> Doc {
|
|
903
|
+
use yrs::{XmlFragment, XmlTextPrelim};
|
|
904
|
+
let doc = Doc::new();
|
|
905
|
+
let root = doc.get_or_insert_xml_fragment("root");
|
|
906
|
+
let mut txn = doc.transact_mut();
|
|
907
|
+
let top = root.push_back(&mut txn, XmlTextPrelim::new(""));
|
|
908
|
+
top.insert_attribute(&mut txn, "__type", "list");
|
|
909
|
+
top.insert_attribute(&mut txn, "__tag", "ul");
|
|
910
|
+
let mut cursor = top;
|
|
911
|
+
for _ in 0..depth {
|
|
912
|
+
let li = cursor.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
|
|
913
|
+
li.insert_attribute(&mut txn, "__type", "listitem");
|
|
914
|
+
let inner = li.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
|
|
915
|
+
inner.insert_attribute(&mut txn, "__type", "list");
|
|
916
|
+
inner.insert_attribute(&mut txn, "__tag", "ul");
|
|
917
|
+
cursor = inner;
|
|
918
|
+
}
|
|
919
|
+
drop(txn);
|
|
920
|
+
doc
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
#[test]
|
|
924
|
+
fn deeply_nested_blocks_do_not_overflow_the_stack() {
|
|
925
|
+
// Nesting this deep would overflow the native call stack (tens of
|
|
926
|
+
// thousands of frames) under recursion; on the heap it renders fine.
|
|
927
|
+
// Runs on a 512 KiB thread so it can't pass just by having room to spare.
|
|
928
|
+
let handle = std::thread::Builder::new()
|
|
929
|
+
.stack_size(512 * 1024)
|
|
930
|
+
.spawn(|| {
|
|
931
|
+
let doc = nested_list_doc(20_000);
|
|
932
|
+
let txn = doc.transact();
|
|
933
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
934
|
+
let html = render(&txn, &frag).expect("lexical-shaped");
|
|
935
|
+
// Well formed: every opened list/item is closed.
|
|
936
|
+
assert_eq!(html.matches("<ul>").count(), html.matches("</ul>").count());
|
|
937
|
+
assert_eq!(html.matches("<li ").count(), html.matches("</li>").count());
|
|
938
|
+
html.len()
|
|
939
|
+
})
|
|
940
|
+
.unwrap();
|
|
941
|
+
assert!(handle.join().unwrap() > 0);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
#[test]
|
|
945
|
+
fn nesting_past_the_cap_truncates_but_stays_well_formed() {
|
|
946
|
+
// Past MAX_BLOCK_DEPTH the deepest content is dropped, but every
|
|
947
|
+
// enclosing tag still closes, so the output remains balanced HTML.
|
|
948
|
+
let doc = nested_list_doc(MAX_BLOCK_DEPTH + 50);
|
|
949
|
+
let txn = doc.transact();
|
|
950
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
951
|
+
let html = render(&txn, &frag).unwrap();
|
|
952
|
+
|
|
953
|
+
assert_eq!(html.matches("<ul>").count(), html.matches("</ul>").count());
|
|
954
|
+
assert_eq!(html.matches("<li ").count(), html.matches("</li>").count());
|
|
955
|
+
// Capped, not fully rendered: fewer levels than were authored.
|
|
956
|
+
assert!(html.matches("<ul>").count() <= MAX_BLOCK_DEPTH + 1);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
#[test]
|
|
960
|
+
fn deeply_nested_links_do_not_overflow_the_stack() {
|
|
961
|
+
// Links recurse (their body is inline content), so they carry their own
|
|
962
|
+
// depth cap. A link-in-link chain renders as nested <a>s up to the cap,
|
|
963
|
+
// then bare text.
|
|
964
|
+
use yrs::{XmlFragment, XmlTextPrelim};
|
|
965
|
+
let doc = Doc::new();
|
|
966
|
+
let root = doc.get_or_insert_xml_fragment("root");
|
|
967
|
+
{
|
|
968
|
+
let mut txn = doc.transact_mut();
|
|
969
|
+
let p = root.push_back(&mut txn, XmlTextPrelim::new(""));
|
|
970
|
+
p.insert_attribute(&mut txn, "__type", "paragraph");
|
|
971
|
+
let mut cursor = p;
|
|
972
|
+
for _ in 0..(MAX_INLINE_DEPTH + 20) {
|
|
973
|
+
let link = cursor.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
|
|
974
|
+
link.insert_attribute(&mut txn, "__type", "link");
|
|
975
|
+
link.insert_attribute(&mut txn, "__url", "https://x.example");
|
|
976
|
+
cursor = link;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
let txn = doc.transact();
|
|
980
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
981
|
+
let html = render(&txn, &frag).unwrap();
|
|
982
|
+
assert_eq!(html.matches("<a").count(), html.matches("</a>").count());
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
#[test]
|
|
986
|
+
fn escaping_matches_the_browser_serializer() {
|
|
987
|
+
assert_eq!(escape_text(r#"<a & "b">"#), r#"<a & "b">"#);
|
|
988
|
+
assert_eq!(
|
|
989
|
+
escape_attr(r#"<a & "b">"#),
|
|
990
|
+
r#"<a & "b">"#
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
}
|
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 lexical_html;
|
|
9
10
|
mod prosemirror_html;
|
|
10
11
|
mod protocol;
|
|
11
12
|
mod read;
|
|
@@ -31,6 +32,7 @@ struct RbDoc(Doc);
|
|
|
31
32
|
fn assert_thread_safe() {
|
|
32
33
|
fn is_send_sync<T: Send + Sync>() {}
|
|
33
34
|
is_send_sync::<Doc>();
|
|
35
|
+
is_send_sync::<RbLexical>();
|
|
34
36
|
is_send_sync::<RbProseMirror>();
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -331,6 +333,56 @@ impl RbDoc {
|
|
|
331
333
|
}
|
|
332
334
|
}
|
|
333
335
|
|
|
336
|
+
// ============================================================================
|
|
337
|
+
// Y::Lexical — schema-pinned rendering of Lexical/Lexxy documents
|
|
338
|
+
// ============================================================================
|
|
339
|
+
|
|
340
|
+
/// A Lexical/Lexxy view over a `Y::Doc`. The schema knowledge (Lexxy 0.9.x
|
|
341
|
+
/// node set and serializer semantics) lives here rather than on the
|
|
342
|
+
/// schema-agnostic `Doc`. Holds a cheap clone of the doc (yrs `Doc` is an Arc
|
|
343
|
+
/// handle), so it reads live state.
|
|
344
|
+
///
|
|
345
|
+
/// Thread safety matches `Y::Doc`: every method opens its own transaction
|
|
346
|
+
/// inside `nogvl` and holds no lock across the GVL boundary.
|
|
347
|
+
#[magnus::wrap(class = "Y::Lexical", free_immediately, size)]
|
|
348
|
+
struct RbLexical(Doc);
|
|
349
|
+
|
|
350
|
+
impl RbLexical {
|
|
351
|
+
/// `Y::Lexical.new(doc)`
|
|
352
|
+
fn new(doc: &RbDoc) -> Self {
|
|
353
|
+
RbLexical(doc.0.clone())
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/// Render the document's XML root (default `"root"`, Lexical's standard
|
|
357
|
+
/// collab root name) to HTML, natively — no Node process or headless
|
|
358
|
+
/// editor. Output matches Lexxy's own serializer (the HTML a lexxy-editor
|
|
359
|
+
/// submits to Rails) byte-for-byte on the reference fixture; see
|
|
360
|
+
/// `lexical_html.rs` for the schema coverage and caveats. Returns nil when
|
|
361
|
+
/// the root is missing or not Lexical-shaped, e.g. a ProseMirror document.
|
|
362
|
+
fn to_html(&self, args: &[Value]) -> Result<Option<String>, Error> {
|
|
363
|
+
if args.len() > 1 {
|
|
364
|
+
let ruby = Ruby::get().unwrap();
|
|
365
|
+
return Err(Error::new(
|
|
366
|
+
ruby.exception_arg_error(),
|
|
367
|
+
format!(
|
|
368
|
+
"wrong number of arguments (given {}, expected 0..1)",
|
|
369
|
+
args.len()
|
|
370
|
+
),
|
|
371
|
+
));
|
|
372
|
+
}
|
|
373
|
+
let name: String = match args.first() {
|
|
374
|
+
Some(arg) => TryConvert::try_convert(*arg)?,
|
|
375
|
+
None => "root".to_string(),
|
|
376
|
+
};
|
|
377
|
+
let doc = &self.0;
|
|
378
|
+
Ok(nogvl(move || {
|
|
379
|
+
let txn = doc.transact();
|
|
380
|
+
let fragment = txn.get_xml_fragment(name.as_str())?;
|
|
381
|
+
lexical_html::render(&txn, &fragment)
|
|
382
|
+
}))
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
334
386
|
// ============================================================================
|
|
335
387
|
// Y::ProseMirror — schema-pinned rendering of ProseMirror/Tiptap documents
|
|
336
388
|
// ============================================================================
|
|
@@ -460,6 +512,10 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
460
512
|
"handle_sync_message",
|
|
461
513
|
method!(RbDoc::handle_sync_message, 1),
|
|
462
514
|
)?;
|
|
515
|
+
// Y::Lexical: schema-pinned Lexical/Lexxy rendering over a Doc.
|
|
516
|
+
let lexical_class = module.define_class("Lexical", ruby.class_object())?;
|
|
517
|
+
lexical_class.define_singleton_method("new", function!(RbLexical::new, 1))?;
|
|
518
|
+
lexical_class.define_method("to_html", method!(RbLexical::to_html, -1))?;
|
|
463
519
|
// Y::ProseMirror: schema-pinned ProseMirror/Tiptap rendering over a Doc.
|
|
464
520
|
let prosemirror_class = module.define_class("ProseMirror", ruby.class_object())?;
|
|
465
521
|
prosemirror_class.define_singleton_method("new", function!(RbProseMirror::new, 1))?;
|
data/ext/yrby/src/read.rs
CHANGED
|
@@ -31,11 +31,15 @@ pub fn xml_blocks_text<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> String
|
|
|
31
31
|
XmlOut::Text(t) => walk_lexical_block(txn, &t, &mut out),
|
|
32
32
|
XmlOut::Element(e) => {
|
|
33
33
|
// ProseMirror blocks have a tag but no `__type`; get_string recurses
|
|
34
|
-
// them (tags kept, caller strips). A Lexical decorator
|
|
35
|
-
//
|
|
36
|
-
//
|
|
34
|
+
// them (tags kept, caller strips). A Lexical decorator is an
|
|
35
|
+
// XmlElement *with* a `__type`: attachments carry readable text
|
|
36
|
+
// (a mention's plain text, an upload's caption); the rest
|
|
37
|
+
// (horizontal rule) have none — skip rather than emit their
|
|
38
|
+
// `<UNDEFINED …>` serialization.
|
|
37
39
|
if e.get_attribute(txn, "__type").is_none() {
|
|
38
40
|
out.push(e.get_string(txn));
|
|
41
|
+
} else if let Some(text) = lexical_decorator_text(txn, &e) {
|
|
42
|
+
out.push(text);
|
|
39
43
|
}
|
|
40
44
|
}
|
|
41
45
|
XmlOut::Fragment(f) => out.push(f.get_string(txn)),
|
|
@@ -62,6 +66,26 @@ fn lexical_type<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> String {
|
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
/// The readable text of a Lexical decorator element, if it has any: a mention
|
|
70
|
+
/// or embed attachment contributes its plain text; an upload attachment its
|
|
71
|
+
/// caption, alt text, or filename. Dividers and unknown decorators yield None.
|
|
72
|
+
fn lexical_decorator_text<T: ReadTxn>(txn: &T, e: &yrs::XmlElementRef) -> Option<String> {
|
|
73
|
+
let attr = |name: &str| match e.get_attribute(txn, name) {
|
|
74
|
+
Some(Out::Any(Any::String(s))) if !s.is_empty() => Some(s.to_string()),
|
|
75
|
+
_ => None,
|
|
76
|
+
};
|
|
77
|
+
match e.get_attribute(txn, "__type") {
|
|
78
|
+
Some(Out::Any(Any::String(ty))) => match ty.as_ref() {
|
|
79
|
+
"custom_action_text_attachment" => attr("plainText"),
|
|
80
|
+
"action_text_attachment" => attr("caption")
|
|
81
|
+
.or_else(|| attr("altText"))
|
|
82
|
+
.or_else(|| attr("fileName")),
|
|
83
|
+
_ => None,
|
|
84
|
+
},
|
|
85
|
+
_ => None,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
65
89
|
/// The `__type` of an embedded Lexical `Y.Map`. Two kinds appear inside a
|
|
66
90
|
/// block: text-node metadata (`"text"`) and node maps like the LineBreakNode
|
|
67
91
|
/// (`"linebreak"`). Structure confirmed from live-editor bytes (see the
|
|
@@ -120,7 +144,13 @@ fn walk_lexical_block<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut Vec<String>
|
|
|
120
144
|
}
|
|
121
145
|
}
|
|
122
146
|
}
|
|
123
|
-
|
|
147
|
+
// An inline decorator (a mention attachment) joins the line.
|
|
148
|
+
Out::YXmlElement(e) => {
|
|
149
|
+
if let Some(text) = lexical_decorator_text(txn, &e) {
|
|
150
|
+
line.push_str(&text);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
_ => {} // other embeds carry no text
|
|
124
154
|
}
|
|
125
155
|
}
|
|
126
156
|
if !line.is_empty() {
|
|
@@ -332,6 +362,43 @@ mod tests {
|
|
|
332
362
|
assert_eq!(xml_blocks_text(&txn, &frag), "foo\nbarbaz");
|
|
333
363
|
}
|
|
334
364
|
|
|
365
|
+
#[test]
|
|
366
|
+
fn lexxy_full_schema_doc_extracts_attachment_text_too() {
|
|
367
|
+
// The full-schema capture (see lexical_html.rs): attachments must now
|
|
368
|
+
// contribute readable text — a mention's plain text inline, an
|
|
369
|
+
// upload's caption as its own line — while the divider stays silent.
|
|
370
|
+
use yrs::updates::decoder::Decode;
|
|
371
|
+
use yrs::{Transact, Update};
|
|
372
|
+
let bytes = include_bytes!("fixtures/lexxy_full.bin");
|
|
373
|
+
let doc = Doc::new();
|
|
374
|
+
doc.transact_mut()
|
|
375
|
+
.apply_update(Update::decode_v1(bytes).unwrap())
|
|
376
|
+
.unwrap();
|
|
377
|
+
let txn = doc.transact();
|
|
378
|
+
let frag = txn.get_xml_fragment("root").unwrap();
|
|
379
|
+
let text = xml_blocks_text(&txn, &frag);
|
|
380
|
+
|
|
381
|
+
assert!(
|
|
382
|
+
text.contains("Mention: @Alice done."),
|
|
383
|
+
"inline mention joins its line:\n{text}"
|
|
384
|
+
);
|
|
385
|
+
assert!(
|
|
386
|
+
text.contains("The team, 2026"),
|
|
387
|
+
"upload caption becomes a line"
|
|
388
|
+
);
|
|
389
|
+
assert!(
|
|
390
|
+
!text.contains("UNDEFINED"),
|
|
391
|
+
"no decorator serialization leaks"
|
|
392
|
+
);
|
|
393
|
+
assert!(
|
|
394
|
+
!text.contains('\u{2504}'),
|
|
395
|
+
"the divider glyph is not emitted"
|
|
396
|
+
);
|
|
397
|
+
for expected in ["Heading H6", "Done item", "def hello", "after-empty"] {
|
|
398
|
+
assert!(text.contains(expected), "missing {expected:?}");
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
335
402
|
#[test]
|
|
336
403
|
fn lexical_complex_doc_extracts_all_nested_text() {
|
|
337
404
|
// A real Lexxy/Lexical doc with every block type: headings, formatted
|
data/lib/y/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- JP Camara
|
|
@@ -83,6 +83,7 @@ files:
|
|
|
83
83
|
- README.md
|
|
84
84
|
- ext/yrby/Cargo.toml
|
|
85
85
|
- ext/yrby/extconf.rb
|
|
86
|
+
- ext/yrby/src/lexical_html.rs
|
|
86
87
|
- ext/yrby/src/lib.rs
|
|
87
88
|
- ext/yrby/src/prosemirror_html.rs
|
|
88
89
|
- ext/yrby/src/protocol.rs
|