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