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!("{site_name}"); let parent_link = match page.parents.get(page.parents.len()-1) { 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); 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 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!("
{stripped}
"); continue; } } tag!("p", line); } 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"); } let class = class.trim(); html!("
  • {output}
  • ") }), 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!( "
    {label}
    "), "mp3"|"wav"|"m4a" => html!(""), 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!("
    {}
    ", 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 {:?} references nonexistent image {file:?}", page.name); continue; } let large = format!("{root}images/large/{file}"); // let small = format!("{root}images/small/{file}"); let thumb = format!("{root}images/thumb/{file}"); html!(""); }), _ => { html!("
    ", language);
                            html!("{}", sanitize_text(content));
                            html!("
    "); }, } } Block::Break => html!("
    "), 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!("{}", 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!("{text}")) } Token::Italic(text) => { let text = &sanitize_text(text); html.push_str(&format!("{text}")) } Token::Monospace(text) => { let text = &sanitize_text(text); html.push_str(&format!("{text}")) } Token::Math(text) => { let text = &sanitize_text(text); html.push_str(&format!("{text}")) } 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!("{label}")) } 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() { label = path.to_string(); for protocol in ["mailto://", "http://", "https://"] { if let Some(stripped) = path.strip_prefix(protocol) { label = stripped.to_string(); } } } // Support absolute local paths. let path = match path.strip_prefix('/') { Some(stripped) => format!("{}{stripped}", page.root()), None => path.to_string(), }; let label = sanitize_text(&label); html.push_str(&format!("{label}")); } } } 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 = 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; }