Skip to main content

asbuilt_core/
svg.rs

1//! A rendered view inlined into a page, so the page's colors reach it.
2//!
3//! The SVG `dot -Tsvg` wrote is rewritten: the prolog and comments go,
4//! and Graphviz's tooltips give way to each known node's own name and a
5//! link to the page that documents it, and to each edge's endpoints
6//! (named on the edge too, with a wide invisible twin of its line to
7//! click); `width` and `height` give way to the page's layout; ids are
8//! prefixed with the view's name, so several views share a page; and
9//! classes name what `theme.css` colors: each node's and group box's
10//! element kind (read from the `.dot` LikeC4 wrote beside it), a node's
11//! secondary text, and an edge label's backing. Only a LikeC4 render is
12//! inlined. Any other SVG is left for an `<img>`, where a script in it
13//! cannot run, and the rewrite drops scripts, `foreignObject` and event
14//! handlers besides. Pure string work over Graphviz's own output.
15
16use std::collections::BTreeMap;
17
18use crate::docs::escape;
19
20/// A rendered view as `render` left it: the SVG, and the `.dot` beside it.
21#[derive(Debug, Clone, PartialEq, Eq, Default)]
22pub struct ViewSource {
23    pub svg: String,
24    pub dot: Option<String>,
25}
26
27/// What a page knows about an element a view may draw: its kind, the
28/// name its node carries on hover, and a link to where it is
29/// documented (`None` for an element the page itself documents).
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Node {
32    pub kind: String,
33    pub title: String,
34    pub href: Option<String>,
35}
36
37/// A view inlined for a page: the markup, and the edges it draws as
38/// pairs of LikeC4 ids, in drawing order, so the page can say what each
39/// stands for.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Inlined {
42    pub html: String,
43    pub edges: Vec<(String, String)>,
44}
45
46/// The clusters an edge is drawn out of and into (`ltail`, `lhead`),
47/// keyed by the Graphviz names of the nodes its statement joins. LikeC4
48/// writes them for a relation to or from an expanded element: the arrow
49/// then ends at the cluster, and the page must say so, not name the
50/// inner node Graphviz routed it through.
51fn 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) = &current {
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
80/// The two Graphviz names an edge's title joins, `a->b` as the SVG
81/// escapes it (`a&#45;&gt;b`), any port dropped.
82fn edge_ends(title: &str) -> Option<(String, String)> {
83    let plain = title.replace("&#45;", "-").replace("&gt;", ">");
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
89/// Whether `dot` was written by `likec4 gen dot`, which names the view in
90/// a `likec4_viewId` graph attribute.
91pub 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
99/// An attribute value at the start of `text`: quoted, or up to the next
100/// separator.
101fn 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
112/// The LikeC4 id of each node and group box in a `.dot` LikeC4 wrote,
113/// keyed by the Graphviz name the SVG's `<title>` carries. LikeC4 writes
114/// one attribute per line, so a statement's name opens a line (`server
115/// [`, `subgraph cluster_app {`) and its `likec4_id` follows; an edge
116/// carries no element, and a cluster's own `graph [` line keeps its name.
117pub 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
144/// The index of the `>` that closes the tag opening `text`, outside
145/// any quoted attribute value.
146fn 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    /// A start tag's name and double-quoted attributes, values kept as
167    /// written; `None` for anything else.
168    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    /// Set `key` to `value`, which the caller has escaped.
200    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    /// Write the tag; returns where its `class` value ends in `out`, so
220    /// a class learned later can be inserted.
221    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    /// Where the group's open tag starts in the output, so a node can be
251    /// wrapped in a link once its `<title>` names the element.
252    open_at: usize,
253    /// Where the group's `class` value ends in the output, until its
254    /// `<title>` names the element and the kind class goes in there.
255    class_end: Option<usize>,
256    /// The fill of a node's first text line, its title.
257    title_fill: Option<String>,
258    /// Whether an `<a>` was opened before the group, to close after it.
259    linked: bool,
260    /// Whether an edge's line has had its twin to click: the first
261    /// `<path>` is the spline, any other an arrowhead.
262    hit: bool,
263}
264
265/// An attribute that could run a script: an event handler, or a link to
266/// a `javascript:` URL.
267fn 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
276/// `source.svg` rewritten for a page, or `None` when it is not a LikeC4
277/// render (no `.dot` beside it, or one LikeC4 did not write) or not SVG
278/// in the shape Graphviz writes. `nodes` maps a LikeC4 id to what the
279/// page knows about its element: a node is classed by its kind, titled
280/// by its name (the hover tooltip) and wrapped in a link to its page
281/// when it has one; `label` is the diagram's accessible name.
282pub 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                    // The edge says what it joins, by id, for the page's
332                    // script and for the hover.
333                    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                        // The element names itself on hover, and its node
358                        // is a link to where the page documents it.
359                        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                // The line is thin: a wide, invisible twin takes the clicks.
427                // Only the first path, the spline; an arrowhead drawn as a
428                // path keeps to its size.
429                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&#45;&gt;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    /// The page's view of the fixture's elements: the crate and its
524    /// module have pages, the external none.
525    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        // Graphviz titles every group with its own name; a node's is
596        // replaced by the element's, the rest go.
597        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            "&#45;&gt;",
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&lt;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        // Neither the node's own path nor the edge's arrowhead path does.
706        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&#45;&gt;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}