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