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, BTreeSet};
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, the edges it draws as pairs of
38/// LikeC4 ids, in drawing order, so the page can say what each stands
39/// for, and for its legend the element kinds its nodes and group boxes
40/// are and the (arrowhead, line) pairs its edges are drawn with.
41#[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
49/// The clusters an edge is drawn out of and into (`ltail`, `lhead`),
50/// keyed by the Graphviz names of the nodes its statement joins. LikeC4
51/// writes them for a relation to or from an expanded element: the arrow
52/// then ends at the cluster, and the page must say so, not name the
53/// inner node Graphviz routed it through.
54fn 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) = &current {
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
83/// How a view's edges are drawn: each edge statement's arrowhead and line
84/// style, as LikeC4 wrote them for Graphviz, with Graphviz's own defaults
85/// (`normal`, solid) where one is absent. What the legend lists is what
86/// the diagram shows, whatever the view's predicates kept.
87fn 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
116/// The two Graphviz names an edge's title joins, `a->b` as the SVG
117/// escapes it (`a&#45;&gt;b`), any port dropped.
118fn edge_ends(title: &str) -> Option<(String, String)> {
119    let plain = title.replace("&#45;", "-").replace("&gt;", ">");
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
125/// Whether `dot` was written by `likec4 gen dot`, which names the view in
126/// a `likec4_viewId` graph attribute.
127pub 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
135/// An attribute value at the start of `text`: quoted, or up to the next
136/// separator.
137fn 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
148/// The LikeC4 id of each node and group box in a `.dot` LikeC4 wrote,
149/// keyed by the Graphviz name the SVG's `<title>` carries. LikeC4 writes
150/// one attribute per line, so a statement's name opens a line (`server
151/// [`, `subgraph cluster_app {`) and its `likec4_id` follows; an edge
152/// carries no element, and a cluster's own `graph [` line keeps its name.
153pub 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
180/// The index of the `>` that closes the tag opening `text`, outside
181/// any quoted attribute value.
182fn 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    /// A start tag's name and double-quoted attributes, values kept as
203    /// written; `None` for anything else.
204    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    /// Set `key` to `value`, which the caller has escaped.
236    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    /// Write the tag; returns where its `class` value ends in `out`, so
256    /// a class learned later can be inserted.
257    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    /// Where the group's open tag starts in the output, so a node can be
287    /// wrapped in a link once its `<title>` names the element.
288    open_at: usize,
289    /// Where the group's `class` value ends in the output, until its
290    /// `<title>` names the element and the kind class goes in there.
291    class_end: Option<usize>,
292    /// The fill of a node's first text line, its title.
293    title_fill: Option<String>,
294    /// Whether an `<a>` was opened before the group, to close after it.
295    linked: bool,
296    /// Whether an edge's line has had its twin to click: the first
297    /// `<path>` is the spline, any other an arrowhead.
298    hit: bool,
299}
300
301/// An attribute that could run a script: an event handler, or a link to
302/// a `javascript:` URL.
303fn 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
312/// `source.svg` rewritten for a page, or `None` when it is not a LikeC4
313/// render (no `.dot` beside it, or one LikeC4 did not write) or not SVG
314/// in the shape Graphviz writes. `nodes` maps a LikeC4 id to what the
315/// page knows about its element: a node is classed by its kind, titled
316/// by its name (the hover tooltip) and wrapped in a link to its page
317/// when it has one; `label` is the diagram's accessible name.
318pub 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                    // The edge says what it joins, by id, for the page's
369                    // script and for the hover.
370                    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                        // The element names itself on hover, and its node
396                        // is a link to where the page documents it.
397                        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                // The line is thin: a wide, invisible twin takes the clicks.
465                // Only the first path, the spline; an arrowhead drawn as a
466                // path keeps to its size.
467                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&#45;&gt;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    /// The page's view of the fixture's elements: the crate and its
567    /// module have pages, the external none.
568    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        // Graphviz titles every group with its own name; a node's is
639        // replaced by the element's, the rest go.
640        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            "&#45;&gt;",
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&lt;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        // Neither the node's own path nor the edge's arrowhead path does.
749        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&#45;&gt;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}