1use std::collections::BTreeMap;
17
18use crate::docs::escape;
19
20#[derive(Debug, Clone, PartialEq, Eq, Default)]
22pub struct ViewSource {
23 pub svg: String,
24 pub dot: Option<String>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Node {
32 pub kind: String,
33 pub title: String,
34 pub href: Option<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Inlined {
42 pub html: String,
43 pub edges: Vec<(String, String)>,
44}
45
46fn compound_ends(dot: &str) -> BTreeMap<(String, String), (Option<String>, Option<String>)> {
52 let mut ends = BTreeMap::new();
53 let mut current: Option<(String, String)> = None;
54 for line in dot.lines().map(str::trim) {
55 if let Some((head, _)) = line.split_once('[')
56 && let Some((a, b)) = head.split_once("->")
57 {
58 current = Some((a.trim().to_string(), b.trim().to_string()));
59 }
60 if let Some(key) = ¤t {
61 for (attr, is_head) in [("ltail=", false), ("lhead=", true)] {
62 if let Some(at) = line.find(attr) {
63 let value = attr_value(&line[at + attr.len()..]);
64 let entry = ends.entry(key.clone()).or_insert((None, None));
65 if is_head {
66 entry.1 = Some(value);
67 } else {
68 entry.0 = Some(value);
69 }
70 }
71 }
72 if line.ends_with("];") {
73 current = None;
74 }
75 }
76 }
77 ends
78}
79
80fn edge_ends(title: &str) -> Option<(String, String)> {
83 let plain = title.replace("-", "-").replace(">", ">");
84 let (from, to) = plain.split_once("->")?;
85 let name = |s: &str| s.trim().split(':').next().unwrap_or_default().to_string();
86 Some((name(from), name(to)))
87}
88
89pub fn is_likec4_dot(dot: &str) -> bool {
92 dot.contains("likec4_viewId=")
93}
94
95fn is_dot_id(text: &str) -> bool {
96 !text.is_empty() && text.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
97}
98
99fn attr_value(text: &str) -> String {
102 match text.strip_prefix('"') {
103 Some(quoted) => quoted.split('"').next().unwrap_or_default().to_string(),
104 None => text
105 .split(|c: char| matches!(c, ',' | ']' | ';') || c.is_whitespace())
106 .next()
107 .unwrap_or_default()
108 .to_string(),
109 }
110}
111
112pub fn likec4_ids(dot: &str) -> BTreeMap<String, String> {
118 let mut ids = BTreeMap::new();
119 let mut current: Option<String> = None;
120 for line in dot.lines().map(str::trim) {
121 if let Some(rest) = line.strip_prefix("subgraph ") {
122 current = rest
123 .split_whitespace()
124 .next()
125 .filter(|name| is_dot_id(name))
126 .map(str::to_string);
127 } else if let Some((head, _)) = line.split_once('[') {
128 let head = head.trim();
129 if head.contains("->") {
130 current = None;
131 } else if head != "graph" && is_dot_id(head) {
132 current = Some(head.to_string());
133 }
134 }
135 if let Some(at) = line.find("likec4_id=")
136 && let Some(name) = current.take()
137 {
138 ids.insert(name, attr_value(&line[at + "likec4_id=".len()..]));
139 }
140 }
141 ids
142}
143
144fn tag_end(text: &str) -> Option<usize> {
147 let mut quoted = false;
148 for (i, c) in text.char_indices() {
149 match c {
150 '"' => quoted = !quoted,
151 '>' if !quoted => return Some(i),
152 _ => {}
153 }
154 }
155 None
156}
157
158#[derive(Debug)]
159struct Tag {
160 name: String,
161 attrs: Vec<(String, String)>,
162 self_closing: bool,
163}
164
165impl Tag {
166 fn parse(inner: &str) -> Option<Tag> {
169 let inner = inner.trim_end();
170 let (inner, self_closing) = match inner.strip_suffix('/') {
171 Some(inner) => (inner, true),
172 None => (inner, false),
173 };
174 let name_end = inner.find(char::is_whitespace).unwrap_or(inner.len());
175 let name = inner[..name_end].to_string();
176 let mut attrs = Vec::new();
177 let mut rest = inner[name_end..].trim_start();
178 while !rest.is_empty() {
179 let (key, after) = rest.split_once('=')?;
180 let quoted = after.trim_start().strip_prefix('"')?;
181 let (value, tail) = quoted.split_once('"')?;
182 attrs.push((key.trim().to_string(), value.to_string()));
183 rest = tail.trim_start();
184 }
185 Some(Tag {
186 name,
187 attrs,
188 self_closing,
189 })
190 }
191
192 fn get(&self, key: &str) -> Option<&str> {
193 self.attrs
194 .iter()
195 .find(|(k, _)| k == key)
196 .map(|(_, v)| v.as_str())
197 }
198
199 fn set(&mut self, key: &str, value: String) {
201 match self.attrs.iter_mut().find(|(k, _)| k == key) {
202 Some((_, v)) => *v = value,
203 None => self.attrs.push((key.to_string(), value)),
204 }
205 }
206
207 fn remove(&mut self, key: &str) {
208 self.attrs.retain(|(k, _)| k != key);
209 }
210
211 fn add_class(&mut self, class: &str) {
212 let classes = match self.get("class") {
213 Some(existing) => format!("{existing} {class}"),
214 None => class.to_string(),
215 };
216 self.set("class", classes);
217 }
218
219 fn write(&self, out: &mut String) -> Option<usize> {
222 out.push('<');
223 out.push_str(&self.name);
224 let mut class_end = None;
225 for (key, value) in &self.attrs {
226 out.push(' ');
227 out.push_str(key);
228 out.push_str("=\"");
229 out.push_str(value);
230 if key == "class" {
231 class_end = Some(out.len());
232 }
233 out.push('"');
234 }
235 out.push_str(if self.self_closing { "/>" } else { ">" });
236 class_end
237 }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241enum Role {
242 Node,
243 Cluster,
244 Edge,
245}
246
247#[derive(Debug)]
248struct Group {
249 role: Option<Role>,
250 open_at: usize,
253 class_end: Option<usize>,
256 title_fill: Option<String>,
258 linked: bool,
260 hit: bool,
263}
264
265fn is_active(key: &str, value: &str) -> bool {
268 key.to_ascii_lowercase().starts_with("on")
269 || (key.ends_with("href")
270 && value
271 .trim_start()
272 .to_ascii_lowercase()
273 .starts_with("javascript:"))
274}
275
276pub fn inline(
283 source: &ViewSource,
284 view: &str,
285 label: &str,
286 nodes: &BTreeMap<String, Node>,
287) -> Option<Inlined> {
288 let dot = source.dot.as_deref().filter(|dot| is_likec4_dot(dot))?;
289 let ids = likec4_ids(dot);
290 let compound = compound_ends(dot);
291 let mut edges: Vec<(String, String)> = Vec::new();
292 let mut rest = &source.svg[source.svg.find("<svg")?..];
293 let mut out = String::with_capacity(rest.len());
294 let mut groups: Vec<Group> = Vec::new();
295 while let Some(lt) = rest.find('<') {
296 out.push_str(&rest[..lt]);
297 rest = &rest[lt..];
298 if let Some(comment) = rest.strip_prefix("<!--") {
299 rest = &comment[comment.find("-->")? + "-->".len()..];
300 continue;
301 }
302 let end = tag_end(rest)?;
303 let inner = &rest[1..end];
304 rest = &rest[end + 1..];
305 if inner.starts_with('?') || inner.starts_with('!') {
306 continue;
307 }
308 if let Some(closing) = inner.strip_prefix('/') {
309 if closing.trim() == "g" {
310 let group = groups.pop();
311 out.push_str("</g>");
312 if group.is_some_and(|g| g.linked) {
313 out.push_str("</a>");
314 }
315 continue;
316 }
317 out.push('<');
318 out.push_str(inner);
319 out.push('>');
320 continue;
321 }
322 let mut tag = Tag::parse(inner)?;
323 if tag.name == "title" && !tag.self_closing {
324 let close = rest.find("</title>")?;
325 let name = &rest[..close];
326 rest = &rest[close + "</title>".len()..];
327 if let Some(group) = groups.last_mut()
328 && let Some(at) = group.class_end.take()
329 {
330 if group.role == Some(Role::Edge) {
331 if let Some((a, b)) = edge_ends(name)
334 && let (tail, head) = compound
335 .get(&(a.clone(), b.clone()))
336 .cloned()
337 .unwrap_or((None, None))
338 && let (Some(from), Some(to)) = (
339 ids.get(tail.as_deref().unwrap_or(&a)),
340 ids.get(head.as_deref().unwrap_or(&b)),
341 )
342 {
343 out.insert_str(
344 at + 1,
345 &format!(" data-from=\"{}\" data-to=\"{}\"", escape(from), escape(to)),
346 );
347 out.push_str(&format!(
348 "<title>{} \u{2192} {}</title>",
349 escape(from),
350 escape(to)
351 ));
352 edges.push((from.clone(), to.clone()));
353 }
354 } else if let Some(node) = ids.get(name).and_then(|id| nodes.get(id)) {
355 out.insert_str(at, &format!(" c4-k-{}", node.kind));
356 if group.role == Some(Role::Node) {
357 out.push_str(&format!("<title>{}</title>", escape(&node.title)));
360 if let Some(href) = &node.href {
361 out.insert_str(
362 group.open_at,
363 &format!("<a href=\"{}\">", escape(href)),
364 );
365 group.linked = true;
366 }
367 }
368 }
369 }
370 continue;
371 }
372 if matches!(tag.name.as_str(), "script" | "foreignObject") {
373 if !tag.self_closing {
374 let close = format!("</{}>", tag.name);
375 rest = &rest[rest.find(&close)? + close.len()..];
376 }
377 continue;
378 }
379 tag.attrs.retain(|(key, value)| !is_active(key, value));
380 if let Some((_, id)) = tag.attrs.iter_mut().find(|(key, _)| key == "id") {
381 *id = format!("{}-{id}", escape(view));
382 }
383 let enclosing = groups.iter().rev().find_map(|g| g.role);
384 match tag.name.as_str() {
385 "svg" => {
386 tag.remove("width");
387 tag.remove("height");
388 tag.set("class", "c4".to_string());
389 tag.set("data-view", escape(view));
390 tag.set("role", "img".to_string());
391 tag.set("aria-label", escape(label));
392 }
393 "g" => {
394 let role = match tag.get("class") {
395 Some("node") => Some(Role::Node),
396 Some("cluster") => Some(Role::Cluster),
397 Some("edge") => Some(Role::Edge),
398 _ => None,
399 };
400 let open_at = out.len();
401 let class_end = tag.write(&mut out);
402 groups.push(Group {
403 role,
404 open_at,
405 class_end: class_end.filter(|_| role.is_some()),
406 title_fill: None,
407 linked: false,
408 hit: false,
409 });
410 continue;
411 }
412 "text" if enclosing == Some(Role::Node) => {
413 let fill = tag.get("fill").unwrap_or_default().to_string();
414 if let Some(node) = groups.iter_mut().rev().find(|g| g.role.is_some()) {
415 match &node.title_fill {
416 None => node.title_fill = Some(fill),
417 Some(title) if *title != fill => tag.add_class("c4-muted"),
418 Some(_) => {}
419 }
420 }
421 }
422 "polygon" if enclosing == Some(Role::Edge) && tag.get("fill-opacity").is_some() => {
423 tag.add_class("c4-label-bg");
424 }
425 "path" if enclosing == Some(Role::Edge) => {
426 tag.write(&mut out);
430 if let Some(group) = groups.last_mut()
431 && !group.hit
432 && let Some(d) = tag.get("d")
433 {
434 group.hit = true;
435 out.push_str(&format!("<path class=\"c4-hit\" d=\"{d}\"/>"));
436 }
437 continue;
438 }
439 _ => {}
440 }
441 tag.write(&mut out);
442 }
443 out.push_str(rest);
444 Some(Inlined { html: out, edges })
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 const DOT: &str = r##"digraph {
452 graph [likec4_viewId=view_app,
453 bgcolor=transparent
454 ];
455 node [color="#2563eb",
456 fillcolor="#3b82f6"
457 ];
458 subgraph cluster_app {
459 graph [color="#57130f",
460 label=<<FONT POINT-SIZE="11">APP [x]</FONT>>,
461 likec4_id=app
462 ];
463 server [color="#2563eb",
464 likec4_id="app.server"];
465 }
466 driver_1 [likec4_id=node_driver];
467 server -> driver_1 [likec4_id="1ab"];
468}
469"##;
470
471 const SVG: &str = r##"<?xml version="1.0" encoding="UTF-8" standalone="no"?>
472<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
473 "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
474<!-- Generated by graphviz -->
475<svg width="300pt" height="200pt"
476 viewBox="0.00 0.00 300.00 200.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
477<g id="graph0" class="graph" transform="scale(1 1)">
478<title>G</title>
479<g id="clust1" class="cluster">
480<title>cluster_app</title>
481<polygon fill="#722f24" stroke="#57130f" points="0,0 1,1"/>
482<text x="1" y="1" fill="#ffffe5" fill-opacity="0.7">APP</text>
483</g>
484<!-- server -->
485<g id="node1" class="node">
486<title>server</title>
487<polygon fill="#3b82f6" stroke="#2563eb" points="0,0 1,1"/>
488<path fill="none" stroke="#2563eb" d="M1,1"/>
489<text x="1" y="1" fill="#eff6ff">server</text>
490<text x="1" y="2" fill="#bfdbfe">Where it runs</text>
491</g>
492<g id="node2" class="node">
493<title>driver_1</title>
494<polygon fill="#3b82f6" stroke="#2563eb" points="0,0 1,1"/>
495<text x="1" y="1" fill="#eff6ff">node</text>
496<text x="1" y="2" fill="#eff6ff">driver</text>
497</g>
498<g id="node3" class="node">
499<title>stranger</title>
500<polygon fill="#3b82f6" stroke="#2563eb" points="0,0 1,1"/>
501</g>
502<g id="edge1" class="edge">
503<title>server->driver_1</title>
504<path fill="none" stroke="#8d8d8d" d="M0,0"/>
505<path fill="#8d8d8d" stroke="#8d8d8d" d="M9,9"/>
506<polygon fill="#8d8d8d" stroke="#8d8d8d" points="0,0 1,1"/>
507<polygon fill="#18191b" fill-opacity="0.627451" stroke="none" points="0,0 1,1"/>
508<text x="1" y="1" fill="#c9c9c9">spawns</text>
509<text x="1" y="2" fill="#aaaaaa">stdio</text>
510</g>
511</g>
512</svg>
513"##;
514
515 fn node(kind: &str, title: &str, href: Option<&str>) -> Node {
516 Node {
517 kind: kind.into(),
518 title: title.into(),
519 href: href.map(String::from),
520 }
521 }
522
523 fn nodes() -> BTreeMap<String, Node> {
526 BTreeMap::from([
527 (
528 "app".to_string(),
529 node("container", "app", Some("containers/app.html")),
530 ),
531 (
532 "app.server".to_string(),
533 node(
534 "component",
535 "app.server",
536 Some("containers/app.html#app.server"),
537 ),
538 ),
539 (
540 "node_driver".to_string(),
541 node("process", "node_driver", None),
542 ),
543 ])
544 }
545
546 fn source() -> ViewSource {
547 ViewSource {
548 svg: SVG.into(),
549 dot: Some(DOT.into()),
550 }
551 }
552
553 fn inlined() -> String {
554 inlined_view().html
555 }
556
557 fn inlined_view() -> Inlined {
558 inline(&source(), "view_app", "app", &nodes()).expect("inlined")
559 }
560
561 #[test]
562 fn likec4_ids_maps_nodes_and_group_boxes_but_not_edges() {
563 let ids = likec4_ids(DOT);
564 let expected: BTreeMap<String, String> = [
565 ("cluster_app", "app"),
566 ("driver_1", "node_driver"),
567 ("server", "app.server"),
568 ]
569 .iter()
570 .map(|(n, id)| (n.to_string(), id.to_string()))
571 .collect();
572 assert_eq!(ids, expected);
573 }
574
575 #[test]
576 fn a_view_without_a_dot_is_not_inlined() {
577 let source = ViewSource {
578 svg: SVG.into(),
579 dot: None,
580 };
581 assert_eq!(inline(&source, "view_app", "app", &nodes()), None);
582 }
583
584 #[test]
585 fn a_view_whose_dot_likec4_did_not_write_is_not_inlined() {
586 let source = ViewSource {
587 svg: SVG.into(),
588 dot: Some("digraph { a -> b }".into()),
589 };
590 assert_eq!(inline(&source, "view_app", "app", &nodes()), None);
591 }
592
593 #[test]
594 fn the_prolog_comments_and_graphviz_s_tooltips_are_dropped() {
595 let svg = inlined();
598 assert!(svg.starts_with("<svg "), "{svg}");
599 for gone in [
600 "<?xml",
601 "<!DOCTYPE",
602 "<!--",
603 "-->",
604 "<title>G</title>",
605 "<title>cluster_app</title>",
606 "<title>server</title>",
607 "<title>driver_1</title>",
608 "->",
609 "graphviz",
610 ] {
611 assert!(!svg.contains(gone), "{gone} in {svg}");
612 }
613 }
614
615 #[test]
616 fn the_root_gives_way_to_the_page_and_names_the_view() {
617 let svg = inline(&source(), "view_app", "a<b", &nodes()).unwrap().html;
618 let root = &svg[..svg.find('>').unwrap()];
619 assert_eq!(
620 root,
621 "<svg viewBox=\"0.00 0.00 300.00 200.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" class=\"c4\" data-view=\"view_app\" role=\"img\" aria-label=\"a<b\""
622 );
623 }
624
625 #[test]
626 fn ids_carry_the_view_name() {
627 let svg = inlined();
628 assert!(svg.contains("<g id=\"view_app-node1\""), "{svg}");
629 assert!(!svg.contains("id=\"node1\""), "{svg}");
630 }
631
632 #[test]
633 fn nodes_and_group_boxes_are_classed_by_element_kind() {
634 let svg = inlined();
635 assert!(
636 svg.contains("<g id=\"view_app-clust1\" class=\"cluster c4-k-container\">"),
637 "{svg}"
638 );
639 assert!(
640 svg.contains("<g id=\"view_app-node1\" class=\"node c4-k-component\">"),
641 "{svg}"
642 );
643 assert!(
644 svg.contains("<g id=\"view_app-node2\" class=\"node c4-k-process\">"),
645 "{svg}"
646 );
647 }
648
649 #[test]
650 fn a_node_with_a_page_is_a_link_to_it_titled_by_its_name() {
651 let out = inlined();
652 assert!(
653 out.contains("<a href=\"containers/app.html#app.server\"><g id=\"view_app-node"),
654 "{out}"
655 );
656 assert!(
657 out.contains("class=\"node c4-k-component\">\n<title>app.server</title>"),
658 "{out}"
659 );
660 assert_eq!(out.matches("</g></a>").count(), 1, "{out}");
661 }
662
663 #[test]
664 fn a_node_without_a_page_is_titled_but_not_linked() {
665 let out = inlined();
666 assert!(
667 out.contains("class=\"node c4-k-process\">\n<title>node_driver</title>"),
668 "{out}"
669 );
670 }
671
672 #[test]
673 fn only_nodes_with_a_page_are_linked() {
674 let out = inlined();
675 assert_eq!(out.matches("<a ").count(), 1, "{out}");
676 }
677
678 #[test]
679 fn a_group_box_is_neither_linked_nor_titled() {
680 let out = inlined();
681 assert!(!out.contains("<title>app</title>"), "{out}");
682 assert!(!out.contains("<title>cluster_app</title>"), "{out}");
683 }
684
685 #[test]
686 fn an_edge_carries_its_endpoints_ids_and_names_them() {
687 let out = inlined();
688 assert!(
689 out.contains("class=\"edge\" data-from=\"app.server\" data-to=\"node_driver\">\n<title>app.server \u{2192} node_driver</title>"),
690 "{out}"
691 );
692 }
693
694 #[test]
695 fn an_edge_s_line_gets_an_invisible_twin_to_click() {
696 let out = inlined();
697 assert!(
698 out.contains("d=\"M0,0\"/><path class=\"c4-hit\" d=\"M0,0\"/>"),
699 "{out}"
700 );
701 }
702
703 #[test]
704 fn only_an_edge_s_line_gets_a_twin_to_click() {
705 let out = inlined();
707 assert_eq!(out.matches("c4-hit").count(), 1, "{out}");
708 }
709
710 #[test]
711 fn an_edge_drawn_into_a_cluster_is_attributed_to_the_cluster() {
712 let dot = DOT.replace(
713 "server -> driver_1 [likec4_id=\"1ab\"];",
714 "server -> driver_1 [ltail=cluster_app,\n likec4_id=\"1ab\"];",
715 );
716 let source = ViewSource {
717 svg: SVG.to_string(),
718 dot: Some(dot),
719 };
720 let out = inline(&source, "view_app", "app", &nodes()).unwrap();
721 assert_eq!(out.edges, [("app".to_string(), "node_driver".to_string())]);
722 assert!(
723 out.html
724 .contains("data-from=\"app\" data-to=\"node_driver\""),
725 "{}",
726 out.html
727 );
728 }
729
730 #[test]
731 fn compound_ends_reads_the_clusters_an_edge_statement_names() {
732 let dot = "digraph {\n a -> b [lhead=cluster_x,\n likec4_id=\"1\"];\n c -> d [ltail=cluster_y, lhead=cluster_z];\n e -> f [likec4_id=\"2\"];\n}\n";
733 let ends = compound_ends(dot);
734 assert_eq!(
735 ends.get(&("a".to_string(), "b".to_string())),
736 Some(&(None, Some("cluster_x".to_string())))
737 );
738 assert_eq!(
739 ends.get(&("c".to_string(), "d".to_string())),
740 Some(&(Some("cluster_y".to_string()), Some("cluster_z".to_string())))
741 );
742 assert_eq!(ends.get(&("e".to_string(), "f".to_string())), None);
743 }
744
745 #[test]
746 fn the_edges_a_view_draws_are_returned_in_order() {
747 assert_eq!(
748 inlined_view().edges,
749 [("app.server".to_string(), "node_driver".to_string())]
750 );
751 }
752
753 #[test]
754 fn an_edge_title_is_two_names_with_ports_dropped() {
755 assert_eq!(
756 edge_ends("a:p->b:q"),
757 Some(("a".to_string(), "b".to_string()))
758 );
759 }
760
761 #[test]
762 fn a_title_with_no_arrow_is_not_an_edge() {
763 assert_eq!(edge_ends("lonely"), None);
764 }
765
766 #[test]
767 fn a_node_the_dot_does_not_name_keeps_its_plain_class() {
768 let svg = inlined();
769 assert!(
770 svg.contains("<g id=\"view_app-node3\" class=\"node\">"),
771 "{svg}"
772 );
773 }
774
775 #[test]
776 fn a_node_s_title_keeps_its_class_and_its_other_lines_are_muted() {
777 let svg = inlined();
778 assert!(
779 svg.contains("<text x=\"1\" y=\"1\" fill=\"#eff6ff\">server</text>"),
780 "{svg}"
781 );
782 assert!(
783 svg.contains(
784 "<text x=\"1\" y=\"2\" fill=\"#bfdbfe\" class=\"c4-muted\">Where it runs</text>"
785 ),
786 "{svg}"
787 );
788 }
789
790 #[test]
791 fn a_title_that_wraps_keeps_every_line_as_title() {
792 let svg = inlined();
793 assert!(
794 svg.contains("<text x=\"1\" y=\"2\" fill=\"#eff6ff\">driver</text>"),
795 "{svg}"
796 );
797 }
798
799 #[test]
800 fn an_edge_label_s_lines_are_never_muted() {
801 let svg = inlined();
802 assert!(
803 svg.contains("<text x=\"1\" y=\"2\" fill=\"#aaaaaa\">stdio</text>"),
804 "{svg}"
805 );
806 }
807
808 #[test]
809 fn an_edge_s_id_is_not_given_to_an_unnamed_node_before_it() {
810 let ids = likec4_ids(
811 "digraph {\n graph [likec4_viewId=v];\n helper [shape=point];\n helper -> server [likec4_id=\"1ab\"];\n}\n",
812 );
813 assert_eq!(ids, BTreeMap::new());
814 }
815
816 #[test]
817 fn a_greater_than_sign_inside_an_attribute_value_does_not_end_the_tag() {
818 let source = ViewSource {
819 svg: SVG.replace(
820 "<text x=\"1\" y=\"2\" fill=\"#bfdbfe\">",
821 "<text x=\"1\" y=\"2\" data-note=\"a>b\" fill=\"#bfdbfe\">",
822 ),
823 dot: Some(DOT.into()),
824 };
825 let svg = inline(&source, "view_app", "app", &nodes()).unwrap().html;
826 assert!(
827 svg.contains("data-note=\"a>b\" fill=\"#bfdbfe\" class=\"c4-muted\">"),
828 "{svg}"
829 );
830 }
831
832 #[test]
833 fn a_processing_instruction_inside_the_drawing_is_dropped() {
834 let source = ViewSource {
835 svg: SVG.replace("<g id=\"graph0\"", "<?render x?><g id=\"graph0\""),
836 dot: Some(DOT.into()),
837 };
838 let svg = inline(&source, "view_app", "app", &nodes()).unwrap().html;
839 assert!(!svg.contains("<?render"), "{svg}");
840 }
841
842 #[test]
843 fn an_edge_label_s_backing_is_classed_and_its_arrowhead_is_not() {
844 let svg = inlined();
845 assert!(
846 svg.contains("<polygon fill=\"#18191b\" fill-opacity=\"0.627451\" stroke=\"none\" points=\"0,0 1,1\" class=\"c4-label-bg\"/>"),
847 "{svg}"
848 );
849 assert!(
850 svg.contains("<polygon fill=\"#8d8d8d\" stroke=\"#8d8d8d\" points=\"0,0 1,1\"/>"),
851 "{svg}"
852 );
853 }
854
855 #[test]
856 fn scripts_foreign_objects_and_event_handlers_are_dropped() {
857 let svg = SVG.replace(
858 "<g id=\"edge1\" class=\"edge\">",
859 "<script>alert(1)</script><foreignObject><div>x</div></foreignObject><a xlink:href=\"javascript:alert(1)\" onclick=\"alert(1)\" href=\"#ok\"></a><g id=\"edge1\" class=\"edge\">",
860 );
861 let source = ViewSource {
862 svg,
863 dot: Some(DOT.into()),
864 };
865 let out = inline(&source, "view_app", "app", &nodes()).unwrap().html;
866 for gone in ["script", "alert", "foreignObject", "<div>", "onclick"] {
867 assert!(!out.contains(gone), "{gone} in {out}");
868 }
869 assert!(out.contains("<a href=\"#ok\"></a>"), "{out}");
870 }
871
872 #[test]
873 fn an_svg_that_does_not_parse_is_not_inlined() {
874 let source = ViewSource {
875 svg: "<svg viewBox=\"0 0 1 1><g>".into(),
876 dot: Some(DOT.into()),
877 };
878 assert_eq!(inline(&source, "view_app", "app", &nodes()), None);
879 }
880}