use std::path::{Path, PathBuf};
use comrak::options::Plugins;
use comrak::{Options, markdown_to_html_with_plugins};
use crate::check;
use crate::document::{Document, Paper, Pdf, Slide};
use crate::highlight::Highlighter;
use crate::nav;
fn options() -> Options<'static> {
let mut options = Options::default();
options.extension.table = true;
options.extension.strikethrough = true;
options.extension.autolink = true;
options.extension.tasklist = true;
options.extension.footnotes = true;
options.extension.header_id_prefix = Some(String::new());
options.render.r#unsafe = true;
options.render.github_pre_lang = true;
options
}
pub const FRAME_IDS: [&str; 7] = [
"devpage-page",
"devpage-nav",
"devpage-navbar",
"devpage-corner",
"devpage-comments",
"devpage-comments-toggle",
"devpage-comment-mode",
];
pub fn markdown(source: &str) -> String {
let mut plugins = Plugins::default();
plugins.render.codefence_syntax_highlighter = Some(&Highlighter);
let mut html = markdown_to_html_with_plugins(source, &options(), &plugins)
.replace("<table>", "<div class=\"table-scroll\"><table>")
.replace("</table>", "</table></div>");
for id in FRAME_IDS {
if html.contains(&format!("id=\"{id}\"")) {
html = html
.replace(&format!("id=\"{id}\""), &format!("id=\"{id}-heading\""))
.replace(
&format!("href=\"#{id}\""),
&format!("href=\"#{id}-heading\""),
);
}
}
embed_designs(&html)
}
pub fn markdown_in(document: &Document, source: &str) -> String {
let widgets = crate::widgets::Widgets::of(document);
markdown(&crate::widgets::expand(source, &widgets, &mut Vec::new()))
}
fn embed_designs(html: &str) -> String {
let mut out = String::new();
let lower = html.to_ascii_lowercase();
let mut from = 0;
while let Some(at) = lower[from..].find("<img").map(|at| from + at) {
out.push_str(&html[from..at]);
let rest = &html[at..];
if !rest[4..].starts_with(|c: char| c.is_ascii_whitespace()) {
out.push_str(&rest[..4]);
from = at + 4;
continue;
}
let end = tag_end(rest);
let tag = &rest[..end];
let attributes = attributes(&tag[4..]);
let attribute = |name: &str| {
attributes
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
};
match attribute("src") {
Some(src)
if src
.split(['?', '#'])
.next()
.is_some_and(|p| p.ends_with(".design")) =>
{
out.push_str(&format!(
"<span class=\"design-embed\"><canvas class=\"design\" src=\"{}\" role=\"img\" aria-label=\"{}\"></canvas></span>",
src.replace('"', """),
attribute("alt").unwrap_or_default().replace('"', """)
));
}
_ => out.push_str(tag),
}
from = at + end;
}
out.push_str(&html[from..]);
out
}
fn attributes(tag: &str) -> Vec<(String, String)> {
let mut attributes = Vec::new();
let mut chars = tag.char_indices().peekable();
loop {
while chars
.next_if(|(_, c)| c.is_ascii_whitespace() || *c == '/')
.is_some()
{}
let Some(&(start, c)) = chars.peek() else {
break;
};
if c == '>' {
break;
}
let mut end = start;
while let Some((i, c)) =
chars.next_if(|(_, c)| !c.is_ascii_whitespace() && !matches!(c, '=' | '>' | '/'))
{
end = i + c.len_utf8();
}
let name = tag[start..end].to_string();
while chars.next_if(|(_, c)| c.is_ascii_whitespace()).is_some() {}
let mut value = String::new();
if chars.next_if(|(_, c)| *c == '=').is_some() {
while chars.next_if(|(_, c)| c.is_ascii_whitespace()).is_some() {}
match chars.peek() {
Some(&(_, quote @ ('"' | '\''))) => {
chars.next();
for (_, c) in chars.by_ref() {
if c == quote {
break;
}
value.push(c);
}
}
_ => {
while let Some((_, c)) =
chars.next_if(|(_, c)| !c.is_ascii_whitespace() && *c != '>')
{
value.push(c);
}
}
}
}
if name.is_empty() {
chars.next();
continue;
}
attributes.push((name, value));
}
attributes
}
fn tag_end(html: &str) -> usize {
let mut quote = None;
for (i, c) in html.char_indices() {
match (quote, c) {
(None, '"' | '\'') => quote = Some(c),
(Some(q), c) if c == q => quote = None,
(None, '>') => return i + 1,
_ => {}
}
}
html.len()
}
pub fn title(source: &str, path: &Path) -> String {
source
.lines()
.find_map(|line| line.strip_prefix("# "))
.map(|heading| heading.trim().to_string())
.unwrap_or_else(|| {
path.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
})
}
pub fn page(document: &Document, file: &Path, source: &str) -> String {
let current = document.url(file);
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{page} · {document}</title>
{theme}
<link rel="stylesheet" href="/__devpage/style.css">
{widgets}
</head>
<body class="{body_class}">
{navbar}{sidebar}{corner}
<main id="devpage-page">{problems}<article data-source="{current}">{content}</article></main>
<script src="/__devpage/pele.js"></script>
<script src="/__devpage/devpage.js"></script>
</body>
</html>"#,
page = escape(&title(source, file)),
document = escape(&document.title),
theme = crate::navbar::head(document),
body_class = site_class(document),
navbar = crate::navbar::render(document, ¤t),
sidebar = sidebar(document, ¤t),
corner = if document.navbar.is_empty() {
crate::navbar::corner(document)
} else {
String::new()
},
content = markdown_in(document, source),
problems = problems(document, &[file.to_path_buf()]),
widgets = crate::widgets::Widgets::of(document).head(),
)
}
pub fn doc(document: &Document, chapters: &[PathBuf], pdf: &Pdf, print: bool) -> String {
let mut content = String::new();
let mut outline = String::new();
let names: Vec<(PathBuf, String)> = chapters
.iter()
.map(|chapter| {
let canonical = chapter.canonicalize().unwrap_or_else(|_| chapter.clone());
let name = chapter
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
(canonical, name)
})
.collect();
for chapter in chapters {
let source = std::fs::read_to_string(chapter).unwrap_or_default();
let mut html = markdown_in(document, &source);
if chapters.len() > 1 {
html = scope_ids(&html, chapter, &names);
}
let html = rebase(&html, chapter, document);
for (level, id, text) in headings(&html) {
outline.push_str(&format!(
"<li class=\"h{level}\"><a href=\"#{id}\">{text}</a></li>"
));
}
let id = if chapters.len() > 1 {
format!(" id=\"{}\"", chapter_name(chapter, &names))
} else {
String::new()
};
content.push_str(&format!(
"<section class=\"chapter\"{id} data-source=\"{}\">{html}</section>",
document.url(chapter)
));
}
let paper = match pdf.paper {
Paper::A4 => "A4",
Paper::Letter => "letter",
};
let outline = if print {
String::new()
} else {
format!(
"<nav id=\"devpage-nav\" class=\"outline\"><a class=\"document-title current\" href=\"#\">{}{}</a><ul>{outline}</ul></nav>",
crate::navbar::logo(document),
escape(&document.title)
)
};
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
{theme}
<link rel="stylesheet" href="/__devpage/style.css">
{widgets}
<style>@page {{ size: {paper}; margin: 22mm 18mm; }}</style>
</head>
<body class="doc{print_class}">
{corner}{outline}
<main id="devpage-page">{problems}<article>{heading}{content}</article></main>
<script src="/__devpage/pele.js"></script>
<script src="/__devpage/devpage.js"{live}></script>
</body>
</html>"#,
title = escape(&document.title),
theme = crate::navbar::head(document),
corner = if print {
String::new()
} else {
crate::navbar::corner(document)
},
widgets = crate::widgets::Widgets::of(document).head(),
heading = if document.file_name.ends_with(".md") {
String::new()
} else {
format!("<p class=\"doc-title\">{}</p>", escape(&document.title))
},
print_class = if print { " print" } else { "" },
live = if print { " data-print" } else { "" },
problems = if print {
String::new()
} else {
problems(document, chapters)
},
)
}
fn chapter_name(chapter: &Path, names: &[(PathBuf, String)]) -> String {
let canonical = chapter
.canonicalize()
.unwrap_or_else(|_| chapter.to_path_buf());
names
.iter()
.find(|(path, _)| *path == canonical)
.map(|(_, name)| name.clone())
.unwrap_or_default()
}
fn scope_ids(html: &str, chapter: &Path, names: &[(PathBuf, String)]) -> String {
let own = chapter_name(chapter, names);
let html = html.replace(" id=\"", &format!(" id=\"{own}--"));
let mut out = String::new();
let mut rest = html.as_str();
while let Some(at) = rest.find(" href=\"") {
out.push_str(&rest[..at + 7]);
rest = &rest[at + 7..];
let end = rest.find('"').unwrap_or(rest.len());
let href = &rest[..end];
let (path, fragment) = href.split_once('#').unwrap_or((href, ""));
let target = if path.is_empty() {
Some(own.clone())
} else if href.contains("://") {
None
} else {
let resolved = chapter
.parent()
.unwrap_or(Path::new("."))
.join(path)
.canonicalize()
.ok();
names
.iter()
.find(|(file, _)| Some(file) == resolved.as_ref())
.map(|(_, name)| name.clone())
};
match target {
Some(name) if fragment.is_empty() => out.push_str(&format!("#{name}")),
Some(name) => out.push_str(&format!("#{name}--{fragment}")),
None => out.push_str(href),
}
rest = &rest[end..];
}
out.push_str(rest);
out
}
fn rebase(html: &str, file: &Path, document: &Document) -> String {
let folder = file.parent().unwrap_or(Path::new("."));
let mut out = String::new();
let mut rest = html;
const STARTS: [(&str, &str); 3] = [(" href=\"", "\""), (" src=\"", "\""), (" href \"", "\"")];
loop {
let next = STARTS
.iter()
.filter_map(|(start, end)| rest.find(start).map(|at| (at + start.len(), *end)))
.min();
let Some((start, close)) = next else { break };
out.push_str(&rest[..start]);
rest = &rest[start..];
let end = rest.find(close).unwrap_or(rest.len());
let value = &rest[..end];
if value.is_empty() || value.starts_with(['#', '/']) || value.contains(':') {
out.push_str(value);
} else {
let (path, fragment) = match value.split_once('#') {
Some((path, fragment)) => (path, format!("#{fragment}")),
None => (value, String::new()),
};
out.push_str(&document.url(&normalize(&folder.join(path))));
out.push_str(&fragment);
}
rest = &rest[end..];
}
out.push_str(rest);
out
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for part in path.components() {
match part {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other),
}
}
out
}
pub fn invalid(entry: &Document, file: &Path, error: &str) -> String {
let address = entry.url(file);
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{address} · {document}</title>
{theme}
<link rel="stylesheet" href="/__devpage/style.css">
</head>
<body class="invalid">
<main id="devpage-page"><div class="problems"><b>{address} is not a valid document</b><ul><li>{error}</li></ul></div></main>
<script src="/__devpage/pele.js"></script>
<script src="/__devpage/devpage.js"></script>
</body>
</html>"#,
address = escape(&address),
document = escape(&entry.title),
theme = crate::navbar::head(entry),
error = escape(error),
)
}
fn problems(document: &Document, pages: &[PathBuf]) -> String {
let found = check::preview(document, pages);
if found.is_empty() {
return String::new();
}
let items: String = found
.iter()
.map(|problem| format!("<li>{}</li>", escape(&problem.to_string())))
.collect();
let broken: Vec<&str> = found.iter().filter_map(|p| p.target.as_deref()).collect();
format!(
"<div class=\"problems\" data-broken=\"{}\"><b>{} problem{}</b><ul>{items}</ul></div>",
escape(&serde_json::to_string(&broken).unwrap_or_default()),
found.len(),
if found.len() == 1 { "" } else { "s" }
)
}
pub fn slides(document: &Document, slides: &[Slide], print: bool) -> String {
let files: Vec<PathBuf> = slides.iter().map(|slide| slide.file.clone()).collect();
let mut content = String::new();
for (index, slide) in files.iter().enumerate() {
let source = std::fs::read_to_string(slide).unwrap_or_default();
content.push_str(&format!(
"<section class=\"slide\" data-slide=\"{}\" data-source=\"{}\"><div class=\"slide-body\">{}</div></section>",
index + 1,
document.url(slide),
rebase(&markdown_in(document, &source), slide, document)
));
}
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
{theme}
<link rel="stylesheet" href="/__devpage/style.css">
{widgets}
<style>@page {{ size: 1920px 1080px; margin: 0; }}</style>
</head>
<body class="slides{print_class}">
{corner}<main id="devpage-page" class="deck">{problems}{content}</main>
<script src="/__devpage/pele.js"></script>
<script src="/__devpage/devpage.js"{live}></script>
</body>
</html>"#,
title = escape(&document.title),
theme = crate::navbar::head(document),
corner = if print {
String::new()
} else {
crate::navbar::corner(document)
},
widgets = crate::widgets::Widgets::of(document).head(),
print_class = if print { " print" } else { "" },
live = if print { " data-print" } else { "" },
problems = if print {
String::new()
} else {
problems(document, &files)
},
)
}
fn headings(html: &str) -> Vec<(u8, String, String)> {
let mut found = Vec::new();
let mut rest = html;
while let Some(start) = rest.find("<h") {
rest = &rest[start + 2..];
let level = rest.as_bytes().first().copied().unwrap_or(0);
if level != b'1' && level != b'2' {
continue;
}
let close = format!("</h{}>", level as char);
let Some(end) = rest.find(&close) else { break };
let inner = &rest[..end];
let id = inner
.split("id=\"")
.nth(1)
.and_then(|tail| tail.split('"').next())
.unwrap_or_default()
.to_string();
let text = strip_tags(inner.split_once('>').map_or(inner, |(_, body)| body));
found.push((level - b'0', id, text));
rest = &rest[end..];
}
found
}
fn strip_tags(html: &str) -> String {
let mut text = String::new();
let mut in_tag = false;
for c in html.chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
c if !in_tag => text.push(c),
_ => {}
}
}
text.trim().to_string()
}
fn site_class(document: &Document) -> String {
let mut class = String::from("site");
if !document.navbar.is_empty() {
class.push_str(" with-navbar");
}
if !document.sidebar {
class.push_str(" no-sidebar");
}
class
}
fn sidebar(document: &Document, current: &str) -> String {
if !document.sidebar {
return String::new();
}
let title = if document.navbar.is_empty() {
format!(
"<a class=\"document-title{}\" href=\"{}\">{}{}</a>\n",
if current == document.entry() {
" current"
} else {
""
},
escape(&document.entry()),
crate::navbar::logo(document),
escape(&document.title)
)
} else {
String::new()
};
format!(
"<nav id=\"devpage-nav\">\n{title}{}\n</nav>",
navigation(document, current)
)
}
fn navigation(document: &Document, current: &str) -> String {
let mut html = String::new();
tree(document, &nav::tree(document), current, &mut html);
html
}
fn tree(document: &Document, groups: &[nav::Group], current: &str, html: &mut String) {
if groups.is_empty() {
return;
}
html.push_str("<ul>");
for group in groups {
if let Some(title) = &group.title {
html.push_str(&format!(
"<li class=\"group\"><span>{}</span></li>",
escape(title)
));
}
for entry in &group.entries {
let href = document.url(&entry.file);
let class = if href == current {
" class=\"current\""
} else {
""
};
html.push_str(&format!(
"<li><a href=\"{href}\"{class}>{}</a>",
escape(&entry.label)
));
tree(document, &entry.children, current, html);
html.push_str("</li>");
}
}
html.push_str("</ul>");
}
pub(crate) fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_image_of_a_design_becomes_its_canvas() {
let html =
markdown("Here:\n\n\n\n\n");
assert!(
html.contains(
"<span class=\"design-embed\"><canvas class=\"design\" src=\"app/checkout.design\" role=\"img\" aria-label=\"The checkout\"></canvas></span>"
),
"{html}"
);
assert!(html.contains("<img src=\"photo.png\" alt=\"A photo\" />"));
assert!(!markdown("").contains("canvas"));
}
#[test]
fn a_raw_image_without_a_closing_slash_leaves_the_embed_after_it_working() {
let html = markdown(
"<img src=\"logo.png\" width=\"40\">\n\nThen:\n\n\n",
);
assert!(
html.contains("<img src=\"logo.png\" width=\"40\">"),
"{html}"
);
assert!(
html.contains("<canvas class=\"design\" src=\"checkout.design\""),
"{html}"
);
assert!(!html.contains("<img src=\"checkout.design\""), "{html}");
}
#[test]
fn a_raw_image_of_a_design_embeds_it_and_keeps_what_follows() {
let html = markdown(
"<img src=\"checkout.design\" alt=\"Raw\" width=\"300\">\n\nA paragraph.\n\n\n",
);
assert!(
html.contains(
"<canvas class=\"design\" src=\"checkout.design\" role=\"img\" aria-label=\"Raw\"></canvas>"
),
"{html}"
);
assert!(html.contains("<p>A paragraph.</p>"), "{html}");
assert!(
html.contains("<img src=\"logo.png\" alt=\"Logo\" />"),
"{html}"
);
let html = markdown("<img alt=\"a > b\" src=\"x.design\">\n\nAfter.\n");
assert!(html.contains("aria-label=\"a > b\""), "{html}");
assert!(html.contains("<p>After.</p>"), "{html}");
}
#[test]
fn a_raw_image_of_a_design_is_found_however_its_attributes_are_written() {
let canvas = |alt: &str| {
format!(
"<canvas class=\"design\" src=\"checkout.design\" role=\"img\" aria-label=\"{alt}\"></canvas>"
)
};
for (source, alt) in [
("<img src='checkout.design' alt='Single'>", "Single"),
(
"<img\n alt=\"New line\"\n src=\"checkout.design\">",
"New line",
),
("<img src=checkout.design alt=Bare>", "Bare"),
("<IMG ALT=\"Upper\" SRC=\"checkout.design\">", "Upper"),
(
"<img\tsrc=\"checkout.design\"\talt='Say \"hi\"'>",
"Say "hi"",
),
] {
let html = markdown(&format!("Before.\n\n{source}\n\nAfter.\n"));
assert!(html.contains(&canvas(alt)), "{source}\n→ {html}");
assert!(html.contains("<p>After.</p>"), "{source}\n→ {html}");
}
let html = markdown("<img data-src=\"checkout.design\" src=\"logo.png\">\n");
assert!(!html.contains("canvas"), "{html}");
}
#[test]
fn keeps_raw_html_and_marks_mermaid_fences() {
let html =
markdown("<div class=\"note\">hi</div>\n\n```mermaid\nflowchart LR\n A --> B\n```\n");
assert!(html.contains("<div class=\"note\">hi</div>"));
assert!(html.contains("<pre lang=\"mermaid\">"));
assert!(html.contains("A --> B"));
}
#[test]
fn highlights_code_without_a_language_label() {
let html = markdown("```rust\nfn main() { let x = \"hi\"; }\n```\n");
assert!(
html.contains("<pre><code>"),
"no lang attribute or label: {html}"
);
assert!(html.contains("hl-keyword"), "{html}");
assert!(html.contains("hl-string"), "{html}");
}
#[test]
fn leaves_unknown_languages_plain_and_escaped() {
let html = markdown("```nosuchlang\na < b\n```\n");
assert!(html.contains("<pre><code>a < b"), "{html}");
assert!(!html.contains("hl-"));
}
#[test]
fn headings_never_take_an_id_the_frame_uses() {
let headings: String = ["Comments", "Nav", "Page", "Navbar", "Theme corner"]
.iter()
.chain(
FRAME_IDS
.iter()
.map(|id| &id[..])
.collect::<Vec<_>>()
.iter(),
)
.map(|name| format!("# {}\n\n", name.replace('-', " ")))
.collect();
let html = markdown(&headings);
for id in FRAME_IDS {
assert!(!html.contains(&format!("id=\"{id}\"")), "{id} in {html}");
}
assert!(html.contains("<h1 id=\"comments\">"), "{html}");
assert!(html.contains("id=\"devpage-comments-heading\""), "{html}");
assert!(
html.contains("href=\"#devpage-comments-heading\""),
"{html}"
);
}
#[test]
fn renders_tables_and_heading_ids() {
let html = markdown("## Two words\n\n| a | b |\n| - | - |\n| 1 | 2 |\n");
assert!(html.contains("id=\"two-words\""));
assert!(html.contains("<table>"));
}
#[test]
fn a_doc_renders_its_chapters_in_order_with_an_outline() {
let dir = std::env::temp_dir().join(format!("devpage-render-doc-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("a.md"),
"# Context\n\n## Why\n\nSee [it](b.md#rollout), [here](#why), [web](https://x.y/a.md).",
)
.unwrap();
std::fs::write(dir.join("b.md"), "# Rollout\n").unwrap();
std::fs::write(
dir.join("d.yml"),
"kind: doc\ntitle: Plan\nchapters: [a.md, b.md]\n",
)
.unwrap();
let document = Document::load(&dir.join("d.yml"), &dir).unwrap();
let crate::document::Kind::Doc { chapters, pdf } = &document.kind else {
panic!()
};
let html = doc(&document, chapters, pdf, false);
let (context, rollout) = (
html.find(">Context<").unwrap(),
html.find(">Rollout<").unwrap(),
);
assert!(context < rollout);
assert!(html.contains("href=\"#a--why\">Why</a>"), "{html}");
assert!(html.contains("id=\"a--why\""), "{html}");
assert!(html.contains("href=\"#b--rollout\""), "{html}");
assert!(html.contains("href=\"https://x.y/a.md\""), "{html}");
assert!(html.contains("size: A4"));
let print = doc(&document, chapters, pdf, true);
assert!(!print.contains("class=\"outline\""));
assert!(print.contains("data-print"));
}
#[test]
fn a_deck_renders_one_section_per_slide_in_order() {
let dir = std::env::temp_dir().join(format!("devpage-render-deck-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("one.md"), "# One\n\n<Notes>Say one.</Notes>\n").unwrap();
std::fs::write(dir.join("two.md"), "# Two").unwrap();
std::fs::write(
dir.join("d.yml"),
"kind: slides\ntitle: Talk\nslides: [two.md, one.md]\n",
)
.unwrap();
let document = Document::load(&dir.join("d.yml"), &dir).unwrap();
let crate::document::Kind::Slides { slides: files, .. } = &document.kind else {
panic!()
};
let html = slides(&document, files, false);
assert_eq!(html.matches("class=\"slide\"").count(), 2);
assert!(html.find(">Two<").unwrap() < html.find(">One<").unwrap());
assert!(html.contains("size: 1920px 1080px"));
assert!(!html.contains("Say one."), "notes are never on the slide");
}
#[test]
fn chapters_and_slides_from_other_folders_keep_their_links() {
let dir =
std::env::temp_dir().join(format!("devpage-render-rebase-{}", std::process::id()));
std::fs::create_dir_all(dir.join("doc")).unwrap();
std::fs::create_dir_all(dir.join("shared")).unwrap();
std::fs::write(
dir.join("shared/glossary.md"),
"# Glossary\n\n [up](../README.md#top) [web](https://x.y/z) [here](#glossary)\n\n```mermaid\nflowchart LR\n a\n click a href \"terms.md\" \"Terms\"\n```\n",
)
.unwrap();
std::fs::write(
dir.join("doc/d.yml"),
"kind: doc\ntitle: Plan\nchapters: [../shared/glossary.md]\n",
)
.unwrap();
let document = Document::load(&dir.join("doc/d.yml"), &dir).unwrap();
let crate::document::Kind::Doc { chapters, pdf } = &document.kind else {
panic!()
};
let html = doc(&document, chapters, pdf, false);
let base = "";
assert!(
html.contains(&format!("src=\"{base}/shared/map.svg\"")),
"{html}"
);
assert!(
html.contains(&format!("href=\"{base}/README.md#top\"")),
"{html}"
);
assert!(html.contains("href=\"https://x.y/z\""), "{html}");
assert!(html.contains("href=\"#glossary\""), "{html}");
assert!(
html.contains(&format!("href \"{base}/shared/terms.md\"")),
"{html}"
);
}
#[test]
fn a_one_file_doc_has_no_separate_title() {
let dir = std::env::temp_dir().join(format!("devpage-render-one-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("design.md"), "# Design\n\nText.\n").unwrap();
let document = Document::load(&dir.join("design.md"), &dir).unwrap();
let crate::document::Kind::Doc { chapters, pdf } = &document.kind else {
panic!()
};
let html = doc(&document, chapters, pdf, false);
assert!(!html.contains("doc-title\">"), "{html}");
assert_eq!(html.matches("<h1").count(), 1, "{html}");
assert_eq!(
html.matches("id=\"design\"").count(),
1,
"ids are unique: {html}"
);
}
#[test]
fn every_frame_carries_the_logo_as_its_tab_icon() {
let dir = std::env::temp_dir().join(format!("devpage-render-logo-{}", std::process::id()));
std::fs::create_dir_all(dir.join("art")).unwrap();
let dir = dir.canonicalize().unwrap();
std::fs::write(
dir.join("art/logo.svg"),
"<svg xmlns=\"http://www.w3.org/2000/svg\"/>",
)
.unwrap();
std::fs::write(
dir.join("README.md"),
"# Home\n\n## Topics\n\n- [Page](page.md)\n",
)
.unwrap();
std::fs::write(dir.join("page.md"), "# Page\n").unwrap();
std::fs::write(
dir.join("devpage.yml"),
"kind: site\ntitle: Garden\nroot: README.md\nlogo: art/logo.svg\n",
)
.unwrap();
std::fs::write(
dir.join("doc.yml"),
"kind: doc\ntitle: Plan\nchapters: [page.md]\nlogo: art/logo.svg\n",
)
.unwrap();
let icon = "<link rel=\"icon\" href=\"/art/logo.svg\">";
let img = "<img class=\"logo\" src=\"/art/logo.svg\" alt=\"\">";
let site = Document::load(&dir.join("devpage.yml"), &dir).unwrap();
let html = page(&site, &dir.join("page.md"), "# Page\n");
assert!(html.contains(icon), "{html}");
assert!(html.contains(&format!("{img}Garden</a>")), "{html}");
let plan = Document::load(&dir.join("doc.yml"), &dir).unwrap();
let crate::document::Kind::Doc { chapters, pdf } = &plan.kind else {
panic!()
};
let html = doc(&plan, chapters, pdf, false);
assert!(html.contains(icon), "{html}");
assert!(html.contains(&format!("{img}Plan</a>")), "{html}");
}
#[test]
fn title_is_first_heading_or_file_name() {
assert_eq!(
title("intro\n# The vision\n", Path::new("vision.md")),
"The vision"
);
assert_eq!(title("no heading", Path::new("notes.md")), "notes");
}
}