1use std::collections::BTreeMap;
12use std::fmt::Write as _;
13
14use pulldown_cmark::{CowStr, Event, 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> {
527 body.lines()
528 .find_map(|l| l.strip_prefix("# ").map(|h| h.trim().to_owned()))
529}
530
531fn escape_html(s: &str) -> String {
532 s.replace('&', "&")
533 .replace('<', "<")
534 .replace('>', ">")
535}
536
537fn escape_attr(s: &str) -> String {
538 escape_html(s).replace('"', """)
539}
540
541#[cfg(test)]
542mod tests {
543 use super::{
544 IndexEntry, NavEntry, PublishedPages, markdown_to_html, render_adr, render_adr_index,
545 render_doc, render_markdown, render_nav, render_site_page,
546 };
547
548 fn no_pages() -> PublishedPages {
551 PublishedPages::new()
552 }
553
554 fn nav() -> Vec<NavEntry> {
555 vec![
556 NavEntry {
557 href: "./".into(),
558 label: "Home".into(),
559 },
560 NavEntry {
561 href: "modes.html".into(),
562 label: "Modes & Co".into(),
563 },
564 ]
565 }
566
567 #[test]
568 fn markdown_renders_headings_and_tables() {
569 let html = markdown_to_html("# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n");
570 assert!(html.contains("<h1 id=\"title\">Title</h1>"), "{html}");
571 assert!(html.contains("<table>"));
572 assert!(html.contains("<td>1</td>"));
573 }
574
575 #[test]
576 fn adr_wiki_links_become_sibling_page_links() {
577 let md = "See [[docs/adr/0001-build-roteiro.md]] and \
580 [[crates/rto-graph/src/store.rs#Store]] here.\n";
581 let html = markdown_to_html(md);
582 assert!(
583 html.contains("<a href=\"0001-build-roteiro.html\">ADR-0001</a>"),
584 "ADR wiki-link → sibling page: {html}"
585 );
586 assert!(
587 html.contains("<code>crates/rto-graph/src/store.rs#Store</code>"),
588 "code reference → inline code: {html}"
589 );
590 assert!(
591 !html.contains("[["),
592 "no literal wiki brackets leak: {html}"
593 );
594 }
595
596 #[test]
597 fn wiki_links_inside_code_are_left_literal() {
598 let inline = markdown_to_html("use `[[docs/adr/0001-x.md]]` in prose\n");
601 assert!(
602 inline.contains("<code>[[docs/adr/0001-x.md]]</code>"),
603 "{inline}"
604 );
605 let fenced = markdown_to_html("```\n[[docs/adr/0001-x.md]]\n```\n");
606 assert!(
607 fenced.contains("[[docs/adr/0001-x.md]]"),
608 "fence literal: {fenced}"
609 );
610 }
611
612 #[test]
613 fn multi_backtick_code_spans_are_honoured() {
614 let tight = markdown_to_html("say ``[[docs/adr/0001-x.md]]`` please\n");
618 assert!(
619 tight.contains("<code>[[docs/adr/0001-x.md]]</code>"),
620 "{tight}"
621 );
622 assert!(!tight.contains("<a "), "no link inside code span: {tight}");
623
624 let nested = markdown_to_html("its `` `[[path#Symbol]]` `` example\n");
625 assert!(
626 nested.contains("<code>`[[path#Symbol]]`</code>"),
627 "{nested}"
628 );
629 assert!(
630 !nested.contains("<a "),
631 "no link inside nested span: {nested}"
632 );
633
634 let stray = markdown_to_html("a ` stray tick then [[docs/adr/0001-x.md]]\n");
636 assert!(
637 stray.contains("<a href=\"0001-x.html\">ADR-0001</a>"),
638 "unterminated backtick must not shield: {stray}"
639 );
640 }
641
642 #[test]
643 fn markdown_md_links_are_rewritten_to_html() {
644 let html = markdown_to_html(
647 "See [ADR-1](adr/0001-x.md) and [§2](adr/0001-x.md#context) and \
648 [home](https://x.dev) and [top](#intro).\n",
649 );
650 assert!(html.contains("href=\"adr/0001-x.html\""), "{html}");
651 assert!(html.contains("href=\"adr/0001-x.html#context\""), "{html}");
652 assert!(
653 html.contains("href=\"https://x.dev\""),
654 "external unchanged: {html}"
655 );
656 assert!(html.contains("href=\"#intro\""), "anchor unchanged: {html}");
657 assert!(!html.contains(".md\""), "no raw .md hrefs remain: {html}");
658 }
659
660 #[test]
661 fn render_doc_links_adrs_into_subdir() {
662 let r = render_doc(
664 "# Build Plan\n\nGoverned by [[docs/adr/0001-x.md]].\n",
665 "Build Plan",
666 &no_pages(),
667 );
668 assert_eq!(r.title, "Build Plan");
669 assert!(
670 r.html.contains("<a href=\"adr/0001-x.html\">ADR-0001</a>"),
671 "root doc → adr/ prefix: {}",
672 r.html
673 );
674 assert!(r.html.contains("href=\"./style.css\""));
676 assert!(r.html.contains("href=\"./favicon.svg\""));
678 assert!(r.html.contains("href=\"./favicon.ico\""));
679 assert!(
680 r.html
681 .contains("rel=\"apple-touch-icon\" href=\"./apple-touch-icon.png\"")
682 );
683 }
684
685 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";
686
687 #[test]
688 fn render_adr_strips_frontmatter_and_themes() {
689 let r = render_adr(ADR, "fallback", &no_pages());
690 assert_eq!(r.title, "ADR-0001: Example");
691 assert!(!r.html.contains("adr-id"));
693 assert!(
694 r.html
695 .contains("<h1 id=\"adr-0001-example\">ADR-0001: Example</h1>")
696 );
697 assert!(r.html.contains("<h2 id=\"context\">Context</h2>"));
700 assert!(r.html.contains("<code>code</code>"));
701 assert!(
703 r.html
704 .contains("<link rel=\"stylesheet\" href=\"../style.css\">")
705 );
706 assert!(r.html.contains("href=\"../favicon.svg\""));
709 assert!(r.html.contains("href=\"../favicon.ico\""));
710 assert!(
711 r.html
712 .contains("rel=\"apple-touch-icon\" href=\"../apple-touch-icon.png\"")
713 );
714 assert!(r.html.contains("← Roteiro home"));
715 assert!(r.html.contains("← Back to roteiro.dev"));
716 assert!(r.html.starts_with("<!doctype html>"));
717 }
718
719 #[test]
720 fn render_adr_falls_back_without_h1() {
721 let r = render_adr("no frontmatter, no heading\n", "slug-name", &no_pages());
722 assert_eq!(r.title, "slug-name");
723 }
724
725 #[test]
726 fn index_lists_entries_and_escapes() {
727 let entries = [
728 IndexEntry {
729 href: "0001-x.html".into(),
730 title: "First & <best>".into(),
731 },
732 IndexEntry {
733 href: "0002-y.html".into(),
734 title: "Second".into(),
735 },
736 ];
737 let lifetime = [IndexEntry {
738 href: "../build-plan.html".into(),
739 title: "Build Plan".into(),
740 }];
741 let html = render_adr_index(&lifetime, &entries);
742 assert!(html.contains("<a href=\"../build-plan.html\">Build Plan</a>"));
743 assert!(html.contains("<a href=\"0001-x.html\">First & <best></a>"));
744 assert!(html.contains("<a href=\"0002-y.html\">Second</a>"));
745 assert!(html.find("0001-x").unwrap() < html.find("0002-y").unwrap());
747 assert!(html.find("build-plan").unwrap() < html.find("0001-x").unwrap());
749 }
750
751 #[test]
752 fn an_explicit_anchor_survives_the_split_that_moved_its_section() {
753 let html = markdown_to_html(
759 "## The five ways to run it {#modes}\n\n## Cross-repo: a hub and its spokes {#crossrepo}\n",
760 );
761 assert!(
762 html.contains("<h2 id=\"modes\">The five ways to run it</h2>"),
763 "{html}"
764 );
765 assert!(
766 html.contains("<h2 id=\"crossrepo\">Cross-repo: a hub and its spokes</h2>"),
767 "{html}"
768 );
769 assert!(!html.contains("{#"), "no literal attribute leaks: {html}");
771 }
772
773 #[test]
774 fn generated_anchors_match_the_graph_s_section_keys_and_stay_unique() {
775 let html = markdown_to_html("## Install & build\n\n## Install & build\n\n## ###\n");
778 assert!(html.contains("id=\"install-build\""), "{html}");
779 assert!(html.contains("id=\"install-build-2\""), "{html}");
782 assert!(html.contains("id=\"section-3\""), "{html}");
784 }
785
786 #[test]
787 fn inline_code_counts_as_heading_text() {
788 let html = markdown_to_html("### What `init` sets up\n");
792 assert!(
793 html.contains("<h3 id=\"what-init-sets-up\">"),
794 "code span is part of the heading's text: {html}"
795 );
796 }
797
798 #[test]
799 fn a_hash_inside_a_fence_is_not_a_heading() {
800 let html = markdown_to_html("```\n## Not a heading\n```\n\n## Real\n");
803 assert!(html.contains("<h2 id=\"real\">Real</h2>"), "{html}");
804 }
805
806 #[test]
807 fn a_site_page_carries_the_bar_with_itself_marked() {
808 let r = render_site_page(
809 "---\nsite-page: modes\n---\n\n# The five ways to run it\n\nSee [[docs/adr/0019-remote.md]].\n",
810 "fallback",
811 &nav(),
812 "modes.html",
813 &no_pages(),
814 );
815 assert_eq!(r.title, "The five ways to run it");
816 assert!(!r.html.contains("site-page"), "{}", r.html);
818 assert!(
820 r.html
821 .contains("<span aria-current=\"page\">Modes & Co</span>"),
822 "{}",
823 r.html
824 );
825 assert!(r.html.contains("<a href=\"./\">Home</a>"), "{}", r.html);
826 assert!(r.html.contains("href=\"./style.css\""), "{}", r.html);
828 assert!(
829 r.html
830 .contains("<a href=\"adr/0019-remote.html\">ADR-0019</a>"),
831 "{}",
832 r.html
833 );
834 }
835
836 #[test]
837 fn the_bar_is_plain_anchors_and_escapes_its_labels() {
838 let bar = render_nav(&nav(), "nothing.html");
839 assert!(bar.starts_with("<nav class=\"sitenav\">"), "{bar}");
840 assert!(!bar.contains("aria-current"), "{bar}");
843 assert!(bar.contains("Modes & Co"), "escaped label: {bar}");
844 assert!(!bar.contains("<script"), "{bar}");
846 }
847
848 #[test]
849 fn a_link_resolves_to_the_page_the_site_actually_serves() {
850 let mut pages = PublishedPages::new();
855 pages.publish("BUILD_PLAN_V2.md", "build-plan-v2.html");
856 let html = render_markdown("See [V2](../BUILD_PLAN_V2.md).\n", "", &pages);
857 assert!(
858 html.contains("href=\"../build-plan-v2.html\""),
859 "served name, and the link's own hop kept: {html}"
860 );
861 let frag = render_markdown("[s](../BUILD_PLAN_V2.md#stage-21)\n", "", &pages);
863 assert!(
864 frag.contains("href=\"../build-plan-v2.html#stage-21\""),
865 "{frag}"
866 );
867 let other = render_markdown("[x](../REVIEW_CHECKLIST.md)\n", "", &pages);
869 assert!(
870 other.contains("href=\"../REVIEW_CHECKLIST.html\""),
871 "{other}"
872 );
873 }
874
875 #[test]
876 fn a_file_name_two_documents_claim_is_left_alone() {
877 let mut pages = PublishedPages::new();
880 pages.publish("GUIDE.md", "guide.html");
881 pages.publish("GUIDE.md", "other-guide.html");
882 let html = render_markdown("[g](GUIDE.md)\n", "", &pages);
883 assert!(html.contains("href=\"GUIDE.html\""), "unrewritten: {html}");
884 let mut same = PublishedPages::new();
886 same.publish("GUIDE.md", "guide.html");
887 same.publish("GUIDE.md", "guide.html");
888 let html = render_markdown("[g](GUIDE.md)\n", "", &same);
889 assert!(html.contains("href=\"guide.html\""), "{html}");
890 }
891
892 #[test]
893 fn site_pages_render_deterministically() {
894 let md = "---\nsite-page: a\n---\n\n# A\n\n## S\n";
895 assert_eq!(
896 render_site_page(md, "f", &nav(), "a.html", &no_pages()),
897 render_site_page(md, "f", &nav(), "a.html", &no_pages())
898 );
899 }
900
901 #[test]
902 fn rendering_is_deterministic() {
903 assert_eq!(
904 render_adr(ADR, "f", &no_pages()),
905 render_adr(ADR, "f", &no_pages())
906 );
907 }
908}