diff options
| author | Ben Bridle <ben@derelict.engineering> | 2025-01-06 17:19:06 +1300 | 
|---|---|---|
| committer | Ben Bridle <ben@derelict.engineering> | 2025-01-06 17:19:06 +1300 | 
| commit | 8b6bb67e39b59f68dc005550dd42f031b6f415e8 (patch) | |
| tree | 7a606b03ee5f56ad1598fad4827b8d1358628ac4 /src/generate_html.rs | |
| download | toaster-8b6bb67e39b59f68dc005550dd42f031b6f415e8.zip | |
Initial versionv1.0.0
Diffstat (limited to 'src/generate_html.rs')
| -rw-r--r-- | src/generate_html.rs | 244 | 
1 files changed, 244 insertions, 0 deletions
diff --git a/src/generate_html.rs b/src/generate_html.rs new file mode 100644 index 0000000..3f22d3b --- /dev/null +++ b/src/generate_html.rs @@ -0,0 +1,244 @@ +use crate::*; + +use markdown::*; + + +pub fn generate_html(document: &MarkdownDocument, page: &SourceFile, website: &Website) -> String { +    format!("\ +<!DOCTYPE html> +<head> +<title>{} — {}</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 � +                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; +}  | 
