1#[cfg(doctest)]
62#[doc = include_str!("../README.md")]
63mod readme_examples {}
64
65pub use yjs_html_core::*;
68use yrs::types::text::YChange;
69use yrs::{
70 Any, GetString, Map, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef,
71 XmlOut, XmlTextRef,
72};
73
74const FMT_BOLD: u32 = 1;
76const FMT_ITALIC: u32 = 1 << 1;
77const FMT_STRIKETHROUGH: u32 = 1 << 2;
78const FMT_UNDERLINE: u32 = 1 << 3;
79const FMT_CODE: u32 = 1 << 4;
80const FMT_SUBSCRIPT: u32 = 1 << 5;
81const FMT_SUPERSCRIPT: u32 = 1 << 6;
82const FMT_HIGHLIGHT: u32 = 1 << 7;
83
84const MAX_BLOCK_DEPTH: usize = 1024;
91const MAX_INLINE_DEPTH: usize = 1024;
92
93enum Work {
100 Open(XmlTextRef, usize),
101 Close(&'static str),
102 CloseOwned(String),
103 EndDeferred {
104 node_type: String,
105 attrs_json: String,
106 child_types: Vec<String>,
107 },
108}
109
110pub fn render_segments<T: ReadTxn>(
121 txn: &T,
122 fragment: &XmlFragmentRef,
123 rules: &Rules,
124) -> Option<Vec<Segment>> {
125 if !is_lexical_shaped(txn, fragment) {
126 return None;
127 }
128 let mut em = Emitter::new();
129 for node in fragment.children(txn) {
130 match node {
131 XmlOut::Text(t) => render_block_tree(txn, &t, &mut em, rules),
132 XmlOut::Element(e) => render_decorator(txn, &e, &mut em, rules),
133 XmlOut::Fragment(f) => em.push_str(&escape_text(&f.get_string(txn))),
136 }
137 }
138 Some(em.into_segments())
139}
140
141pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
145 render_segments(txn, fragment, &Rules::empty()).map(|segs| {
146 yjs_html_core::flatten(segs)
147 .into_html()
148 .expect("no callback rules registered")
149 })
150}
151
152pub fn is_builtin(ty: &str) -> bool {
155 matches!(
156 ty,
157 "paragraph"
158 | "heading"
159 | "quote"
160 | "code"
161 | "list"
162 | "listitem"
163 | "table"
164 | "tablerow"
165 | "tablecell"
166 | "link"
167 | "autolink"
168 | "linebreak"
169 | "tab"
170 | "text"
171 | "horizontalrule"
172 )
173}
174
175pub fn collect_node_types<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<TypeMap> {
180 if !is_lexical_shaped(txn, fragment) {
181 return None;
182 }
183 let mut map = TypeMap::new();
184 for node in fragment.children(txn) {
185 match node {
186 XmlOut::Text(t) => observe_text_node(txn, &t, &mut map, 0),
187 XmlOut::Element(e) => observe_element(txn, &e, &mut map),
188 XmlOut::Fragment(_) => {}
189 }
190 }
191 Some(map)
192}
193
194fn observe_text_node<T: ReadTxn>(txn: &T, t: &XmlTextRef, map: &mut TypeMap, depth: usize) {
195 let ty = node_type(txn, t);
196 let info = map.entry(ty.clone()).or_default();
197 info.count += 1;
198 for (key, _) in t.attributes(txn) {
199 if key != "__type" {
200 info.attrs.insert(key.to_string());
201 }
202 }
203 if depth >= MAX_BLOCK_DEPTH {
204 return;
205 }
206 let mut children = Vec::new();
207 for d in t.diff(txn, YChange::identity) {
208 match d.insert {
209 Out::Any(Any::String(_)) => {
210 map.get_mut(&ty).expect("just inserted").text = true;
211 }
212 Out::YXmlText(child) => {
213 let child_ty = node_type(txn, &child);
214 map.get_mut(&ty)
215 .expect("just inserted")
216 .children
217 .insert(child_ty);
218 children.push(child);
219 }
220 Out::YXmlElement(child) => {
221 let child_ty = elem_type(txn, &child);
222 map.get_mut(&ty)
223 .expect("just inserted")
224 .children
225 .insert(child_ty);
226 observe_element(txn, &child, map);
227 }
228 _ => {} }
230 }
231 for child in children {
232 observe_text_node(txn, &child, map, depth + 1);
233 }
234}
235
236fn observe_element<T: ReadTxn>(txn: &T, e: &XmlElementRef, map: &mut TypeMap) {
237 let ty = elem_type(txn, e);
238 let info = map.entry(ty).or_default();
239 info.count += 1;
240 for (key, _) in e.attributes(txn) {
241 if key != "__type" {
242 info.attrs.insert(key.to_string());
243 }
244 }
245}
246
247fn is_lexical_shaped<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> bool {
250 let mut any_child = false;
251 for node in fragment.children(txn) {
252 any_child = true;
253 let typed = match &node {
254 XmlOut::Text(t) => t.get_attribute(txn, "__type").is_some(),
255 XmlOut::Element(e) => e.get_attribute(txn, "__type").is_some(),
256 XmlOut::Fragment(_) => false,
257 };
258 if typed {
259 return true;
260 }
261 }
262 !any_child }
264
265fn node_type<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> String {
267 match t.get_attribute(txn, "__type") {
268 Some(Out::Any(Any::String(s))) => s.to_string(),
269 _ => String::new(),
270 }
271}
272
273fn str_attr<T: ReadTxn>(txn: &T, t: &XmlTextRef, name: &str) -> Option<String> {
275 match t.get_attribute(txn, name) {
276 Some(Out::Any(Any::String(s))) => Some(s.to_string()),
277 _ => None,
278 }
279}
280
281fn render_block_tree<T: ReadTxn>(txn: &T, root: &XmlTextRef, em: &mut Emitter, rules: &Rules) {
286 let mut stack: Vec<Work> = vec![Work::Open(root.clone(), 0)];
287 while let Some(work) = stack.pop() {
288 match work {
289 Work::Close(tag) => em.push_str(tag),
290 Work::CloseOwned(tag) => em.push_str(&tag),
291 Work::EndDeferred {
292 node_type,
293 attrs_json,
294 child_types,
295 } => {
296 let content = em.end_frame();
297 em.emit_deferred(node_type, attrs_json, child_types, content);
298 }
299 Work::Open(node, depth) => open_block(txn, &node, depth, em, &mut stack, rules),
300 }
301 }
302}
303
304fn open_block<T: ReadTxn>(
309 txn: &T,
310 t: &XmlTextRef,
311 depth: usize,
312 em: &mut Emitter,
313 stack: &mut Vec<Work>,
314 rules: &Rules,
315) {
316 let ty = node_type(txn, t);
317 if let Some(rule) = rules.nodes.get(ty.as_str()) {
318 open_rule_block(txn, t, &ty, rule, depth, em, stack, rules);
319 return;
320 }
321 match ty.as_str() {
322 "paragraph" => {
323 em.begin_frame();
326 render_inline(txn, t, 0, true, em, rules);
327 let inline = em.end_frame();
328 if inline.is_empty() {
329 em.push_str("<p><br></p>");
330 } else {
331 em.push_str("<p>");
332 em.append(inline);
333 em.push_str("</p>");
334 }
335 }
336 "heading" => {
337 let tag = match str_attr(txn, t, "__tag").as_deref() {
338 Some(tag @ ("h1" | "h2" | "h3" | "h4" | "h5" | "h6")) => tag.to_string(),
339 _ => "h1".to_string(),
340 };
341 em.push('<');
342 em.push_str(&tag);
343 em.push('>');
344 render_inline(txn, t, 0, true, em, rules);
345 em.push_str("</");
346 em.push_str(&tag);
347 em.push('>');
348 }
349 "quote" => {
350 em.push_str("<blockquote>");
351 render_inline(txn, t, 0, true, em, rules);
352 em.push_str("</blockquote>");
353 }
354 "code" => {
355 em.push_str("<pre");
358 if let Some(lang) = str_attr(txn, t, "__language").filter(|l| !l.is_empty()) {
359 em.push_str(" data-language=\"");
360 em.push_str(&escape_attr(&lang));
361 em.push('"');
362 }
363 em.push('>');
364 render_inline(txn, t, 0, true, em, rules);
365 em.push_str("</pre>");
366 }
367 "list" => {
368 let tag = match str_attr(txn, t, "__tag").as_deref() {
369 Some("ol") => "ol",
370 _ => "ul",
371 };
372 em.push('<');
373 em.push_str(tag);
374 em.push('>');
375 render_inline(txn, t, 0, false, em, rules);
379 let close = if tag == "ol" { "</ol>" } else { "</ul>" };
380 push_block_children(txn, t, depth, close, false, stack);
381 }
382 "listitem" => open_listitem(txn, t, depth, em, stack, rules),
383 "table" => {
384 em.push_str("<table><tbody>");
385 render_inline(txn, t, 0, false, em, rules);
386 push_block_children(txn, t, depth, "</tbody></table>", false, stack);
387 }
388 "tablerow" => {
389 em.push_str("<tr>");
390 render_inline(txn, t, 0, false, em, rules);
391 push_block_children(txn, t, depth, "</tr>", false, stack);
392 }
393 "tablecell" => {
394 let header = matches!(
395 t.get_attribute(txn, "__headerState"),
396 Some(Out::Any(Any::Number(n))) if n > 0.0
397 ) || matches!(
398 t.get_attribute(txn, "__headerState"),
399 Some(Out::Any(Any::BigInt(n))) if n > 0
400 );
401 let close = if header {
402 em.push_str("<th>");
403 "</th>"
404 } else {
405 em.push_str("<td>");
406 "</td>"
407 };
408 render_inline(txn, t, 0, false, em, rules);
409 push_block_children(txn, t, depth, close, false, stack);
410 }
411 _ => {
416 if !block_children(txn, t).is_empty() {
417 render_inline(txn, t, 0, false, em, rules);
418 push_block_children(txn, t, depth, "", false, stack);
419 } else {
420 em.begin_frame();
421 render_inline(txn, t, 0, true, em, rules);
422 let inline = em.end_frame();
423 if !inline.is_empty() {
424 em.push_str("<p>");
425 em.append(inline);
426 em.push_str("</p>");
427 }
428 }
429 }
430 }
431}
432
433#[allow(clippy::too_many_arguments)]
437fn open_rule_block<T: ReadTxn>(
438 txn: &T,
439 t: &XmlTextRef,
440 ty: &str,
441 rule: &NodeRule,
442 depth: usize,
443 em: &mut Emitter,
444 stack: &mut Vec<Work>,
445 rules: &Rules,
446) {
447 let (tag, void, attrs, text, content) = match rule {
448 NodeRule::Callback { content } => {
449 em.begin_frame();
450 stack.push(Work::EndDeferred {
455 node_type: ty.to_string(),
456 attrs_json: xml_attrs_json(txn, t),
457 child_types: text_child_types(txn, t),
458 });
459 match content {
460 Content::Inline => render_inline(txn, t, 0, true, em, rules),
461 Content::Blocks => {
462 render_inline(txn, t, 0, false, em, rules);
463 for child in block_children(txn, t).into_iter().rev() {
464 if depth < MAX_BLOCK_DEPTH {
465 stack.push(Work::Open(child, depth + 1));
466 }
467 }
468 }
469 Content::None => {}
470 }
471 return;
472 }
473 NodeRule::Declarative {
474 tag,
475 void,
476 attrs,
477 text,
478 content,
479 } => (tag, *void, attrs, text, *content),
480 };
481
482 em.push('<');
483 em.push_str(tag);
484 for (name, parts) in attrs {
485 if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, t, r)) {
486 em.push(' ');
487 em.push_str(name);
488 em.push_str("=\"");
489 em.push_str(&escape_attr(&value));
490 em.push('\"');
491 }
492 }
493 em.push('>');
494 if void {
495 return;
496 }
497 if let Some(text) = text {
498 if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, t, r)) {
499 em.push_str(&escape_text(&value));
500 }
501 }
502 match content {
503 Content::Inline => {
504 render_inline(txn, t, 0, true, em, rules);
505 em.push_str("</");
506 em.push_str(tag);
507 em.push('>');
508 }
509 Content::Blocks => {
510 render_inline(txn, t, 0, false, em, rules);
511 stack.push(Work::CloseOwned(format!("</{tag}>")));
512 if depth < MAX_BLOCK_DEPTH {
513 for child in block_children(txn, t).into_iter().rev() {
514 stack.push(Work::Open(child, depth + 1));
515 }
516 }
517 }
518 Content::None => {
519 em.push_str("</");
520 em.push_str(tag);
521 em.push('>');
522 }
523 }
524}
525
526fn push_block_children<T: ReadTxn>(
532 txn: &T,
533 t: &XmlTextRef,
534 depth: usize,
535 close: &'static str,
536 only_lists: bool,
537 stack: &mut Vec<Work>,
538) {
539 stack.push(Work::Close(close));
540 if depth >= MAX_BLOCK_DEPTH {
541 return;
542 }
543 for child in block_children(txn, t).into_iter().rev() {
545 if only_lists && node_type(txn, &child) != "list" {
546 continue;
547 }
548 stack.push(Work::Open(child, depth + 1));
549 }
550}
551
552fn open_listitem<T: ReadTxn>(
557 txn: &T,
558 t: &XmlTextRef,
559 depth: usize,
560 em: &mut Emitter,
561 stack: &mut Vec<Work>,
562 rules: &Rules,
563) {
564 let value = match t.get_attribute(txn, "__value") {
565 Some(Out::Any(Any::Number(n))) => n as i64,
566 Some(Out::Any(Any::BigInt(n))) => n,
567 _ => 1,
568 };
569 let checked = match t.get_attribute(txn, "__checked") {
570 Some(Out::Any(Any::Bool(b))) => Some(b),
571 _ => None,
572 };
573
574 em.push_str("<li");
575 if let Some(c) = checked {
576 em.push_str(" aria-checked=\"");
577 em.push_str(if c { "true" } else { "false" });
578 em.push('"');
579 }
580 em.push_str(" value=\"");
581 em.push_str(&value.to_string());
582 em.push('"');
583 em.push('>');
584 render_inline(txn, t, 0, true, em, rules);
587 push_block_children(txn, t, depth, "</li>", true, stack);
588}
589
590fn block_children<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> Vec<XmlTextRef> {
593 let mut out = Vec::new();
594 for d in t.diff(txn, YChange::identity) {
595 if let Out::YXmlText(child) = d.insert {
596 if !is_inline_type(&node_type(txn, &child)) {
597 out.push(child);
598 }
599 }
600 }
601 out
602}
603
604fn is_inline_type(ty: &str) -> bool {
605 matches!(ty, "link" | "autolink")
606}
607
608fn text_child_types<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> Vec<String> {
612 let mut out = Vec::new();
613 for d in t.diff(txn, YChange::identity) {
614 match d.insert {
615 Out::YXmlText(child) => out.push(node_type(txn, &child)),
616 Out::YXmlElement(child) => out.push(elem_type(txn, &child)),
617 _ => {}
618 }
619 }
620 out
621}
622
623fn render_inline<T: ReadTxn>(
639 txn: &T,
640 t: &XmlTextRef,
641 depth: usize,
642 ruled_inline: bool,
643 em: &mut Emitter,
644 rules: &Rules,
645) {
646 let mut format: u32 = 0;
651 let mut style = String::new();
652 let mut is_tab = false;
653 for d in t.diff(txn, YChange::identity) {
654 match d.insert {
655 Out::YMap(m) => {
656 let ty = match m.get(txn, "__type") {
657 Some(Out::Any(Any::String(s))) => s.to_string(),
658 _ => String::new(),
659 };
660 match ty.as_str() {
661 "linebreak" => em.push_str("<br>"),
662 "tab" => {
663 is_tab = true;
664 format = 0;
665 style.clear();
666 }
667 _ => {
670 is_tab = false;
671 format = match m.get(txn, "__format") {
672 Some(Out::Any(Any::Number(n))) => n as u32,
673 Some(Out::Any(Any::BigInt(n))) => n as u32,
674 _ => 0,
675 };
676 style = match m.get(txn, "__style") {
677 Some(Out::Any(Any::String(s))) => s.to_string(),
678 _ => String::new(),
679 };
680 }
681 }
682 }
683 Out::Any(Any::String(s)) => {
684 if is_tab {
685 em.push_str("<span>\t</span>");
687 is_tab = false;
688 } else {
689 em.push_str(&render_run(&s, format, &style));
690 }
691 }
692 Out::YXmlText(child) => {
693 let ty = node_type(txn, &child);
694 if is_inline_type(&ty) {
695 match rules.nodes.get(ty.as_str()) {
696 Some(rule) => render_rule_inline(txn, &child, &ty, rule, depth, em, rules),
697 None => render_link(txn, &child, depth, em, rules),
698 }
699 } else if ruled_inline && !is_builtin(&ty) {
700 if let Some(rule) = rules.nodes.get(ty.as_str()) {
701 render_rule_inline(txn, &child, &ty, rule, depth, em, rules);
702 }
703 }
704 }
706 Out::YXmlElement(e) => render_decorator(txn, &e, em, rules),
707 _ => {}
708 }
709 }
710}
711
712fn render_run(text: &str, format: u32, style: &str) -> String {
714 let mut html = escape_text(text);
715 let css = lexxy_style(style);
720 let outer_tag = if format & FMT_CODE != 0 {
721 Some("code")
722 } else if format & FMT_HIGHLIGHT != 0 {
723 Some("mark")
724 } else if format & FMT_SUBSCRIPT != 0 {
725 Some("sub")
726 } else if format & FMT_SUPERSCRIPT != 0 {
727 Some("sup")
728 } else {
729 None
730 };
731
732 let inner_style = if outer_tag.is_none() {
735 css.as_deref()
736 } else {
737 None
738 };
739 if format & FMT_BOLD != 0 {
740 html = format_wrap(html, "strong", inner_style);
741 } else if format & FMT_ITALIC != 0 {
742 html = format_wrap(html, "em", inner_style);
743 }
744 if let Some(tag) = outer_tag {
746 html = format_wrap(html, tag, css.as_deref());
747 }
748 if format & FMT_ITALIC != 0 && format & FMT_BOLD != 0 {
752 html = format_wrap(html, "i", None);
753 }
754 if format & FMT_STRIKETHROUGH != 0 {
755 html = format_wrap(html, "s", None);
756 }
757 if format & FMT_UNDERLINE != 0 {
758 html = format_wrap(html, "u", None);
759 }
760 html
761}
762
763fn format_wrap(inner: String, tag: &str, style: Option<&str>) -> String {
764 match style {
765 Some(css) => format!("<{tag} style=\"{}\">{inner}</{tag}>", escape_attr(css)),
766 None => format!("<{tag}>{inner}</{tag}>"),
767 }
768}
769
770fn lexxy_style(style: &str) -> Option<String> {
776 let mut css = String::new();
777 for decl in style.split(';') {
778 let Some((prop, value)) = decl.split_once(':') else {
779 continue;
780 };
781 let (prop, value) = (prop.trim(), value.trim());
782 if (prop == "color" || prop == "background-color") && !value.is_empty() {
783 css.push_str(prop);
784 css.push_str(": ");
785 css.push_str(value);
786 css.push(';');
787 }
788 }
789 if css.is_empty() { None } else { Some(css) }
790}
791
792fn render_link<T: ReadTxn>(txn: &T, t: &XmlTextRef, depth: usize, em: &mut Emitter, rules: &Rules) {
795 em.push_str("<a");
796 if let Some(url) = str_attr(txn, t, "__url") {
797 em.push_str(" href=\"");
798 em.push_str(&escape_attr(&url));
799 em.push('"');
800 }
801 if let Some(title) = str_attr(txn, t, "__title").filter(|s| !s.is_empty()) {
802 em.push_str(" title=\"");
803 em.push_str(&escape_attr(&title));
804 em.push('"');
805 }
806 em.push('>');
807 if depth < MAX_INLINE_DEPTH {
808 render_inline(txn, t, depth + 1, true, em, rules);
809 } else {
810 em.push_str(&escape_text(&t.get_string(txn)));
813 }
814 em.push_str("</a>");
815}
816
817fn render_rule_inline<T: ReadTxn>(
822 txn: &T,
823 t: &XmlTextRef,
824 ty: &str,
825 rule: &NodeRule,
826 depth: usize,
827 em: &mut Emitter,
828 rules: &Rules,
829) {
830 let (tag, void, attrs, text, content) = match rule {
831 NodeRule::Callback { content } => {
832 em.begin_frame();
833 if *content != Content::None && depth < MAX_INLINE_DEPTH {
834 render_inline(txn, t, depth + 1, true, em, rules);
835 }
836 let captured = em.end_frame();
837 em.emit_deferred(
838 ty.to_string(),
839 xml_attrs_json(txn, t),
840 text_child_types(txn, t),
841 captured,
842 );
843 return;
844 }
845 NodeRule::Declarative {
846 tag,
847 void,
848 attrs,
849 text,
850 content,
851 } => (tag, *void, attrs, text, *content),
852 };
853 em.push('<');
854 em.push_str(tag);
855 for (name, parts) in attrs {
856 if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, t, r)) {
857 em.push(' ');
858 em.push_str(name);
859 em.push_str("=\"");
860 em.push_str(&escape_attr(&value));
861 em.push('\"');
862 }
863 }
864 em.push('>');
865 if void {
866 return;
867 }
868 if let Some(text) = text {
869 if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, t, r)) {
870 em.push_str(&escape_text(&value));
871 }
872 }
873 if content != Content::None && depth < MAX_INLINE_DEPTH {
874 render_inline(txn, t, depth + 1, true, em, rules);
875 }
876 em.push_str("</");
877 em.push_str(tag);
878 em.push('>');
879}
880
881fn render_decorator<T: ReadTxn>(txn: &T, e: &XmlElementRef, em: &mut Emitter, rules: &Rules) {
885 let ty = elem_type(txn, e);
886 if let Some(rule) = rules.nodes.get(ty.as_str()) {
887 render_rule_element(txn, e, &ty, rule, em);
888 return;
889 }
890 if ty == "horizontalrule" {
893 em.push_str("<hr>");
894 }
895}
896
897fn render_rule_element<T: ReadTxn>(
902 txn: &T,
903 e: &XmlElementRef,
904 ty: &str,
905 rule: &NodeRule,
906 em: &mut Emitter,
907) {
908 let (tag, void, attrs, text) = match rule {
909 NodeRule::Callback { .. } => {
910 em.emit_deferred(
911 ty.to_string(),
912 xml_attrs_json(txn, e),
913 Vec::new(),
914 Vec::new(),
915 );
916 return;
917 }
918 NodeRule::Declarative {
919 tag,
920 void,
921 attrs,
922 text,
923 ..
924 } => (tag, *void, attrs, text),
925 };
926 em.push('<');
927 em.push_str(tag);
928 for (name, parts) in attrs {
929 if let Some(value) = resolve_parts(parts, |r| xml_ref_attr(txn, e, r)) {
930 em.push(' ');
931 em.push_str(name);
932 em.push_str("=\"");
933 em.push_str(&escape_attr(&value));
934 em.push('\"');
935 }
936 }
937 em.push('>');
938 if void {
939 return;
940 }
941 if let Some(text) = text {
942 if let Some(value) = resolve_parts(text, |r| xml_ref_attr(txn, e, r)) {
943 em.push_str(&escape_text(&value));
944 }
945 }
946 em.push_str("</");
947 em.push_str(tag);
948 em.push('>');
949}
950
951fn elem_type<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> String {
952 match e.get_attribute(txn, "__type") {
953 Some(Out::Any(Any::String(s))) => s.to_string(),
954 _ => String::new(),
955 }
956}
957
958fn escape_text(s: &str) -> String {
961 s.replace('&', "&")
962 .replace('<', "<")
963 .replace('>', ">")
964}
965
966fn escape_attr(s: &str) -> String {
968 escape_text(s).replace('"', """)
969}
970
971#[cfg(test)]
972mod tests {
973 use super::*;
974 use yrs::updates::decoder::Decode;
975 use yrs::{Doc, Transact, Update};
976
977 #[test]
987 fn core_rendering_of_the_full_fixture_is_pinned() {
988 let bytes = include_bytes!("fixtures/lexxy_full.bin");
989 let expected = include_str!("fixtures/lexxy_full.core.html");
990 let doc = Doc::new();
991 doc.transact_mut()
992 .apply_update(Update::decode_v1(bytes).unwrap())
993 .unwrap();
994 let txn = doc.transact();
995 let frag = txn.get_xml_fragment("root").unwrap();
996 assert_eq!(render(&txn, &frag).unwrap(), expected.trim_end());
997 }
998
999 #[test]
1006 fn core_rendering_of_the_torture_fixture_is_pinned() {
1007 let bytes = include_bytes!("fixtures/lexxy_torture.bin");
1008 let expected = include_str!("fixtures/lexxy_torture.core.html");
1009 let doc = Doc::new();
1010 doc.transact_mut()
1011 .apply_update(Update::decode_v1(bytes).unwrap())
1012 .unwrap();
1013 let txn = doc.transact();
1014 let frag = txn.get_xml_fragment("root").unwrap();
1015 let html = render(&txn, &frag).unwrap();
1016 assert_eq!(html, expected.trim_end());
1017
1018 for (what, probe) in [
1023 ("list nested in a table cell", "<td><ul><li"),
1024 ("quote in a table cell", "<td><blockquote>"),
1025 (
1026 "five-level mixed list nesting",
1027 "<ol><li value=\"1\"><s><i><strong>Level five</strong></i></s></li></ol>",
1028 ),
1029 (
1030 "the full format stack on one run",
1031 "<u><s><i><code><strong>g</strong></code></i></s></u>",
1032 ),
1033 (
1034 "a titled link in a heading wrapping formatted runs",
1035 "<a href=\"https://spec.example.com\" title=\"The Spec\">v<i><strong>2</strong></i><code>.final</code></a>",
1036 ),
1037 (
1038 "already-escaped-looking text escapes again",
1039 "<strong>a && b < c > d \"q\" '&amp;'</strong>",
1040 ),
1041 ("emoji, CJK and RTL text", "🚀🎉 你好世界 العربية café"),
1042 (
1043 "whitespace-only paragraphs",
1044 "<p><span>\t</span></p><p><br></p><p><br></p>",
1045 ),
1046 ] {
1047 assert!(html.contains(probe), "{what}: missing {probe}");
1048 }
1049 }
1050
1051 #[test]
1057 fn renders_the_captured_styles_document_byte_for_byte() {
1058 let bytes = include_bytes!("fixtures/lexxy_styles.bin");
1059 let expected = include_str!("fixtures/lexxy_styles.html");
1060 let doc = Doc::new();
1061 doc.transact_mut()
1062 .apply_update(Update::decode_v1(bytes).unwrap())
1063 .unwrap();
1064 let txn = doc.transact();
1065 let frag = txn.get_xml_fragment("root").unwrap();
1066 assert_eq!(render(&txn, &frag).unwrap(), expected.trim_end());
1067 }
1068
1069 #[test]
1070 fn run_styles_ride_the_createdom_tag() {
1071 assert_eq!(
1073 render_run("x", 128, "background-color: var(--highlight-bg-2);"),
1074 "<mark style=\"background-color: var(--highlight-bg-2);\">x</mark>"
1075 );
1076 assert_eq!(
1077 render_run("x", 1 | 128, "background-color: red;"),
1078 "<mark style=\"background-color: red;\"><strong>x</strong></mark>"
1079 );
1080 assert_eq!(
1082 render_run("x", 1, "color: red;"),
1083 "<strong style=\"color: red;\">x</strong>"
1084 );
1085 assert_eq!(render_run("x", 0, "color: red;"), "x");
1087 assert_eq!(render_run("x", 4, "color: red;"), "<s>x</s>");
1088 assert_eq!(
1090 render_run("x", 128, "color: red; background-color: blue;"),
1091 "<mark style=\"color: red;background-color: blue;\">x</mark>"
1092 );
1093 assert_eq!(
1095 render_run("x", 128, "font-size: 40px; color: r\"ed;"),
1096 "<mark style=\"color: r"ed;\">x</mark>"
1097 );
1098 }
1099
1100 #[test]
1103 fn a_known_container_keeps_stray_inline_content() {
1104 use yrs::{XmlFragment, XmlTextPrelim};
1105 let doc = Doc::new();
1106 let frag = doc.get_or_insert_xml_fragment("root");
1107 {
1108 let mut txn = doc.transact_mut();
1109 let list = frag.push_back(&mut txn, XmlTextPrelim::new("stray"));
1110 list.insert_attribute(&mut txn, "__type", "list");
1111 list.insert_attribute(&mut txn, "__tag", "ul");
1112 let li = list.insert_embed(&mut txn, 5, XmlTextPrelim::new("item"));
1113 li.insert_attribute(&mut txn, "__type", "listitem");
1114 }
1115 let txn = doc.transact();
1116 let frag = txn.get_xml_fragment("root").unwrap();
1117 let html = render(&txn, &frag).unwrap();
1118
1119 assert!(html.contains("stray"), "stray inline text kept: {html}");
1120 assert!(html.contains("<li value=\"1\">item</li>"), "{html}");
1121 }
1122
1123 #[test]
1129 fn core_degrades_lexxy_only_nodes_readably() {
1130 let bytes = include_bytes!("fixtures/lexxy_gallery.bin");
1131 let expected = include_str!("fixtures/lexxy_gallery.core.html");
1132 let doc = Doc::new();
1133 doc.transact_mut()
1134 .apply_update(Update::decode_v1(bytes).unwrap())
1135 .unwrap();
1136 let txn = doc.transact();
1137 let frag = txn.get_xml_fragment("root").unwrap();
1138 let html = render(&txn, &frag).unwrap();
1139 assert_eq!(html, expected.trim_end());
1140 assert!(html.contains("<p>Before gallery</p>"));
1141 }
1142
1143 #[test]
1144 fn format_runs_match_lexxys_export_algorithm() {
1145 assert_eq!(render_run("x", 0, ""), "x");
1147 assert_eq!(render_run("x", 1, ""), "<strong>x</strong>");
1148 assert_eq!(render_run("x", 2, ""), "<em>x</em>");
1149 assert_eq!(render_run("x", 4, ""), "<s>x</s>");
1150 assert_eq!(render_run("x", 8, ""), "<u>x</u>");
1151 assert_eq!(render_run("x", 16, ""), "<code>x</code>");
1152 assert_eq!(render_run("x", 32, ""), "<sub>x</sub>");
1153 assert_eq!(render_run("x", 64, ""), "<sup>x</sup>");
1154 assert_eq!(render_run("x", 128, ""), "<mark>x</mark>");
1155 assert_eq!(render_run("x", 3, ""), "<i><strong>x</strong></i>");
1157 assert_eq!(render_run("x", 4 | 8, ""), "<u><s>x</s></u>");
1159 assert_eq!(render_run("x", 1 | 8, ""), "<u><strong>x</strong></u>");
1160 assert_eq!(
1162 render_run("x", 1 | 16, ""),
1163 "<code><strong>x</strong></code>"
1164 );
1165 assert_eq!(render_run("x", 2 | 128, ""), "<mark><em>x</em></mark>");
1166 assert_eq!(
1168 render_run("x", 1 | 2 | 4 | 8 | 16, ""),
1169 "<u><s><i><code><strong>x</strong></code></i></s></u>"
1170 );
1171 }
1172
1173 #[test]
1174 fn a_prosemirror_shaped_root_is_refused_not_mangled() {
1175 use yrs::{XmlElementPrelim, XmlFragment, XmlTextPrelim};
1179 let doc = Doc::new();
1180 let frag = doc.get_or_insert_xml_fragment("pm");
1184 let empty = doc.get_or_insert_xml_fragment("empty");
1185 {
1186 let mut txn = doc.transact_mut();
1187 let p = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph"));
1188 p.push_back(&mut txn, XmlTextPrelim::new("Body"));
1189 }
1190 let txn = doc.transact();
1191 assert_eq!(render(&txn, &frag), None);
1192
1193 assert_eq!(render(&txn, &empty).as_deref(), Some(""));
1195 }
1196
1197 fn nested_list_doc(depth: usize) -> Doc {
1201 use yrs::{XmlFragment, XmlTextPrelim};
1202 let doc = Doc::new();
1203 let root = doc.get_or_insert_xml_fragment("root");
1204 let mut txn = doc.transact_mut();
1205 let top = root.push_back(&mut txn, XmlTextPrelim::new(""));
1206 top.insert_attribute(&mut txn, "__type", "list");
1207 top.insert_attribute(&mut txn, "__tag", "ul");
1208 let mut cursor = top;
1209 for _ in 0..depth {
1210 let li = cursor.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
1211 li.insert_attribute(&mut txn, "__type", "listitem");
1212 let inner = li.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
1213 inner.insert_attribute(&mut txn, "__type", "list");
1214 inner.insert_attribute(&mut txn, "__tag", "ul");
1215 cursor = inner;
1216 }
1217 drop(txn);
1218 doc
1219 }
1220
1221 #[test]
1222 fn deeply_nested_blocks_do_not_overflow_the_stack() {
1223 let handle = std::thread::Builder::new()
1227 .stack_size(512 * 1024)
1228 .spawn(|| {
1229 let doc = nested_list_doc(20_000);
1230 let txn = doc.transact();
1231 let frag = txn.get_xml_fragment("root").unwrap();
1232 let html = render(&txn, &frag).expect("lexical-shaped");
1233 assert_eq!(html.matches("<ul>").count(), html.matches("</ul>").count());
1235 assert_eq!(html.matches("<li ").count(), html.matches("</li>").count());
1236 html.len()
1237 })
1238 .unwrap();
1239 assert!(handle.join().unwrap() > 0);
1240 }
1241
1242 #[test]
1243 fn nesting_past_the_cap_truncates_but_stays_well_formed() {
1244 let doc = nested_list_doc(MAX_BLOCK_DEPTH + 50);
1247 let txn = doc.transact();
1248 let frag = txn.get_xml_fragment("root").unwrap();
1249 let html = render(&txn, &frag).unwrap();
1250
1251 assert_eq!(html.matches("<ul>").count(), html.matches("</ul>").count());
1252 assert_eq!(html.matches("<li ").count(), html.matches("</li>").count());
1253 assert!(html.matches("<ul>").count() <= MAX_BLOCK_DEPTH + 1);
1255 }
1256
1257 #[test]
1258 fn deeply_nested_links_do_not_overflow_the_stack() {
1259 use yrs::{XmlFragment, XmlTextPrelim};
1263 let doc = Doc::new();
1264 let root = doc.get_or_insert_xml_fragment("root");
1265 {
1266 let mut txn = doc.transact_mut();
1267 let p = root.push_back(&mut txn, XmlTextPrelim::new(""));
1268 p.insert_attribute(&mut txn, "__type", "paragraph");
1269 let mut cursor = p;
1270 for _ in 0..(MAX_INLINE_DEPTH + 20) {
1271 let link = cursor.insert_embed(&mut txn, 0, XmlTextPrelim::new(""));
1272 link.insert_attribute(&mut txn, "__type", "link");
1273 link.insert_attribute(&mut txn, "__url", "https://x.example");
1274 cursor = link;
1275 }
1276 }
1277 let txn = doc.transact();
1278 let frag = txn.get_xml_fragment("root").unwrap();
1279 let html = render(&txn, &frag).unwrap();
1280 assert_eq!(html.matches("<a").count(), html.matches("</a>").count());
1281 }
1282
1283 #[test]
1288 fn rules_render_inline_nodes_and_override_links() {
1289 use yrs::{XmlFragment, XmlTextPrelim};
1290 let rules = Rules::parse(
1291 r#"{ "nodes": {
1292 "link": { "tag": "a", "attrs": [["class", [{"lit": "app-link"}]],
1293 ["href", [{"ref": "url"}]]] },
1294 "keyword": { "tag": "kbd", "attrs": [["data-kind", [{"ref": "kind"}]]] } } }"#,
1295 )
1296 .unwrap();
1297 let doc = Doc::new();
1298 let root = doc.get_or_insert_xml_fragment("root");
1299 {
1300 let mut txn = doc.transact_mut();
1301 let p = root.push_back(&mut txn, XmlTextPrelim::new(""));
1302 p.insert_attribute(&mut txn, "__type", "paragraph");
1303 let link = p.insert_embed(&mut txn, 0, XmlTextPrelim::new("site"));
1304 link.insert_attribute(&mut txn, "__type", "link");
1305 link.insert_attribute(&mut txn, "__url", "https://x.example");
1306 let kw = p.insert_embed(&mut txn, 1, XmlTextPrelim::new("crdt"));
1307 kw.insert_attribute(&mut txn, "__type", "keyword");
1308 kw.insert_attribute(&mut txn, "__kind", "term");
1309 }
1310 let txn = doc.transact();
1311 let frag = txn.get_xml_fragment("root").unwrap();
1312 let segs = render_segments(&txn, &frag, &rules).unwrap();
1313 let html = yjs_html_core::flatten(segs).into_html().unwrap();
1314 assert_eq!(
1315 html,
1316 "<p><a class=\"app-link\" href=\"https://x.example\">site</a>\
1317 <kbd data-kind=\"term\">crdt</kbd></p>"
1318 );
1319 }
1320
1321 #[test]
1322 fn escaping_matches_the_browser_serializer() {
1323 assert_eq!(escape_text(r#"<a & "b">"#), r#"<a & "b">"#);
1324 assert_eq!(
1325 escape_attr(r#"<a & "b">"#),
1326 r#"<a & "b">"#
1327 );
1328 }
1329}