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, true); let site_name = sanitize_text(&website.name, true); 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!("{site_name}"); let parent_link = match page.parents.last() { Some(name) => format!("{name}", make_url_safe(name)), 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!("\ {page_name} — {site_name} {head}

{page_name}

{main}
") } pub fn generate_html_redirect(path: &str) -> String { let path = sanitize_text(path, false); format!("\ Redirect ") } 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.iter().filter(|h| h.level != Level::Heading3).count() < 3 { return String::new(); } let mut toc = String::from("
\n"); return toc; } pub fn document_to_html(document: &MarkdownDocument, page: &Page, website: &Website) -> String { let from = &page.name; 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 } => { let id = make_url_safe(strip_appendix(&line_to_html!(line))); match level { Level::Heading1 => tag!("h1", line, format!("id='{id}'")), Level::Heading2 => tag!("h2", line, format!("id='{id}'")), Level::Heading3 => tag!("h3", line, format!("id='{id}'")), } } Block::Paragraph(line) => tag!("p", line), Block::Math(content) => html!("
{}
", sanitize_text(content, false)), Block::List(lines) => wrap!("ul", for line in lines { // Insert a
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("
"); class.push_str("extended"); depth += 99; } prev = c; } // Replace a leading checkbox with a real checkbox. if let Some(stripped) = output.strip_prefix("[ ]") { output = format!("{stripped}"); class.push_str(" checkbox"); } else if let Some(stripped) = output.strip_prefix("[x]") { output = format!("{stripped}"); class.push_str(" checkbox"); } else if let Some(stripped) = output.strip_prefix("[X]") { output = format!("{stripped}"); class.push_str(" checkbox"); }else if let Some(stripped) = output.strip_prefix("[ ]") { output = format!("{stripped}"); class.push_str(" checkbox"); } else if let Some(stripped) = output.strip_prefix("[x]") { output = format!("{stripped}"); class.push_str(" checkbox"); } else if let Some(stripped) = output.strip_prefix("[X]") { output = format!("{stripped}"); class.push_str(" checkbox"); } let class = class.trim(); match class.is_empty() { true => html!("
  • {output}
  • "), false => html!("
  • {output}
  • "), } }), Block::Note(lines) => wrap!("aside", for line in lines { tag!("p", line) }), Block::Embed { label, path } => match path.rsplit_once('.') { Some((_, extension)) => { let mut path = path.to_string(); if !path.contains("://") { match website.has_static(page, &path) { Some(resolved) => path = resolved, None => warn!("Page {from:?} embeds nonexistent static file {path:?}"), } } let label = sanitize_text(label, true); match extension.to_lowercase().as_str() { "jpg"|"jpeg"|"png"|"webp"|"gif"|"tiff" => html!( "
    {label}
    "), "mp3"|"wav"|"m4a" => html!(""), "mp4"|"avi" => html!(""), ext @ _ => warn!("Unrecognised extension for embedded file {path:?} with extension {ext:?} in page {from:?}"), } } _ => warn!("Cannot embed file {path:?} with no file extension in page {from:?}"), } Block::Fragment { language, content } => { match language.as_str() { "math" => html!("
    {}
    ", content.replace("\n", " \\\\\n")), "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!("

    "); for paragraph in recipe.process { html!("

    {paragraph}

    ") } html!("
    "); }, "gallery" => wrap!("div", "class='gallery'", for line in content.lines() { let file = line.trim(); if !website.has_image(file) { warn!("Gallery on page {from:?} references nonexistent image {file:?}"); continue; } let large = format!("{root}images/large/{file}"); // let small = format!("{root}images/small/{file}"); let thumb = format!("{root}images/thumb/{file}"); html!(""); }), "gallery-nav" => wrap!("div", "class='gallery-nav'", for line in content.lines() { let line = line.trim(); if let Some((name, image)) = line.split_once("::") { let name = name.trim(); let image = image.trim(); let ParsedLink { path, class, label } = parse_internal_link(name, page, website); if website.has_image(image) { let thumb = format!("{root}images/thumb/{image}"); html!("

    {label}

    ") } else { warn!("Gallery-nav on page {from:?} references nonexistent image {image:?}"); } } else { warn!("Gallery-nav on page {from:?} has line without a '::' separator"); } }), _ => { html!("
    ");
                            html!("{}", sanitize_text(content, false));
                            html!("
    "); }, } } Block::Break => html!("
    "), Block::Table(table) => wrap!("div", "class='table'", wrap!("table", { wrap!("thead", wrap!("tr", for column in &table.columns { match column.border_right { true => tag!("th", column.name, "class='border'"), false => 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 mut class = match text { "--" => "c", _ => match column.alignment { Alignment::Left => "l", Alignment::Center => "c", Alignment::Right => "r", }, }.to_string(); if ["No", "--", "0"].contains(&text_raw.as_str()) { class.push_str(" dim"); }; if column.border_right { class.push_str(" border"); } html!("{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, true); html.push_str(text) } Token::Bold(text) => { let text = &sanitize_text(text, true); html.push_str(&format!("{text}")) } Token::Italic(text) => { let text = &sanitize_text(text, true); html.push_str(&format!("{text}")) } Token::Monospace(text) => { let text = &sanitize_text(text, false); html.push_str(&format!("{text}")) } Token::Math(text) => { let text = &sanitize_text(text, false); html.push_str(&format!("{text}")) } Token::InternalLink(name) => { let ParsedLink { path, class, label } = parse_internal_link(name, page, website); html.push_str(&format!("{label}")) } Token::ExternalLink { label, path } => { let ParsedLink { path, class, label } = parse_external_link(label, path, page, website); html.push_str(&format!("{label}")); } } } return html; } struct ParsedLink { pub path: String, pub label: String, pub class: &'static str, } fn parse_internal_link(name: &str, page: &Page, website: &Website) -> ParsedLink { let from = &page.name; let (class, label, path) = match name.split_once('#') { Some(("", heading)) => ("heading", heading, format!("#{}", strip_appendix(heading))), Some((page, heading)) => ("page", heading, format!("{page}.html#{}", strip_appendix(heading))), _ => ("page", name, format!("{name}.html")), }; let mut path = make_url_safe(&path); let label = match label.rsplit_once('/') { Some((_, label)) => sanitize_text(label.trim(), true), None => sanitize_text(label.trim(), true), }; // Check that the linked internal page exists. if class == "page" { match website.has_page(page, &path, "html") { Some(resolved) => path = resolved, None => warn!("Page {from:?} contains link to nonexistent page {path:?}"), } } // Check that the heading exists. if class == "heading" { let heading = path.strip_prefix('#').unwrap(); if !page.headings.iter().any(|h| h.url == heading) { warn!("Page {from:?} contains link to nonexistent internal heading {heading:?}"); } } ParsedLink { path, class, label } } fn parse_external_link(label: &str, path: &str, page: &Page, website: &Website) -> ParsedLink { let from = &page.name; let mut path = path.to_owned(); let mut label = label.to_string(); let mut is_internal = true; for protocol in ["mailto:", "http://", "https://"] { if let Some(stripped) = path.strip_prefix(protocol) { is_internal = false; if label.is_empty() { label = stripped.to_string(); } break; } } if is_internal { // Check that the linked static file exists. match website.has_static(page, &path) { Some(resolved) => path = resolved, None => warn!("Page {from:?} contains link to nonexistent static file {path:?}"), } // Take the file name as the label if the link is unlabeled. if label.is_empty() { label = match path.rsplit_once('/') { Some((_, file)) => file.to_string(), None => path.clone(), }; } } let label = sanitize_text(&label, true); ParsedLink { path, class: "external", label } } /// Replace each HTML-reserved character with an HTML-escaped character. fn sanitize_text(text: &str, fancy: bool) -> String { let mut output = String::new(); let chars: Vec = 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(">"), '"' if fancy => match prev.is_whitespace() { true => output.push('“'), false => output.push('”'), }, '\'' if fancy => match prev.is_whitespace() { true => output.push('‘'), false => output.push('’'), }, '-' if fancy => match prev.is_whitespace() && next.is_whitespace() { true => output.push('—'), false => output.push('-'), } _ => output.push(*c), } } return output; } /// Remove a 'Appendix #: ' prefix from a string. pub fn strip_appendix(text: &str) -> &str { if let Some((prefix, name)) = text.split_once(": ") { if prefix.starts_with("Appendix") { return name; } } return text; }