1use std::collections::BTreeMap;
12use std::fmt::Write as _;
13
14use pulldown_cmark::{CowStr, Event, HeadingLevel, Options, Parser, Tag, TagEnd, html};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct RenderedAdr {
19 pub title: String,
22 pub html: String,
24}
25
26#[derive(Debug, Default, Clone, PartialEq, Eq)]
49pub struct PublishedPages(BTreeMap<String, Option<String>>);
50
51impl PublishedPages {
52 #[must_use]
54 pub fn new() -> Self {
55 Self::default()
56 }
57
58 pub fn publish(&mut self, source_file: &str, served_as: &str) {
64 self.0
65 .entry(source_file.to_owned())
66 .and_modify(|slot| {
67 if slot.as_deref() != Some(served_as) {
68 *slot = None;
69 }
70 })
71 .or_insert_with(|| Some(served_as.to_owned()));
72 }
73
74 fn served(&self, source_file: &str) -> Option<&str> {
77 self.0.get(source_file)?.as_deref()
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct NavEntry {
90 pub href: String,
92 pub label: String,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct IndexEntry {
99 pub href: String,
101 pub title: String,
103}
104
105#[must_use]
113pub fn markdown_to_html(md: &str) -> String {
114 render_markdown(md, "", &PublishedPages::new())
115}
116
117fn render_markdown(md: &str, adr_prefix: &str, pages: &PublishedPages) -> String {
121 let pre = rewrite_wiki_links(md, adr_prefix);
122 let ids = heading_ids(&pre);
123 let mut next_id = 0usize;
124 let parser = Parser::new_ext(&pre, options()).map(|event| match event {
128 Event::Start(Tag::Link {
129 link_type,
130 dest_url,
131 title,
132 id,
133 }) => Event::Start(Tag::Link {
134 link_type,
135 dest_url: rewrite_doc_link(&dest_url, pages).map_or(dest_url, CowStr::from),
136 title,
137 id,
138 }),
139 Event::Start(Tag::Heading {
140 level,
141 classes,
142 attrs,
143 ..
144 }) => {
145 let id = ids.get(next_id).cloned().map(CowStr::from);
146 next_id += 1;
147 Event::Start(Tag::Heading {
148 level,
149 id,
150 classes,
151 attrs,
152 })
153 }
154 other => other,
155 });
156 let mut out = String::new();
157 html::push_html(&mut out, parser);
158 out
159}
160
161fn options() -> Options {
171 let mut opts = Options::empty();
172 opts.insert(Options::ENABLE_TABLES);
173 opts.insert(Options::ENABLE_STRIKETHROUGH);
174 opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
175 opts
176}
177
178fn heading_ids(md: &str) -> Vec<String> {
192 let mut ids: Vec<String> = Vec::new();
193 let mut seen: BTreeMap<String, usize> = BTreeMap::new();
194 let mut current: Option<(Option<String>, String)> = None;
195 for event in Parser::new_ext(md, options()) {
196 match event {
197 Event::Start(Tag::Heading { id, .. }) => {
198 current = Some((id.map(|i| i.to_string()), String::new()));
199 }
200 Event::Text(t) | Event::Code(t) => {
201 if let Some((_, text)) = current.as_mut() {
202 text.push_str(&t);
203 }
204 }
205 Event::End(TagEnd::Heading(_)) => {
206 let Some((explicit, text)) = current.take() else {
207 continue;
208 };
209 let base = explicit
210 .filter(|e| !e.is_empty())
211 .unwrap_or_else(|| rto_graph::slugify(&text));
212 let base = if base.is_empty() {
213 format!("section-{}", ids.len() + 1)
214 } else {
215 base
216 };
217 let n = seen.entry(base.clone()).or_insert(0);
218 *n += 1;
219 ids.push(if *n == 1 { base } else { format!("{base}-{n}") });
220 }
221 _ => {}
222 }
223 }
224 ids
225}
226
227fn rewrite_doc_link(dest: &str, pages: &PublishedPages) -> Option<String> {
232 if dest.starts_with("http://")
233 || dest.starts_with("https://")
234 || dest.starts_with("//")
235 || dest.starts_with("mailto:")
236 || dest.starts_with('#')
237 {
238 return None;
239 }
240 let (path, frag) = dest
241 .split_once('#')
242 .map_or((dest, None), |(p, f)| (p, Some(f)));
243 path.strip_suffix(".md")?;
244 let (dir, file) = path.rsplit_once('/').map_or(("", path), |(d, f)| (d, f));
247 let served = match pages.served(file) {
248 Some(served) => served.to_owned(),
249 None => format!("{}.html", file.trim_end_matches(".md")),
253 };
254 let sep = if dir.is_empty() { "" } else { "/" };
255 Some(match frag {
256 Some(frag) => format!("{dir}{sep}{served}#{frag}"),
257 None => format!("{dir}{sep}{served}"),
258 })
259}
260
261#[must_use]
265pub fn render_adr(markdown: &str, fallback_title: &str, pages: &PublishedPages) -> RenderedAdr {
266 let body = strip_frontmatter(markdown);
267 let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
268 let content = render_markdown(body, "", pages);
269 let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a> · \
270 <a href=\"./\">All ADRs</a> · <a href=\"../build-plan.html\">Build Plan</a></p>";
271 let html = page(&format!("{title} — Roteiro"), "../", nav, &content);
272 RenderedAdr { title, html }
273}
274
275#[must_use]
278pub fn render_doc(markdown: &str, fallback_title: &str, pages: &PublishedPages) -> RenderedAdr {
279 let body = strip_frontmatter(markdown);
280 let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
281 let content = render_markdown(body, "adr/", pages);
282 let nav = "<p class=\"nav\"><a href=\"./\">← Roteiro home</a> · \
283 <a href=\"adr/\">ADRs</a></p>";
284 let html = page(&format!("{title} — Roteiro"), "./", nav, &content);
285 RenderedAdr { title, html }
286}
287
288#[must_use]
301pub fn render_site_page(
302 markdown: &str,
303 fallback_title: &str,
304 nav: &[NavEntry],
305 current_href: &str,
306 pages: &PublishedPages,
307) -> RenderedAdr {
308 let body = strip_frontmatter(markdown);
309 let title = first_heading(body).unwrap_or_else(|| fallback_title.to_owned());
310 let content = render_markdown(body, "adr/", pages);
311 let bar = render_nav(nav, current_href);
312 let html = page(&format!("{title} — Roteiro"), "./", &bar, &content);
313 RenderedAdr { title, html }
314}
315
316#[must_use]
323pub fn render_nav(nav: &[NavEntry], current_href: &str) -> String {
324 let mut out = String::from("<nav class=\"sitenav\">");
325 for entry in nav {
326 if entry.href == current_href {
327 let _ = write!(
328 out,
329 "<span aria-current=\"page\">{}</span>",
330 escape_html(&entry.label)
331 );
332 } else {
333 let _ = write!(
334 out,
335 "<a href=\"{}\">{}</a>",
336 escape_attr(&entry.href),
337 escape_html(&entry.label)
338 );
339 }
340 }
341 out.push_str("</nav>");
342 out
343}
344
345#[must_use]
347pub fn render_adr_index(lifetime: &[IndexEntry], entries: &[IndexEntry]) -> String {
348 let mut list = String::new();
349 if !lifetime.is_empty() {
350 list.push_str("<h1>Documentation</h1><ul>");
351 for e in lifetime {
352 let _ = write!(
353 list,
354 "<li><a href=\"{}\">{}</a></li>",
355 escape_attr(&e.href),
356 escape_html(&e.title)
357 );
358 }
359 list.push_str("</ul>");
360 }
361 list.push_str("<h1>Architecture Decision Records</h1><ul>");
362 for e in entries {
363 let _ = write!(
364 list,
365 "<li><a href=\"{}\">{}</a></li>",
366 escape_attr(&e.href),
367 escape_html(&e.title)
368 );
369 }
370 list.push_str("</ul>");
371 let nav = "<p class=\"nav\"><a href=\"../\">← Roteiro home</a></p>";
372 page("Documentation — Roteiro", "../", nav, &list)
373}
374
375fn rewrite_wiki_links(md: &str, adr_prefix: &str) -> String {
381 let mut out = String::new();
382 let mut in_fence = false;
383 for line in md.lines() {
384 let trimmed = line.trim_start();
385 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
386 in_fence = !in_fence;
387 out.push_str(line);
388 out.push('\n');
389 continue;
390 }
391 if in_fence {
392 out.push_str(line);
393 out.push('\n');
394 continue;
395 }
396 rewrite_line_outside_code(line, adr_prefix, &mut out);
397 out.push('\n');
398 }
399 out
400}
401
402fn rewrite_line_outside_code(line: &str, adr_prefix: &str, out: &mut String) {
408 let bytes = line.as_bytes();
409 let mut text_start = 0;
410 let mut i = 0;
411 while i < bytes.len() {
412 if bytes[i] != b'`' {
413 i += 1;
414 continue;
415 }
416 let run_start = i;
417 while i < bytes.len() && bytes[i] == b'`' {
418 i += 1;
419 }
420 let run = i - run_start;
421 if let Some(rel) = find_closing_run(&bytes[i..], run) {
422 rewrite_wiki_in(&line[text_start..run_start], adr_prefix, out);
424 let code_end = i + rel + run;
425 out.push_str(&line[run_start..code_end]); i = code_end;
427 text_start = i;
428 }
429 }
432 rewrite_wiki_in(&line[text_start..], adr_prefix, out);
433}
434
435fn find_closing_run(bytes: &[u8], run: usize) -> Option<usize> {
438 let mut i = 0;
439 while i < bytes.len() {
440 if bytes[i] != b'`' {
441 i += 1;
442 continue;
443 }
444 let start = i;
445 while i < bytes.len() && bytes[i] == b'`' {
446 i += 1;
447 }
448 if i - start == run {
449 return Some(start);
450 }
451 }
452 None
453}
454
455fn rewrite_wiki_in(seg: &str, adr_prefix: &str, out: &mut String) {
457 let mut rest = seg;
458 while let Some(open) = rest.find("[[") {
459 out.push_str(&rest[..open]);
460 let after = &rest[open + 2..];
461 if let Some(close) = after.find("]]") {
462 out.push_str(&wiki_target(&after[..close], adr_prefix));
463 rest = &after[close + 2..];
464 } else {
465 out.push_str("[[");
466 rest = after;
467 }
468 }
469 out.push_str(rest);
470}
471
472fn wiki_target(inner: &str, adr_prefix: &str) -> String {
474 let inner = inner.trim();
475 let path = inner.split_once('#').map_or(inner, |(p, _)| p.trim());
476 if let Some(rest) = path.strip_prefix("docs/adr/")
477 && let Some(stem) = rest.strip_suffix(".md")
478 {
479 return format!("[{}]({adr_prefix}{stem}.html)", adr_label(stem));
480 }
481 format!("`{inner}`")
483}
484
485fn adr_label(stem: &str) -> String {
487 let digits: String = stem.chars().take_while(char::is_ascii_digit).collect();
488 if digits.is_empty() {
489 stem.to_owned()
490 } else {
491 format!("ADR-{digits}")
492 }
493}
494
495fn page(title: &str, root: &str, nav: &str, body: &str) -> String {
498 format!(
499 "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
500 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
501 <link rel=\"icon\" href=\"{root}favicon.svg\" type=\"image/svg+xml\">\
502 <link rel=\"icon\" href=\"{root}favicon.ico\" type=\"image/x-icon\" sizes=\"16x16 32x32 48x48\">\
503 <link rel=\"apple-touch-icon\" href=\"{root}apple-touch-icon.png\">\
504 <link rel=\"stylesheet\" href=\"{root}style.css\">\
505 <title>{title}</title></head><body>\
506 {nav}{body}\
507 <p class=\"backlink\"><a href=\"{root}\">← Back to roteiro.dev</a></p>\
508 <footer>Dual-licensed MIT OR Apache-2.0 · The Roteiro Project Team</footer>\
509 </body></html>",
510 title = escape_html(title),
511 )
512}
513
514fn strip_frontmatter(text: &str) -> &str {
516 let Some(rest) = text.strip_prefix("---\n") else {
517 return text;
518 };
519 match rest.find("\n---\n") {
520 Some(end) => &rest[end + 5..],
521 None => rest.strip_suffix("\n---").unwrap_or(text),
522 }
523}
524
525fn first_heading(body: &str) -> Option<String> {
546 let mut text: Option<String> = None;
547 for event in Parser::new_ext(body, options()) {
548 match event {
549 Event::Start(Tag::Heading {
550 level: HeadingLevel::H1,
551 ..
552 }) => text = Some(String::new()),
553 Event::Text(t) | Event::Code(t) => {
556 if let Some(text) = text.as_mut() {
557 text.push_str(&t);
558 }
559 }
560 Event::End(TagEnd::Heading(HeadingLevel::H1)) => break,
561 _ => {}
562 }
563 }
564 text.map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
567}
568
569fn escape_html(s: &str) -> String {
570 s.replace('&', "&")
571 .replace('<', "<")
572 .replace('>', ">")
573}
574
575fn escape_attr(s: &str) -> String {
576 escape_html(s).replace('"', """)
577}
578
579#[cfg(test)]
580mod tests {
581 use super::{
582 IndexEntry, NavEntry, PublishedPages, escape_html, markdown_to_html, render_adr,
583 render_adr_index, render_doc, render_markdown, render_nav, render_site_page,
584 };
585
586 fn no_pages() -> PublishedPages {
589 PublishedPages::new()
590 }
591
592 fn nav() -> Vec<NavEntry> {
593 vec![
594 NavEntry {
595 href: "./".into(),
596 label: "Home".into(),
597 },
598 NavEntry {
599 href: "modes.html".into(),
600 label: "Modes & Co".into(),
601 },
602 ]
603 }
604
605 #[test]
606 fn markdown_renders_headings_and_tables() {
607 let html = markdown_to_html("# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n");
608 assert!(html.contains("<h1 id=\"title\">Title</h1>"), "{html}");
609 assert!(html.contains("<table>"));
610 assert!(html.contains("<td>1</td>"));
611 }
612
613 #[test]
614 fn adr_wiki_links_become_sibling_page_links() {
615 let md = "See [[docs/adr/0001-build-roteiro.md]] and \
618 [[crates/rto-graph/src/store.rs#Store]] here.\n";
619 let html = markdown_to_html(md);
620 assert!(
621 html.contains("<a href=\"0001-build-roteiro.html\">ADR-0001</a>"),
622 "ADR wiki-link → sibling page: {html}"
623 );
624 assert!(
625 html.contains("<code>crates/rto-graph/src/store.rs#Store</code>"),
626 "code reference → inline code: {html}"
627 );
628 assert!(
629 !html.contains("[["),
630 "no literal wiki brackets leak: {html}"
631 );
632 }
633
634 #[test]
635 fn wiki_links_inside_code_are_left_literal() {
636 let inline = markdown_to_html("use `[[docs/adr/0001-x.md]]` in prose\n");
639 assert!(
640 inline.contains("<code>[[docs/adr/0001-x.md]]</code>"),
641 "{inline}"
642 );
643 let fenced = markdown_to_html("```\n[[docs/adr/0001-x.md]]\n```\n");
644 assert!(
645 fenced.contains("[[docs/adr/0001-x.md]]"),
646 "fence literal: {fenced}"
647 );
648 }
649
650 #[test]
651 fn multi_backtick_code_spans_are_honoured() {
652 let tight = markdown_to_html("say ``[[docs/adr/0001-x.md]]`` please\n");
656 assert!(
657 tight.contains("<code>[[docs/adr/0001-x.md]]</code>"),
658 "{tight}"
659 );
660 assert!(!tight.contains("<a "), "no link inside code span: {tight}");
661
662 let nested = markdown_to_html("its `` `[[path#Symbol]]` `` example\n");
663 assert!(
664 nested.contains("<code>`[[path#Symbol]]`</code>"),
665 "{nested}"
666 );
667 assert!(
668 !nested.contains("<a "),
669 "no link inside nested span: {nested}"
670 );
671
672 let stray = markdown_to_html("a ` stray tick then [[docs/adr/0001-x.md]]\n");
674 assert!(
675 stray.contains("<a href=\"0001-x.html\">ADR-0001</a>"),
676 "unterminated backtick must not shield: {stray}"
677 );
678 }
679
680 #[test]
681 fn markdown_md_links_are_rewritten_to_html() {
682 let html = markdown_to_html(
685 "See [ADR-1](adr/0001-x.md) and [§2](adr/0001-x.md#context) and \
686 [home](https://x.dev) and [top](#intro).\n",
687 );
688 assert!(html.contains("href=\"adr/0001-x.html\""), "{html}");
689 assert!(html.contains("href=\"adr/0001-x.html#context\""), "{html}");
690 assert!(
691 html.contains("href=\"https://x.dev\""),
692 "external unchanged: {html}"
693 );
694 assert!(html.contains("href=\"#intro\""), "anchor unchanged: {html}");
695 assert!(!html.contains(".md\""), "no raw .md hrefs remain: {html}");
696 }
697
698 #[test]
699 fn render_doc_links_adrs_into_subdir() {
700 let r = render_doc(
702 "# Build Plan\n\nGoverned by [[docs/adr/0001-x.md]].\n",
703 "Build Plan",
704 &no_pages(),
705 );
706 assert_eq!(r.title, "Build Plan");
707 assert!(
708 r.html.contains("<a href=\"adr/0001-x.html\">ADR-0001</a>"),
709 "root doc → adr/ prefix: {}",
710 r.html
711 );
712 assert!(r.html.contains("href=\"./style.css\""));
714 assert!(r.html.contains("href=\"./favicon.svg\""));
716 assert!(r.html.contains("href=\"./favicon.ico\""));
717 assert!(
718 r.html
719 .contains("rel=\"apple-touch-icon\" href=\"./apple-touch-icon.png\"")
720 );
721 }
722
723 const ADR: &str = "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001: Example\n\n## Context\n\nSome `code` and a [link](https://x).\n";
724
725 #[test]
726 fn render_adr_strips_frontmatter_and_themes() {
727 let r = render_adr(ADR, "fallback", &no_pages());
728 assert_eq!(r.title, "ADR-0001: Example");
729 assert!(!r.html.contains("adr-id"));
731 assert!(
732 r.html
733 .contains("<h1 id=\"adr-0001-example\">ADR-0001: Example</h1>")
734 );
735 assert!(r.html.contains("<h2 id=\"context\">Context</h2>"));
738 assert!(r.html.contains("<code>code</code>"));
739 assert!(
741 r.html
742 .contains("<link rel=\"stylesheet\" href=\"../style.css\">")
743 );
744 assert!(r.html.contains("href=\"../favicon.svg\""));
747 assert!(r.html.contains("href=\"../favicon.ico\""));
748 assert!(
749 r.html
750 .contains("rel=\"apple-touch-icon\" href=\"../apple-touch-icon.png\"")
751 );
752 assert!(r.html.contains("← Roteiro home"));
753 assert!(r.html.contains("← Back to roteiro.dev"));
754 assert!(r.html.starts_with("<!doctype html>"));
755 }
756
757 #[test]
758 fn render_adr_falls_back_without_h1() {
759 let r = render_adr("no frontmatter, no heading\n", "slug-name", &no_pages());
760 assert_eq!(r.title, "slug-name");
761 }
762
763 #[test]
764 fn index_lists_entries_and_escapes() {
765 let entries = [
766 IndexEntry {
767 href: "0001-x.html".into(),
768 title: "First & <best>".into(),
769 },
770 IndexEntry {
771 href: "0002-y.html".into(),
772 title: "Second".into(),
773 },
774 ];
775 let lifetime = [IndexEntry {
776 href: "../build-plan.html".into(),
777 title: "Build Plan".into(),
778 }];
779 let html = render_adr_index(&lifetime, &entries);
780 assert!(html.contains("<a href=\"../build-plan.html\">Build Plan</a>"));
781 assert!(html.contains("<a href=\"0001-x.html\">First & <best></a>"));
782 assert!(html.contains("<a href=\"0002-y.html\">Second</a>"));
783 assert!(html.find("0001-x").unwrap() < html.find("0002-y").unwrap());
785 assert!(html.find("build-plan").unwrap() < html.find("0001-x").unwrap());
787 }
788
789 #[test]
790 fn an_explicit_anchor_survives_the_split_that_moved_its_section() {
791 let html = markdown_to_html(
797 "## The five ways to run it {#modes}\n\n## Cross-repo: a hub and its spokes {#crossrepo}\n",
798 );
799 assert!(
800 html.contains("<h2 id=\"modes\">The five ways to run it</h2>"),
801 "{html}"
802 );
803 assert!(
804 html.contains("<h2 id=\"crossrepo\">Cross-repo: a hub and its spokes</h2>"),
805 "{html}"
806 );
807 assert!(!html.contains("{#"), "no literal attribute leaks: {html}");
809 }
810
811 #[test]
812 fn generated_anchors_match_the_graph_s_section_keys_and_stay_unique() {
813 let html = markdown_to_html("## Install & build\n\n## Install & build\n\n## ###\n");
816 assert!(html.contains("id=\"install-build\""), "{html}");
817 assert!(html.contains("id=\"install-build-2\""), "{html}");
820 assert!(html.contains("id=\"section-3\""), "{html}");
822 }
823
824 #[test]
825 fn inline_code_counts_as_heading_text() {
826 let html = markdown_to_html("### What `init` sets up\n");
830 assert!(
831 html.contains("<h3 id=\"what-init-sets-up\">"),
832 "code span is part of the heading's text: {html}"
833 );
834 }
835
836 #[test]
837 fn a_hash_inside_a_fence_is_not_a_heading() {
838 let html = markdown_to_html("```\n## Not a heading\n```\n\n## Real\n");
841 assert!(html.contains("<h2 id=\"real\">Real</h2>"), "{html}");
842 }
843
844 #[test]
845 fn a_heading_s_anchor_never_reaches_the_title() {
846 let r = render_site_page(
849 "---\nsite-page: modes\n---\n\n# The five ways to run it {#modes}\n\nBody.\n",
850 "fallback",
851 &nav(),
852 "modes.html",
853 &no_pages(),
854 );
855 assert!(
857 r.html
858 .contains("<h1 id=\"modes\">The five ways to run it</h1>"),
859 "{}",
860 r.html
861 );
862 assert_eq!(r.title, "The five ways to run it");
863 assert!(
864 r.html
865 .contains("<title>The five ways to run it — Roteiro</title>"),
866 "{}",
867 r.html
868 );
869 assert!(
872 !r.html.contains("{#"),
873 "no literal attribute leaks: {}",
874 r.html
875 );
876 }
877
878 #[test]
879 fn the_same_holds_for_an_adr_and_for_a_root_level_doc() {
880 let adr = render_adr("# ADR-0001: Example {#adr1}\n", "slug", &no_pages());
884 assert_eq!(adr.title, "ADR-0001: Example");
885 assert!(
886 adr.html
887 .contains("<title>ADR-0001: Example — Roteiro</title>"),
888 "{}",
889 adr.html
890 );
891 let doc = render_doc(
892 "# Roteiro — Build Plan {#plan}\n",
893 "Build Plan",
894 &no_pages(),
895 );
896 assert_eq!(doc.title, "Roteiro — Build Plan");
897 assert!(!doc.html.contains("{#"), "{}", doc.html);
898 }
899
900 #[test]
901 fn a_title_that_legitimately_spells_the_anchor_syntax_keeps_it() {
902 let coded = render_doc(
909 "# Why `{#anchor}` outlives a restructure\n",
910 "fallback",
911 &no_pages(),
912 );
913 assert_eq!(coded.title, "Why {#anchor} outlives a restructure");
914 assert!(
915 coded
916 .html
917 .contains("<title>Why {#anchor} outlives a restructure — Roteiro</title>"),
918 "{}",
919 coded.html
920 );
921 let mid = render_doc(
924 "# Anchors are written {#id}, in prose\n",
925 "fallback",
926 &no_pages(),
927 );
928 assert_eq!(mid.title, "Anchors are written {#id}, in prose");
929 }
930
931 #[test]
932 fn the_title_and_the_heading_never_disagree() {
933 for md in [
939 "# The five ways to run it {#modes}\n",
940 "# Why `{#anchor}` outlives a restructure\n",
941 "# Anchors are written {#id}, in prose\n",
942 "# Install & build {#build}\n",
943 "# What `init` sets up\n",
944 "# Sets like {#1, #2}\n",
945 ] {
946 let r = render_doc(md, "fallback", &no_pages());
947 let inner = r
948 .html
949 .split_once("<h1")
950 .and_then(|(_, rest)| rest.split_once('>'))
951 .and_then(|(_, rest)| rest.split_once("</h1>"))
952 .map(|(text, _)| text.to_owned())
953 .unwrap_or_default();
954 let mut heading = String::new();
958 let mut depth = 0usize;
959 for c in inner.chars() {
960 match c {
961 '<' => depth += 1,
962 '>' => depth = depth.saturating_sub(1),
963 _ if depth == 0 => heading.push(c),
964 _ => {}
965 }
966 }
967 assert_eq!(
968 heading,
969 escape_html(&r.title),
970 "title and heading disagree for {md:?}: {}",
971 r.html
972 );
973 }
974 }
975
976 #[test]
977 fn the_title_is_the_heading_the_reader_sees() {
978 let code = render_doc("# What `init` sets up\n", "fallback", &no_pages());
981 assert_eq!(code.title, "What init sets up");
982 let fenced = render_doc(
985 "```\n# Not a title\n```\n\n# The real one\n",
986 "fallback",
987 &no_pages(),
988 );
989 assert_eq!(fenced.title, "The real one");
990 let setext = render_doc("Underlined\n==========\n", "fallback", &no_pages());
993 assert!(
994 setext.html.contains("<h1 id=\"underlined\">"),
995 "{}",
996 setext.html
997 );
998 assert_eq!(setext.title, "Underlined");
999 }
1000
1001 #[test]
1002 fn a_document_with_no_h1_falls_back_and_the_fallback_is_used_verbatim() {
1003 let none = render_site_page(
1007 "---\nsite-page: modes\n---\n\nNo heading at all.\n",
1008 "The five ways to run it",
1009 &nav(),
1010 "modes.html",
1011 &no_pages(),
1012 );
1013 assert_eq!(none.title, "The five ways to run it");
1014 assert!(
1015 none.html
1016 .contains("<title>The five ways to run it — Roteiro</title>"),
1017 "{}",
1018 none.html
1019 );
1020 let empty = render_doc("#\n\nBody.\n", "build-plan", &no_pages());
1023 assert_eq!(empty.title, "build-plan");
1024 let sub = render_doc("## Only a section {#s}\n", "build-plan", &no_pages());
1026 assert_eq!(sub.title, "build-plan");
1027 }
1028
1029 #[test]
1030 fn a_site_page_carries_the_bar_with_itself_marked() {
1031 let r = render_site_page(
1032 "---\nsite-page: modes\n---\n\n# The five ways to run it\n\nSee [[docs/adr/0019-remote.md]].\n",
1033 "fallback",
1034 &nav(),
1035 "modes.html",
1036 &no_pages(),
1037 );
1038 assert_eq!(r.title, "The five ways to run it");
1039 assert!(!r.html.contains("site-page"), "{}", r.html);
1041 assert!(
1043 r.html
1044 .contains("<span aria-current=\"page\">Modes & Co</span>"),
1045 "{}",
1046 r.html
1047 );
1048 assert!(r.html.contains("<a href=\"./\">Home</a>"), "{}", r.html);
1049 assert!(r.html.contains("href=\"./style.css\""), "{}", r.html);
1051 assert!(
1052 r.html
1053 .contains("<a href=\"adr/0019-remote.html\">ADR-0019</a>"),
1054 "{}",
1055 r.html
1056 );
1057 }
1058
1059 #[test]
1060 fn the_bar_is_plain_anchors_and_escapes_its_labels() {
1061 let bar = render_nav(&nav(), "nothing.html");
1062 assert!(bar.starts_with("<nav class=\"sitenav\">"), "{bar}");
1063 assert!(!bar.contains("aria-current"), "{bar}");
1066 assert!(bar.contains("Modes & Co"), "escaped label: {bar}");
1067 assert!(!bar.contains("<script"), "{bar}");
1069 }
1070
1071 #[test]
1072 fn a_link_resolves_to_the_page_the_site_actually_serves() {
1073 let mut pages = PublishedPages::new();
1078 pages.publish("BUILD_PLAN_V2.md", "build-plan-v2.html");
1079 let html = render_markdown("See [V2](../BUILD_PLAN_V2.md).\n", "", &pages);
1080 assert!(
1081 html.contains("href=\"../build-plan-v2.html\""),
1082 "served name, and the link's own hop kept: {html}"
1083 );
1084 let frag = render_markdown("[s](../BUILD_PLAN_V2.md#stage-21)\n", "", &pages);
1086 assert!(
1087 frag.contains("href=\"../build-plan-v2.html#stage-21\""),
1088 "{frag}"
1089 );
1090 let other = render_markdown("[x](../REVIEW_CHECKLIST.md)\n", "", &pages);
1092 assert!(
1093 other.contains("href=\"../REVIEW_CHECKLIST.html\""),
1094 "{other}"
1095 );
1096 }
1097
1098 #[test]
1099 fn a_file_name_two_documents_claim_is_left_alone() {
1100 let mut pages = PublishedPages::new();
1103 pages.publish("GUIDE.md", "guide.html");
1104 pages.publish("GUIDE.md", "other-guide.html");
1105 let html = render_markdown("[g](GUIDE.md)\n", "", &pages);
1106 assert!(html.contains("href=\"GUIDE.html\""), "unrewritten: {html}");
1107 let mut same = PublishedPages::new();
1109 same.publish("GUIDE.md", "guide.html");
1110 same.publish("GUIDE.md", "guide.html");
1111 let html = render_markdown("[g](GUIDE.md)\n", "", &same);
1112 assert!(html.contains("href=\"guide.html\""), "{html}");
1113 }
1114
1115 #[test]
1116 fn site_pages_render_deterministically() {
1117 let md = "---\nsite-page: a\n---\n\n# A\n\n## S\n";
1118 assert_eq!(
1119 render_site_page(md, "f", &nav(), "a.html", &no_pages()),
1120 render_site_page(md, "f", &nav(), "a.html", &no_pages())
1121 );
1122 }
1123
1124 #[test]
1125 fn rendering_is_deterministic() {
1126 assert_eq!(
1127 render_adr(ADR, "f", &no_pages()),
1128 render_adr(ADR, "f", &no_pages())
1129 );
1130 }
1131}