use crate::*;
use markdown::*;
pub fn generate_html(document: &MarkdownDocument, page: &Page, website: &Website) -> String {
let root = page.root();
let page_name = &page.name;
let site_name = &website.name;
let mut parent_url = String::new();
for segment in &page.parents {
parent_url.push_str(&make_url_safe(segment)); parent_url.push('/');
}
parent_url.pop();
let parent_name = match page.parents.get(page.parents.len()-1) {
Some(parent) => parent.to_string(),
None => String::new(),
};
let head = get_html_head(document, page); let head = head.trim();
let mut home = format!("<a id='home' href='{root}index.html'>{site_name}</a>");
let mut parent = format!("<a id='parent' href='../{parent_url}.html'>{parent_name}</a>");
let mut title = format!("<h1 id='title'>{page_name}</h1>");
let mut toc = get_table_of_contents(page);
let main = document_to_html(document, page, website); let main = main.trim();
if page.parents.is_empty() {
parent.clear();
if page.name_url == "index" {
home.clear();
title.clear();
toc.clear();
}
}
format!("\
<!DOCTYPE html>
<head>
<title>{page_name} — {site_name}</title>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
{head}
</head>
<body>
<header>
<nav id='up'>
{home}
{parent}
</nav>
{title}
{toc}
</header>
<main>
{main}
</main>
</body>
</html>")
}
pub fn get_html_head(document: &MarkdownDocument, page: &Page) -> String {
if let Some(Block::Fragment { language, content }) = document.blocks.first() {
if language == "embed-html-head" {
return content.to_string();
}
}
let root = page.root();
format!("\
<link rel='stylesheet' type='text/css' media='screen' href='{root}static/screen.css'>
<link rel='stylesheet' type='text/css' media='print' href='{root}static/print.css'>
<script src='{root}static/render_math.js' defer></script> \
")
}
pub fn get_table_of_contents(page: &Page) -> String {
if page.headings.len() < 3 {
return String::new();
}
let mut toc = String::from("<nav id='toc'><details><summary></summary><ul>\n");
for heading in &page.headings {
let name = &heading.name;
let url = &heading.url;
let class = match heading.level {
Level::Heading1 => "l1",
Level::Heading2 => "l2",
Level::Heading3 => "l3",
};
toc.push_str(&format!("<li><a href='#{url}' class='{class}'>{name}</a></li>\n"));
}
toc.push_str("</ul></details></nav>\n");
return toc;
}
pub fn document_to_html(document: &MarkdownDocument, page: &Page, website: &Website) -> String {
let mut html = String::new();
macro_rules! line_to_html {
($l:expr) => {{ line_to_html(&$l, page, website) }}; }
macro_rules! html {
($($arg:tt)*) => {{ html.push_str(&format!($($arg)*)); html.push('\n'); }}; }
macro_rules! tag {
($t:expr,$l:expr,$c:expr) => { html!("<{} {}>{}</{}>", $t, $c, line_to_html!($l), $t) };
($t:expr,$l:expr) => { html!("<{}>{}</{}>", $t, line_to_html!($l), $t) }; }
macro_rules! wrap {
($t:expr,$c:expr,$f:expr) => {{ html!("<{} {}>", $t, $c); $f; html!("</{}>", $t); }};
($t:expr,$f:expr) => {{ html!("<{}>", $t); $f; html!("</{}>", $t); }}; }
let root = page.root();
for block in &document.blocks {
match block {
Block::Heading { level, line } => match level {
Level::Heading1 => tag!("h1", line, format!("id='{}'", make_url_safe(&line_to_html!(line)))),
Level::Heading2 => tag!("h2", line, format!("id='{}'", make_url_safe(&line_to_html!(line)))),
Level::Heading3 => tag!("h3", line, format!("id='{}'", make_url_safe(&line_to_html!(line)))),
}
Block::Paragraph(line) => {
if let Some(stripped) = line.to_string().strip_prefix("$$ ") {
if let Some(stripped) = stripped.strip_suffix(" $$") {
html!("<div class='math'>{stripped}</div>");
continue;
}
}
tag!("p", line);
}
Block::List(lines) => wrap!("ul", for line in lines {
// Insert a <br> tag directly after the first untagged colon.
let mut depth = 0;
let mut prev = '\0';
let mut output = String::new();
for c in line_to_html!(line).chars() {
output.push(c);
if c == '<' {
depth += 1;
} else if c == '/' && prev == '<' {
depth -= 2;
} else if c == ':' && depth == 0 {
output.pop(); output.push_str("<br>"); depth += 99;
}
prev = c;
}
match output.contains("<br>") {
true => html!("<li class='extended'>{output}</li>"),
false => html!("<li>{output}</li>"),
}
}),
Block::Note(lines) => wrap!("aside", for line in lines { tag!("p", line) }),
Block::Embedded { label, path } => match path.rsplit_once('.') {
Some((_, extension)) => {
let path = match path.strip_prefix('/') {
Some(stripped) => format!("{root}{stripped}"),
None => path.to_string(),
};
match extension.to_lowercase().as_str() {
"jpg"|"jpeg"|"png"|"webp"|"gif"|"tiff" => html!(
"<figure><a href='{path}'><img src='{path}' alt='{label}' title='{label}'></a></figure>"),
"mp3"|"wav"|"m4a" => html!("<audio src='{path}' controls>"),
ext @ _ => warn!("Unrecognised extension for embedded file {path:?} with extension {ext:?} in page {:?}", page.name),
}
}
_ => warn!("Cannot embed file {path:?} with no file extension in page {:?}", page.name),
}
Block::Fragment { language, content } => {
match language.as_str() {
"math" => html!("<div class='math'>{content}</div>"),
"embed-html" => html!("{content}"),
"embed-css" => wrap!("style", html!("{content}")),
"embed-javascript"|"embed-js" => wrap!("script", html!("{content}")),
"hidden"|"todo"|"embed-html-head" => (),
_ => {
html!("<pre class='{}'>", language);
html!("{}", sanitize_text(content));
html!("</pre>");
},
}
}
Block::Break => html!("<hr>"),
Block::Table(table) => wrap!("div", "class='table'", wrap!("table", {
wrap!("thead",
wrap!("tr", for column in &table.columns {
tag!("th", column.name);
})
);
for section in &table.sections {
wrap!("tbody", for row in section {
wrap!("tr", for (column, cell) in std::iter::zip(&table.columns, row) {
let text_raw = line_to_html!(cell);
let text = match text_raw.as_str() {
"Yes" => "✓",
"No" => "✗",
other => other,
};
let align = match text {
"--" => "c",
_ => match column.alignment {
Alignment::Left => "l",
Alignment::Center => "c",
Alignment::Right => "r",
},
};
let class = match ["No", "--", "0"].contains(&text_raw.as_str()) {
true => format!("{align} dim"),
false => format!("{align}"),
};
html!("<td class='{}'>{}</td>", class, text);
})
})
};
}))
}
}
return html;
}
fn line_to_html(line: &Line, page: &Page, website: &Website) -> String {
let mut html = String::new();
for line_element in &line.tokens {
match line_element {
Token::Normal(text) => {
let text = &sanitize_text(text); html.push_str(text) }
Token::Bold(text) => {
let text = &sanitize_text(text); html.push_str(&format!("<b>{text}</b>")) }
Token::Italic(text) => {
let text = &sanitize_text(text); html.push_str(&format!("<i>{text}</i>")) }
Token::Monospace(text) => {
let text = &sanitize_text(text); html.push_str(&format!("<code>{text}</code>")) }
Token::Math(text) => {
let text = &sanitize_text(text); html.push_str(&format!("<span class='math'>{text}</span>")) }
Token::InternalLink(path) => {
let (label, class, path) = match path.split_once('#') {
Some(("", section)) => (section, "heading", format!("#{section}")),
Some((page, section)) => (section, "page", format!("{page}.html#{section}")),
_ => (path.as_str(), "page", format!("{path}.html")),
};
let mut path = make_url_safe(&path);
let full_label = sanitize_text(label);
let label = match full_label.rsplit_once('/') {
Some((_parent, label)) => label.trim(),
None => &full_label,
};
// Check that the linked internal page exists.
if class == "page" {
match website.has_page(page, &path, "html") {
Some(resolved_path) => path = resolved_path,
None => warn!("Page {:?} contains link to nonexistent page {path:?}", page.name),
}
}
// Check that the heading exists.
if class == "heading" {
let heading = path.strip_prefix('#').unwrap().to_string();
if !page.headings.iter().any(|h| h.url == heading) {
warn!("Page {:?} contains link to nonexistent internal heading {heading:?}", page.name);
}
}
html.push_str(&format!("<a href='{path}' class='{class}'>{label}</a>"))
}
Token::ExternalLink { label, path } => {
let mut label = label.to_string();
// Strip the protocol from the path when using the path as a label.
if label.is_empty() {
for protocol in ["mailto://", "http://", "https://"] {
if let Some(stripped) = path.strip_prefix(protocol) {
label = stripped.to_string(); } } }
let label = sanitize_text(&label);
html.push_str(&format!("<a href='{path}' class='external'>{label}</a>"));
}
}
}
return html;
}
/// Replace each HTML-reserved character with an HTML-escaped character.
fn sanitize_text(text: &str) -> String {
let mut output = String::new();
let chars: Vec<char> = text.chars().collect();
for (i, c) in chars.iter().enumerate() {
let prev = match i > 0 {
true => chars[i - 1],
false => ' ',
};
let next = match i + 1 < chars.len() {
true => chars[i + 1],
false => ' ',
};
match c {
'&' => {
// The HTML syntax for unicode characters is �
if let Some('#') = chars.get(i+1) { output.push(*c) }
else { output.push_str("&") }
},
'<' => output.push_str("<"),
'>' => output.push_str(">"),
'"' => match prev.is_whitespace() {
true => output.push('“'),
false => output.push('”'),
},
'\'' => match prev.is_whitespace() {
true => output.push('‘'),
false => output.push('’'),
},
'-' => match prev.is_whitespace() && next.is_whitespace() {
true => output.push('—'),
false => output.push('-'),
}
_ => output.push(*c),
}
}
return output;
}