summaryrefslogblamecommitdiff
path: root/src/generate_html.rs
blob: 3f22d3b66f53d2a6941cd1f34088e208b7253ec1 (plain) (tree)


















































































































































































































































                                                                                                                                                                          
use crate::*;

use markdown::*;


pub fn generate_html(document: &MarkdownDocument, page: &SourceFile, website: &Website) -> String {
    format!("\
<!DOCTYPE html>
<head>
<title>{} &mdash; {}</title>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
{}
</head>
<body>
<main>
{}
</main>
</body>
</html> \
",
        page.name, website.name,
        get_html_head(document),
        document_to_html(document, page, website)
    )
}



pub fn get_html_head(document: &MarkdownDocument) -> String {
    if let Some(Block::Fragment { language, content }) = document.blocks.first() {
        if language == "embed-html-head" {
            return content.to_string();
        }
    }
    String::from("\
<link rel='stylesheet' type='text/css' media='screen' href='static/screen.css'>
<link rel='stylesheet' type='text/css' media='print' href='static/print.css'>
<script src='static/render_math.js' defer></script> \
    ")
}



pub fn document_to_html(document: &MarkdownDocument, page: &SourceFile, 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) => { html!("<{}>{}</{}>", $t, line_to_html!($l), $t) }; }
    macro_rules! wrap {
        ($t:expr,$f:expr) => {{ html!("<{}>", $t); $f; html!("</{}>", $t); }};
    }

    for block in &document.blocks {
        match block {
            Block::Heading { level, line } => match level {
                Level::Heading1 => tag!("h1", line),
                Level::Heading2 => tag!("h2", line),
                Level::Heading3 => tag!("h3", line),
            }
            Block::Paragraph(line) => 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)) => match extension.to_lowercase().as_str() {
                    "jpg"|"jpeg"|"png"|"webp"|"gif"|"tiff" => html!(
                        "<figure><a href='{}'><img src='{}' alt='{}' title='{}'></a></figure>",
                        path, path, label, label
                    ),
                    "mp3"|"wav"|"m4a" => html!("<audio src='{path}' controls>"),
                    ext @ _ => error!("Unrecognised extension for embedded file '{path}' with extension '{ext}'"),
                }
                _ => error!("Cannot embed file '{path}' with no file extension"),
            }
            Block::Fragment { language, content } => {
                match language.as_str() {
                    "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!("table", {
                wrap!("thead",
                    wrap!("tr", for column in &table.columns {
                        tag!("th", column.name);
                    })
                );
                wrap!("tbody", for section in &table.sections {
                    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: &SourceFile, 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!("#{}", make_url_safe(path))),
                    Some((page, section)) => (section, "page", format!("{}.html#{}", make_url_safe(page), make_url_safe(section))),
                    _ => (path.as_str(), "page", format!("{}.html", make_url_safe(path))),
                };
                let full_label = sanitize_text(label);
                let label = match full_label.split_once('/') {
                    Some((_parent, label)) => label.trim(),
                    None => &full_label,
                };
                // Check that the linked internal page exists.
                if class == "page" {
                    let path_no_ext = path.strip_suffix(".html").unwrap();
                    if !website.has_page(&path_no_ext) {
                        error!("Page {:?} contains invalid link to {:?}", page.name, path_no_ext);
                    }
                }
                // Return to the site root before descending into a link.
                let mut back = String::new();
                let levels = page.full_url.chars().filter(|c| *c == '/').count();
                for _ in 0..levels { back.push_str("../") }
                html.push_str(&format!("<a href='{back}{path}' class='{class}'>{label}</a>"))
            }
            Token::ExternalLink { label, path } => {
                let is_internal = path.find("/").is_none();
                let (new_label, class, path) = match (is_internal, path.split_once("#")) {
                    (true, Some(("", frag)))   => (sanitize_text(frag),   "heading",  format!("#{}", make_url_safe(frag)) ),
                    (true, Some((page, frag))) => (sanitize_text(frag),   "page", format!("{}.html#{}", make_url_safe(page), make_url_safe(frag)) ),
                    (true, None)               => (sanitize_text(path), "page", if path.contains(".") { path.clone() } else { format!("{}.html", make_url_safe(path)) } ),
                    (false, _)                 => (sanitize_text(path), "external", path.clone() ) };
                let label = match label.is_empty() { true => new_label, false => sanitize_text(label) };
                html.push_str(&format!("<a href='{path}' class='{class}'>{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 &#0000
                if let Some('#') = chars.get(i+1) { output.push(*c) }
                else { output.push_str("&amp;") }
            },
            '<' => output.push_str("&lt;"),
            '>' => output.push_str("&gt;"),
            '"' => 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;
}