yrby 0.4.0 → 0.6.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 +80 -1
- data/README.md +251 -9
- data/ext/yrby/src/lexical_html.rs +1316 -0
- data/ext/yrby/src/lib.rs +224 -38
- data/ext/yrby/src/prosemirror_html.rs +736 -267
- data/ext/yrby/src/read.rs +71 -4
- data/ext/yrby/src/render_rules.rs +529 -0
- data/lib/y/lexxy.rb +100 -0
- data/lib/y/rendering.rb +282 -0
- data/lib/y/tiptap.rb +63 -0
- data/lib/y/version.rb +1 -1
- data/lib/y.rb +4 -0
- metadata +6 -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
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
//! Custom render rules and segmented output — the extensibility core shared
|
|
2
|
+
//! by both HTML renderers.
|
|
3
|
+
//!
|
|
4
|
+
//! Callers register per-node rules. Two tiers:
|
|
5
|
+
//!
|
|
6
|
+
//! - **Declarative rules** (tag, attributes, text, content slot) compile to
|
|
7
|
+
//! [`NodeRule`]/[`MarkRule`] here and render natively, inside the document
|
|
8
|
+
//! transaction, at full speed. This covers the tiptap-php `renderHTML`
|
|
9
|
+
//! shape: markup as data.
|
|
10
|
+
//! - **Callback rules** defer to the caller. Rendering never runs app code
|
|
11
|
+
//! while the document is locked — the renderer emits [`Segment::Deferred`]
|
|
12
|
+
//! entries carrying the node's type, attributes (as JSON), and its
|
|
13
|
+
//! already-rendered children, and the caller fills them in after the
|
|
14
|
+
//! render returns. (In the Ruby gem, that caller is the app's block, run
|
|
15
|
+
//! once the transaction has closed and the GVL is held again.)
|
|
16
|
+
//!
|
|
17
|
+
//! Rules arrive as one JSON document (see `parse`), so the same format
|
|
18
|
+
//! serves any binding or caller.
|
|
19
|
+
|
|
20
|
+
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
|
21
|
+
use yrs::{Any, Out, ReadTxn, Xml};
|
|
22
|
+
|
|
23
|
+
/// One piece of renderer output. `Html` is finished markup; `Deferred` is a
|
|
24
|
+
/// callback node whose markup the caller supplies after the render, carrying
|
|
25
|
+
/// everything needed to produce it. Content nests, so callback nodes inside
|
|
26
|
+
/// callback nodes resolve depth-first. `child_types` lists the node's
|
|
27
|
+
/// element/block children by type, in document order — structural facts a
|
|
28
|
+
/// callback can't recover from `attrs` or the rendered content (an image
|
|
29
|
+
/// count, whether a list item holds a nested list).
|
|
30
|
+
#[derive(Debug)]
|
|
31
|
+
pub enum Segment {
|
|
32
|
+
Html(String),
|
|
33
|
+
Deferred {
|
|
34
|
+
node_type: String,
|
|
35
|
+
attrs_json: String,
|
|
36
|
+
child_types: Vec<String>,
|
|
37
|
+
content: Vec<Segment>,
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Builds segmented output. Renderers append markup through this instead of a
|
|
42
|
+
/// bare `String`; frames capture sub-output (a deferred node's children, or a
|
|
43
|
+
/// "did this render anything?" probe) without string sentinels.
|
|
44
|
+
pub struct Emitter {
|
|
45
|
+
frames: Vec<Vec<Segment>>,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
impl Emitter {
|
|
49
|
+
pub fn new() -> Self {
|
|
50
|
+
Emitter {
|
|
51
|
+
frames: vec![Vec::new()],
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
pub fn push_str(&mut self, s: &str) {
|
|
56
|
+
if s.is_empty() {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
let frame = self.frames.last_mut().expect("emitter frame");
|
|
60
|
+
if let Some(Segment::Html(last)) = frame.last_mut() {
|
|
61
|
+
last.push_str(s);
|
|
62
|
+
} else {
|
|
63
|
+
frame.push(Segment::Html(s.to_string()));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
pub fn push(&mut self, c: char) {
|
|
68
|
+
let mut buf = [0u8; 4];
|
|
69
|
+
self.push_str(c.encode_utf8(&mut buf));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Start capturing output into a sub-frame.
|
|
73
|
+
pub fn begin_frame(&mut self) {
|
|
74
|
+
self.frames.push(Vec::new());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Finish the current sub-frame and return what it captured.
|
|
78
|
+
pub fn end_frame(&mut self) -> Vec<Segment> {
|
|
79
|
+
debug_assert!(self.frames.len() > 1, "unbalanced emitter frame");
|
|
80
|
+
self.frames.pop().unwrap_or_default()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Append previously captured segments to the current frame.
|
|
84
|
+
pub fn append(&mut self, segments: Vec<Segment>) {
|
|
85
|
+
for seg in segments {
|
|
86
|
+
match seg {
|
|
87
|
+
Segment::Html(s) => self.push_str(&s),
|
|
88
|
+
deferred => self
|
|
89
|
+
.frames
|
|
90
|
+
.last_mut()
|
|
91
|
+
.expect("emitter frame")
|
|
92
|
+
.push(deferred),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
pub fn emit_deferred(
|
|
98
|
+
&mut self,
|
|
99
|
+
node_type: String,
|
|
100
|
+
attrs_json: String,
|
|
101
|
+
child_types: Vec<String>,
|
|
102
|
+
content: Vec<Segment>,
|
|
103
|
+
) {
|
|
104
|
+
self.frames
|
|
105
|
+
.last_mut()
|
|
106
|
+
.expect("emitter frame")
|
|
107
|
+
.push(Segment::Deferred {
|
|
108
|
+
node_type,
|
|
109
|
+
attrs_json,
|
|
110
|
+
child_types,
|
|
111
|
+
content,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
pub fn into_segments(mut self) -> Vec<Segment> {
|
|
116
|
+
debug_assert_eq!(self.frames.len(), 1, "unbalanced emitter frame");
|
|
117
|
+
self.frames.pop().unwrap_or_default()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// What flattening produced. Both variants are normal outcomes — `Deferred`
|
|
122
|
+
/// means callback nodes are present and need splicing — so this is an enum
|
|
123
|
+
/// rather than a `Result`.
|
|
124
|
+
pub enum Flattened {
|
|
125
|
+
Html(String),
|
|
126
|
+
Deferred(Vec<Segment>),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
impl Flattened {
|
|
130
|
+
/// The finished markup, or `None` when callback nodes still need
|
|
131
|
+
/// splicing. Handy where the caller knows no callback rules exist.
|
|
132
|
+
/// (Only test code needs it today, but it's part of the shape the
|
|
133
|
+
/// extracted crates will expose.)
|
|
134
|
+
#[cfg_attr(not(test), allow(dead_code))]
|
|
135
|
+
pub fn into_html(self) -> Option<String> {
|
|
136
|
+
match self {
|
|
137
|
+
Flattened::Html(html) => Some(html),
|
|
138
|
+
Flattened::Deferred(_) => None,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// Join the segments when every one is finished markup, so the common
|
|
144
|
+
/// no-callback path stays a single string and the splicing layer can be
|
|
145
|
+
/// skipped; hand the segments back untouched when callback nodes are present.
|
|
146
|
+
pub fn flatten(segments: Vec<Segment>) -> Flattened {
|
|
147
|
+
if segments
|
|
148
|
+
.iter()
|
|
149
|
+
.any(|s| matches!(s, Segment::Deferred { .. }))
|
|
150
|
+
{
|
|
151
|
+
return Flattened::Deferred(segments);
|
|
152
|
+
}
|
|
153
|
+
// The merge invariant makes the common case exactly one Html segment;
|
|
154
|
+
// move it out rather than copying the whole document.
|
|
155
|
+
let mut out = String::new();
|
|
156
|
+
for seg in segments {
|
|
157
|
+
if let Segment::Html(s) = seg {
|
|
158
|
+
if out.is_empty() {
|
|
159
|
+
out = s;
|
|
160
|
+
} else {
|
|
161
|
+
out.push_str(&s);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
Flattened::Html(out)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// A piece of an attribute value or text template: a literal, or a reference
|
|
169
|
+
/// to one of the node's stored attributes.
|
|
170
|
+
pub enum AttrPart {
|
|
171
|
+
Lit(String),
|
|
172
|
+
Ref(String),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// Resolve a lit/ref template against a node's attributes. `None` (attribute
|
|
176
|
+
/// or text skipped) when the resolved value is empty — matching how the
|
|
177
|
+
/// built-in renderers omit absent attributes.
|
|
178
|
+
pub fn resolve_parts<F: Fn(&str) -> Option<String>>(
|
|
179
|
+
parts: &[AttrPart],
|
|
180
|
+
lookup: F,
|
|
181
|
+
) -> Option<String> {
|
|
182
|
+
let mut out = String::new();
|
|
183
|
+
for part in parts {
|
|
184
|
+
match part {
|
|
185
|
+
AttrPart::Lit(s) => out.push_str(s),
|
|
186
|
+
AttrPart::Ref(name) => {
|
|
187
|
+
if let Some(v) = lookup(name) {
|
|
188
|
+
out.push_str(&v);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if out.is_empty() {
|
|
194
|
+
None
|
|
195
|
+
} else {
|
|
196
|
+
Some(out)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/// An attribute reference on a node: rules say `:kind`; Lexical stores its own
|
|
201
|
+
/// props as `__kind` — try the raw name first, then prefixed. (ProseMirror
|
|
202
|
+
/// stores attrs bare, so the fallback never fires there.)
|
|
203
|
+
pub fn xml_ref_attr<T: ReadTxn, N: Xml>(txn: &T, node: &N, name: &str) -> Option<String> {
|
|
204
|
+
let value = |out: Option<Out>| match out {
|
|
205
|
+
Some(Out::Any(any)) => any_attr_string(&any),
|
|
206
|
+
_ => None,
|
|
207
|
+
};
|
|
208
|
+
value(node.get_attribute(txn, name))
|
|
209
|
+
.or_else(|| value(node.get_attribute(txn, &format!("__{name}"))))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// A stored attribute as a string: strings pass through; numbers print
|
|
213
|
+
/// JS-style; bools as true/false. Anything else is None.
|
|
214
|
+
pub fn any_attr_string(any: &Any) -> Option<String> {
|
|
215
|
+
match any {
|
|
216
|
+
Any::String(s) => Some(s.to_string()),
|
|
217
|
+
Any::Number(n) => Some(if n.fract() == 0.0 {
|
|
218
|
+
format!("{}", *n as i64)
|
|
219
|
+
} else {
|
|
220
|
+
format!("{n}")
|
|
221
|
+
}),
|
|
222
|
+
Any::BigInt(n) => Some(format!("{n}")),
|
|
223
|
+
Any::Bool(b) => Some(if *b { "true" } else { "false" }.to_string()),
|
|
224
|
+
_ => None,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// A node's stored attributes as a JSON object, for callback rules. Keys as
|
|
229
|
+
/// stored (`__type` and friends keep their prefix); values via yrs's own JSON
|
|
230
|
+
/// encoding.
|
|
231
|
+
pub fn xml_attrs_json<T: ReadTxn, N: Xml>(txn: &T, node: &N) -> String {
|
|
232
|
+
let mut out = String::from("{");
|
|
233
|
+
let mut first = true;
|
|
234
|
+
for (key, value) in node.attributes(txn) {
|
|
235
|
+
let Out::Any(any) = value else { continue };
|
|
236
|
+
if !first {
|
|
237
|
+
out.push(',');
|
|
238
|
+
}
|
|
239
|
+
first = false;
|
|
240
|
+
out.push_str(&serde_json::to_string(key).unwrap_or_else(|_| "\"\"".into()));
|
|
241
|
+
out.push(':');
|
|
242
|
+
let mut v = String::new();
|
|
243
|
+
any.to_json(&mut v);
|
|
244
|
+
out.push_str(&v);
|
|
245
|
+
}
|
|
246
|
+
out.push('}');
|
|
247
|
+
out
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// What goes inside a custom node's element.
|
|
251
|
+
#[derive(Clone, Copy, PartialEq)]
|
|
252
|
+
pub enum Content {
|
|
253
|
+
Blocks,
|
|
254
|
+
Inline,
|
|
255
|
+
None,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// One node rule: markup as data, or a deferral to the caller.
|
|
259
|
+
pub enum NodeRule {
|
|
260
|
+
/// Render natively: the element, its attribute/text templates, and what
|
|
261
|
+
/// goes inside it.
|
|
262
|
+
Declarative {
|
|
263
|
+
tag: String,
|
|
264
|
+
void: bool,
|
|
265
|
+
attrs: Vec<(String, Vec<AttrPart>)>,
|
|
266
|
+
text: Option<Vec<AttrPart>>,
|
|
267
|
+
content: Content,
|
|
268
|
+
},
|
|
269
|
+
/// Emit a [`Segment::Deferred`] for the caller to fill in; `content` is
|
|
270
|
+
/// what renders into its children.
|
|
271
|
+
Callback { content: Content },
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/// A custom mark (ProseMirror only): a wrapping tag with attributes read from
|
|
275
|
+
/// the mark's own value map.
|
|
276
|
+
pub struct MarkRule {
|
|
277
|
+
pub tag: String,
|
|
278
|
+
pub attrs: Vec<(String, Vec<AttrPart>)>,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
pub struct Rules {
|
|
282
|
+
pub nodes: HashMap<String, NodeRule>,
|
|
283
|
+
pub marks: HashMap<String, MarkRule>,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// What a document walk observed about one node type — the facts behind
|
|
287
|
+
/// `Y::Lexical#node_types` / `Y::ProseMirror#node_types`, the discovery aid
|
|
288
|
+
/// for writing rules against a real document.
|
|
289
|
+
#[derive(Default)]
|
|
290
|
+
pub struct TypeInfo {
|
|
291
|
+
pub count: usize,
|
|
292
|
+
pub attrs: BTreeSet<String>,
|
|
293
|
+
pub children: BTreeSet<String>,
|
|
294
|
+
pub text: bool,
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/// Per-type observations, ordered for stable output.
|
|
298
|
+
pub type TypeMap = BTreeMap<String, TypeInfo>;
|
|
299
|
+
|
|
300
|
+
/// Serialize the observations, annotating each type with what already
|
|
301
|
+
/// handles it (`"rule"`, `"builtin"`, or null — the ones a rule author needs
|
|
302
|
+
/// to cover).
|
|
303
|
+
pub fn type_map_json(map: &TypeMap, handled: impl Fn(&str) -> Option<&'static str>) -> String {
|
|
304
|
+
let mut root = serde_json::Map::new();
|
|
305
|
+
for (ty, info) in map {
|
|
306
|
+
let mut entry = serde_json::Map::new();
|
|
307
|
+
entry.insert("count".into(), info.count.into());
|
|
308
|
+
entry.insert(
|
|
309
|
+
"attrs".into(),
|
|
310
|
+
info.attrs.iter().cloned().collect::<Vec<_>>().into(),
|
|
311
|
+
);
|
|
312
|
+
entry.insert(
|
|
313
|
+
"children".into(),
|
|
314
|
+
info.children.iter().cloned().collect::<Vec<_>>().into(),
|
|
315
|
+
);
|
|
316
|
+
entry.insert("text".into(), info.text.into());
|
|
317
|
+
entry.insert(
|
|
318
|
+
"handled".into(),
|
|
319
|
+
match handled(ty) {
|
|
320
|
+
Some(by) => by.into(),
|
|
321
|
+
None => serde_json::Value::Null,
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
root.insert(ty.clone(), entry.into());
|
|
325
|
+
}
|
|
326
|
+
serde_json::Value::Object(root).to_string()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
impl Rules {
|
|
330
|
+
pub fn empty() -> Self {
|
|
331
|
+
Rules {
|
|
332
|
+
nodes: HashMap::new(),
|
|
333
|
+
marks: HashMap::new(),
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/// Parse the rules JSON (however the caller compiled it). Absent keys
|
|
338
|
+
/// take their defaults (`void`/`callback` false, `content` inline), so a
|
|
339
|
+
/// typical document looks like:
|
|
340
|
+
///
|
|
341
|
+
/// ```json
|
|
342
|
+
/// { "nodes": { "callout": { "tag": "aside",
|
|
343
|
+
/// "attrs": [["class", [{"lit": "callout"}]],
|
|
344
|
+
/// ["data-kind", [{"ref": "kind"}]]],
|
|
345
|
+
/// "content": "blocks" },
|
|
346
|
+
/// "video": { "callback": true } },
|
|
347
|
+
/// "marks": { "comment": { "tag": "span",
|
|
348
|
+
/// "attrs": [["data-id", [{"ref": "id"}]]] } } }
|
|
349
|
+
/// ```
|
|
350
|
+
pub fn parse(json: &str) -> Result<Rules, String> {
|
|
351
|
+
let root: serde_json::Value =
|
|
352
|
+
serde_json::from_str(json).map_err(|e| format!("invalid rules JSON: {e}"))?;
|
|
353
|
+
let mut rules = Rules::empty();
|
|
354
|
+
|
|
355
|
+
if let Some(nodes) = root.get("nodes").and_then(|v| v.as_object()) {
|
|
356
|
+
for (name, spec) in nodes {
|
|
357
|
+
rules
|
|
358
|
+
.nodes
|
|
359
|
+
.insert(name.clone(), parse_node_rule(name, spec)?);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if let Some(marks) = root.get("marks").and_then(|v| v.as_object()) {
|
|
363
|
+
for (name, spec) in marks {
|
|
364
|
+
rules
|
|
365
|
+
.marks
|
|
366
|
+
.insert(name.clone(), parse_mark_rule(name, spec)?);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
Ok(rules)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
fn parse_node_rule(name: &str, spec: &serde_json::Value) -> Result<NodeRule, String> {
|
|
374
|
+
let content = match spec.get("content").and_then(|v| v.as_str()) {
|
|
375
|
+
Some("blocks") => Content::Blocks,
|
|
376
|
+
Some("inline") | None => Content::Inline,
|
|
377
|
+
Some("none") => Content::None,
|
|
378
|
+
Some(other) => {
|
|
379
|
+
return Err(format!(
|
|
380
|
+
"rule for {name:?}: unknown content kind {other:?} (blocks|inline|none)"
|
|
381
|
+
))
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
if spec
|
|
385
|
+
.get("callback")
|
|
386
|
+
.and_then(|v| v.as_bool())
|
|
387
|
+
.unwrap_or(false)
|
|
388
|
+
{
|
|
389
|
+
return Ok(NodeRule::Callback { content });
|
|
390
|
+
}
|
|
391
|
+
let Some(tag) = spec.get("tag").and_then(|v| v.as_str()) else {
|
|
392
|
+
return Err(format!("rule for {name:?} needs a tag (or a callback)"));
|
|
393
|
+
};
|
|
394
|
+
Ok(NodeRule::Declarative {
|
|
395
|
+
tag: tag.to_string(),
|
|
396
|
+
void: spec.get("void").and_then(|v| v.as_bool()).unwrap_or(false),
|
|
397
|
+
attrs: parse_attrs(name, spec.get("attrs"))?,
|
|
398
|
+
text: match spec.get("text") {
|
|
399
|
+
Some(serde_json::Value::Array(parts)) => Some(parse_parts(name, parts)?),
|
|
400
|
+
Some(serde_json::Value::Null) | None => None,
|
|
401
|
+
Some(_) => return Err(format!("rule for {name:?}: text must be a template array")),
|
|
402
|
+
},
|
|
403
|
+
content,
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
fn parse_mark_rule(name: &str, spec: &serde_json::Value) -> Result<MarkRule, String> {
|
|
408
|
+
let Some(tag) = spec.get("tag").and_then(|v| v.as_str()) else {
|
|
409
|
+
return Err(format!("mark rule for {name:?} needs a tag"));
|
|
410
|
+
};
|
|
411
|
+
Ok(MarkRule {
|
|
412
|
+
tag: tag.to_string(),
|
|
413
|
+
attrs: parse_attrs(name, spec.get("attrs"))?,
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
fn parse_attrs(
|
|
418
|
+
name: &str,
|
|
419
|
+
attrs: Option<&serde_json::Value>,
|
|
420
|
+
) -> Result<Vec<(String, Vec<AttrPart>)>, String> {
|
|
421
|
+
let mut out = Vec::new();
|
|
422
|
+
let entries = match attrs {
|
|
423
|
+
None | Some(serde_json::Value::Null) => return Ok(out),
|
|
424
|
+
Some(serde_json::Value::Array(entries)) => entries,
|
|
425
|
+
Some(_) => {
|
|
426
|
+
return Err(format!(
|
|
427
|
+
"rule for {name:?}: attrs must be an array of [name, template] pairs"
|
|
428
|
+
))
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
for entry in entries {
|
|
432
|
+
let (Some(attr_name), Some(serde_json::Value::Array(parts))) =
|
|
433
|
+
(entry.get(0).and_then(|v| v.as_str()), entry.get(1))
|
|
434
|
+
else {
|
|
435
|
+
return Err(format!("rule for {name:?}: malformed attrs entry"));
|
|
436
|
+
};
|
|
437
|
+
out.push((attr_name.to_string(), parse_parts(name, parts)?));
|
|
438
|
+
}
|
|
439
|
+
Ok(out)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
fn parse_parts(name: &str, parts: &[serde_json::Value]) -> Result<Vec<AttrPart>, String> {
|
|
443
|
+
parts
|
|
444
|
+
.iter()
|
|
445
|
+
.map(|part| {
|
|
446
|
+
if let Some(lit) = part.get("lit").and_then(|v| v.as_str()) {
|
|
447
|
+
Ok(AttrPart::Lit(lit.to_string()))
|
|
448
|
+
} else if let Some(r) = part.get("ref").and_then(|v| v.as_str()) {
|
|
449
|
+
Ok(AttrPart::Ref(r.to_string()))
|
|
450
|
+
} else {
|
|
451
|
+
Err(format!(
|
|
452
|
+
"rule for {name:?}: template part must be lit or ref"
|
|
453
|
+
))
|
|
454
|
+
}
|
|
455
|
+
})
|
|
456
|
+
.collect()
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#[cfg(test)]
|
|
460
|
+
mod tests {
|
|
461
|
+
use super::*;
|
|
462
|
+
|
|
463
|
+
#[test]
|
|
464
|
+
fn parses_the_compiled_rule_shape() {
|
|
465
|
+
let rules = Rules::parse(
|
|
466
|
+
r#"{ "nodes": { "callout": { "tag": "aside",
|
|
467
|
+
"attrs": [["class", [{"lit": "callout"}]],
|
|
468
|
+
["data-kind", [{"ref": "kind"}]]],
|
|
469
|
+
"content": "blocks" },
|
|
470
|
+
"video": { "callback": true } },
|
|
471
|
+
"marks": { "comment": { "tag": "span",
|
|
472
|
+
"attrs": [["data-id", [{"ref": "id"}]]] } } }"#,
|
|
473
|
+
)
|
|
474
|
+
.unwrap();
|
|
475
|
+
assert_eq!(rules.nodes.len(), 2);
|
|
476
|
+
let NodeRule::Declarative {
|
|
477
|
+
tag,
|
|
478
|
+
attrs,
|
|
479
|
+
content,
|
|
480
|
+
..
|
|
481
|
+
} = &rules.nodes["callout"]
|
|
482
|
+
else {
|
|
483
|
+
panic!("callout should be declarative");
|
|
484
|
+
};
|
|
485
|
+
assert_eq!(tag, "aside");
|
|
486
|
+
assert!(matches!(content, Content::Blocks));
|
|
487
|
+
assert_eq!(attrs.len(), 2);
|
|
488
|
+
assert!(matches!(rules.nodes["video"], NodeRule::Callback { .. }));
|
|
489
|
+
assert_eq!(rules.marks["comment"].tag, "span");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#[test]
|
|
493
|
+
fn rejects_malformed_rules_loudly() {
|
|
494
|
+
assert!(Rules::parse("not json").is_err());
|
|
495
|
+
assert!(Rules::parse(r#"{ "nodes": { "x": {} } }"#).is_err()); // no tag, no callback
|
|
496
|
+
assert!(Rules::parse(r#"{ "nodes": { "x": { "tag": "a", "content": "wat" } } }"#).is_err());
|
|
497
|
+
assert!(Rules::parse(r#"{ "marks": { "x": {} } }"#).is_err());
|
|
498
|
+
// attrs present but not the array-of-pairs form must fail loudly,
|
|
499
|
+
// not silently drop the attributes.
|
|
500
|
+
assert!(
|
|
501
|
+
Rules::parse(r#"{ "nodes": { "x": { "tag": "a", "attrs": {"class": "y"} } } }"#)
|
|
502
|
+
.is_err()
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
#[test]
|
|
507
|
+
fn emitter_frames_capture_and_merge() {
|
|
508
|
+
let mut em = Emitter::new();
|
|
509
|
+
em.push_str("<p>");
|
|
510
|
+
em.begin_frame();
|
|
511
|
+
em.push_str("inner");
|
|
512
|
+
let captured = em.end_frame();
|
|
513
|
+
em.emit_deferred("video".into(), "{}".into(), Vec::new(), captured);
|
|
514
|
+
em.push_str("</p>");
|
|
515
|
+
let segs = em.into_segments();
|
|
516
|
+
assert_eq!(segs.len(), 3);
|
|
517
|
+
assert!(matches!(&segs[0], Segment::Html(s) if s == "<p>"));
|
|
518
|
+
assert!(matches!(&segs[1], Segment::Deferred { node_type, .. } if node_type == "video"));
|
|
519
|
+
assert!(matches!(&segs[2], Segment::Html(s) if s == "</p>"));
|
|
520
|
+
|
|
521
|
+
// Adjacent Html merges; flatten() hands deferred segments back.
|
|
522
|
+
let mut em = Emitter::new();
|
|
523
|
+
em.push_str("a");
|
|
524
|
+
em.push_str("b");
|
|
525
|
+
let segs = em.into_segments();
|
|
526
|
+
assert_eq!(segs.len(), 1);
|
|
527
|
+
assert_eq!(flatten(segs).into_html().unwrap(), "ab");
|
|
528
|
+
}
|
|
529
|
+
}
|