1use 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
30pub struct DocsOptions {
31 pub title: String,
32 pub source_url: Option<String>,
36 pub home_url: Option<String>,
41 pub home_title: Option<String>,
44 pub stylesheet: Option<String>,
48 pub views: BTreeMap<String, ViewSource>,
53 pub theme: BTreeMap<String, ThemeColor>,
55 pub color_scheme: ColorScheme,
57 pub scheme_toggle: bool,
60 pub viewer: bool,
64 pub nouns: BTreeMap<String, String>,
68 pub asbuilt_version: Option<String>,
71}
72
73pub const GENERATOR_META_PREFIX: &str = "<meta name=\"generator\" content=\"asbuilt docs";
78
79const ASBUILT_URL: &str = "https://padamson.github.io/asbuilt/";
81
82#[derive(Debug, Clone, PartialEq, Eq, Default)]
84pub struct Site {
85 pub pages: BTreeMap<String, String>,
87 pub missing_views: Vec<String>,
89}
90
91pub const STYLESHEET: &str = include_str!("docs.css");
93
94pub const MARK_PATH: &str = include_str!("mark.path");
97
98fn 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
108pub const SCHEME_SCRIPT: &str = include_str!("theme.js");
110
111pub const VIEWER_SCRIPT: &str = include_str!("viewer.js");
113
114pub const VIEWER_STYLESHEET: &str = include_str!("viewer.css");
117
118const 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
125const 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
129pub 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("&"),
139 '<' => out.push_str("<"),
140 '>' => out.push_str(">"),
141 '"' => out.push_str("""),
142 '\'' => out.push_str("'"),
143 c => out.push(c),
144 }
145 }
146 out
147}
148
149pub 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
162pub fn container_page(id: &[String]) -> String {
164 format!("containers/{}.html", sanitize_id(id))
165}
166
167pub fn anchor(id: &[String]) -> String {
169 sanitize_id(id)
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
175struct Place {
176 page: String,
177 fragment: Option<String>,
178}
179
180fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218enum Here<'a> {
219 Index,
220 Container(&'a [String]),
221 Views,
222}
223
224fn up(depth: usize) -> String {
226 "../".repeat(depth)
227}
228
229fn 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 views: BTreeMap<Id, String>,
249 places: BTreeMap<Id, Place>,
251 curated: Vec<String>,
254 by_likec4: BTreeMap<String, &'a Element>,
256 missing: BTreeSet<String>,
257}
258
259impl Ctx<'_> {
260 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 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 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 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 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 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 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 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 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 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 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 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 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 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
872fn 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
894pub 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 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 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 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->lib</title>\n<path fill=\"none\" stroke=\"#8d8d8d\" d=\"M0,0\"/>\n</g>\n<g id=\"edge2\" class=\"edge\">\n<title>lib->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 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 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->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 let mut opts = options();
1195 opts.viewer = true;
1196 let mut scoped = likec4_scoped_view();
1197 scoped.svg = scoped.svg.replace(
1198 "<title>server->driver_1</title>",
1199 "<title>driver_1->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 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 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 "<a href="x">&'</a>"
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<b</a>"));
1738 assert!(page(&site, "containers/lib.html").contains("<h1>a<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 <b>hi</b></p>\n"
1746 );
1747 assert_eq!(
1748 markdown("<div>\nblock\n</div>"),
1749 "<div>\nblock\n</div>"
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 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 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<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 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 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 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}