use crate::*; use markdown::*; use recipe::*; pub fn generate_html(document: &MarkdownDocument, page: &Page, website: &Website) -> String { let root = page.root(); let page_name = sanitize_text(&page.name); let site_name = sanitize_text(&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 head = get_html_head(page, website); let head = head.trim(); let home_link = format!("<a id='home' href='{root}index.html'>{site_name}</a>"); let parent_link = match page.parents.get(page.parents.len()-1) { Some(name) => format!("<a id='parent' href='../{parent_url}.html'>{name}</a>"), None => String::new(), }; let table_of_contents = get_table_of_contents(page); let main = document_to_html(document, page, website); let main = main.trim(); 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_link} {parent_link} </nav> <h1 id='title'>{page_name}</h1> <nav id='toc'> {table_of_contents} </nav> </header> <main> {main} </main> </body> </html>") } pub fn generate_html_redirect(path: &str) -> String { let path = sanitize_text(path); format!("\ <!DOCTYPE html> <head> <title>Redirect</title> <meta http-equiv='refresh' content='0; url={path}'> </head> <html>") } pub fn get_html_head(page: &Page, website: &Website) -> String { let root = page.root(); website.get_config("html.head") .replace("href='/", &format!("href='{root}")) .replace("src='/", &format!("src='{root}")) } pub fn get_table_of_contents(page: &Page) -> String { if page.headings.len() < 3 { return String::new(); } let mut toc = String::from("<details><summary></summary><ul>\n"); let site_name = sanitize_text(&page.name); toc.push_str(&format!("<li class='l1'><a href='#title'>{site_name}</a></li>\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 class='{class}'><a href='#{url}'>{name}</a></li>\n")); } toc.push_str("</ul></details>\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(); let mut class = String::new(); for c in line_to_html!(line).chars() { output.push(c); if c == '<' { depth += 1; } else if c == '/' && prev == '<' { depth -= 2; // 2 because prev was a '<' as well. } else if c == ':' && depth == 0 { output.pop(); output.push_str("<br>"); class.push_str("extended"); depth += 99; } prev = c; } // Replace a leading checkbox with a real checkbox. if let Some(stripped) = output.strip_prefix("<code>[ ]</code>") { output = format!("<input type='checkbox' disabled>{stripped}"); class.push_str(" checkbox"); } else if let Some(stripped) = output.strip_prefix("<code>[x]</code>") { output = format!("<input type='checkbox' disabled checked>{stripped}"); class.push_str(" checkbox"); } else if let Some(stripped) = output.strip_prefix("<code>[X]</code>") { output = format!("<input type='checkbox' disabled checked>{stripped}"); class.push_str(" checkbox"); } let class = class.trim(); html!("<li class='{class}'>{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(), }; let label = sanitize_text(label); 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" => (), "recipe" => { let recipe = Recipe::parse(content); html!("<div class='recipe'><ul>"); for ingredient in recipe.ingredients { html!("<li>{ingredient}</li>") } html!("</ul><hr>"); for paragraph in recipe.process { html!("<p>{paragraph}</p>") } html!("</div>"); }, _ => { 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; }