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