Skip to main content

asbuilt_core/
docs.rs

1//! The documentation generator: a [`Model`] and the SVGs `asbuilt render`
2//! produced, to a static HTML tree that mounts anywhere. A view LikeC4
3//! drew is inlined into its page ([`crate::svg`]) and colored by
4//! `theme.css` ([`crate::theme`]) for the system's light or dark scheme or
5//! a visitor's choice; `theme.js`, the tree's one script, carries that
6//! choice and is left out when `scheme_toggle` is off.
7//!
8//! Pure: no filesystem, no process. LikeC4 and Graphviz supply the
9//! pictures; this module supplies what only the model knows (each crate
10//! and module, its path and doc paragraph, what it references and what
11//! references it). Every link is relative and no page is deeper than one
12//! directory, so the tree serves from `file://`, a GitHub Pages
13//! subdirectory, or a versioned snapshot unchanged.
14
15use std::collections::{BTreeMap, BTreeSet};
16use std::fmt::Write;
17
18use pulldown_cmark::{Event, Options, Parser, html};
19
20use crate::config::{ColorScheme, ThemeColor};
21use crate::emit::{children_of, link_encode, view_ids};
22use crate::model::{Element, ElementKind, Id, Model, Relation, dotted, sanitize_id};
23use crate::svg::{self, ViewSource};
24use crate::theme;
25
26/// What the pages need beyond the model.
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
28pub struct DocsOptions {
29    pub title: String,
30    /// A prefix that turns a repo-relative path into a link
31    /// (`https://github.com/owner/repo/blob/main/`); paths are plain
32    /// text when absent.
33    pub source_url: Option<String>,
34    /// Where the tree is mounted: a link back to the site that hosts it,
35    /// first in every page's header trail. Relative
36    /// values (`../`) are resolved from each page's own depth, so they
37    /// hold under a versioned snapshot; absolute URLs are used verbatim.
38    pub home_url: Option<String>,
39    /// The text of that link; `home_url` itself when absent.
40    pub home_title: Option<String>,
41    /// A stylesheet linked last in every page's head, after the tree's
42    /// own, so a host site can restate the page tokens in its palette.
43    /// Resolved like `home_url`.
44    pub stylesheet: Option<String>,
45    /// The rendered views under `views/`, by file stem (`index`,
46    /// `view_app`, `context`): the SVG and the `.dot` beside it. A view
47    /// with a LikeC4 `.dot` is inlined into its page; any other SVG is an
48    /// image.
49    pub views: BTreeMap<String, ViewSource>,
50    /// A color per element kind from `[theme]`, the source of `theme.css`.
51    pub theme: BTreeMap<String, ThemeColor>,
52    /// The scheme the pages show before a visitor chooses one.
53    pub color_scheme: ColorScheme,
54    /// Whether every page carries a System / Light / Dark control, and
55    /// with it `theme.js`, the tree's one script.
56    pub scheme_toggle: bool,
57}
58
59/// The `<meta name="generator">` every page carries. `asbuilt docs` looks
60/// for it before clearing an output directory: a tree that has it was
61/// written by a previous run and is safe to replace.
62pub const GENERATOR_META: &str = "<meta name=\"generator\" content=\"asbuilt docs\">";
63
64/// The generated tree.
65#[derive(Debug, Clone, PartialEq, Eq, Default)]
66pub struct Site {
67    /// Relative `/`-separated path to contents, `style.css` included.
68    pub pages: BTreeMap<String, String>,
69    /// Views a page wanted and `options.views` lacked, sorted.
70    pub missing_views: Vec<String>,
71}
72
73/// The stylesheet every page links.
74pub const STYLESHEET: &str = include_str!("docs.css");
75
76/// The asbuilt mark as SVG path data for a `0 0 24 24` viewBox, a copy of
77/// `brand/mark.path` (kept identical by `scripts/check-brand-copy.sh`).
78pub const MARK_PATH: &str = include_str!("mark.path");
79
80/// The mark as every page's header draws it, before the tree's title: the
81/// section asbuilt generated, in the page's accent. Decorative, since the
82/// title beside it is the link's name.
83fn mark_svg() -> String {
84    format!(
85        "<svg class=\"mark\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><path d=\"{}\"/></svg>",
86        MARK_PATH.trim()
87    )
88}
89
90/// The script behind the scheme control, linked in every page's head.
91pub const SCHEME_SCRIPT: &str = include_str!("theme.js");
92
93/// The visitor's scheme control. It ships hidden, and `theme.js` reveals
94/// it, so a page without the script shows no dead control.
95const 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>";
96
97/// HTML-escape the five characters that matter in text and attributes.
98///
99/// ```
100/// assert_eq!(asbuilt_core::docs::escape("a < b & \"c\""), "a &lt; b &amp; &quot;c&quot;");
101/// ```
102pub fn escape(text: &str) -> String {
103    let mut out = String::with_capacity(text.len());
104    for c in text.chars() {
105        match c {
106            '&' => out.push_str("&amp;"),
107            '<' => out.push_str("&lt;"),
108            '>' => out.push_str("&gt;"),
109            '"' => out.push_str("&quot;"),
110            '\'' => out.push_str("&#39;"),
111            c => out.push(c),
112        }
113    }
114    out
115}
116
117/// A description paragraph as HTML. Raw HTML in the source is rendered
118/// as text: the pages are published, and a `<script>` in a doc comment
119/// must not become one here.
120pub fn markdown(text: &str) -> String {
121    let events = Parser::new_ext(text, Options::empty()).map(|event| match event {
122        Event::Html(raw) | Event::InlineHtml(raw) => Event::Text(raw),
123        other => other,
124    });
125    let mut out = String::new();
126    html::push_html(&mut out, events);
127    out
128}
129
130/// The page of a top-level element.
131pub fn container_page(id: &[String]) -> String {
132    format!("containers/{}.html", sanitize_id(id))
133}
134
135/// The fragment id of an element within its container's page.
136pub fn anchor(id: &[String]) -> String {
137    sanitize_id(id)
138}
139
140/// Where an element is documented: its page, and the fragment when it
141/// is not the page's own subject.
142#[derive(Debug, Clone, PartialEq, Eq)]
143struct Place {
144    page: String,
145    fragment: Option<String>,
146}
147
148impl Place {
149    fn href(&self, depth: usize) -> String {
150        let mut out = format!("{}{}", up(depth), self.page);
151        if let Some(fragment) = &self.fragment {
152            out.push('#');
153            out.push_str(fragment);
154        }
155        out
156    }
157}
158
159/// Which page a layout is for, so the header can mark it current.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161enum Here<'a> {
162    Index,
163    Container(&'a [String]),
164    Other,
165}
166
167/// The relative prefix from a page at `depth` back to the tree root.
168fn up(depth: usize) -> String {
169    "../".repeat(depth)
170}
171
172/// A configured URL as a page at `depth` should write it: absolute URLs
173/// and root-relative paths verbatim, anything else relative to the tree
174/// root and so prefixed by the climb.
175fn resolve(depth: usize, url: &str) -> String {
176    if url.contains("://") || url.starts_with('/') {
177        url.to_string()
178    } else {
179        format!("{}{url}", up(depth))
180    }
181}
182
183fn is_external(element: &Element) -> bool {
184    matches!(element.kind, ElementKind::External(_))
185}
186
187struct Ctx<'a> {
188    model: &'a Model,
189    options: &'a DocsOptions,
190    /// Generated view name per element with children.
191    views: BTreeMap<Id, String>,
192    /// Where every element is documented.
193    places: BTreeMap<Id, Place>,
194    /// The element kind of each LikeC4 id, for the classes an inlined
195    /// view's nodes carry.
196    kinds: BTreeMap<String, String>,
197    missing: BTreeSet<String>,
198}
199
200impl Ctx<'_> {
201    /// The home link as `<a class="home">`, when there is a home.
202    fn home_link(&self, depth: usize) -> Option<String> {
203        let url = self.options.home_url.as_deref()?;
204        let title = self.options.home_title.as_deref().unwrap_or(url);
205        Some(format!(
206            "<a class=\"home\" href=\"{}\">{}</a>",
207            escape(&resolve(depth, url)),
208            escape(title)
209        ))
210    }
211
212    /// A page: the header's trail (the home link, when there is one, and
213    /// the tree's index) with the scheme control, the row of containers
214    /// with the current one unlinked, then `body`. Every page's own
215    /// heading names it, so nothing below the header repeats the trail.
216    fn layout(&self, depth: usize, here: Here<'_>, page_title: &str, body: &str) -> String {
217        let prefix = up(depth);
218        let mut containers = String::new();
219        for element in children_of(self.model, &[]) {
220            if is_external(element) {
221                continue;
222            }
223            if here == Here::Container(&element.id) {
224                let _ = write!(
225                    containers,
226                    "<span aria-current=\"page\">{}</span>",
227                    escape(&element.title)
228                );
229            } else {
230                let _ = write!(
231                    containers,
232                    r#"<a href="{prefix}{}">{}</a>"#,
233                    container_page(&element.id),
234                    escape(&element.title)
235                );
236            }
237        }
238        let containers = if containers.is_empty() {
239            String::new()
240        } else {
241            format!("<nav class=\"containers\" aria-label=\"Containers\">{containers}</nav>")
242        };
243        let home = self
244            .home_link(depth)
245            .map(|link| format!("{link}<span class=\"sep\" aria-hidden=\"true\">/</span>"))
246            .unwrap_or_default();
247        let index_current = if here == Here::Index {
248            " aria-current=\"page\""
249        } else {
250            ""
251        };
252        let host_css = self
253            .options
254            .stylesheet
255            .as_deref()
256            .map(|css| {
257                format!(
258                    "<link rel=\"stylesheet\" href=\"{}\">\n",
259                    escape(&resolve(depth, css))
260                )
261            })
262            .unwrap_or_default();
263        let scheme = self.options.color_scheme;
264        let toggle = self.options.scheme_toggle;
265        let mut root_attrs = String::new();
266        if scheme != ColorScheme::System {
267            let _ = write!(root_attrs, " data-theme=\"{}\"", scheme.as_str());
268        }
269        if toggle {
270            let _ = write!(root_attrs, " data-theme-default=\"{}\"", scheme.as_str());
271        }
272        let script = if toggle {
273            format!("<script src=\"{prefix}theme.js\"></script>\n")
274        } else {
275            String::new()
276        };
277        let control = if toggle { SCHEME_CONTROL } else { "" };
278        format!(
279            "<!doctype html>\n<html lang=\"en\"{root_attrs}>\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n{GENERATOR_META}\n<title>{page} · {site}</title>\n<link rel=\"stylesheet\" href=\"{prefix}style.css\">\n<link rel=\"stylesheet\" href=\"{prefix}theme.css\">\n{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>{control}</div>{containers}</header>\n<main>\n{body}</main>\n</body>\n</html>\n",
280            page = escape(page_title),
281            site = escape(&self.options.title),
282            mark = mark_svg(),
283        )
284    }
285
286    /// A view as the page shows it: inlined when LikeC4 drew it, so the
287    /// page's colors reach it, else an image; `None` when there is no SVG
288    /// for it.
289    fn view_markup(&self, depth: usize, view: &str, alt: &str) -> Option<String> {
290        let source = self.options.views.get(view)?;
291        Some(match svg::inline(source, view, alt, &self.kinds) {
292            Some(inline) => inline.trim_end().to_string(),
293            None => format!(
294                "<img src=\"{}views/{}.svg\" alt=\"{}\">",
295                up(depth),
296                escape(view),
297                escape(alt)
298            ),
299        })
300    }
301
302    /// The `<figure>` for a view, or the placeholder when its SVG is
303    /// missing (recorded for the report).
304    fn view_figure(&mut self, depth: usize, view: &str, alt: &str) -> String {
305        match self.view_markup(depth, view, alt) {
306            Some(markup) => format!("<figure class=\"view\">{markup}</figure>\n"),
307            None => {
308                self.missing.insert(view.to_string());
309                format!(
310                    "<p class=\"missing\">No diagram for <code>{}</code>: run <code>asbuilt render</code>.</p>\n",
311                    escape(view)
312                )
313            }
314        }
315    }
316
317    fn path_html(&self, path: &str) -> String {
318        match &self.options.source_url {
319            Some(base) => {
320                let target = if path == "." {
321                    String::new()
322                } else {
323                    link_encode(path)
324                };
325                format!(
326                    "<a href=\"{}{}\"><code>{}</code></a>",
327                    escape(base),
328                    target,
329                    escape(path)
330                )
331            }
332            None => format!("<code>{}</code>", escape(path)),
333        }
334    }
335
336    /// Technology, tags and path on one muted line, then the description.
337    fn meta_html(&self, element: &Element) -> String {
338        let mut out = String::new();
339        let mut parts: Vec<String> = Vec::new();
340        if let Some(technology) = &element.technology {
341            parts.push(escape(technology));
342        }
343        for tag in &element.tags {
344            parts.push(format!("<span class=\"tag\">{}</span>", escape(tag)));
345        }
346        if let Some(path) = &element.path {
347            parts.push(self.path_html(path));
348        }
349        if !parts.is_empty() {
350            let _ = writeln!(out, "<p class=\"meta\">{}</p>", parts.join(" · "));
351        }
352        if let Some(description) = &element.description {
353            out.push_str(&markdown(description));
354        }
355        out
356    }
357
358    fn link_to(&self, id: &Id, depth: usize) -> String {
359        match self.places.get(id) {
360            Some(place) => format!(
361                "<a href=\"{}\">{}</a>",
362                place.href(depth),
363                escape(&dotted(id))
364            ),
365            None => escape(&dotted(id)),
366        }
367    }
368
369    fn relation_row(&self, relation: &Relation, other: &Id, depth: usize) -> String {
370        format!(
371            "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
372            relation.kind.keyword(),
373            self.link_to(other, depth),
374            escape(&relation.items.join(", ")),
375            relation
376                .technology
377                .as_deref()
378                .map(escape)
379                .unwrap_or_default()
380        )
381    }
382
383    /// "Uses" (this element as source) and "Used by" (as target).
384    fn relation_tables(&self, id: &Id, depth: usize) -> String {
385        let mut out = String::new();
386        let uses: Vec<&Relation> = self
387            .model
388            .relations
389            .iter()
390            .filter(|r| &r.source == id)
391            .collect();
392        let used_by: Vec<&Relation> = self
393            .model
394            .relations
395            .iter()
396            .filter(|r| &r.target == id)
397            .collect();
398        for (heading, rows, column) in [("Uses", uses, "Target"), ("Used by", used_by, "Source")] {
399            if rows.is_empty() {
400                continue;
401            }
402            let _ = writeln!(
403                out,
404                "<h3>{heading}</h3>\n<table><thead><tr><th>Kind</th><th>{column}</th><th>Items</th><th>Technology</th></tr></thead><tbody>"
405            );
406            for relation in rows {
407                let other = if heading == "Uses" {
408                    &relation.target
409                } else {
410                    &relation.source
411                };
412                out.push_str(&self.relation_row(relation, other, depth));
413            }
414            out.push_str("</tbody></table>\n");
415        }
416        out
417    }
418
419    /// The nested list of an element's descendants, as anchors.
420    fn module_list(&self, parent: &Id) -> String {
421        let children = children_of(self.model, parent);
422        if children.is_empty() {
423            return String::new();
424        }
425        let mut out = String::from("<ul>\n");
426        for child in children {
427            let _ = writeln!(
428                out,
429                "<li><a href=\"#{}\">{}</a>{}</li>",
430                anchor(&child.id),
431                escape(&child.title),
432                self.module_list(&child.id)
433            );
434        }
435        out.push_str("</ul>\n");
436        out
437    }
438
439    fn index_page(&mut self) -> String {
440        let mut body = String::new();
441        let _ = writeln!(body, "<h1>{}</h1>", escape(&self.options.title));
442        body.push_str(&self.view_figure(0, "index", "Overview"));
443        let top = children_of(self.model, &[]);
444        let containers: Vec<&Element> = top.iter().copied().filter(|e| !is_external(e)).collect();
445        let externals: Vec<&Element> = top.iter().copied().filter(|e| is_external(e)).collect();
446        if !containers.is_empty() {
447            body.push_str("<h2>Containers</h2>\n<table><thead><tr><th>Name</th><th>Technology</th><th>Description</th></tr></thead><tbody>\n");
448            for element in &containers {
449                let _ = writeln!(
450                    body,
451                    "<tr><td><a href=\"{}\">{}</a></td><td>{}</td><td>{}</td></tr>",
452                    container_page(&element.id),
453                    escape(&element.title),
454                    element
455                        .technology
456                        .as_deref()
457                        .map(escape)
458                        .unwrap_or_default(),
459                    element
460                        .description
461                        .as_deref()
462                        .map(markdown)
463                        .unwrap_or_default()
464                );
465            }
466            body.push_str("</tbody></table>\n");
467        }
468        if !externals.is_empty() {
469            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");
470            for element in &externals {
471                let kind = match &element.kind {
472                    ElementKind::External(kind) => kind.as_str(),
473                    _ => "",
474                };
475                let users: Vec<String> = self
476                    .model
477                    .relations
478                    .iter()
479                    .filter(|r| r.target == element.id)
480                    .map(|r| {
481                        format!(
482                            "{} ({}: {})",
483                            self.link_to(&r.source, 0),
484                            r.kind.keyword(),
485                            escape(&r.items.join(", "))
486                        )
487                    })
488                    .collect();
489                let _ = writeln!(
490                    body,
491                    "<tr id=\"{}\"><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
492                    anchor(&element.id),
493                    escape(&element.title),
494                    escape(kind),
495                    element
496                        .technology
497                        .as_deref()
498                        .map(escape)
499                        .unwrap_or_default(),
500                    element
501                        .description
502                        .as_deref()
503                        .map(markdown)
504                        .unwrap_or_default(),
505                    users.join("<br>")
506                );
507            }
508            body.push_str("</tbody></table>\n");
509        }
510        let title = self.options.title.clone();
511        self.layout(0, Here::Index, &title, &body)
512    }
513
514    fn container_page_html(&mut self, element: &Element) -> String {
515        let mut body = String::new();
516        let _ = writeln!(body, "<h1>{}</h1>", escape(&element.title));
517        body.push_str(&self.meta_html(element));
518        if let Some(view) = self.views.get(&element.id).cloned() {
519            body.push_str(&self.view_figure(1, &view, &element.title));
520        }
521        let modules = self.module_list(&element.id);
522        if !modules.is_empty() {
523            let _ = write!(
524                body,
525                "<h2>Modules</h2>\n<div class=\"modules\">{modules}</div>\n"
526            );
527        }
528        body.push_str(&self.relation_tables(&element.id, 1));
529        let descendants: Vec<&Element> = self
530            .model
531            .elements
532            .iter()
533            .filter(|e| e.id.len() > element.id.len() && e.id.starts_with(&element.id))
534            .collect();
535        for descendant in descendants {
536            let _ = writeln!(
537                body,
538                "<section id=\"{}\">\n<h2>{} <small>{}</small></h2>",
539                anchor(&descendant.id),
540                escape(descendant.id.last().map(String::as_str).unwrap_or_default()),
541                escape(&dotted(&descendant.id))
542            );
543            body.push_str(&self.meta_html(descendant));
544            if let Some(view) = self.views.get(&descendant.id).cloned() {
545                body.push_str(&self.view_figure(1, &view, &dotted(&descendant.id)));
546            }
547            body.push_str(&self.relation_tables(&descendant.id, 1));
548            body.push_str("</section>\n");
549        }
550        self.layout(1, Here::Container(&element.id), &element.title, &body)
551    }
552
553    /// Curated views: stems that are neither `index` nor generated.
554    fn views_page(&mut self) -> Option<String> {
555        let generated: BTreeSet<&String> = self.views.values().collect();
556        let options = self.options;
557        let curated: Vec<&String> = options
558            .views
559            .keys()
560            .filter(|v| v.as_str() != "index" && !generated.contains(v))
561            .collect();
562        if curated.is_empty() {
563            return None;
564        }
565        let mut body = String::from("<h1>Curated views</h1>\n");
566        for view in curated {
567            let markup = self.view_markup(0, view, view).unwrap_or_default();
568            let _ = writeln!(
569                body,
570                "<figure class=\"view\"><figcaption>{}</figcaption>{markup}</figure>",
571                escape(view)
572            );
573        }
574        Some(self.layout(0, Here::Other, "Curated views", &body))
575    }
576}
577
578/// The whole tree for a model. The model is normalized on a copy first,
579/// so the caller's element order does not matter.
580pub fn generate(model: &Model, options: &DocsOptions) -> Site {
581    let mut model = model.clone();
582    model.normalize();
583    let views = view_ids(&model);
584
585    let mut places: BTreeMap<Id, Place> = BTreeMap::new();
586    for element in children_of(&model, &[]) {
587        if is_external(element) {
588            places.insert(
589                element.id.clone(),
590                Place {
591                    page: "index.html".into(),
592                    fragment: Some(anchor(&element.id)),
593                },
594            );
595            continue;
596        }
597        let page = container_page(&element.id);
598        places.insert(
599            element.id.clone(),
600            Place {
601                page: page.clone(),
602                fragment: None,
603            },
604        );
605        for descendant in model
606            .elements
607            .iter()
608            .filter(|e| e.id.len() > element.id.len() && e.id.starts_with(&element.id))
609        {
610            places.insert(
611                descendant.id.clone(),
612                Place {
613                    page: page.clone(),
614                    fragment: Some(anchor(&descendant.id)),
615                },
616            );
617        }
618    }
619
620    let kinds: BTreeMap<String, String> = model
621        .elements
622        .iter()
623        .map(|e| (sanitize_id(&e.id), e.kind.keyword().to_string()))
624        .collect();
625    let kind_names: BTreeSet<String> = kinds.values().cloned().collect();
626    let mut ctx = Ctx {
627        model: &model,
628        options,
629        views,
630        places,
631        kinds,
632        missing: BTreeSet::new(),
633    };
634    let mut pages = BTreeMap::new();
635    pages.insert("index.html".to_string(), ctx.index_page());
636    let containers: Vec<Element> = children_of(&model, &[])
637        .into_iter()
638        .filter(|e| !is_external(e))
639        .cloned()
640        .collect();
641    for element in &containers {
642        pages.insert(
643            container_page(&element.id),
644            ctx.container_page_html(element),
645        );
646    }
647    if let Some(views_page) = ctx.views_page() {
648        pages.insert("views.html".to_string(), views_page);
649    }
650    pages.insert("style.css".to_string(), STYLESHEET.to_string());
651    pages.insert(
652        "theme.css".to_string(),
653        theme::stylesheet(&kind_names, &options.theme),
654    );
655    if options.scheme_toggle {
656        pages.insert("theme.js".to_string(), SCHEME_SCRIPT.to_string());
657    }
658    Site {
659        pages,
660        missing_views: ctx.missing.into_iter().collect(),
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use crate::emit::{EmitOptions, emit};
668    use crate::model::RelationKind;
669
670    fn id(s: &str) -> Id {
671        s.split('.').map(str::to_string).collect()
672    }
673
674    fn element(s: &str, kind: ElementKind) -> Element {
675        Element {
676            id: id(s),
677            kind,
678            title: s.rsplit('.').next().unwrap().to_string(),
679            description: None,
680            technology: None,
681            path: None,
682            tags: vec![],
683        }
684    }
685
686    fn relation(source: &str, target: &str, kind: RelationKind, items: &[&str]) -> Relation {
687        Relation {
688            source: id(source),
689            target: id(target),
690            kind,
691            items: items.iter().map(|s| s.to_string()).collect(),
692            technology: None,
693        }
694    }
695
696    /// `app` (with `server`, `view` → `view.element`, `tests`), `lib`
697    /// (with `util`), and an external `node_driver`.
698    fn sample() -> Model {
699        let mut app = element("app", ElementKind::Container);
700        app.technology = Some("library crate".into());
701        app.path = Some("crates/app".into());
702        app.description = Some("The app. It's `here`.".into());
703        let mut server = element("app.server", ElementKind::Component);
704        server.description = Some("Where `Page` is *sent*.".into());
705        server.path = Some("crates/app/src/server.rs".into());
706        let view = element("app.view", ElementKind::Component);
707        let view_element = element("app.view.element", ElementKind::Component);
708        let mut tests = element("app.tests", ElementKind::Component);
709        tests.tags = vec!["tests".into()];
710        let mut lib = element("lib", ElementKind::Container);
711        lib.path = Some(".".into());
712        let util = element("lib.util", ElementKind::Component);
713        let mut driver = element("node_driver", ElementKind::External("process".into()));
714        driver.tags = vec!["external".into()];
715        driver.technology = Some("Node.js".into());
716        driver.description = Some("The driver.".into());
717        let mut spawns = relation("app.server", "node_driver", RelationKind::Uses, &["spawns"]);
718        spawns.technology = Some("stdio".into());
719        Model {
720            elements: vec![driver, util, lib, tests, view_element, view, server, app],
721            relations: vec![
722                relation(
723                    "app.tests",
724                    "app.server",
725                    RelationKind::Calls,
726                    &["Server", "connect"],
727                ),
728                spawns,
729                relation("app.view", "app.server", RelationKind::NamesType, &[]),
730                relation(
731                    "app.view.element",
732                    "app.server",
733                    RelationKind::Constructs,
734                    &["Page"],
735                ),
736                relation(
737                    "app.server",
738                    "lib.util",
739                    RelationKind::NamesType,
740                    &["Helper"],
741                ),
742                relation("lib.util", "app", RelationKind::Uses, &["App"]),
743            ],
744            ..Default::default()
745        }
746    }
747
748    /// Views drawn by something other than LikeC4 (no `.dot`), so each
749    /// is an image; `likec4_view` makes one LikeC4 drew.
750    fn all_views() -> BTreeMap<String, ViewSource> {
751        ["index", "view_app", "view_app_view_", "view_lib"]
752            .iter()
753            .map(|s| (s.to_string(), plain_view()))
754            .collect()
755    }
756
757    fn plain_view() -> ViewSource {
758        ViewSource {
759            svg: "<svg xmlns=\"http://www.w3.org/2000/svg\"/>".into(),
760            dot: None,
761        }
762    }
763
764    /// The `index` view as LikeC4 and Graphviz write it: one node, `app`.
765    fn likec4_view() -> ViewSource {
766        ViewSource {
767            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(),
768            dot: Some("digraph {\n    graph [likec4_viewId=index];\n    app [likec4_id=app];\n}\n".into()),
769        }
770    }
771
772    fn options() -> DocsOptions {
773        DocsOptions {
774            title: "Sample".into(),
775            views: all_views(),
776            ..Default::default()
777        }
778    }
779
780    fn page<'a>(site: &'a Site, path: &str) -> &'a str {
781        site.pages
782            .get(path)
783            .unwrap_or_else(|| panic!("no page {path}; have {:?}", site.pages.keys()))
784    }
785
786    #[test]
787    fn the_page_set_is_index_the_stylesheet_and_one_page_per_container() {
788        let site = generate(&sample(), &options());
789        let keys: Vec<&String> = site.pages.keys().collect();
790        assert_eq!(
791            keys,
792            [
793                "containers/app.html",
794                "containers/lib.html",
795                "index.html",
796                "style.css",
797                "theme.css"
798            ]
799        );
800        assert_eq!(page(&site, "style.css"), STYLESHEET);
801    }
802
803    #[test]
804    fn the_scheme_toggle_adds_the_script_to_the_page_set() {
805        let mut opts = options();
806        opts.scheme_toggle = true;
807        let site = generate(&sample(), &opts);
808        assert_eq!(page(&site, "theme.js"), SCHEME_SCRIPT);
809    }
810
811    #[test]
812    fn theme_css_colors_every_kind_the_model_has() {
813        let mut opts = options();
814        opts.theme = BTreeMap::from([(
815            "container".to_string(),
816            ThemeColor {
817                light: "#ce422b".into(),
818                dark: None,
819            },
820        )]);
821        let site = generate(&sample(), &opts);
822        let kinds: BTreeSet<String> = ["component", "container", "process"]
823            .iter()
824            .map(|s| s.to_string())
825            .collect();
826        assert_eq!(
827            page(&site, "theme.css"),
828            theme::stylesheet(&kinds, &opts.theme)
829        );
830    }
831
832    #[test]
833    fn a_view_likec4_drew_is_inlined_with_its_nodes_classed_by_kind() {
834        let mut opts = options();
835        opts.views.insert("index".into(), likec4_view());
836        let site = generate(&sample(), &opts);
837        let index = page(&site, "index.html");
838        assert!(
839            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\">"),
840            "{index}"
841        );
842        assert!(
843            index.contains("<g id=\"index-node1\" class=\"node c4-k-container\">"),
844            "{index}"
845        );
846        assert!(!index.contains("<img src=\"views/index.svg\""), "{index}");
847    }
848
849    #[test]
850    fn a_curated_view_likec4_drew_is_inlined_under_its_caption() {
851        let mut opts = options();
852        opts.views.insert("context".into(), likec4_view());
853        let site = generate(&sample(), &opts);
854        assert!(
855            page(&site, "views.html")
856                .contains("<figure class=\"view\"><figcaption>context</figcaption><svg "),
857            "{}",
858            page(&site, "views.html")
859        );
860    }
861
862    #[test]
863    fn every_page_links_the_theme_stylesheet_between_its_own_and_the_host_s() {
864        let mut opts = options();
865        opts.stylesheet = Some("site.css".into());
866        let site = generate(&sample(), &opts);
867        assert!(
868            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>"),
869            "{}",
870            page(&site, "containers/app.html")
871        );
872    }
873
874    #[test]
875    fn the_scheme_toggle_links_the_script_and_puts_a_hidden_control_in_the_header() {
876        let mut opts = options();
877        opts.scheme_toggle = true;
878        let site = generate(&sample(), &opts);
879        let app = page(&site, "containers/app.html");
880        assert!(
881            app.contains("<link rel=\"stylesheet\" href=\"../theme.css\">\n<script src=\"../theme.js\"></script>\n</head>"),
882            "{app}"
883        );
884        assert!(
885            app.contains(&format!("</nav>{SCHEME_CONTROL}</div>")),
886            "{app}"
887        );
888        assert!(
889            app.contains("<html lang=\"en\" data-theme-default=\"system\">"),
890            "{app}"
891        );
892    }
893
894    #[test]
895    fn without_the_scheme_toggle_there_is_no_script_and_no_control() {
896        let site = generate(&sample(), &options());
897        for (path, contents) in &site.pages {
898            assert!(!contents.contains("theme.js"), "{path}: {contents}");
899            assert!(!contents.contains("class=\"scheme\""), "{path}: {contents}");
900        }
901    }
902
903    #[test]
904    fn a_fixed_color_scheme_is_written_on_the_root() {
905        let mut opts = options();
906        opts.color_scheme = ColorScheme::Dark;
907        let site = generate(&sample(), &opts);
908        assert!(
909            page(&site, "index.html").contains("<html lang=\"en\" data-theme=\"dark\">"),
910            "{}",
911            page(&site, "index.html")
912        );
913        opts.scheme_toggle = true;
914        let site = generate(&sample(), &opts);
915        assert!(
916            page(&site, "index.html")
917                .contains("<html lang=\"en\" data-theme=\"dark\" data-theme-default=\"dark\">"),
918            "{}",
919            page(&site, "index.html")
920        );
921    }
922
923    #[test]
924    fn the_system_scheme_writes_no_scheme_on_the_root() {
925        let site = generate(&sample(), &options());
926        assert!(
927            page(&site, "index.html").contains("<html lang=\"en\">"),
928            "{}",
929            page(&site, "index.html")
930        );
931    }
932
933    #[test]
934    fn the_index_links_every_container_and_lists_the_externals_with_who_uses_them() {
935        let site = generate(&sample(), &options());
936        let index = page(&site, "index.html");
937        // Table rows, not the header nav, which links the same pages.
938        assert!(index.contains("<h2>Containers</h2>"), "{index}");
939        assert!(
940            index.contains(
941                "<tr><td><a href=\"containers/app.html\">app</a></td><td>library crate</td>"
942            ),
943            "{index}"
944        );
945        assert!(
946            index.contains("<tr><td><a href=\"containers/lib.html\">lib</a></td>"),
947            "{index}"
948        );
949        assert!(!index.contains("containers/node_driver.html"), "{index}");
950        assert!(
951            index.contains(
952                "<tr id=\"node_driver\"><td>node_driver</td><td>process</td><td>Node.js</td>"
953            ),
954            "{index}"
955        );
956        assert!(
957            index.contains(
958                "<a href=\"containers/app.html#app.server\">app.server</a> (uses: spawns)"
959            ),
960            "{index}"
961        );
962        assert!(index.contains("<h1>Sample</h1>"), "{index}");
963        assert!(index.contains("<img src=\"views/index.svg\""), "{index}");
964    }
965
966    #[test]
967    fn a_container_page_lists_its_modules_as_a_nested_list_of_anchors() {
968        let site = generate(&sample(), &options());
969        let app = page(&site, "containers/app.html");
970        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";
971        assert!(app.contains(expected), "{app}");
972        assert!(app.contains("<section id=\"app.view_.element\">\n<h2>element <small>app.view.element</small></h2>"), "{app}");
973    }
974
975    #[test]
976    fn an_element_section_lists_outgoing_and_incoming_relations_with_kind_items_and_technology() {
977        let site = generate(&sample(), &options());
978        let app = page(&site, "containers/app.html");
979        let server = app
980            .split("<section id=\"app.server\">")
981            .nth(1)
982            .unwrap()
983            .split("</section>")
984            .next()
985            .unwrap();
986        assert!(server.contains("<h3>Uses</h3>"), "{server}");
987        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}");
988        assert!(server.contains("<h3>Used by</h3>"), "{server}");
989        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}");
990        // Empty items and no technology render as empty cells.
991        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}");
992    }
993
994    #[test]
995    fn a_relation_to_an_element_on_another_page_links_across_with_its_anchor() {
996        let site = generate(&sample(), &options());
997        let app = page(&site, "containers/app.html");
998        assert!(
999            app.contains(
1000                "<a href=\"../containers/lib.html#lib.util\">lib.util</a></td><td>Helper</td>"
1001            ),
1002            "{app}"
1003        );
1004        let lib = page(&site, "containers/lib.html");
1005        assert!(lib.contains("<h3>Used by</h3>"), "{lib}");
1006        assert!(
1007            lib.contains("<a href=\"../containers/app.html#app.server\">app.server</a>"),
1008            "{lib}"
1009        );
1010    }
1011
1012    #[test]
1013    fn a_model_with_only_externals_has_no_containers_table() {
1014        let mut model = sample();
1015        model.elements.retain(is_external);
1016        model.relations.clear();
1017        let mut opts = options();
1018        opts.views = BTreeMap::from([("index".to_string(), plain_view())]);
1019        let site = generate(&model, &opts);
1020        let index = page(&site, "index.html");
1021        assert!(!index.contains("<h2>Containers</h2>"), "{index}");
1022        assert!(index.contains("<h2>Externals</h2>"), "{index}");
1023        assert_eq!(
1024            site.pages.keys().collect::<Vec<_>>(),
1025            ["index.html", "style.css", "theme.css"]
1026        );
1027    }
1028
1029    #[test]
1030    fn a_model_with_only_containers_has_no_externals_table() {
1031        let mut model = sample();
1032        model.elements.retain(|e| !is_external(e));
1033        model.relations.retain(|r| r.target != id("node_driver"));
1034        let site = generate(&model, &options());
1035        assert!(!page(&site, "index.html").contains("<h2>Externals</h2>"));
1036    }
1037
1038    #[test]
1039    fn a_container_page_documents_its_own_descendants_only() {
1040        let site = generate(&sample(), &options());
1041        let app = page(&site, "containers/app.html");
1042        assert!(!app.contains("<section id=\"app\">"), "{app}");
1043        assert!(!app.contains("<section id=\"lib.util\">"), "{app}");
1044        assert!(!app.contains("<section id=\"lib\">"), "{app}");
1045        assert_eq!(app.matches("<section id=").count(), 4, "{app}");
1046    }
1047
1048    #[test]
1049    fn a_relation_to_a_container_links_its_page_without_a_fragment() {
1050        let site = generate(&sample(), &options());
1051        let lib = page(&site, "containers/lib.html");
1052        assert!(
1053            lib.contains("<tr><td>uses</td><td><a href=\"../containers/app.html\">app</a></td><td>App</td><td></td></tr>"),
1054            "{lib}"
1055        );
1056        let app = page(&site, "containers/app.html");
1057        let top = app.split("<section").next().unwrap();
1058        assert!(top.contains("<h3>Used by</h3>"), "{top}");
1059        assert!(
1060            top.contains(
1061                "<a href=\"../containers/lib.html#lib.util\">lib.util</a></td><td>App</td>"
1062            ),
1063            "{top}"
1064        );
1065    }
1066
1067    #[test]
1068    fn an_element_without_relations_has_no_tables() {
1069        let site = generate(&sample(), &options());
1070        let lib = page(&site, "containers/lib.html");
1071        let top = lib.split("<section").next().unwrap();
1072        assert!(
1073            !top.contains("<h3>Uses</h3>") && !top.contains("<h3>Used by</h3>"),
1074            "{top}"
1075        );
1076    }
1077
1078    #[test]
1079    fn an_element_with_children_embeds_its_scoped_view_and_a_leaf_does_not() {
1080        let site = generate(&sample(), &options());
1081        let app = page(&site, "containers/app.html");
1082        assert!(
1083            app.contains("<img src=\"../views/view_app.svg\" alt=\"app\""),
1084            "{app}"
1085        );
1086        assert!(
1087            app.contains("<img src=\"../views/view_app_view_.svg\" alt=\"app.view\""),
1088            "{app}"
1089        );
1090        let server = app
1091            .split("<section id=\"app.server\">")
1092            .nth(1)
1093            .unwrap()
1094            .split("</section>")
1095            .next()
1096            .unwrap();
1097        assert!(!server.contains("<img"), "{server}");
1098    }
1099
1100    #[test]
1101    fn a_reserved_word_id_uses_the_same_view_file_name_the_emitter_declares() {
1102        let model = sample();
1103        let text = emit(&model, &EmitOptions::for_output_path("m.c4"));
1104        assert!(
1105            text.contains("view view_app_view_ of app.view_ {"),
1106            "{text}"
1107        );
1108        let site = generate(&model, &options());
1109        assert!(page(&site, "containers/app.html").contains("views/view_app_view_.svg"));
1110    }
1111
1112    #[test]
1113    fn a_missing_view_is_a_placeholder_and_is_reported() {
1114        let mut opts = options();
1115        opts.views.remove("view_app");
1116        opts.views.remove("index");
1117        let site = generate(&sample(), &opts);
1118        assert_eq!(site.missing_views, ["index", "view_app"]);
1119        let app = page(&site, "containers/app.html");
1120        assert!(app.contains("<p class=\"missing\">No diagram for <code>view_app</code>: run <code>asbuilt render</code>.</p>"), "{app}");
1121        assert!(!app.contains("views/view_app.svg"), "{app}");
1122        assert!(page(&site, "index.html").contains("No diagram for <code>index</code>"));
1123    }
1124
1125    #[test]
1126    fn a_curated_svg_gets_a_section_on_the_views_page_and_none_means_no_page() {
1127        let mut opts = options();
1128        opts.views.insert("context".into(), plain_view());
1129        let site = generate(&sample(), &opts);
1130        let views = page(&site, "views.html");
1131        assert!(
1132            views.contains("<figcaption>context</figcaption><img src=\"views/context.svg\""),
1133            "{views}"
1134        );
1135        assert!(!views.contains("views/view_app.svg"), "{views}");
1136        assert!(
1137            !generate(&sample(), &options())
1138                .pages
1139                .contains_key("views.html")
1140        );
1141    }
1142
1143    #[test]
1144    fn escape_handles_the_five_characters() {
1145        assert_eq!(
1146            escape("<a href=\"x\">&'</a>"),
1147            "&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;"
1148        );
1149        assert_eq!(escape("plain"), "plain");
1150    }
1151
1152    #[test]
1153    fn a_lt_in_a_title_is_escaped() {
1154        let mut model = sample();
1155        model
1156            .elements
1157            .iter_mut()
1158            .find(|e| e.id == ["lib"])
1159            .unwrap()
1160            .title = "a<b".into();
1161        let site = generate(&model, &options());
1162        assert!(page(&site, "index.html").contains(">a&lt;b</a>"));
1163        assert!(page(&site, "containers/lib.html").contains("<h1>a&lt;b</h1>"));
1164    }
1165
1166    #[test]
1167    fn raw_html_in_a_description_is_text_inline_and_as_a_block() {
1168        assert_eq!(
1169            markdown("say <b>hi</b>"),
1170            "<p>say &lt;b&gt;hi&lt;/b&gt;</p>\n"
1171        );
1172        assert_eq!(
1173            markdown("<div>\nblock\n</div>"),
1174            "&lt;div&gt;\nblock\n&lt;/div&gt;"
1175        );
1176    }
1177
1178    #[test]
1179    fn backticks_and_emphasis_in_a_description_become_markup() {
1180        let site = generate(&sample(), &options());
1181        assert!(
1182            page(&site, "containers/app.html")
1183                .contains("<p>Where <code>Page</code> is <em>sent</em>.</p>")
1184        );
1185    }
1186
1187    #[test]
1188    fn a_source_url_makes_paths_links_and_its_absence_leaves_them_as_text() {
1189        let mut opts = options();
1190        opts.source_url = Some("https://github.com/o/r/blob/main/".into());
1191        let site = generate(&sample(), &opts);
1192        let app = page(&site, "containers/app.html");
1193        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}");
1194        // The root path links to the base itself.
1195        assert!(
1196            page(&site, "containers/lib.html")
1197                .contains("<a href=\"https://github.com/o/r/blob/main/\"><code>.</code></a>")
1198        );
1199        let plain = generate(&sample(), &options());
1200        assert!(
1201            page(&plain, "containers/app.html").contains("<code>crates/app/src/server.rs</code>")
1202        );
1203        assert!(!page(&plain, "containers/app.html").contains("blob/main"));
1204    }
1205
1206    #[test]
1207    fn a_space_in_a_path_is_percent_encoded_in_the_link_only() {
1208        let mut model = sample();
1209        model
1210            .elements
1211            .iter_mut()
1212            .find(|e| e.id == ["lib"])
1213            .unwrap()
1214            .path = Some("my lib".into());
1215        let mut opts = options();
1216        opts.source_url = Some("https://x/".into());
1217        let site = generate(&model, &opts);
1218        assert!(
1219            page(&site, "containers/lib.html")
1220                .contains("<a href=\"https://x/my%20lib\"><code>my lib</code></a>")
1221        );
1222    }
1223
1224    #[test]
1225    fn pages_below_the_root_link_the_stylesheet_and_the_index_through_the_parent() {
1226        let site = generate(&sample(), &options());
1227        let app = page(&site, "containers/app.html");
1228        assert!(
1229            app.contains("<link rel=\"stylesheet\" href=\"../style.css\">"),
1230            "{app}"
1231        );
1232        assert!(
1233            app.contains(&format!(
1234                "<a class=\"site\" href=\"../index.html\">{MARK}Sample</a>",
1235                MARK = mark_svg()
1236            )),
1237            "{app}"
1238        );
1239        assert!(app.contains("<title>app · Sample</title>"), "{app}");
1240        let index = page(&site, "index.html");
1241        assert!(
1242            index.contains("<link rel=\"stylesheet\" href=\"style.css\">"),
1243            "{index}"
1244        );
1245        assert!(
1246            index.contains(&format!(
1247                "<a class=\"site\" href=\"index.html\" aria-current=\"page\">{MARK}Sample</a>",
1248                MARK = mark_svg()
1249            )),
1250            "{index}"
1251        );
1252    }
1253
1254    #[test]
1255    fn the_tree_title_carries_the_asbuilt_mark_hidden_from_screen_readers() {
1256        assert_eq!(
1257            mark_svg(),
1258            format!(
1259                "<svg class=\"mark\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><path d=\"{}\"/></svg>",
1260                MARK_PATH.trim()
1261            )
1262        );
1263        assert!(
1264            MARK_PATH.starts_with("M14.24 3.21L17.61 1.26"),
1265            "{MARK_PATH}"
1266        );
1267        assert!(!MARK_PATH.trim().contains('\n'));
1268    }
1269
1270    #[test]
1271    fn a_container_page_s_header_is_the_trail_then_the_containers_with_this_one_current() {
1272        let site = generate(&sample(), &options());
1273        assert!(
1274            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())),
1275            "{}",
1276            page(&site, "containers/app.html")
1277        );
1278    }
1279
1280    #[test]
1281    fn no_page_repeats_the_trail_below_the_header() {
1282        let mut opts = options();
1283        opts.home_url = Some("../".into());
1284        let site = generate(&sample(), &opts);
1285        for (path, contents) in &site.pages {
1286            assert!(!contents.contains("crumbs"), "{path}: {contents}");
1287            assert_eq!(
1288                contents.matches("aria-label=\"Breadcrumb\"").count(),
1289                usize::from(path.ends_with(".html")),
1290                "{path}"
1291            );
1292        }
1293    }
1294
1295    #[test]
1296    fn a_model_without_containers_has_no_containers_row() {
1297        let mut model = sample();
1298        model.elements.retain(is_external);
1299        model.relations.clear();
1300        let site = generate(&model, &options());
1301        assert!(
1302            !page(&site, "index.html").contains("class=\"containers\""),
1303            "{}",
1304            page(&site, "index.html")
1305        );
1306    }
1307
1308    #[test]
1309    fn every_page_carries_the_generator_meta() {
1310        let site = generate(&sample(), &options());
1311        for (path, contents) in &site.pages {
1312            if path.ends_with(".html") {
1313                assert!(contents.contains(GENERATOR_META), "{path}: {contents}");
1314            }
1315        }
1316    }
1317
1318    #[test]
1319    fn no_image_is_lazy() {
1320        let site = generate(&sample(), &options());
1321        for (path, contents) in &site.pages {
1322            assert!(!contents.contains("loading="), "{path}: {contents}");
1323        }
1324    }
1325
1326    #[test]
1327    fn a_home_url_opens_the_trail_resolved_from_each_depth() {
1328        let mut opts = options();
1329        opts.home_url = Some("../".into());
1330        opts.home_title = Some("Home".into());
1331        let site = generate(&sample(), &opts);
1332        let index = page(&site, "index.html");
1333        assert!(
1334            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())),
1335            "{index}"
1336        );
1337        let app = page(&site, "containers/app.html");
1338        assert!(
1339            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())),
1340            "{app}"
1341        );
1342    }
1343
1344    #[test]
1345    fn an_absolute_home_url_is_verbatim_and_names_itself_without_a_title() {
1346        let mut opts = options();
1347        opts.home_url = Some("https://example.test/".into());
1348        let site = generate(&sample(), &opts);
1349        let app = page(&site, "containers/app.html");
1350        assert!(
1351            app.contains(
1352                "<a class=\"home\" href=\"https://example.test/\">https://example.test/</a>"
1353            ),
1354            "{app}"
1355        );
1356    }
1357
1358    #[test]
1359    fn without_a_home_url_the_trail_is_the_tree_s_title_alone() {
1360        let site = generate(&sample(), &options());
1361        let index = page(&site, "index.html");
1362        assert!(!index.contains("class=\"home\""), "{index}");
1363        assert!(!index.contains("class=\"sep\""), "{index}");
1364        assert!(
1365            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())),
1366            "{index}"
1367        );
1368    }
1369
1370    #[test]
1371    fn a_stylesheet_is_linked_last_in_the_head_and_resolved_from_each_depth() {
1372        let mut opts = options();
1373        opts.stylesheet = Some("site.css".into());
1374        let site = generate(&sample(), &opts);
1375        let index = page(&site, "index.html");
1376        assert!(
1377            index.contains("<link rel=\"stylesheet\" href=\"theme.css\">\n<link rel=\"stylesheet\" href=\"site.css\">\n</head>"),
1378            "{index}"
1379        );
1380        let app = page(&site, "containers/app.html");
1381        assert!(
1382            app.contains("<link rel=\"stylesheet\" href=\"../theme.css\">\n<link rel=\"stylesheet\" href=\"../site.css\">\n</head>"),
1383            "{app}"
1384        );
1385        let mut opts = options();
1386        opts.stylesheet = Some("/theme.css".into());
1387        let site = generate(&sample(), &opts);
1388        assert!(
1389            page(&site, "containers/app.html").contains("href=\"/theme.css\">\n</head>"),
1390            "root-relative is verbatim"
1391        );
1392        assert!(
1393            !page(&site, "index.html").contains("site.css"),
1394            "no host link without a stylesheet"
1395        );
1396    }
1397
1398    #[test]
1399    fn the_stylesheet_defines_the_page_tokens_for_light_and_dark_and_no_white_slab() {
1400        for token in ["--bg:", "--fg:", "--muted:", "--accent:", "--figure-bg:"] {
1401            assert!(STYLESHEET.contains(token), "{token}");
1402        }
1403        assert!(STYLESHEET.contains("@media (prefers-color-scheme: dark)"));
1404        assert!(!STYLESHEET.contains("background: #fff"), "{STYLESHEET}");
1405        assert!(STYLESHEET.contains(".modules ul"), "{STYLESHEET}");
1406    }
1407
1408    #[test]
1409    fn a_missing_view_placeholder_escapes_the_view_name() {
1410        // Generated view names are sanitized to identifier characters, so
1411        // only a direct call can hand the placeholder a name to escape.
1412        let model = sample();
1413        let opts = options();
1414        let mut ctx = Ctx {
1415            model: &model,
1416            options: &opts,
1417            views: BTreeMap::new(),
1418            places: BTreeMap::new(),
1419            kinds: BTreeMap::new(),
1420            missing: BTreeSet::new(),
1421        };
1422        assert_eq!(
1423            ctx.view_figure(0, "a<b", "alt"),
1424            "<p class=\"missing\">No diagram for <code>a&lt;b</code>: run <code>asbuilt render</code>.</p>\n"
1425        );
1426    }
1427
1428    #[test]
1429    fn the_views_page_trail_links_the_index_without_marking_it_current() {
1430        let mut opts = options();
1431        opts.views.insert("context".into(), plain_view());
1432        let site = generate(&sample(), &opts);
1433        assert!(
1434            page(&site, "views.html").contains(&format!(
1435                "<a class=\"site\" href=\"index.html\">{MARK}Sample</a></nav>",
1436                MARK = mark_svg()
1437            )),
1438            "{}",
1439            page(&site, "views.html")
1440        );
1441    }
1442
1443    #[test]
1444    fn the_header_s_container_row_is_a_labeled_navigation_region() {
1445        let site = generate(&sample(), &options());
1446        assert!(
1447            page(&site, "index.html")
1448                .contains("<nav class=\"containers\" aria-label=\"Containers\"><a href=\"containers/app.html\">app</a><a href=\"containers/lib.html\">lib</a></nav>"),
1449            "{}",
1450            page(&site, "index.html")
1451        );
1452    }
1453
1454    #[test]
1455    fn the_header_lists_every_container_but_no_external() {
1456        let site = generate(&sample(), &options());
1457        let index = page(&site, "index.html");
1458        let header = index
1459            .split("<header>")
1460            .nth(1)
1461            .unwrap()
1462            .split("</header>")
1463            .next()
1464            .unwrap();
1465        assert!(
1466            header.contains("<a href=\"containers/app.html\">app</a>"),
1467            "{header}"
1468        );
1469        assert!(
1470            header.contains("<a href=\"containers/lib.html\">lib</a>"),
1471            "{header}"
1472        );
1473        assert!(!header.contains("node_driver"), "{header}");
1474    }
1475
1476    #[test]
1477    fn technology_tags_and_path_share_the_meta_line() {
1478        let site = generate(&sample(), &options());
1479        let app = page(&site, "containers/app.html");
1480        assert!(
1481            app.contains("<p class=\"meta\">library crate · <code>crates/app</code></p>"),
1482            "{app}"
1483        );
1484        assert!(
1485            app.contains("<p class=\"meta\"><span class=\"tag\">tests</span></p>"),
1486            "{app}"
1487        );
1488    }
1489
1490    #[test]
1491    fn an_element_with_nothing_to_say_has_no_meta_line() {
1492        let site = generate(&sample(), &options());
1493        let app = page(&site, "containers/app.html");
1494        let view = app
1495            .split("<section id=\"app.view_\">")
1496            .nth(1)
1497            .unwrap()
1498            .split("<section")
1499            .next()
1500            .unwrap();
1501        assert!(!view.contains("class=\"meta\""), "{view}");
1502    }
1503
1504    #[test]
1505    fn output_does_not_depend_on_input_order() {
1506        let a = generate(&sample(), &options());
1507        let mut shuffled = sample();
1508        shuffled.elements.reverse();
1509        shuffled.relations.reverse();
1510        let b = generate(&shuffled, &options());
1511        assert_eq!(a, b);
1512    }
1513
1514    #[test]
1515    fn a_relative_prefix_climbs_one_directory_per_depth() {
1516        assert_eq!(up(0), "");
1517        assert_eq!(up(2), "../../");
1518        let place = Place {
1519            page: "index.html".into(),
1520            fragment: Some("x".into()),
1521        };
1522        assert_eq!(place.href(1), "../index.html#x");
1523        assert_eq!(container_page(&id("my-app")), "containers/my_app.html");
1524    }
1525}