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