Skip to main content

asbuilt_core/
docs.rs

1//! The documentation generator: a [`Model`] and the SVGs `asbuilt render`
2//! produced, to a static HTML tree that mounts anywhere. A view LikeC4
3//! drew is inlined into its page ([`crate::svg`]) and colored by
4//! `theme.css` ([`crate::theme`]) for the system's light or dark scheme or
5//! a visitor's choice; `theme.js`, the tree's one script, carries that
6//! choice and is left out when `scheme_toggle` is off.
7//!
8//! Pure: no filesystem, no process. LikeC4 and Graphviz supply the
9//! pictures; this module supplies what only the model knows (each crate
10//! and module, its path and doc paragraph, what it references and what
11//! references it). Every link is relative and no page is deeper than one
12//! directory, so the tree serves from `file://`, a GitHub Pages
13//! subdirectory, or a versioned snapshot unchanged.
14
15use std::collections::{BTreeMap, BTreeSet};
16use std::fmt::Write;
17
18use pulldown_cmark::{Event, Options, Parser, html};
19
20use crate::config::{ColorScheme, ThemeColor};
21use crate::emit::{children_of, link_encode, view_ids};
22use crate::model::{Element, ElementKind, Id, Model, Relation, dotted, sanitize_id};
23use crate::svg::{self, ViewSource};
24use crate::theme;
25
26/// What the pages need beyond the model.
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
28pub struct DocsOptions {
29    pub title: String,
30    /// A prefix that turns a repo-relative path into a link
31    /// (`https://github.com/owner/repo/blob/main/`); paths are plain
32    /// text when absent.
33    pub source_url: Option<String>,
34    /// Where the tree is mounted: a link back to the site that hosts it,
35    /// first in every page's header trail. Relative
36    /// values (`../`) are resolved from each page's own depth, so they
37    /// hold under a versioned snapshot; absolute URLs are used verbatim.
38    pub home_url: Option<String>,
39    /// The text of that link; `home_url` itself when absent. Every
40    /// page's `<title>` starts with it, so a tab names the host.
41    pub home_title: Option<String>,
42    /// A stylesheet linked last in every page's head, after the tree's
43    /// own, so a host site can restate the page tokens in its palette.
44    /// Resolved like `home_url`.
45    pub stylesheet: Option<String>,
46    /// The rendered views under `views/`, by file stem (`index`,
47    /// `view_app`, `context`): the SVG and the `.dot` beside it. A view
48    /// with a LikeC4 `.dot` is inlined into its page; any other SVG is an
49    /// image.
50    pub views: BTreeMap<String, ViewSource>,
51    /// A color per element kind from `[theme]`, the source of `theme.css`.
52    pub theme: BTreeMap<String, ThemeColor>,
53    /// The scheme the pages show before a visitor chooses one.
54    pub color_scheme: ColorScheme,
55    /// Whether every page carries a System / Light / Dark control, and
56    /// with it `theme.js`.
57    pub scheme_toggle: bool,
58    /// Whether every inlined view gets the viewer (`viewer.js`): a frame
59    /// at a readable scale, zoom and pan, and Fit, 1:1, Wide and
60    /// Fullscreen controls.
61    pub viewer: bool,
62}
63
64/// The `<meta name="generator">` every page carries. `asbuilt docs` looks
65/// for it before clearing an output directory: a tree that has it was
66/// written by a previous run and is safe to replace.
67pub const GENERATOR_META: &str = "<meta name=\"generator\" content=\"asbuilt docs\">";
68
69/// The generated tree.
70#[derive(Debug, Clone, PartialEq, Eq, Default)]
71pub struct Site {
72    /// Relative `/`-separated path to contents, `style.css` included.
73    pub pages: BTreeMap<String, String>,
74    /// Views a page wanted and `options.views` lacked, sorted.
75    pub missing_views: Vec<String>,
76}
77
78/// The stylesheet every page links.
79pub const STYLESHEET: &str = include_str!("docs.css");
80
81/// The asbuilt mark as SVG path data for a `0 0 24 24` viewBox, a copy of
82/// `brand/mark.path` (kept identical by `scripts/check-brand-copy.sh`).
83pub const MARK_PATH: &str = include_str!("mark.path");
84
85/// The mark as every page's header draws it, before the tree's title: the
86/// section asbuilt generated, in the page's accent. Decorative, since the
87/// title beside it is the link's name.
88fn mark_svg() -> String {
89    format!(
90        "<svg class=\"mark\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><path d=\"{}\"/></svg>",
91        MARK_PATH.trim()
92    )
93}
94
95/// The script behind the scheme control, linked in every page's head.
96pub const SCHEME_SCRIPT: &str = include_str!("theme.js");
97
98/// `viewer.js`: the diagram viewer, when `viewer` is on.
99pub const VIEWER_SCRIPT: &str = include_str!("viewer.js");
100
101/// `viewer.css`: the viewer's own styles, beside `style.css` so a host
102/// page can embed a figure without the tree's page styles.
103pub const VIEWER_STYLESHEET: &str = include_str!("viewer.css");
104
105/// The controls of a viewer figure. Hidden until the script reveals
106/// them, so a page without JavaScript shows no dead buttons. The scale
107/// readout is visual only: it changes on every resize, and the pressed
108/// buttons already say the mode. The hint names the zoom modifier for
109/// the platform, which only the script knows.
110const VIEWER_BAR: &str = "<div class=\"viewer-bar\" hidden><span class=\"viewer-scale\"></span><button type=\"button\" data-viewer-zoom=\"out\" aria-label=\"Zoom out\" title=\"Zoom out (\u{2212})\">\u{2212}</button><button type=\"button\" data-viewer-zoom=\"in\" aria-label=\"Zoom in\" title=\"Zoom in (+)\">+</button><button type=\"button\" data-viewer-mode=\"fit\" aria-pressed=\"false\">Fit</button><button type=\"button\" data-viewer-mode=\"one\" aria-pressed=\"false\">1:1</button><button type=\"button\" data-viewer-wide aria-pressed=\"false\">Wide</button><button type=\"button\" data-viewer-full>Fullscreen</button><span class=\"viewer-hint\"></span></div>";
111
112/// The visitor's scheme control. It ships hidden, and `theme.js` reveals
113/// it, so a page without the script shows no dead control.
114const SCHEME_CONTROL: &str = "<label class=\"scheme\" hidden>Theme <select id=\"scheme\"><option value=\"system\">System</option><option value=\"light\">Light</option><option value=\"dark\">Dark</option></select></label>";
115
116/// HTML-escape the five characters that matter in text and attributes.
117///
118/// ```
119/// assert_eq!(asbuilt_core::docs::escape("a < b & \"c\""), "a &lt; b &amp; &quot;c&quot;");
120/// ```
121pub fn escape(text: &str) -> String {
122    let mut out = String::with_capacity(text.len());
123    for c in text.chars() {
124        match c {
125            '&' => out.push_str("&amp;"),
126            '<' => out.push_str("&lt;"),
127            '>' => out.push_str("&gt;"),
128            '"' => out.push_str("&quot;"),
129            '\'' => out.push_str("&#39;"),
130            c => out.push(c),
131        }
132    }
133    out
134}
135
136/// A description paragraph as HTML. Raw HTML in the source is rendered
137/// as text: the pages are published, and a `<script>` in a doc comment
138/// must not become one here.
139pub fn markdown(text: &str) -> String {
140    let events = Parser::new_ext(text, Options::empty()).map(|event| match event {
141        Event::Html(raw) | Event::InlineHtml(raw) => Event::Text(raw),
142        other => other,
143    });
144    let mut out = String::new();
145    html::push_html(&mut out, events);
146    out
147}
148
149/// The page of a top-level element.
150pub fn container_page(id: &[String]) -> String {
151    format!("containers/{}.html", sanitize_id(id))
152}
153
154/// The fragment id of an element within its container's page.
155pub fn anchor(id: &[String]) -> String {
156    sanitize_id(id)
157}
158
159/// Where an element is documented: its page, and the fragment when it
160/// is not the page's own subject.
161#[derive(Debug, Clone, PartialEq, Eq)]
162struct Place {
163    page: String,
164    fragment: Option<String>,
165}
166
167/// A JSON string literal; `<` escaped too, so the text can sit inside a
168/// `<script>` element.
169fn json(text: &str) -> String {
170    let mut out = String::with_capacity(text.len() + 2);
171    out.push('"');
172    for c in text.chars() {
173        match c {
174            '"' => out.push_str("\\\""),
175            '\\' => out.push_str("\\\\"),
176            '\n' => out.push_str("\\n"),
177            '\r' => out.push_str("\\r"),
178            '\t' => out.push_str("\\t"),
179            '<' => out.push_str("\\u003c"),
180            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
181            c => out.push(c),
182        }
183    }
184    out.push('"');
185    out
186}
187
188fn json_opt(text: Option<String>) -> String {
189    text.map(|t| json(&t)).unwrap_or_else(|| "null".to_string())
190}
191
192impl Place {
193    fn href(&self, depth: usize) -> String {
194        let mut out = format!("{}{}", up(depth), self.page);
195        if let Some(fragment) = &self.fragment {
196            out.push('#');
197            out.push_str(fragment);
198        }
199        out
200    }
201}
202
203/// Which page a layout is for, so the header can mark it current.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205enum Here<'a> {
206    Index,
207    Container(&'a [String]),
208    Views,
209}
210
211/// The relative prefix from a page at `depth` back to the tree root.
212fn up(depth: usize) -> String {
213    "../".repeat(depth)
214}
215
216/// A configured URL as a page at `depth` should write it: absolute URLs
217/// and root-relative paths verbatim, anything else relative to the tree
218/// root and so prefixed by the climb.
219fn resolve(depth: usize, url: &str) -> String {
220    if url.contains("://") || url.starts_with('/') {
221        url.to_string()
222    } else {
223        format!("{}{url}", up(depth))
224    }
225}
226
227fn is_external(element: &Element) -> bool {
228    matches!(element.kind, ElementKind::External(_))
229}
230
231struct Ctx<'a> {
232    model: &'a Model,
233    options: &'a DocsOptions,
234    /// Generated view name per element with children.
235    views: BTreeMap<Id, String>,
236    /// Where every element is documented.
237    places: BTreeMap<Id, Place>,
238    /// The rendered views that are neither `index` nor generated, which
239    /// `views.html` shows; every header links that page when there are any.
240    curated: Vec<String>,
241    /// Each element by its LikeC4 id: what a view's nodes and edges name.
242    by_likec4: BTreeMap<String, &'a Element>,
243    missing: BTreeSet<String>,
244}
245
246impl Ctx<'_> {
247    /// The home link as `<a class="home">`, when there is a home.
248    fn home_link(&self, depth: usize) -> Option<String> {
249        let url = self.options.home_url.as_deref()?;
250        let title = self.options.home_title.as_deref().unwrap_or(url);
251        Some(format!(
252            "<a class=\"home\" href=\"{}\">{}</a>",
253            escape(&resolve(depth, url)),
254            escape(title)
255        ))
256    }
257
258    /// The `<title>`, in the header trail's order: the host when
259    /// `home_title` names it, the tree, then the page. The index is the
260    /// tree's own page, so it ends at the tree.
261    fn document_title(&self, page: Option<&str>) -> String {
262        self.options
263            .home_title
264            .as_deref()
265            .into_iter()
266            .chain([self.options.title.as_str()])
267            .chain(page)
268            .map(escape)
269            .collect::<Vec<_>>()
270            .join(" · ")
271    }
272
273    /// A page: the header's trail (the home link, when there is one, and
274    /// the tree's index) with the scheme control, the row of containers
275    /// with the current one unlinked, then `body`. Every page's own
276    /// heading names it, so nothing below the header repeats the trail.
277    fn layout(&self, depth: usize, here: Here<'_>, page_title: Option<&str>, body: &str) -> String {
278        let prefix = up(depth);
279        let mut containers = String::new();
280        for element in children_of(self.model, &[]) {
281            if is_external(element) {
282                continue;
283            }
284            if here == Here::Container(&element.id) {
285                let _ = write!(
286                    containers,
287                    "<span aria-current=\"page\">{}</span>",
288                    escape(&element.title)
289                );
290            } else {
291                let _ = write!(
292                    containers,
293                    r#"<a href="{prefix}{}">{}</a>"#,
294                    container_page(&element.id),
295                    escape(&element.title)
296                );
297            }
298        }
299        let containers = if containers.is_empty() {
300            String::new()
301        } else {
302            format!("<nav class=\"containers\" aria-label=\"Containers\">{containers}</nav>")
303        };
304        let home = self
305            .home_link(depth)
306            .map(|link| format!("{link}<span class=\"sep\" aria-hidden=\"true\">/</span>"))
307            .unwrap_or_default();
308        let index_current = if here == Here::Index {
309            " aria-current=\"page\""
310        } else {
311            ""
312        };
313        let host_css = self
314            .options
315            .stylesheet
316            .as_deref()
317            .map(|css| {
318                format!(
319                    "<link rel=\"stylesheet\" href=\"{}\">\n",
320                    escape(&resolve(depth, css))
321                )
322            })
323            .unwrap_or_default();
324        let scheme = self.options.color_scheme;
325        let toggle = self.options.scheme_toggle;
326        let mut root_attrs = String::new();
327        if scheme != ColorScheme::System {
328            let _ = write!(root_attrs, " data-theme=\"{}\"", scheme.as_str());
329        }
330        if toggle {
331            let _ = write!(root_attrs, " data-theme-default=\"{}\"", scheme.as_str());
332        }
333        let mut script = String::new();
334        if toggle {
335            let _ = writeln!(script, "<script src=\"{prefix}theme.js\"></script>");
336        }
337        let mut viewer_css = String::new();
338        if self.options.viewer {
339            let _ = writeln!(
340                viewer_css,
341                "<link rel=\"stylesheet\" href=\"{prefix}viewer.css\">"
342            );
343            let _ = writeln!(script, "<script src=\"{prefix}viewer.js\" defer></script>");
344        }
345        let control = if toggle { SCHEME_CONTROL } else { "" };
346        let curated = if self.curated.is_empty() {
347            String::new()
348        } else if here == Here::Views {
349            "<nav class=\"views\" aria-label=\"Views\"><span aria-current=\"page\">Curated views</span></nav>".to_string()
350        } else {
351            format!(
352                "<nav class=\"views\" aria-label=\"Views\"><a href=\"{prefix}views.html\">Curated views</a></nav>"
353            )
354        };
355        format!(
356            "<!doctype html>\n<html lang=\"en\"{root_attrs}>\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n{GENERATOR_META}\n<title>{title}</title>\n<link rel=\"stylesheet\" href=\"{prefix}style.css\">\n<link rel=\"stylesheet\" href=\"{prefix}theme.css\">\n{viewer_css}{script}{host_css}</head>\n<body>\n<header><div class=\"bar\"><nav class=\"trail\" aria-label=\"Breadcrumb\">{home}<a class=\"site\" href=\"{prefix}index.html\"{index_current}>{mark}{site}</a></nav>{curated}{control}</div>{containers}</header>\n<main>\n{body}</main>\n</body>\n</html>\n",
357            title = self.document_title(page_title),
358            site = escape(&self.options.title),
359            mark = mark_svg(),
360        )
361    }
362
363    /// A view as the page shows it: inlined when LikeC4 drew it, so the
364    /// page's colors reach it, else an image; `None` when there is no SVG
365    /// for it.
366    /// What the inliner needs per element for the page `here` at `depth`:
367    /// its kind, its dotted id as the name a node shows on hover, and a
368    /// link to where it is documented, except for the page's own element.
369    /// Built once per page and shared by its views.
370    fn nodes(&self, depth: usize, here: Here<'_>) -> BTreeMap<String, svg::Node> {
371        let this_page = match here {
372            Here::Container(id) => Some(container_page(id)),
373            Here::Index | Here::Views => None,
374        };
375        self.by_likec4
376            .iter()
377            .map(|(key, element)| {
378                let place = self.places.get(&element.id);
379                let here = place.is_some_and(|p| {
380                    p.fragment.is_none() && this_page.as_deref() == Some(p.page.as_str())
381                });
382                let href = if here {
383                    None
384                } else {
385                    place.map(|p| p.href(depth))
386                };
387                (
388                    key.clone(),
389                    svg::Node {
390                        kind: element.kind.keyword().to_string(),
391                        title: dotted(&element.id),
392                        href,
393                    },
394                )
395            })
396            .collect()
397    }
398
399    fn view_markup(
400        &self,
401        depth: usize,
402        nodes: &BTreeMap<String, svg::Node>,
403        view: &str,
404        alt: &str,
405    ) -> Option<svg::Inlined> {
406        let source = self.options.views.get(view)?;
407        Some(match svg::inline(source, view, alt, nodes) {
408            Some(mut inlined) => {
409                inlined.html.truncate(inlined.html.trim_end().len());
410                inlined
411            }
412            None => svg::Inlined {
413                html: format!(
414                    "<img src=\"{}views/{}.svg\" alt=\"{}\">",
415                    up(depth),
416                    escape(view),
417                    escape(alt)
418                ),
419                edges: Vec::new(),
420            },
421        })
422    }
423
424    /// The relations behind each edge a view draws, as JSON for the
425    /// viewer's popover: keyed `from->to` by LikeC4 id, each relation
426    /// with its endpoints' ids and links (the page's own, from `nodes`,
427    /// so its own element is named but not linked here either), its
428    /// kind, items and technology. Empty when no edge has any. The
429    /// relations are sorted by source, so one source prefix is one range.
430    fn edge_data(&self, edges: &[(String, String)], nodes: &BTreeMap<String, svg::Node>) -> String {
431        let href = |id: &Id| json_opt(nodes.get(&sanitize_id(id)).and_then(|n| n.href.clone()));
432        let relations = &self.model.relations;
433        let mut entries = Vec::new();
434        for (from, to) in edges {
435            let (Some(from_el), Some(to_el)) = (self.by_likec4.get(from), self.by_likec4.get(to))
436            else {
437                continue;
438            };
439            let (from_id, to_id) = (&from_el.id, &to_el.id);
440            let start = relations.partition_point(|r| r.source.as_slice() < from_id.as_slice());
441            let behind: Vec<String> = relations[start..]
442                .iter()
443                .take_while(|r| r.source.starts_with(from_id))
444                .filter(|r| r.target.starts_with(to_id))
445                .map(|r| {
446                    format!(
447                        "{{\"source\":{},\"source_href\":{},\"target\":{},\"target_href\":{},\"kind\":{},\"items\":[{}],\"technology\":{}}}",
448                        json(&dotted(&r.source)),
449                        href(&r.source),
450                        json(&dotted(&r.target)),
451                        href(&r.target),
452                        json(r.kind.keyword()),
453                        r.items.iter().map(|i| json(i)).collect::<Vec<_>>().join(","),
454                        json_opt(r.technology.clone()),
455                    )
456                })
457                .collect();
458            if behind.is_empty() {
459                continue;
460            }
461            entries.push(format!(
462                "{}:[{}]",
463                json(&format!("{from}->{to}")),
464                behind.join(",")
465            ));
466        }
467        if entries.is_empty() {
468            return String::new();
469        }
470        format!(
471            "<script type=\"application/json\" class=\"viewer-edges\">{{{}}}</script>",
472            entries.join(",")
473        )
474    }
475
476    /// A view's `<figure>`: with the viewer, an inlined SVG sits in a
477    /// frame (a group named for the view; the script makes it a tab stop
478    /// when it takes it over) under the controls, with the relations
479    /// behind its edges beside it; an `<img>` (a view LikeC4 did not
480    /// draw) and a tree without the viewer keep the bare figure.
481    fn figure(&self, label: &str, caption: &str, markup: &str, edges: &str) -> String {
482        if self.options.viewer && markup.starts_with("<svg") {
483            format!(
484                "<figure class=\"view\" data-viewer>{caption}{VIEWER_BAR}{edges}<div class=\"viewer-frame\" role=\"group\" aria-label=\"{}: diagram\">{markup}</div></figure>",
485                escape(label)
486            )
487        } else {
488            format!("<figure class=\"view\">{caption}{markup}</figure>")
489        }
490    }
491
492    /// The `<figure>` for a view, or the placeholder when its SVG is
493    /// missing (recorded for the report).
494    fn view_figure(
495        &mut self,
496        depth: usize,
497        nodes: &BTreeMap<String, svg::Node>,
498        view: &str,
499        alt: &str,
500    ) -> String {
501        match self.view_markup(depth, nodes, view, alt) {
502            Some(inlined) => {
503                let edges = self.edge_data(&inlined.edges, nodes);
504                format!("{}\n", self.figure(alt, "", &inlined.html, &edges))
505            }
506            None => {
507                self.missing.insert(view.to_string());
508                format!(
509                    "<p class=\"missing\">No diagram for <code>{}</code>: run <code>asbuilt render</code>.</p>\n",
510                    escape(view)
511                )
512            }
513        }
514    }
515
516    fn path_html(&self, path: &str) -> String {
517        match &self.options.source_url {
518            Some(base) => {
519                let target = if path == "." {
520                    String::new()
521                } else {
522                    link_encode(path)
523                };
524                format!(
525                    "<a href=\"{}{}\"><code>{}</code></a>",
526                    escape(base),
527                    target,
528                    escape(path)
529                )
530            }
531            None => format!("<code>{}</code>", escape(path)),
532        }
533    }
534
535    /// Technology, tags and path on one muted line, then the description.
536    fn meta_html(&self, element: &Element) -> String {
537        let mut out = String::new();
538        let mut parts: Vec<String> = Vec::new();
539        if let Some(technology) = &element.technology {
540            parts.push(escape(technology));
541        }
542        for tag in &element.tags {
543            parts.push(format!("<span class=\"tag\">{}</span>", escape(tag)));
544        }
545        if let Some(path) = &element.path {
546            parts.push(self.path_html(path));
547        }
548        if !parts.is_empty() {
549            let _ = writeln!(out, "<p class=\"meta\">{}</p>", parts.join(" · "));
550        }
551        if let Some(description) = &element.description {
552            out.push_str(&markdown(description));
553        }
554        out
555    }
556
557    fn link_to(&self, id: &Id, depth: usize) -> String {
558        match self.places.get(id) {
559            Some(place) => format!(
560                "<a href=\"{}\">{}</a>",
561                place.href(depth),
562                escape(&dotted(id))
563            ),
564            None => escape(&dotted(id)),
565        }
566    }
567
568    fn relation_row(&self, relation: &Relation, other: &Id, depth: usize) -> String {
569        format!(
570            "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
571            relation.kind.keyword(),
572            self.link_to(other, depth),
573            escape(&relation.items.join(", ")),
574            relation
575                .technology
576                .as_deref()
577                .map(escape)
578                .unwrap_or_default()
579        )
580    }
581
582    /// "Uses" (this element as source) and "Used by" (as target).
583    fn relation_tables(&self, id: &Id, depth: usize) -> String {
584        let mut out = String::new();
585        let uses: Vec<&Relation> = self
586            .model
587            .relations
588            .iter()
589            .filter(|r| &r.source == id)
590            .collect();
591        let used_by: Vec<&Relation> = self
592            .model
593            .relations
594            .iter()
595            .filter(|r| &r.target == id)
596            .collect();
597        for (heading, rows, column) in [("Uses", uses, "Target"), ("Used by", used_by, "Source")] {
598            if rows.is_empty() {
599                continue;
600            }
601            let _ = writeln!(
602                out,
603                "<h3>{heading}</h3>\n<table><thead><tr><th>Kind</th><th>{column}</th><th>Items</th><th>Technology</th></tr></thead><tbody>"
604            );
605            for relation in rows {
606                let other = if heading == "Uses" {
607                    &relation.target
608                } else {
609                    &relation.source
610                };
611                out.push_str(&self.relation_row(relation, other, depth));
612            }
613            out.push_str("</tbody></table>\n");
614        }
615        out
616    }
617
618    /// The nested list of an element's descendants, as anchors.
619    fn module_list(&self, parent: &Id) -> String {
620        let children = children_of(self.model, parent);
621        if children.is_empty() {
622            return String::new();
623        }
624        let mut out = String::from("<ul>\n");
625        for child in children {
626            let _ = writeln!(
627                out,
628                "<li><a href=\"#{}\">{}</a>{}</li>",
629                anchor(&child.id),
630                escape(&child.title),
631                self.module_list(&child.id)
632            );
633        }
634        out.push_str("</ul>\n");
635        out
636    }
637
638    fn index_page(&mut self) -> String {
639        let mut body = String::new();
640        let _ = writeln!(body, "<h1>{}</h1>", escape(&self.options.title));
641        let nodes = self.nodes(0, Here::Index);
642        body.push_str(&self.view_figure(0, &nodes, "index", "Overview"));
643        let top = children_of(self.model, &[]);
644        let containers: Vec<&Element> = top.iter().copied().filter(|e| !is_external(e)).collect();
645        let externals: Vec<&Element> = top.iter().copied().filter(|e| is_external(e)).collect();
646        if !containers.is_empty() {
647            body.push_str("<h2>Containers</h2>\n<table><thead><tr><th>Name</th><th>Technology</th><th>Description</th></tr></thead><tbody>\n");
648            for element in &containers {
649                let _ = writeln!(
650                    body,
651                    "<tr><td><a href=\"{}\">{}</a></td><td>{}</td><td>{}</td></tr>",
652                    container_page(&element.id),
653                    escape(&element.title),
654                    element
655                        .technology
656                        .as_deref()
657                        .map(escape)
658                        .unwrap_or_default(),
659                    element
660                        .description
661                        .as_deref()
662                        .map(markdown)
663                        .unwrap_or_default()
664                );
665            }
666            body.push_str("</tbody></table>\n");
667        }
668        if !externals.is_empty() {
669            body.push_str("<h2>Externals</h2>\n<table><thead><tr><th>Name</th><th>Kind</th><th>Technology</th><th>Description</th><th>Used by</th></tr></thead><tbody>\n");
670            for element in &externals {
671                let kind = match &element.kind {
672                    ElementKind::External(kind) => kind.as_str(),
673                    _ => "",
674                };
675                let users: Vec<String> = self
676                    .model
677                    .relations
678                    .iter()
679                    .filter(|r| r.target == element.id)
680                    .map(|r| {
681                        format!(
682                            "{} ({}: {})",
683                            self.link_to(&r.source, 0),
684                            r.kind.keyword(),
685                            escape(&r.items.join(", "))
686                        )
687                    })
688                    .collect();
689                let _ = writeln!(
690                    body,
691                    "<tr id=\"{}\"><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
692                    anchor(&element.id),
693                    escape(&element.title),
694                    escape(kind),
695                    element
696                        .technology
697                        .as_deref()
698                        .map(escape)
699                        .unwrap_or_default(),
700                    element
701                        .description
702                        .as_deref()
703                        .map(markdown)
704                        .unwrap_or_default(),
705                    users.join("<br>")
706                );
707            }
708            body.push_str("</tbody></table>\n");
709        }
710        self.layout(0, Here::Index, None, &body)
711    }
712
713    fn container_page_html(&mut self, element: &Element) -> String {
714        let nodes = self.nodes(1, Here::Container(&element.id));
715        let mut body = String::new();
716        let _ = writeln!(body, "<h1>{}</h1>", escape(&element.title));
717        body.push_str(&self.meta_html(element));
718        if let Some(view) = self.views.get(&element.id).cloned() {
719            body.push_str(&self.view_figure(1, &nodes, &view, &element.title));
720        }
721        let modules = self.module_list(&element.id);
722        if !modules.is_empty() {
723            let _ = write!(
724                body,
725                "<h2>Modules</h2>\n<div class=\"modules\">{modules}</div>\n"
726            );
727        }
728        body.push_str(&self.relation_tables(&element.id, 1));
729        let descendants: Vec<&Element> = self
730            .model
731            .elements
732            .iter()
733            .filter(|e| e.id.len() > element.id.len() && e.id.starts_with(&element.id))
734            .collect();
735        for descendant in descendants {
736            let _ = writeln!(
737                body,
738                "<section id=\"{}\">\n<h2>{} <small>{}</small></h2>",
739                anchor(&descendant.id),
740                escape(descendant.id.last().map(String::as_str).unwrap_or_default()),
741                escape(&dotted(&descendant.id))
742            );
743            body.push_str(&self.meta_html(descendant));
744            if let Some(view) = self.views.get(&descendant.id).cloned() {
745                body.push_str(&self.view_figure(1, &nodes, &view, &dotted(&descendant.id)));
746            }
747            body.push_str(&self.relation_tables(&descendant.id, 1));
748            body.push_str("</section>\n");
749        }
750        self.layout(1, Here::Container(&element.id), Some(&element.title), &body)
751    }
752
753    /// The curated views on one page; `None` when there are none.
754    fn views_page(&mut self) -> Option<String> {
755        if self.curated.is_empty() {
756            return None;
757        }
758        let mut body = String::from("<h1>Curated views</h1>\n");
759        let nodes = self.nodes(0, Here::Views);
760        for view in &self.curated {
761            let inlined = self
762                .view_markup(0, &nodes, view, view)
763                .unwrap_or(svg::Inlined {
764                    html: String::new(),
765                    edges: Vec::new(),
766                });
767            let caption = format!("<figcaption>{}</figcaption>", escape(view));
768            let edges = self.edge_data(&inlined.edges, &nodes);
769            let _ = writeln!(
770                body,
771                "{}",
772                self.figure(view, &caption, &inlined.html, &edges)
773            );
774        }
775        Some(self.layout(0, Here::Views, Some("Curated views"), &body))
776    }
777}
778
779/// The whole tree for a model. The model is normalized on a copy first,
780/// so the caller's element order does not matter.
781pub fn generate(model: &Model, options: &DocsOptions) -> Site {
782    let mut model = model.clone();
783    model.normalize();
784    let views = view_ids(&model);
785
786    let mut places: BTreeMap<Id, Place> = BTreeMap::new();
787    for element in children_of(&model, &[]) {
788        if is_external(element) {
789            places.insert(
790                element.id.clone(),
791                Place {
792                    page: "index.html".into(),
793                    fragment: Some(anchor(&element.id)),
794                },
795            );
796            continue;
797        }
798        let page = container_page(&element.id);
799        places.insert(
800            element.id.clone(),
801            Place {
802                page: page.clone(),
803                fragment: None,
804            },
805        );
806        for descendant in model
807            .elements
808            .iter()
809            .filter(|e| e.id.len() > element.id.len() && e.id.starts_with(&element.id))
810        {
811            places.insert(
812                descendant.id.clone(),
813                Place {
814                    page: page.clone(),
815                    fragment: Some(anchor(&descendant.id)),
816                },
817            );
818        }
819    }
820
821    let kind_names: BTreeSet<String> = model
822        .elements
823        .iter()
824        .map(|e| e.kind.keyword().to_string())
825        .collect();
826    let by_likec4: BTreeMap<String, &Element> = model
827        .elements
828        .iter()
829        .map(|e| (sanitize_id(&e.id), e))
830        .collect();
831    let generated: BTreeSet<&String> = views.values().collect();
832    let curated: Vec<String> = options
833        .views
834        .keys()
835        .filter(|v| v.as_str() != "index" && !generated.contains(v))
836        .cloned()
837        .collect();
838    let mut ctx = Ctx {
839        model: &model,
840        options,
841        views,
842        places,
843        curated,
844        by_likec4,
845        missing: BTreeSet::new(),
846    };
847    let mut pages = BTreeMap::new();
848    pages.insert("index.html".to_string(), ctx.index_page());
849    let containers: Vec<Element> = children_of(&model, &[])
850        .into_iter()
851        .filter(|e| !is_external(e))
852        .cloned()
853        .collect();
854    for element in &containers {
855        pages.insert(
856            container_page(&element.id),
857            ctx.container_page_html(element),
858        );
859    }
860    if let Some(views_page) = ctx.views_page() {
861        pages.insert("views.html".to_string(), views_page);
862    }
863    pages.insert("style.css".to_string(), STYLESHEET.to_string());
864    pages.insert(
865        "theme.css".to_string(),
866        theme::stylesheet(&kind_names, &options.theme),
867    );
868    if options.scheme_toggle {
869        pages.insert("theme.js".to_string(), SCHEME_SCRIPT.to_string());
870    }
871    if options.viewer {
872        pages.insert("viewer.css".to_string(), VIEWER_STYLESHEET.to_string());
873        pages.insert("viewer.js".to_string(), VIEWER_SCRIPT.to_string());
874    }
875    Site {
876        pages,
877        missing_views: ctx.missing.into_iter().collect(),
878    }
879}
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884    use crate::emit::{EmitOptions, emit};
885    use crate::model::RelationKind;
886
887    fn id(s: &str) -> Id {
888        s.split('.').map(str::to_string).collect()
889    }
890
891    fn element(s: &str, kind: ElementKind) -> Element {
892        Element {
893            id: id(s),
894            kind,
895            title: s.rsplit('.').next().unwrap().to_string(),
896            description: None,
897            technology: None,
898            path: None,
899            tags: vec![],
900        }
901    }
902
903    fn relation(source: &str, target: &str, kind: RelationKind, items: &[&str]) -> Relation {
904        Relation {
905            source: id(source),
906            target: id(target),
907            kind,
908            items: items.iter().map(|s| s.to_string()).collect(),
909            technology: None,
910        }
911    }
912
913    /// `app` (with `server`, `view` → `view.element`, `tests`), `lib`
914    /// (with `util`), and an external `node_driver`.
915    fn sample() -> Model {
916        let mut app = element("app", ElementKind::Container);
917        app.technology = Some("library crate".into());
918        app.path = Some("crates/app".into());
919        app.description = Some("The app. It's `here`.".into());
920        let mut server = element("app.server", ElementKind::Component);
921        server.description = Some("Where `Page` is *sent*.".into());
922        server.path = Some("crates/app/src/server.rs".into());
923        let view = element("app.view", ElementKind::Component);
924        let view_element = element("app.view.element", ElementKind::Component);
925        let mut tests = element("app.tests", ElementKind::Tests);
926        tests.tags = vec!["tests".into()];
927        let mut lib = element("lib", ElementKind::Container);
928        lib.path = Some(".".into());
929        let util = element("lib.util", ElementKind::Component);
930        let mut driver = element("node_driver", ElementKind::External("process".into()));
931        driver.tags = vec!["external".into()];
932        driver.technology = Some("Node.js".into());
933        driver.description = Some("The driver.".into());
934        let mut spawns = relation("app.server", "node_driver", RelationKind::Uses, &["spawns"]);
935        spawns.technology = Some("stdio".into());
936        Model {
937            elements: vec![driver, util, lib, tests, view_element, view, server, app],
938            relations: vec![
939                relation(
940                    "app.tests",
941                    "app.server",
942                    RelationKind::Calls,
943                    &["Server", "connect"],
944                ),
945                spawns,
946                relation("app.view", "app.server", RelationKind::NamesType, &[]),
947                relation(
948                    "app.view.element",
949                    "app.server",
950                    RelationKind::Constructs,
951                    &["Page"],
952                ),
953                relation(
954                    "app.server",
955                    "lib.util",
956                    RelationKind::NamesType,
957                    &["Helper"],
958                ),
959                relation("lib.util", "app", RelationKind::Uses, &["App"]),
960            ],
961            ..Default::default()
962        }
963    }
964
965    /// Views drawn by something other than LikeC4 (no `.dot`), so each
966    /// is an image; `likec4_view` makes one LikeC4 drew.
967    fn all_views() -> BTreeMap<String, ViewSource> {
968        ["index", "view_app", "view_app_view_", "view_lib"]
969            .iter()
970            .map(|s| (s.to_string(), plain_view()))
971            .collect()
972    }
973
974    fn plain_view() -> ViewSource {
975        ViewSource {
976            svg: "<svg xmlns=\"http://www.w3.org/2000/svg\"/>".into(),
977            dot: None,
978        }
979    }
980
981    /// The `index` view as LikeC4 and Graphviz write it: one node, `app`.
982    fn likec4_view() -> ViewSource {
983        ViewSource {
984            svg: "<svg width=\"10pt\" height=\"10pt\" viewBox=\"0 0 10 10\" xmlns=\"http://www.w3.org/2000/svg\">\n<g id=\"node1\" class=\"node\">\n<title>app</title>\n<polygon fill=\"#3b82f6\" points=\"0,0 1,1\"/>\n</g>\n<g id=\"node2\" class=\"node\">\n<title>lib</title>\n<polygon fill=\"#3b82f6\" points=\"0,0 1,1\"/>\n</g>\n<g id=\"node3\" class=\"node\">\n<title>driver_1</title>\n<polygon fill=\"#3b82f6\" points=\"0,0 1,1\"/>\n</g>\n<g id=\"edge1\" class=\"edge\">\n<title>app&#45;&gt;lib</title>\n<path fill=\"none\" stroke=\"#8d8d8d\" d=\"M0,0\"/>\n</g>\n<g id=\"edge2\" class=\"edge\">\n<title>lib&#45;&gt;driver_1</title>\n<path fill=\"none\" stroke=\"#8d8d8d\" d=\"M0,0\"/>\n</g>\n</svg>\n".into(),
985            dot: Some("digraph {\n    graph [likec4_viewId=index];\n    app [likec4_id=app];\n    lib [likec4_id=lib];\n    driver_1 [likec4_id=node_driver];\n    app -> lib [likec4_id=\"1ab\"];\n    lib -> driver_1 [likec4_id=\"1ac\"];\n}\n".into()),
986        }
987    }
988
989    /// A view with one node and no edge, for claims about the figure's
990    /// shape around a view.
991    fn likec4_bare_view() -> ViewSource {
992        ViewSource {
993            svg: "<svg width=\"10pt\" height=\"10pt\" viewBox=\"0 0 10 10\" xmlns=\"http://www.w3.org/2000/svg\">\n<g id=\"node1\" class=\"node\">\n<title>app</title>\n<polygon fill=\"#3b82f6\" points=\"0,0 1,1\"/>\n</g>\n</svg>\n".into(),
994            dot: Some("digraph {\n    graph [likec4_viewId=index];\n    app [likec4_id=app];\n}\n".into()),
995        }
996    }
997    /// `view_app` as LikeC4 would draw it: the crate itself, its `server`
998    /// module and the external it spawns, each a node.
999    fn likec4_scoped_view() -> ViewSource {
1000        ViewSource {
1001            svg: "<svg width=\"10pt\" height=\"10pt\" viewBox=\"0 0 10 10\" xmlns=\"http://www.w3.org/2000/svg\">\n<g id=\"node1\" class=\"node\">\n<title>app</title>\n<polygon fill=\"#3b82f6\" points=\"0,0 1,1\"/>\n</g>\n<g id=\"node2\" class=\"node\">\n<title>server</title>\n<polygon fill=\"#3b82f6\" points=\"0,0 1,1\"/>\n</g>\n<g id=\"node3\" class=\"node\">\n<title>driver_1</title>\n<polygon fill=\"#3b82f6\" points=\"0,0 1,1\"/>\n</g>\n<g id=\"edge1\" class=\"edge\">\n<title>server&#45;&gt;driver_1</title>\n<path fill=\"none\" stroke=\"#8d8d8d\" d=\"M0,0\"/>\n</g>\n</svg>\n".into(),
1002            dot: Some("digraph {\n    graph [likec4_viewId=view_app];\n    app [likec4_id=app];\n    server [likec4_id=\"app.server\"];\n    driver_1 [likec4_id=node_driver];\n    server -> driver_1 [likec4_id=\"1ab\"];\n}\n".into()),
1003        }
1004    }
1005    fn options() -> DocsOptions {
1006        DocsOptions {
1007            title: "Sample".into(),
1008            views: all_views(),
1009            ..Default::default()
1010        }
1011    }
1012
1013    fn page<'a>(site: &'a Site, path: &str) -> &'a str {
1014        site.pages
1015            .get(path)
1016            .unwrap_or_else(|| panic!("no page {path}; have {:?}", site.pages.keys()))
1017    }
1018
1019    #[test]
1020    fn the_page_set_is_index_the_stylesheet_and_one_page_per_container() {
1021        let site = generate(&sample(), &options());
1022        let keys: Vec<&String> = site.pages.keys().collect();
1023        assert_eq!(
1024            keys,
1025            [
1026                "containers/app.html",
1027                "containers/lib.html",
1028                "index.html",
1029                "style.css",
1030                "theme.css"
1031            ]
1032        );
1033        assert_eq!(page(&site, "style.css"), STYLESHEET);
1034    }
1035
1036    #[test]
1037    fn the_scheme_toggle_adds_the_script_to_the_page_set() {
1038        let mut opts = options();
1039        opts.scheme_toggle = true;
1040        let site = generate(&sample(), &opts);
1041        assert_eq!(page(&site, "theme.js"), SCHEME_SCRIPT);
1042    }
1043
1044    fn app_page_with_scoped_view() -> String {
1045        let mut opts = options();
1046        opts.viewer = true;
1047        opts.views.insert("view_app".into(), likec4_scoped_view());
1048        let site = generate(&sample(), &opts);
1049        page(&site, "containers/app.html").to_string()
1050    }
1051
1052    #[test]
1053    fn a_figure_carries_the_relations_behind_each_edge_it_draws() {
1054        let app = app_page_with_scoped_view();
1055        assert!(
1056            app.contains("<script type=\"application/json\" class=\"viewer-edges\">{\"app.server->node_driver\":[{\"source\":\"app.server\",\"source_href\":\"../containers/app.html#app.server\",\"target\":\"node_driver\",\"target_href\":\"../index.html#node_driver\",\"kind\":\"uses\",\"items\":[\"spawns\"],\"technology\":\"stdio\"}]}</script><div class=\"viewer-frame\""),
1057            "{app}"
1058        );
1059    }
1060
1061    #[test]
1062    fn an_edge_between_crates_lists_the_relations_between_their_modules() {
1063        let mut opts = options();
1064        opts.viewer = true;
1065        opts.views.insert("index".into(), likec4_view());
1066        let site = generate(&sample(), &opts);
1067        let index = page(&site, "index.html");
1068        assert!(
1069            index.contains("\"app->lib\":[{\"source\":\"app.server\",\"source_href\":\"containers/app.html#app.server\",\"target\":\"lib.util\",\"target_href\":\"containers/lib.html#lib.util\",\"kind\":\"names\",\"items\":[\"Helper\"],\"technology\":null}]"),
1070            "{index}"
1071        );
1072    }
1073
1074    #[test]
1075    fn the_popover_does_not_link_the_page_s_own_crate_either() {
1076        // The sample has lib.util -> app; on lib's page an edge lib->app
1077        // would list it with app, the other page, linked; on app's page
1078        // the same relation's target is the page itself.
1079        let mut opts = options();
1080        opts.viewer = true;
1081        let mut scoped = likec4_scoped_view();
1082        scoped.svg = scoped.svg.replace(
1083            "<title>server&#45;&gt;driver_1</title>",
1084            "<title>driver_1&#45;&gt;app</title>",
1085        );
1086        scoped.dot = scoped.dot.map(|d| {
1087            d.replace(
1088                "server -> driver_1 [likec4_id=\"1ab\"];",
1089                "driver_1 -> app [likec4_id=\"1ab\"];",
1090            )
1091        });
1092        let mut model = sample();
1093        model
1094            .relations
1095            .push(relation("node_driver", "app", RelationKind::Uses, &["App"]));
1096        opts.views.insert("view_app".into(), scoped);
1097        let site = generate(&model, &opts);
1098        let app = page(&site, "containers/app.html");
1099        assert!(
1100            app.contains("\"target\":\"app\",\"target_href\":null"),
1101            "{app}"
1102        );
1103    }
1104
1105    #[test]
1106    fn an_edge_with_no_relation_behind_it_is_left_out_of_the_data() {
1107        let mut opts = options();
1108        opts.viewer = true;
1109        opts.views.insert("index".into(), likec4_view());
1110        let site = generate(&sample(), &opts);
1111        let index = page(&site, "index.html");
1112        assert!(index.contains("viewer-edges"), "{index}");
1113        assert!(!index.contains("lib->node_driver"), "{index}");
1114    }
1115
1116    #[test]
1117    fn json_escapes_what_would_break_a_string_or_a_script_element() {
1118        assert_eq!(json("a\"b\\c\nd<e"), "\"a\\\"b\\\\c\\nd\\u003ce\"");
1119    }
1120
1121    #[test]
1122    fn an_absent_text_is_json_null() {
1123        assert_eq!(json_opt(None), "null");
1124        assert_eq!(json_opt(Some("x".into())), "\"x\"");
1125    }
1126
1127    #[test]
1128    fn json_escapes_every_control_character_and_nothing_printable() {
1129        assert_eq!(json("\u{1}x\u{1f}"), "\"\\u0001x\\u001f\"");
1130        assert_eq!(json(" ~\u{7f}é"), "\" ~\u{7f}é\"");
1131    }
1132
1133    #[test]
1134    fn on_a_crate_page_a_module_node_links_to_its_section() {
1135        let app = app_page_with_scoped_view();
1136        assert!(
1137            app.contains("<a href=\"../containers/app.html#app.server\"><g id=\"view_app-node2\" class=\"node c4-k-component\">\n<title>app.server</title>"),
1138            "{app}"
1139        );
1140    }
1141
1142    #[test]
1143    fn on_a_crate_page_its_own_node_is_named() {
1144        let app = app_page_with_scoped_view();
1145        assert!(
1146            app.contains(
1147                "<g id=\"view_app-node1\" class=\"node c4-k-container\">\n<title>app</title>"
1148            ),
1149            "{app}"
1150        );
1151    }
1152
1153    #[test]
1154    fn on_a_crate_page_its_own_node_is_not_linked_to_itself() {
1155        let app = app_page_with_scoped_view();
1156        assert!(
1157            !app.contains("<a href=\"../containers/app.html\">"),
1158            "{app}"
1159        );
1160    }
1161
1162    #[test]
1163    fn on_a_crate_page_an_external_node_links_to_its_row_on_the_index() {
1164        let app = app_page_with_scoped_view();
1165        assert!(
1166            app.contains("<a href=\"../index.html#node_driver\"><g id=\"view_app-node3\" class=\"node c4-k-process\">\n<title>node_driver</title>"),
1167            "{app}"
1168        );
1169    }
1170
1171    #[test]
1172    fn theme_css_colors_every_kind_the_model_has() {
1173        let mut opts = options();
1174        opts.theme = BTreeMap::from([(
1175            "container".to_string(),
1176            ThemeColor {
1177                light: "#ce422b".into(),
1178                dark: None,
1179            },
1180        )]);
1181        let site = generate(&sample(), &opts);
1182        let kinds: BTreeSet<String> = ["component", "container", "process", "tests"]
1183            .iter()
1184            .map(|s| s.to_string())
1185            .collect();
1186        assert_eq!(
1187            page(&site, "theme.css"),
1188            theme::stylesheet(&kinds, &opts.theme)
1189        );
1190    }
1191
1192    #[test]
1193    fn a_view_likec4_drew_is_inlined_with_its_nodes_classed_by_kind() {
1194        let mut opts = options();
1195        opts.views.insert("index".into(), likec4_view());
1196        let site = generate(&sample(), &opts);
1197        let index = page(&site, "index.html");
1198        assert!(
1199            index.contains("<figure class=\"view\"><svg viewBox=\"0 0 10 10\" xmlns=\"http://www.w3.org/2000/svg\" class=\"c4\" data-view=\"index\" role=\"img\" aria-label=\"Overview\">"),
1200            "{index}"
1201        );
1202        assert!(
1203            index.contains("<a href=\"containers/app.html\"><g id=\"index-node1\" class=\"node c4-k-container\">\n<title>app</title>"),
1204            "{index}"
1205        );
1206        assert!(!index.contains("<img src=\"views/index.svg\""), "{index}");
1207    }
1208
1209    #[test]
1210    fn a_curated_view_likec4_drew_is_inlined_under_its_caption() {
1211        let mut opts = options();
1212        opts.views.insert("context".into(), likec4_view());
1213        let site = generate(&sample(), &opts);
1214        assert!(
1215            page(&site, "views.html")
1216                .contains("<figure class=\"view\"><figcaption>context</figcaption><svg "),
1217            "{}",
1218            page(&site, "views.html")
1219        );
1220    }
1221
1222    #[test]
1223    fn every_page_links_the_theme_stylesheet_between_its_own_and_the_host_s() {
1224        let mut opts = options();
1225        opts.stylesheet = Some("site.css".into());
1226        let site = generate(&sample(), &opts);
1227        assert!(
1228            page(&site, "containers/app.html").contains("<link rel=\"stylesheet\" href=\"../style.css\">\n<link rel=\"stylesheet\" href=\"../theme.css\">\n<link rel=\"stylesheet\" href=\"../site.css\">\n</head>"),
1229            "{}",
1230            page(&site, "containers/app.html")
1231        );
1232    }
1233
1234    #[test]
1235    fn the_scheme_toggle_links_the_script_and_puts_a_hidden_control_in_the_header() {
1236        let mut opts = options();
1237        opts.scheme_toggle = true;
1238        let site = generate(&sample(), &opts);
1239        let app = page(&site, "containers/app.html");
1240        assert!(
1241            app.contains("<link rel=\"stylesheet\" href=\"../theme.css\">\n<script src=\"../theme.js\"></script>\n</head>"),
1242            "{app}"
1243        );
1244        assert!(
1245            app.contains(&format!("</nav>{SCHEME_CONTROL}</div>")),
1246            "{app}"
1247        );
1248        assert!(
1249            app.contains("<html lang=\"en\" data-theme-default=\"system\">"),
1250            "{app}"
1251        );
1252    }
1253
1254    #[test]
1255    fn the_viewer_wraps_an_inlined_view_in_a_frame_under_hidden_controls() {
1256        let mut opts = options();
1257        opts.viewer = true;
1258        opts.views.insert("index".into(), likec4_bare_view());
1259        let site = generate(&sample(), &opts);
1260        let index = page(&site, "index.html");
1261        assert!(
1262            index.contains(&format!(
1263                "<figure class=\"view\" data-viewer>{VIEWER_BAR}<div class=\"viewer-frame\" role=\"group\" aria-label=\"Overview: diagram\"><svg viewBox="
1264            )),
1265            "{index}"
1266        );
1267    }
1268
1269    #[test]
1270    fn the_viewer_controls_ship_hidden() {
1271        assert!(VIEWER_BAR.starts_with("<div class=\"viewer-bar\" hidden>"));
1272    }
1273
1274    #[test]
1275    fn the_viewer_links_its_script_deferred_and_adds_it_to_the_page_set() {
1276        let mut opts = options();
1277        opts.viewer = true;
1278        let site = generate(&sample(), &opts);
1279        assert!(
1280            page(&site, "containers/app.html")
1281                .contains("<script src=\"../viewer.js\" defer></script>\n</head>"),
1282            "{}",
1283            page(&site, "containers/app.html")
1284        );
1285        assert_eq!(page(&site, "viewer.js"), VIEWER_SCRIPT);
1286    }
1287
1288    #[test]
1289    fn the_viewer_links_its_own_stylesheet_after_the_tree_s_and_adds_it_to_the_page_set() {
1290        let mut opts = options();
1291        opts.viewer = true;
1292        let site = generate(&sample(), &opts);
1293        assert!(
1294            page(&site, "containers/app.html").contains(
1295                "<link rel=\"stylesheet\" href=\"../theme.css\">\n<link rel=\"stylesheet\" href=\"../viewer.css\">\n"
1296            ),
1297            "{}",
1298            page(&site, "containers/app.html")
1299        );
1300        assert_eq!(page(&site, "viewer.css"), VIEWER_STYLESHEET);
1301    }
1302
1303    #[test]
1304    fn the_viewer_s_rules_live_in_its_own_stylesheet_not_the_page_s() {
1305        assert!(VIEWER_STYLESHEET.contains(".viewer-frame"));
1306        assert!(VIEWER_STYLESHEET.contains(".viewer-popover"));
1307        assert!(!STYLESHEET.contains("viewer-"), "{STYLESHEET}");
1308    }
1309
1310    #[test]
1311    fn the_viewer_leaves_an_image_fallback_as_a_bare_figure() {
1312        let mut opts = options();
1313        opts.viewer = true;
1314        opts.views.insert("context".into(), plain_view());
1315        let site = generate(&sample(), &opts);
1316        let views = page(&site, "views.html");
1317        assert!(
1318            views.contains("<figure class=\"view\"><figcaption>context</figcaption><img "),
1319            "{views}"
1320        );
1321    }
1322
1323    #[test]
1324    fn a_curated_view_puts_its_caption_before_the_controls() {
1325        let mut opts = options();
1326        opts.viewer = true;
1327        opts.views.insert("context".into(), likec4_bare_view());
1328        let site = generate(&sample(), &opts);
1329        let views = page(&site, "views.html");
1330        assert!(
1331            views.contains(&format!(
1332                "<figure class=\"view\" data-viewer><figcaption>context</figcaption>{VIEWER_BAR}<div class=\"viewer-frame\" role=\"group\" aria-label=\"context: diagram\">"
1333            )),
1334            "{views}"
1335        );
1336    }
1337
1338    #[test]
1339    fn without_the_viewer_there_is_no_script_and_no_frame() {
1340        let mut opts = options();
1341        opts.views.insert("index".into(), likec4_view());
1342        let site = generate(&sample(), &opts);
1343        assert!(!site.pages.contains_key("viewer.js"));
1344        assert!(!site.pages.contains_key("viewer.css"));
1345        for (path, contents) in site.pages.iter().filter(|(p, _)| p.ends_with(".html")) {
1346            assert!(!contents.contains("viewer.js"), "{path}: {contents}");
1347            assert!(!contents.contains("viewer.css"), "{path}: {contents}");
1348            assert!(!contents.contains("data-viewer"), "{path}: {contents}");
1349            assert!(!contents.contains("viewer-frame"), "{path}: {contents}");
1350            assert!(!contents.contains("viewer-edges"), "{path}: {contents}");
1351        }
1352    }
1353
1354    #[test]
1355    fn without_the_scheme_toggle_there_is_no_script_and_no_control() {
1356        let site = generate(&sample(), &options());
1357        for (path, contents) in &site.pages {
1358            assert!(!contents.contains("theme.js"), "{path}: {contents}");
1359            assert!(!contents.contains("class=\"scheme\""), "{path}: {contents}");
1360        }
1361    }
1362
1363    #[test]
1364    fn a_fixed_color_scheme_is_written_on_the_root() {
1365        let mut opts = options();
1366        opts.color_scheme = ColorScheme::Dark;
1367        let site = generate(&sample(), &opts);
1368        assert!(
1369            page(&site, "index.html").contains("<html lang=\"en\" data-theme=\"dark\">"),
1370            "{}",
1371            page(&site, "index.html")
1372        );
1373        opts.scheme_toggle = true;
1374        let site = generate(&sample(), &opts);
1375        assert!(
1376            page(&site, "index.html")
1377                .contains("<html lang=\"en\" data-theme=\"dark\" data-theme-default=\"dark\">"),
1378            "{}",
1379            page(&site, "index.html")
1380        );
1381    }
1382
1383    #[test]
1384    fn the_system_scheme_writes_no_scheme_on_the_root() {
1385        let site = generate(&sample(), &options());
1386        assert!(
1387            page(&site, "index.html").contains("<html lang=\"en\">"),
1388            "{}",
1389            page(&site, "index.html")
1390        );
1391    }
1392
1393    #[test]
1394    fn the_index_links_every_container_and_lists_the_externals_with_who_uses_them() {
1395        let site = generate(&sample(), &options());
1396        let index = page(&site, "index.html");
1397        // Table rows, not the header nav, which links the same pages.
1398        assert!(index.contains("<h2>Containers</h2>"), "{index}");
1399        assert!(
1400            index.contains(
1401                "<tr><td><a href=\"containers/app.html\">app</a></td><td>library crate</td>"
1402            ),
1403            "{index}"
1404        );
1405        assert!(
1406            index.contains("<tr><td><a href=\"containers/lib.html\">lib</a></td>"),
1407            "{index}"
1408        );
1409        assert!(!index.contains("containers/node_driver.html"), "{index}");
1410        assert!(
1411            index.contains(
1412                "<tr id=\"node_driver\"><td>node_driver</td><td>process</td><td>Node.js</td>"
1413            ),
1414            "{index}"
1415        );
1416        assert!(
1417            index.contains(
1418                "<a href=\"containers/app.html#app.server\">app.server</a> (uses: spawns)"
1419            ),
1420            "{index}"
1421        );
1422        assert!(index.contains("<h1>Sample</h1>"), "{index}");
1423        assert!(index.contains("<img src=\"views/index.svg\""), "{index}");
1424    }
1425
1426    #[test]
1427    fn a_container_page_lists_its_modules_as_a_nested_list_of_anchors() {
1428        let site = generate(&sample(), &options());
1429        let app = page(&site, "containers/app.html");
1430        let expected = "<ul>\n<li><a href=\"#app.server\">server</a></li>\n<li><a href=\"#app.tests\">tests</a></li>\n<li><a href=\"#app.view_\">view</a><ul>\n<li><a href=\"#app.view_.element\">element</a></li>\n</ul>\n</li>\n</ul>\n";
1431        assert!(app.contains(expected), "{app}");
1432        assert!(app.contains("<section id=\"app.view_.element\">\n<h2>element <small>app.view.element</small></h2>"), "{app}");
1433    }
1434
1435    #[test]
1436    fn an_element_section_lists_outgoing_and_incoming_relations_with_kind_items_and_technology() {
1437        let site = generate(&sample(), &options());
1438        let app = page(&site, "containers/app.html");
1439        let server = app
1440            .split("<section id=\"app.server\">")
1441            .nth(1)
1442            .unwrap()
1443            .split("</section>")
1444            .next()
1445            .unwrap();
1446        assert!(server.contains("<h3>Uses</h3>"), "{server}");
1447        assert!(server.contains("<tr><td>uses</td><td><a href=\"../index.html#node_driver\">node_driver</a></td><td>spawns</td><td>stdio</td></tr>"), "{server}");
1448        assert!(server.contains("<h3>Used by</h3>"), "{server}");
1449        assert!(server.contains("<tr><td>calls</td><td><a href=\"../containers/app.html#app.tests\">app.tests</a></td><td>Server, connect</td><td></td></tr>"), "{server}");
1450        // Empty items and no technology render as empty cells.
1451        assert!(server.contains("<tr><td>names</td><td><a href=\"../containers/app.html#app.view_\">app.view</a></td><td></td><td></td></tr>"), "{server}");
1452    }
1453
1454    #[test]
1455    fn a_relation_to_an_element_on_another_page_links_across_with_its_anchor() {
1456        let site = generate(&sample(), &options());
1457        let app = page(&site, "containers/app.html");
1458        assert!(
1459            app.contains(
1460                "<a href=\"../containers/lib.html#lib.util\">lib.util</a></td><td>Helper</td>"
1461            ),
1462            "{app}"
1463        );
1464        let lib = page(&site, "containers/lib.html");
1465        assert!(lib.contains("<h3>Used by</h3>"), "{lib}");
1466        assert!(
1467            lib.contains("<a href=\"../containers/app.html#app.server\">app.server</a>"),
1468            "{lib}"
1469        );
1470    }
1471
1472    #[test]
1473    fn a_model_with_only_externals_has_no_containers_table() {
1474        let mut model = sample();
1475        model.elements.retain(is_external);
1476        model.relations.clear();
1477        let mut opts = options();
1478        opts.views = BTreeMap::from([("index".to_string(), plain_view())]);
1479        let site = generate(&model, &opts);
1480        let index = page(&site, "index.html");
1481        assert!(!index.contains("<h2>Containers</h2>"), "{index}");
1482        assert!(index.contains("<h2>Externals</h2>"), "{index}");
1483        assert_eq!(
1484            site.pages.keys().collect::<Vec<_>>(),
1485            ["index.html", "style.css", "theme.css"]
1486        );
1487    }
1488
1489    #[test]
1490    fn a_model_with_only_containers_has_no_externals_table() {
1491        let mut model = sample();
1492        model.elements.retain(|e| !is_external(e));
1493        model.relations.retain(|r| r.target != id("node_driver"));
1494        let site = generate(&model, &options());
1495        assert!(!page(&site, "index.html").contains("<h2>Externals</h2>"));
1496    }
1497
1498    #[test]
1499    fn a_container_page_documents_its_own_descendants_only() {
1500        let site = generate(&sample(), &options());
1501        let app = page(&site, "containers/app.html");
1502        assert!(!app.contains("<section id=\"app\">"), "{app}");
1503        assert!(!app.contains("<section id=\"lib.util\">"), "{app}");
1504        assert!(!app.contains("<section id=\"lib\">"), "{app}");
1505        assert_eq!(app.matches("<section id=").count(), 4, "{app}");
1506    }
1507
1508    #[test]
1509    fn a_relation_to_a_container_links_its_page_without_a_fragment() {
1510        let site = generate(&sample(), &options());
1511        let lib = page(&site, "containers/lib.html");
1512        assert!(
1513            lib.contains("<tr><td>uses</td><td><a href=\"../containers/app.html\">app</a></td><td>App</td><td></td></tr>"),
1514            "{lib}"
1515        );
1516        let app = page(&site, "containers/app.html");
1517        let top = app.split("<section").next().unwrap();
1518        assert!(top.contains("<h3>Used by</h3>"), "{top}");
1519        assert!(
1520            top.contains(
1521                "<a href=\"../containers/lib.html#lib.util\">lib.util</a></td><td>App</td>"
1522            ),
1523            "{top}"
1524        );
1525    }
1526
1527    #[test]
1528    fn an_element_without_relations_has_no_tables() {
1529        let site = generate(&sample(), &options());
1530        let lib = page(&site, "containers/lib.html");
1531        let top = lib.split("<section").next().unwrap();
1532        assert!(
1533            !top.contains("<h3>Uses</h3>") && !top.contains("<h3>Used by</h3>"),
1534            "{top}"
1535        );
1536    }
1537
1538    #[test]
1539    fn an_element_with_children_embeds_its_scoped_view_and_a_leaf_does_not() {
1540        let site = generate(&sample(), &options());
1541        let app = page(&site, "containers/app.html");
1542        assert!(
1543            app.contains("<img src=\"../views/view_app.svg\" alt=\"app\""),
1544            "{app}"
1545        );
1546        assert!(
1547            app.contains("<img src=\"../views/view_app_view_.svg\" alt=\"app.view\""),
1548            "{app}"
1549        );
1550        let server = app
1551            .split("<section id=\"app.server\">")
1552            .nth(1)
1553            .unwrap()
1554            .split("</section>")
1555            .next()
1556            .unwrap();
1557        assert!(!server.contains("<img"), "{server}");
1558    }
1559
1560    #[test]
1561    fn a_reserved_word_id_uses_the_same_view_file_name_the_emitter_declares() {
1562        let model = sample();
1563        let text = emit(&model, &EmitOptions::for_output_path("m.c4"));
1564        assert!(
1565            text.contains("view view_app_view_ of app.view_ {"),
1566            "{text}"
1567        );
1568        let site = generate(&model, &options());
1569        assert!(page(&site, "containers/app.html").contains("views/view_app_view_.svg"));
1570    }
1571
1572    #[test]
1573    fn a_missing_view_is_a_placeholder_and_is_reported() {
1574        let mut opts = options();
1575        opts.views.remove("view_app");
1576        opts.views.remove("index");
1577        let site = generate(&sample(), &opts);
1578        assert_eq!(site.missing_views, ["index", "view_app"]);
1579        let app = page(&site, "containers/app.html");
1580        assert!(app.contains("<p class=\"missing\">No diagram for <code>view_app</code>: run <code>asbuilt render</code>.</p>"), "{app}");
1581        assert!(!app.contains("views/view_app.svg"), "{app}");
1582        assert!(page(&site, "index.html").contains("No diagram for <code>index</code>"));
1583    }
1584
1585    #[test]
1586    fn a_curated_svg_gets_a_section_on_the_views_page_and_none_means_no_page() {
1587        let mut opts = options();
1588        opts.views.insert("context".into(), plain_view());
1589        let site = generate(&sample(), &opts);
1590        let views = page(&site, "views.html");
1591        assert!(
1592            views.contains("<figcaption>context</figcaption><img src=\"views/context.svg\""),
1593            "{views}"
1594        );
1595        assert!(!views.contains("views/view_app.svg"), "{views}");
1596        assert!(
1597            !generate(&sample(), &options())
1598                .pages
1599                .contains_key("views.html")
1600        );
1601    }
1602
1603    #[test]
1604    fn escape_handles_the_five_characters() {
1605        assert_eq!(
1606            escape("<a href=\"x\">&'</a>"),
1607            "&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;"
1608        );
1609        assert_eq!(escape("plain"), "plain");
1610    }
1611
1612    #[test]
1613    fn a_lt_in_a_title_is_escaped() {
1614        let mut model = sample();
1615        model
1616            .elements
1617            .iter_mut()
1618            .find(|e| e.id == ["lib"])
1619            .unwrap()
1620            .title = "a<b".into();
1621        let site = generate(&model, &options());
1622        assert!(page(&site, "index.html").contains(">a&lt;b</a>"));
1623        assert!(page(&site, "containers/lib.html").contains("<h1>a&lt;b</h1>"));
1624    }
1625
1626    #[test]
1627    fn raw_html_in_a_description_is_text_inline_and_as_a_block() {
1628        assert_eq!(
1629            markdown("say <b>hi</b>"),
1630            "<p>say &lt;b&gt;hi&lt;/b&gt;</p>\n"
1631        );
1632        assert_eq!(
1633            markdown("<div>\nblock\n</div>"),
1634            "&lt;div&gt;\nblock\n&lt;/div&gt;"
1635        );
1636    }
1637
1638    #[test]
1639    fn backticks_and_emphasis_in_a_description_become_markup() {
1640        let site = generate(&sample(), &options());
1641        assert!(
1642            page(&site, "containers/app.html")
1643                .contains("<p>Where <code>Page</code> is <em>sent</em>.</p>")
1644        );
1645    }
1646
1647    #[test]
1648    fn a_source_url_makes_paths_links_and_its_absence_leaves_them_as_text() {
1649        let mut opts = options();
1650        opts.source_url = Some("https://github.com/o/r/blob/main/".into());
1651        let site = generate(&sample(), &opts);
1652        let app = page(&site, "containers/app.html");
1653        assert!(app.contains("<a href=\"https://github.com/o/r/blob/main/crates/app/src/server.rs\"><code>crates/app/src/server.rs</code></a>"), "{app}");
1654        // The root path links to the base itself.
1655        assert!(
1656            page(&site, "containers/lib.html")
1657                .contains("<a href=\"https://github.com/o/r/blob/main/\"><code>.</code></a>")
1658        );
1659        let plain = generate(&sample(), &options());
1660        assert!(
1661            page(&plain, "containers/app.html").contains("<code>crates/app/src/server.rs</code>")
1662        );
1663        assert!(!page(&plain, "containers/app.html").contains("blob/main"));
1664    }
1665
1666    #[test]
1667    fn a_space_in_a_path_is_percent_encoded_in_the_link_only() {
1668        let mut model = sample();
1669        model
1670            .elements
1671            .iter_mut()
1672            .find(|e| e.id == ["lib"])
1673            .unwrap()
1674            .path = Some("my lib".into());
1675        let mut opts = options();
1676        opts.source_url = Some("https://x/".into());
1677        let site = generate(&model, &opts);
1678        assert!(
1679            page(&site, "containers/lib.html")
1680                .contains("<a href=\"https://x/my%20lib\"><code>my lib</code></a>")
1681        );
1682    }
1683
1684    #[test]
1685    fn pages_below_the_root_link_the_stylesheet_and_the_index_through_the_parent() {
1686        let site = generate(&sample(), &options());
1687        let app = page(&site, "containers/app.html");
1688        assert!(
1689            app.contains("<link rel=\"stylesheet\" href=\"../style.css\">"),
1690            "{app}"
1691        );
1692        assert!(
1693            app.contains(&format!(
1694                "<a class=\"site\" href=\"../index.html\">{MARK}Sample</a>",
1695                MARK = mark_svg()
1696            )),
1697            "{app}"
1698        );
1699        assert!(app.contains("<title>Sample · app</title>"), "{app}");
1700        let index = page(&site, "index.html");
1701        assert!(
1702            index.contains("<link rel=\"stylesheet\" href=\"style.css\">"),
1703            "{index}"
1704        );
1705        assert!(
1706            index.contains(&format!(
1707                "<a class=\"site\" href=\"index.html\" aria-current=\"page\">{MARK}Sample</a>",
1708                MARK = mark_svg()
1709            )),
1710            "{index}"
1711        );
1712    }
1713
1714    #[test]
1715    fn the_tree_title_carries_the_asbuilt_mark_hidden_from_screen_readers() {
1716        assert_eq!(
1717            mark_svg(),
1718            format!(
1719                "<svg class=\"mark\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><path d=\"{}\"/></svg>",
1720                MARK_PATH.trim()
1721            )
1722        );
1723        assert!(
1724            MARK_PATH.starts_with("M14.24 3.21L17.61 1.26"),
1725            "{MARK_PATH}"
1726        );
1727        assert!(!MARK_PATH.trim().contains('\n'));
1728    }
1729
1730    #[test]
1731    fn a_container_page_s_header_is_the_trail_then_the_containers_with_this_one_current() {
1732        let site = generate(&sample(), &options());
1733        assert!(
1734            page(&site, "containers/app.html").contains(&format!("<header><div class=\"bar\"><nav class=\"trail\" aria-label=\"Breadcrumb\"><a class=\"site\" href=\"../index.html\">{MARK}Sample</a></nav></div><nav class=\"containers\" aria-label=\"Containers\"><span aria-current=\"page\">app</span><a href=\"../containers/lib.html\">lib</a></nav></header>", MARK = mark_svg())),
1735            "{}",
1736            page(&site, "containers/app.html")
1737        );
1738    }
1739
1740    #[test]
1741    fn no_page_repeats_the_trail_below_the_header() {
1742        let mut opts = options();
1743        opts.home_url = Some("../".into());
1744        let site = generate(&sample(), &opts);
1745        for (path, contents) in &site.pages {
1746            assert!(!contents.contains("crumbs"), "{path}: {contents}");
1747            assert_eq!(
1748                contents.matches("aria-label=\"Breadcrumb\"").count(),
1749                usize::from(path.ends_with(".html")),
1750                "{path}"
1751            );
1752        }
1753    }
1754
1755    #[test]
1756    fn a_model_without_containers_has_no_containers_row() {
1757        let mut model = sample();
1758        model.elements.retain(is_external);
1759        model.relations.clear();
1760        let site = generate(&model, &options());
1761        assert!(
1762            !page(&site, "index.html").contains("class=\"containers\""),
1763            "{}",
1764            page(&site, "index.html")
1765        );
1766    }
1767
1768    #[test]
1769    fn every_page_carries_the_generator_meta() {
1770        let site = generate(&sample(), &options());
1771        for (path, contents) in &site.pages {
1772            if path.ends_with(".html") {
1773                assert!(contents.contains(GENERATOR_META), "{path}: {contents}");
1774            }
1775        }
1776    }
1777
1778    #[test]
1779    fn no_image_is_lazy() {
1780        let site = generate(&sample(), &options());
1781        for (path, contents) in &site.pages {
1782            assert!(!contents.contains("loading="), "{path}: {contents}");
1783        }
1784    }
1785
1786    #[test]
1787    fn a_home_url_opens_the_trail_resolved_from_each_depth() {
1788        let mut opts = options();
1789        opts.home_url = Some("../".into());
1790        opts.home_title = Some("Home".into());
1791        let site = generate(&sample(), &opts);
1792        let index = page(&site, "index.html");
1793        assert!(
1794            index.contains(&format!("<nav class=\"trail\" aria-label=\"Breadcrumb\"><a class=\"home\" href=\"../\">Home</a><span class=\"sep\" aria-hidden=\"true\">/</span><a class=\"site\" href=\"index.html\" aria-current=\"page\">{MARK}Sample</a></nav>", MARK = mark_svg())),
1795            "{index}"
1796        );
1797        let app = page(&site, "containers/app.html");
1798        assert!(
1799            app.contains(&format!("<nav class=\"trail\" aria-label=\"Breadcrumb\"><a class=\"home\" href=\"../../\">Home</a><span class=\"sep\" aria-hidden=\"true\">/</span><a class=\"site\" href=\"../index.html\">{MARK}Sample</a></nav>", MARK = mark_svg())),
1800            "{app}"
1801        );
1802    }
1803
1804    #[test]
1805    fn the_index_title_names_the_tree_once() {
1806        let site = generate(&sample(), &options());
1807        let index = page(&site, "index.html");
1808        assert!(index.contains("<title>Sample</title>"), "{index}");
1809    }
1810
1811    #[test]
1812    fn a_home_title_starts_the_index_title() {
1813        let mut opts = options();
1814        opts.home_title = Some("Host".into());
1815        let site = generate(&sample(), &opts);
1816        let index = page(&site, "index.html");
1817        assert!(index.contains("<title>Host · Sample</title>"), "{index}");
1818    }
1819
1820    #[test]
1821    fn a_home_title_starts_a_container_page_title() {
1822        let mut opts = options();
1823        opts.home_title = Some("Host".into());
1824        let site = generate(&sample(), &opts);
1825        let app = page(&site, "containers/app.html");
1826        assert!(app.contains("<title>Host · Sample · app</title>"), "{app}");
1827    }
1828
1829    #[test]
1830    fn an_absolute_home_url_is_verbatim_and_names_itself_without_a_title() {
1831        let mut opts = options();
1832        opts.home_url = Some("https://example.test/".into());
1833        let site = generate(&sample(), &opts);
1834        let app = page(&site, "containers/app.html");
1835        assert!(
1836            app.contains(
1837                "<a class=\"home\" href=\"https://example.test/\">https://example.test/</a>"
1838            ),
1839            "{app}"
1840        );
1841    }
1842
1843    #[test]
1844    fn without_a_home_url_the_trail_is_the_tree_s_title_alone() {
1845        let site = generate(&sample(), &options());
1846        let index = page(&site, "index.html");
1847        assert!(!index.contains("class=\"home\""), "{index}");
1848        assert!(!index.contains("class=\"sep\""), "{index}");
1849        assert!(
1850            index.contains(&format!("<nav class=\"trail\" aria-label=\"Breadcrumb\"><a class=\"site\" href=\"index.html\" aria-current=\"page\">{MARK}Sample</a></nav>", MARK = mark_svg())),
1851            "{index}"
1852        );
1853    }
1854
1855    #[test]
1856    fn a_stylesheet_is_linked_last_in_the_head_and_resolved_from_each_depth() {
1857        let mut opts = options();
1858        opts.stylesheet = Some("site.css".into());
1859        let site = generate(&sample(), &opts);
1860        let index = page(&site, "index.html");
1861        assert!(
1862            index.contains("<link rel=\"stylesheet\" href=\"theme.css\">\n<link rel=\"stylesheet\" href=\"site.css\">\n</head>"),
1863            "{index}"
1864        );
1865        let app = page(&site, "containers/app.html");
1866        assert!(
1867            app.contains("<link rel=\"stylesheet\" href=\"../theme.css\">\n<link rel=\"stylesheet\" href=\"../site.css\">\n</head>"),
1868            "{app}"
1869        );
1870        let mut opts = options();
1871        opts.stylesheet = Some("/theme.css".into());
1872        let site = generate(&sample(), &opts);
1873        assert!(
1874            page(&site, "containers/app.html").contains("href=\"/theme.css\">\n</head>"),
1875            "root-relative is verbatim"
1876        );
1877        assert!(
1878            !page(&site, "index.html").contains("site.css"),
1879            "no host link without a stylesheet"
1880        );
1881    }
1882
1883    #[test]
1884    fn the_stylesheet_defines_the_page_tokens_for_light_and_dark_and_no_white_slab() {
1885        for token in ["--bg:", "--fg:", "--muted:", "--accent:", "--figure-bg:"] {
1886            assert!(STYLESHEET.contains(token), "{token}");
1887        }
1888        assert!(STYLESHEET.contains("@media (prefers-color-scheme: dark)"));
1889        assert!(!STYLESHEET.contains("background: #fff"), "{STYLESHEET}");
1890        assert!(STYLESHEET.contains(".modules ul"), "{STYLESHEET}");
1891    }
1892
1893    #[test]
1894    fn a_missing_view_placeholder_escapes_the_view_name() {
1895        // Generated view names are sanitized to identifier characters, so
1896        // only a direct call can hand the placeholder a name to escape.
1897        let model = sample();
1898        let opts = options();
1899        let mut ctx = Ctx {
1900            model: &model,
1901            options: &opts,
1902            views: BTreeMap::new(),
1903            places: BTreeMap::new(),
1904            curated: vec![],
1905            by_likec4: BTreeMap::new(),
1906            missing: BTreeSet::new(),
1907        };
1908        assert_eq!(
1909            ctx.view_figure(0, &BTreeMap::new(), "a<b", "alt"),
1910            "<p class=\"missing\">No diagram for <code>a&lt;b</code>: run <code>asbuilt render</code>.</p>\n"
1911        );
1912    }
1913
1914    #[test]
1915    fn a_page_below_the_root_links_the_curated_views_through_the_parent() {
1916        let mut opts = options();
1917        opts.views.insert("context".into(), plain_view());
1918        let site = generate(&sample(), &opts);
1919        let app = page(&site, "containers/app.html");
1920        assert!(
1921            app.contains("<nav class=\"views\" aria-label=\"Views\"><a href=\"../views.html\">Curated views</a></nav>"),
1922            "{app}"
1923        );
1924    }
1925
1926    #[test]
1927    fn the_views_page_marks_its_own_header_link_current() {
1928        let mut opts = options();
1929        opts.views.insert("context".into(), plain_view());
1930        let site = generate(&sample(), &opts);
1931        let views = page(&site, "views.html");
1932        assert!(
1933            views.contains("<nav class=\"views\" aria-label=\"Views\"><span aria-current=\"page\">Curated views</span></nav>"),
1934            "{views}"
1935        );
1936    }
1937
1938    #[test]
1939    fn a_tree_with_no_curated_views_has_no_views_link() {
1940        let site = generate(&sample(), &options());
1941        assert!(
1942            site.pages.values().all(|p| !p.contains("class=\"views\"")),
1943            "a page links a views page that does not exist"
1944        );
1945    }
1946
1947    #[test]
1948    fn the_views_page_trail_links_the_index_without_marking_it_current() {
1949        let mut opts = options();
1950        opts.views.insert("context".into(), plain_view());
1951        let site = generate(&sample(), &opts);
1952        assert!(
1953            page(&site, "views.html").contains(&format!(
1954                "<a class=\"site\" href=\"index.html\">{MARK}Sample</a></nav>",
1955                MARK = mark_svg()
1956            )),
1957            "{}",
1958            page(&site, "views.html")
1959        );
1960    }
1961
1962    #[test]
1963    fn the_header_s_container_row_is_a_labeled_navigation_region() {
1964        let site = generate(&sample(), &options());
1965        assert!(
1966            page(&site, "index.html")
1967                .contains("<nav class=\"containers\" aria-label=\"Containers\"><a href=\"containers/app.html\">app</a><a href=\"containers/lib.html\">lib</a></nav>"),
1968            "{}",
1969            page(&site, "index.html")
1970        );
1971    }
1972
1973    #[test]
1974    fn the_header_lists_every_container_but_no_external() {
1975        let site = generate(&sample(), &options());
1976        let index = page(&site, "index.html");
1977        let header = index
1978            .split("<header>")
1979            .nth(1)
1980            .unwrap()
1981            .split("</header>")
1982            .next()
1983            .unwrap();
1984        assert!(
1985            header.contains("<a href=\"containers/app.html\">app</a>"),
1986            "{header}"
1987        );
1988        assert!(
1989            header.contains("<a href=\"containers/lib.html\">lib</a>"),
1990            "{header}"
1991        );
1992        assert!(!header.contains("node_driver"), "{header}");
1993    }
1994
1995    #[test]
1996    fn technology_tags_and_path_share_the_meta_line() {
1997        let site = generate(&sample(), &options());
1998        let app = page(&site, "containers/app.html");
1999        assert!(
2000            app.contains("<p class=\"meta\">library crate · <code>crates/app</code></p>"),
2001            "{app}"
2002        );
2003        assert!(
2004            app.contains("<p class=\"meta\"><span class=\"tag\">tests</span></p>"),
2005            "{app}"
2006        );
2007    }
2008
2009    #[test]
2010    fn an_element_with_nothing_to_say_has_no_meta_line() {
2011        let site = generate(&sample(), &options());
2012        let app = page(&site, "containers/app.html");
2013        let view = app
2014            .split("<section id=\"app.view_\">")
2015            .nth(1)
2016            .unwrap()
2017            .split("<section")
2018            .next()
2019            .unwrap();
2020        assert!(!view.contains("class=\"meta\""), "{view}");
2021    }
2022
2023    #[test]
2024    fn output_does_not_depend_on_input_order() {
2025        let a = generate(&sample(), &options());
2026        let mut shuffled = sample();
2027        shuffled.elements.reverse();
2028        shuffled.relations.reverse();
2029        let b = generate(&shuffled, &options());
2030        assert_eq!(a, b);
2031    }
2032
2033    #[test]
2034    fn a_relative_prefix_climbs_one_directory_per_depth() {
2035        assert_eq!(up(0), "");
2036        assert_eq!(up(2), "../../");
2037        let place = Place {
2038            page: "index.html".into(),
2039            fragment: Some("x".into()),
2040        };
2041        assert_eq!(place.href(1), "../index.html#x");
2042        assert_eq!(container_page(&id("my-app")), "containers/my_app.html");
2043    }
2044}