yrby 0.5.0 → 0.6.1

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