yrby 0.5.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 +62 -1
- data/README.md +222 -16
- data/ext/yrby/src/lexical_html.rs +611 -288
- data/ext/yrby/src/lib.rs +206 -76
- data/ext/yrby/src/prosemirror_html.rs +736 -267
- 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 +5 -1
|
@@ -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
|
+
}
|
data/lib/y/lexxy.rb
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Y
|
|
4
|
+
# The Lexxy renderer: Y::Lexical (core Lexical) plus the Lexxy-specific
|
|
5
|
+
# schema, applied beneath the app's rules — an app rule for one of these
|
|
6
|
+
# types simply replaces it. This is the byte-parity class: the fixture
|
|
7
|
+
# tests hold `Y::Lexxy.new(doc).to_html` identical to a live editor's own
|
|
8
|
+
# serialized value.
|
|
9
|
+
#
|
|
10
|
+
# The schema doubles as the reference for augmenting a renderer: simple
|
|
11
|
+
# nodes are declarative hashes, nodes with logic are plain methods mapped
|
|
12
|
+
# in NODES.
|
|
13
|
+
class Lexxy < Lexical
|
|
14
|
+
# A cursor-placement placeholder; empty ones export to nothing.
|
|
15
|
+
def self.provisional_paragraph(node)
|
|
16
|
+
node.content.empty? ? "" : "<p>#{node.content}</p>"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Adjacent previewable images; the class carries the image count
|
|
20
|
+
# (ActionText's convention).
|
|
21
|
+
def self.gallery(node)
|
|
22
|
+
%(<div class="attachment-gallery attachment-gallery--#{node.child_types.length}">#{node.content}</div>)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Lexxy wraps tables in a styled figure.
|
|
26
|
+
def self.table(node)
|
|
27
|
+
%(<figure class="lexxy-content__table-wrapper"><table><tbody>#{node.content}</tbody></table></figure>)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Class + background match Lexxy's own header-cell export.
|
|
31
|
+
def self.table_cell(node)
|
|
32
|
+
header = node.attrs["__headerState"].is_a?(Numeric) && node.attrs["__headerState"].positive?
|
|
33
|
+
return "<td>#{node.content}</td>" unless header
|
|
34
|
+
|
|
35
|
+
style = %(style="background-color: rgb(242, 243, 245);")
|
|
36
|
+
%(<th class="lexxy-content__table-cell--header" #{style}>#{node.content}</th>)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Attribute order follows Lexxy's export — checked items put aria-checked
|
|
40
|
+
# before value; items holding a nested list append the
|
|
41
|
+
# lexxy-nested-listitem class after value.
|
|
42
|
+
def self.list_item(node)
|
|
43
|
+
out = +"<li"
|
|
44
|
+
checked = node.attrs["__checked"]
|
|
45
|
+
out << %( aria-checked="#{checked}") unless checked.nil?
|
|
46
|
+
out << %( value="#{node.attrs["__value"] || 1}")
|
|
47
|
+
out << %( class="lexxy-nested-listitem") if node.child_types.include?("list")
|
|
48
|
+
"#{out}>#{node.content}</li>"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# An upload, in the exact shape ActionText round-trips: attribute order
|
|
52
|
+
# and presence mirror Lexxy's exportDOM (nulls omitted, `previewable`
|
|
53
|
+
# only when true, `presentation="gallery"` always).
|
|
54
|
+
def self.upload(node)
|
|
55
|
+
out = +"<action-text-attachment"
|
|
56
|
+
out << attachment_attr(node, "sgid", "sgid")
|
|
57
|
+
out << %( previewable="true") if node.attrs["previewable"] == true
|
|
58
|
+
[%w[url src], %w[alt altText], %w[caption caption],
|
|
59
|
+
%w[content-type contentType], %w[filename fileName],
|
|
60
|
+
%w[filesize fileSize], %w[width width], %w[height height]]
|
|
61
|
+
.each { |html_name, stored| out << attachment_attr(node, html_name, stored) }
|
|
62
|
+
%(#{out} presentation="gallery"></action-text-attachment>)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A content attachment (mention, embed): `content` carries the escaped
|
|
66
|
+
# inner HTML; `plainText` is not exported.
|
|
67
|
+
def self.mention(node)
|
|
68
|
+
out = +"<action-text-attachment"
|
|
69
|
+
out << attachment_attr(node, "sgid", "sgid")
|
|
70
|
+
out << attachment_attr(node, "content", "innerHtml")
|
|
71
|
+
out << attachment_attr(node, "content-type", "contentType")
|
|
72
|
+
"#{out}></action-text-attachment>"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A stored nil (unset) is skipped; a stored empty string still emits.
|
|
76
|
+
def self.attachment_attr(node, html_name, stored)
|
|
77
|
+
return "" if node.attrs[stored].nil?
|
|
78
|
+
|
|
79
|
+
%( #{html_name}="#{RenderRules.escape_attr(node.attrs[stored])}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
NODES = {
|
|
83
|
+
# Lexxy's replacement for Lexical's CodeNode.
|
|
84
|
+
"early_escape_code" => { tag: "pre", attrs: { "data-language" => :language } },
|
|
85
|
+
"horizontal_divider" => { tag: "hr", void: true },
|
|
86
|
+
"provisonal_paragraph" => method(:provisional_paragraph), # (sic: Lexxy's spelling)
|
|
87
|
+
"image_gallery" => method(:gallery),
|
|
88
|
+
"table" => { contains: :blocks, render: method(:table) },
|
|
89
|
+
"wrapped_table_node" => { contains: :blocks, render: method(:table) },
|
|
90
|
+
"tablecell" => { contains: :blocks, render: method(:table_cell) },
|
|
91
|
+
"listitem" => { contains: :blocks, render: method(:list_item) },
|
|
92
|
+
"action_text_attachment" => method(:upload),
|
|
93
|
+
"custom_action_text_attachment" => method(:mention)
|
|
94
|
+
}.freeze
|
|
95
|
+
|
|
96
|
+
def initialize(doc, nodes: {}, &)
|
|
97
|
+
super(doc, nodes: NODES.merge(nodes.transform_keys(&:to_s)), &)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|