diff options
| -rw-r--r-- | Cargo.lock | 183 | ||||
| -rw-r--r-- | Cargo.toml | 9 | ||||
| -rw-r--r-- | src/collect_files.rs | 154 | ||||
| -rw-r--r-- | src/generate_html.rs | 443 | ||||
| -rw-r--r-- | src/main.rs | 6 |
5 files changed, 574 insertions, 221 deletions
@@ -3,9 +3,55 @@ version = 4 [[package]] -name = "ansi" +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "highlight" +version = "1.0.2" +source = "git+git://benbridle.com/highlight?tag=v1.0.2#e4802ab2a5422a87e4aadcb11b1cdb2d68f9c355" +dependencies = [ + "fancy-regex", +] + +[[package]] +name = "inked" version = "1.0.0" -source = "git+git://benbridle.com/ansi?tag=v1.0.0#81d47867c2c97a9ae1d1c8fdfcd42c582410ad2a" +source = "git+git://benbridle.com/inked?tag=v1.0.0#2954d37b638fa2c1dd3d51ff53f08f475aea6ea3" +dependencies = [ + "termcolor", +] [[package]] name = "log" @@ -14,16 +60,22 @@ source = "git+git://benbridle.com/log?tag=v1.1.1#930f3d0e2b82df1243f423c092a3854 [[package]] name = "log" -version = "1.1.2" -source = "git+git://benbridle.com/log?tag=v1.1.2#3d5d1f7a19436151ba1dd52a2b50664969d90db6" +version = "2.0.0" +source = "git+git://benbridle.com/log?tag=v2.0.0#a38d3dd487594f41151db57625410d1b786bebe4" dependencies = [ - "ansi", + "inked", ] [[package]] name = "markdown" -version = "3.3.0" -source = "git+git://benbridle.com/markdown?tag=v3.3.0#df45ffb3affb7cb1d53b567b70fef721353ccffe" +version = "3.3.1" +source = "git+git://benbridle.com/markdown?tag=v3.3.1#2bff486208df17cae8498f602f81c6e2024a1a11" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "paste" @@ -37,6 +89,23 @@ version = "1.4.0" source = "git+git://benbridle.com/recipe?tag=v1.4.0#652aaee3130e2ee02742fdcc248ddd1bee285737" [[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] name = "switchboard" version = "1.0.0" source = "git+git://benbridle.com/switchboard?tag=v1.0.0#ea70fa89659e5cf1a9d4ca6ea31fb67f7a2cc633" @@ -46,10 +115,20 @@ dependencies = [ ] [[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] name = "toaster" -version = "1.10.0" +version = "1.12.0" dependencies = [ - "log 1.1.2", + "highlight", + "log 2.0.0", "markdown", "recipe", "switchboard", @@ -58,5 +137,87 @@ dependencies = [ [[package]] name = "vagabond" -version = "1.1.0" -source = "git+git://benbridle.com/vagabond?tag=v1.1.0#6e759a3abb3bc3e5da42d69a6f20ec2c31eb33de" +version = "1.1.1" +source = "git+git://benbridle.com/vagabond?tag=v1.1.1#b190582517e6008ad1deff1859f15988e4efaa26" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" @@ -1,14 +1,15 @@ [package] name = "toaster" -version = "1.10.0" +version = "1.12.0" edition = "2021" [dependencies] -vagabond = { git = "git://benbridle.com/vagabond", tag = "v1.1.0" } -markdown = { git = "git://benbridle.com/markdown", tag = "v3.3.0" } +vagabond = { git = "git://benbridle.com/vagabond", tag = "v1.1.1" } +markdown = { git = "git://benbridle.com/markdown", tag = "v3.3.1" } recipe = { git = "git://benbridle.com/recipe", tag = "v1.4.0" } -log = { git = "git://benbridle.com/log", tag = "v1.1.2" } +log = { git = "git://benbridle.com/log", tag = "v2.0.0" } switchboard = { git = "git://benbridle.com/switchboard", tag = "v1.0.0" } +highlight = { git = "git://benbridle.com/highlight", tag = "v1.0.2" } [profile.release] lto=true diff --git a/src/collect_files.rs b/src/collect_files.rs index 7a3c464..e3d3a11 100644 --- a/src/collect_files.rs +++ b/src/collect_files.rs @@ -1,13 +1,16 @@ use crate::*; +use highlight::*; use vagabond::*; use std::collections::HashMap; +use std::fmt::Debug; pub struct Website { pub name: String, pub config: HashMap<String, String>, + pub highlighters: Highlighters, pub pages: Vec<Page>, pub redirects: Vec<Redirect>, pub static_files: Vec<StaticItem>, // Redirects, !-prefixed-dir contents @@ -16,8 +19,8 @@ pub struct Website { pub struct Page { pub name: String, // Display name of this page - pub name_url: String, // URL name for this page, no extension - pub full_url: String, // Full URL for this page, no extension + pub name_url: String, // Safe URL name, no extension + pub full_url: String, // Safe full URL, no extension pub parents: Vec<String>, // Parent directory components, unsafe pub parent_url: String, // Base URL for links in this page pub source_path: PathBuf, // Absolute path to source file @@ -30,6 +33,7 @@ pub struct Heading { pub name: String, pub url: String, pub level: Level, + pub block_id: usize, } pub struct StaticItem { @@ -47,7 +51,7 @@ pub struct Redirect { pub last_modified: Option<SystemTime>, // last-modified time of source file } -pub trait LinkFrom { +pub trait LinkFrom: Debug { fn name(&self) -> &str; fn parent_url(&self) -> &str; fn parents(&self) -> &[String]; @@ -58,8 +62,23 @@ pub trait LinkFrom { } return root; } + fn qualified_name(&self) -> String { + match self.parents().last() { + Some(parent) => format!("{parent}/{}", self.name()), + None => format!("/{}", self.name()), + } + } +} + +pub struct Highlighters { + pub languages: HashMap<String, usize>, + pub highlighters: Vec<Highlighter>, } +pub struct ImagePaths { + pub thumb: String, + pub large: String, +} impl Page { pub fn root(&self) -> String { @@ -71,6 +90,18 @@ impl Page { } } +impl Debug for Page { + fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> { + write!(f, "\"{}\"", self.qualified_name()) + } +} + +impl Debug for Redirect { + fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> { + write!(f, "\"{}\"", self.qualified_name()) + } +} + impl LinkFrom for Page { fn name(&self) -> &str { &self.name } fn parent_url(&self) -> &str { &self.parent_url } @@ -96,8 +127,13 @@ impl Website { Err(err) => fatal!("Couldn't open {:?}: {:?}", &path, err), }, config: HashMap::new(), + highlighters: Highlighters { + languages: HashMap::new(), + highlighters: Vec::new(), + }, }; new.collect_entry(path, path); + new.parse_highlighters(); return new; } @@ -107,11 +143,7 @@ impl Website { if entry.name.starts_with('.') { return } // Get name and extension. let (mut name, extension) = entry.split_name(); - if let Some((prefix, suffix)) = name.split_once(' ') { - if prefix.chars().all(|c| "0123456789-".contains(c)) { - name = suffix.to_string(); - } - } + name = strip_numeric_prefix(&name); let name_url = make_url_safe(&name); // Get last-modified time. let last_modified = entry.last_modified; @@ -120,7 +152,9 @@ impl Website { let relative_path = source_path.strip_prefix(prefix).unwrap_or_else( |_| fatal!("Path doesn't start with {prefix:?}: {source_path:?}")); let mut parents: Vec<_> = relative_path.components() - .map(|c| c.as_os_str().to_string_lossy().to_string()).collect(); + .map(|c| c.as_os_str().to_string_lossy().to_string()) + .map(|s| strip_numeric_prefix(&s)) + .collect(); parents.pop(); // Remove file segment. // Process each entry. @@ -149,7 +183,7 @@ impl Website { let mut key = None; let mut value = String::new(); for line in config.lines() { - if line.starts_with(" ") { + if line.starts_with(" ") || line.trim().is_empty() { value.push_str(line.trim()); value.push('\n'); } else { @@ -163,27 +197,52 @@ impl Website { self.config.insert(key, std::mem::take(&mut value)); } } else { + let full_name = match parents.last() { + Some(parent) => format!("{parent}/{name}"), + None => name.to_string(), + }; match extension.as_str() { "md" => { let markdown = std::fs::read_to_string(&source_path).unwrap(); let document = MarkdownDocument::from_str(&markdown); + // Collect headings, check for duplicates. let mut heading_set = HashSet::new(); let mut duplicates = HashSet::new(); - let headings = document.blocks.iter() - .filter_map(|block| if let Block::Heading { line, level } = block { + let mut headings: Vec<_> = document.blocks.iter().enumerate() + .filter_map(|(block_id, block)| if let Block::Heading { line, level } = block { let name = line.to_string(); let url = make_url_safe(strip_appendix(&name)); let level = level.to_owned(); if !heading_set.insert(url.clone()) { duplicates.insert(url.clone()); } - Some(Heading { name, url, level }) + Some(Heading { name, url, level, block_id }) } else { None }).collect(); + + // Namespace any duplicate headings to the parent h1 heading. + let mut parent_url = String::new(); + for heading in &mut headings { + if let Level::Heading1 = heading.level { + parent_url = heading.url.clone(); + } + if duplicates.contains(&heading.url) { + heading.url = format!("{parent_url}-{}", heading.url); + } + } + // Check for duplicates again, and warn if any. + heading_set.clear(); + duplicates.clear(); + for heading in &headings { + if !heading_set.insert(heading.url.clone()) { + duplicates.insert(heading.url.clone()); + } + } for url in duplicates { - warn!("Page {name:?} contains multiple headings with ID \"#{url}\""); + warn!("Page {full_name:?} contains multiple headings with ID \"#{url}\""); } + if name_url == "+index" { if parents.is_empty() { // This is the index file for the whole site. @@ -268,6 +327,40 @@ impl Website { } } + pub fn parse_highlighters(&mut self) { + let mut languages = Vec::new(); + let mut source = String::new(); + for line in self.get_config("highlighters").lines() { + if let Some(line) = line.trim().strip_prefix('[') { + if let Some(line) = line.strip_suffix(']') { + // Bank the current source. + if !languages.is_empty() { + let i = self.highlighters.highlighters.len(); + for language in languages { + self.highlighters.languages.insert(language, i); + } + let highlighter = Highlighter::from_str(&source); + self.highlighters.highlighters.push(highlighter); + } + languages = line.split('/').map(|s| s.trim().to_string()).collect(); + source.clear(); + continue; + } + } + source.push_str(line); + source.push('\n'); + } + // Bank the current source. + if !languages.is_empty() { + let i = self.highlighters.highlighters.len(); + for language in languages { + self.highlighters.languages.insert(language, i); + } + let highlighter = Highlighter::from_str(&source); + self.highlighters.highlighters.push(highlighter); + } + } + // Ext is extension without a dot. // Checks if a relative link to an internal page name can be reached from // the current page, and returns a resolved absolute link to the page with extension. @@ -285,7 +378,7 @@ impl Website { if !path.starts_with('/') { path = format!("{}{path}", from.parent_url()); } - let path = make_url_safe(&collapse_path(&path)); + path = make_url_safe(&collapse_path(&path)); // Find page with this path in website. for page in &self.pages { @@ -294,7 +387,7 @@ impl Website { if let Some(heading) = heading { let heading = make_url_safe(strip_appendix(heading)); if !page.headings.iter().any(|h| h.url == heading) { - warn!("Page {:?} contains link to nonexistent heading {heading:?} on page {path:?}", from.name()); + warn!("Page {from:?} contains link to nonexistent heading {heading:?} on page {path:?}"); } return Some(format!("{root}{path}.{ext}#{heading}")); } else { @@ -320,9 +413,22 @@ impl Website { return None; } - pub fn has_image(&self, file_name: &str) -> bool { - let image_path = format!("images/thumb/{file_name}"); - self.static_files.iter().any(|s| s.full_url == image_path) + pub fn has_image(&self, file_name: &str, root: &str) -> Option<ImagePaths> { + let check = |path: String| + match self.static_files.iter().any(|s| s.full_url == path) { + true => Some(format!("{root}{path}")), + false => None, + }; + let thumb_path = check(format!("images/thumb/{file_name}")); + let large_path = check(format!("images/large/{file_name}")); + let fallback_path = check(format!("images/{file_name}")) + .or(large_path.clone()) + .or(thumb_path.clone()); + + Some(ImagePaths { + thumb: thumb_path.or(fallback_path.clone())?, + large: large_path.or(fallback_path.clone())?, + }) } pub fn get_config(&self, key: &str) -> String { @@ -351,3 +457,13 @@ fn collapse_path(path: &str) -> String { return segments.join("/"); } } + + +fn strip_numeric_prefix(name: &str) -> String { + if let Some((prefix, suffix)) = name.split_once(' ') { + if prefix.chars().all(|c| "0123456789-".contains(c)) { + return suffix.to_string(); + } + } + return name.to_string(); +} diff --git a/src/generate_html.rs b/src/generate_html.rs index 55bd8ef..2b979ea 100644 --- a/src/generate_html.rs +++ b/src/generate_html.rs @@ -4,9 +4,16 @@ use markdown::*; use recipe::*; -pub fn generate_html(document: &MarkdownDocument, page: &Page, website: &Website) -> String { +pub fn generate_html(page: &Page, website: &Website) -> String { let root = page.root(); - let page_name = sanitize_text(&page.name, true); + let mut page_name = sanitize_text(&page.name, true); + for block in &page.document.blocks { + if let Block::Fragment { language, content } = block { + if language == "override-title" { + page_name = sanitize_text(content.trim(), true); + } + } + } let site_name = sanitize_text(&website.name, true); let mut parent_url = String::new(); for segment in &page.parents { @@ -20,8 +27,12 @@ pub fn generate_html(document: &MarkdownDocument, page: &Page, website: &Website Some(name) => format!("<a id='parent' href='../{}.html'>{name}</a>", 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(); + let toc = get_table_of_contents(page); + let toc_main = if page.headings.len() >= 3 { + format!("<details><summary></summary>\n{toc}</details>\n") + } else { String::new() }; + let toc_side = format!("<div>{toc}</div>\n"); + let main = document_to_html(page, website); let main = main.trim(); format!("\ <!DOCTYPE html> @@ -32,6 +43,11 @@ pub fn generate_html(document: &MarkdownDocument, page: &Page, website: &Website {head} </head> <body> +<nav id='outline' class='hidden'> +<h1></h1> +{toc_side} +</nav> +<div id='page'> <header> <nav id='up'> {home_link} @@ -39,42 +55,58 @@ pub fn generate_html(document: &MarkdownDocument, page: &Page, website: &Website </nav> <h1 id='title'>{page_name}</h1> <nav id='toc'> -{table_of_contents} +{toc_main} </nav> </header> <main> {main} </main> +</div> </body> </html>") } -pub fn generate_html_redirect(path: &str) -> String { +pub fn generate_html_redirect(path: &str, website: &Website) -> String { + let head = website.get_config("html.redirect.head"); let head = head.trim(); let path = sanitize_text(path, false); format!("\ <!DOCTYPE html> <head> -<title>Redirect</title> +<title>Redirecting...</title> <meta http-equiv='refresh' content='0; url={path}'> +{head} </head> -<html>") +</html>") } pub fn get_html_head(page: &Page, website: &Website) -> String { + let mut include_default_head = true; + let mut html_head = String::new(); + for block in &page.document.blocks { + if let markdown::Block::Fragment { language, content } = block { + if language == "override-html-head" { + html_head.push_str(content); + include_default_head = false; + } + if language == "embed-html-head" { + html_head.push_str(content); + } + } + } + if include_default_head { + html_head.insert_str(0, &website.get_config("html.head")); + } let root = page.root(); - website.get_config("html.head") + 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("<details><summary></summary><ul>\n"); + let mut toc = String::from("<ul>\n"); let site_name = sanitize_text(&page.name, true); toc.push_str(&format!("<li class='l1'><a href='#title'>{site_name}</a></li>\n")); @@ -88,13 +120,14 @@ pub fn get_table_of_contents(page: &Page) -> String { }; toc.push_str(&format!("<li class='{class}'><a href='#{url}'>{name}</a></li>\n")); } - toc.push_str("</ul></details>\n"); + toc.push_str("</ul>\n"); return toc; } -pub fn document_to_html(document: &MarkdownDocument, page: &Page, website: &Website) -> String { - let from = &page.name; +pub fn document_to_html(page: &Page, website: &Website) -> String { + let from = &page; + let root = page.root(); let mut html = String::new(); macro_rules! line_to_html { @@ -108,176 +141,209 @@ pub fn document_to_html(document: &MarkdownDocument, page: &Page, website: &Webs ($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!("<div class='math'>{}</div>", sanitize_text(content, false)), - 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"); - }else if let Some(stripped) = output.strip_prefix("[ ]") { - output = format!("<input type='checkbox' disabled>{stripped}"); - class.push_str(" checkbox"); - } else if let Some(stripped) = output.strip_prefix("[x]") { - output = format!("<input type='checkbox' disabled checked>{stripped}"); - class.push_str(" checkbox"); - } else if let Some(stripped) = output.strip_prefix("[X]") { - output = format!("<input type='checkbox' disabled checked>{stripped}"); - class.push_str(" checkbox"); - } - let class = class.trim(); - match class.is_empty() { - true => html!("<li>{output}</li>"), - false => html!("<li class='{class}'>{output}</li>"), + wrap!("article", + for (i, block) in page.document.blocks.iter().enumerate() { + match block { + Block::Heading { level, line } => { + if let Level::Heading1 = level { + html!("</article>"); + html!("<article>"); + // html!("<nav class='return'><a href='#'></a></nav>"); + }; + // Find namespaced heading ID from headings list. + let url = match page.headings.iter().find(|h| h.block_id == i) { + Some(heading) => heading.url.clone(), + None => unreachable!("Couldn't find heading in headings list"), + }; + // let url = make_url_safe(strip_appendix(&line.to_string())); + let heading_tag = match level { + Level::Heading1 => "h1", + Level::Heading2 => "h2", + Level::Heading3 => "h3", + }; + wrap!(heading_tag, format!("id='{url}'"), { + tag!("a", line, format!("href='#{url}'")); + }); } - }), - 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:?}"), + Block::Paragraph(line) => tag!("p", line), + Block::Math(content) => html!("<div class='math'>{}</div>", sanitize_text(content, false)), + 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; } - let label = sanitize_text(label, true); - 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 controls src='{path}'>{label}</audio>"), - "mp4"|"avi" => html!("<video controls src='{path}'>{label}</video>"), - ext @ _ => warn!("Unrecognised extension for embedded file {path:?} with extension {ext:?} in page {from:?}"), + // 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"); + }else if let Some(stripped) = output.strip_prefix("[ ]") { + output = format!("<input type='checkbox' disabled>{stripped}"); + class.push_str(" checkbox"); + } else if let Some(stripped) = output.strip_prefix("[x]") { + output = format!("<input type='checkbox' disabled checked>{stripped}"); + class.push_str(" checkbox"); + } else if let Some(stripped) = output.strip_prefix("[X]") { + output = format!("<input type='checkbox' disabled checked>{stripped}"); + class.push_str(" checkbox"); } - } - _ => warn!("Cannot embed file {path:?} with no file extension in page {from:?}"), - } - Block::Fragment { language, content } => { - match language.as_str() { - "math" => html!("<div class='math'>{}</div>", 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" => (), - "poem" => wrap!("div", "class='poem'", for line in content.lines() { - let line = line.trim_end(); - match line.is_empty() { - true => html!("<br>"), - false => html!("<p>{}</p>", sanitize_text(line, true)), + let class = class.trim(); + match class.is_empty() { + true => html!("<li>{output}</li>"), + false => html!("<li class='{class}'>{output}</li>"), + } + }), + 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:?}"), + } } - }), - "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>"); - }, - "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 label = sanitize_text(label, true); + let path = sanitize_text(&path, false); + 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 controls src='{path}'>{label}</audio>"), + "mp4"|"avi" => html!("<figure><video controls src='{path}'>{label}</video></figure>"), + ext @ _ => warn!("Unrecognised extension for embedded file {path:?} with extension {ext:?} in page {from:?}"), } - let large = format!("{root}images/large/{file}"); - // let small = format!("{root}images/small/{file}"); - let thumb = format!("{root}images/thumb/{file}"); - html!("<a href='{large}'><img src='{thumb}' /></a>"); - }), - "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!("<a href='{path}' class='{class}'><img src='{thumb}'/><p>{label}</p></a>") + } + _ => warn!("Cannot embed file {path:?} with no file extension in page {from:?}"), + } + Block::Fragment { language, content } => { + match language.as_str() { + "math" => html!("<div class='math'>{}</div>", content.replace("\n", " \\\\\n")), + "embed-html" => html!("{content}"), + "embed-css" => wrap!("style", html!("{content}")), + "embed-javascript"|"embed-js" => wrap!("script", html!("{content}")), + "embed-html-head"|"override-html-head"|"override-title" => (), + "hidden"|"todo" => (), + "poem" => wrap!("div", "class='poem'", for line in content.lines() { + let line = line.trim_end(); + match line.is_empty() { + true => html!("<br>"), + false => html!("<p>{}</p>", sanitize_text(line, true)), + } + }), + "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>"); + }, + "gallery" => wrap!("div", "class='gallery'", for line in content.lines() { + let file_name = line.trim(); + if let Some(image_paths) = website.has_image(file_name, &root) { + let large = sanitize_text(&image_paths.large, false); + let thumb = sanitize_text(&image_paths.thumb, false); + html!("<a href='{large}'><img src='{thumb}' /></a>"); } else { - warn!("Gallery-nav on page {from:?} references nonexistent image {image:?}"); + warn!("Gallery on page {from:?} references nonexistent image {file_name:?}"); } - } else { - warn!("Gallery-nav on page {from:?} has line without a '::' separator"); - } - }), - _ => wrap!("pre", format!("class='{language}'"), html!("{}", sanitize_text(content, false))), + }), + "gallery-nav" => wrap!("div", "class='gallery-nav'", for line in content.lines() { + let line = line.trim(); + if let Some((name, file_name)) = line.split_once("::") { + let name = name.trim(); + let file_name = file_name.trim(); + let ParsedLink { path, class, label } = parse_internal_link(name, page, website); + if let Some(image_paths) = website.has_image(file_name, &root) { + let thumb = sanitize_text(&image_paths.thumb, false); + html!("<a href='{path}' class='{class}'><img src='{thumb}'/><p>{label}</p></a>") + } else { + warn!("Gallery on page {from:?} references nonexistent image {file_name:?}"); + warn!("Gallery-nav on page {from:?} references nonexistent image {file_name:?}"); + } + } else { + warn!("Gallery-nav on page {from:?} has line without a '::' separator"); + } + }), + _ => wrap!("pre", format!("class='{language}'"), { + if let Some(i) = website.highlighters.languages.get(language) { + let mut source = String::new(); + let highlighter = &website.highlighters.highlighters[*i]; + for span in highlighter.highlight(content) { + if span.tag.is_empty() { + source.push_str(&sanitize_text(&span.text, false)); + } else { + source.push_str(&format!("<span class='{}'>", span.tag.to_lowercase())); + source.push_str(&sanitize_text(&span.text, false)); + source.push_str("</span>"); + } + } + html!("{source}"); + } else { + html!("{}", sanitize_text(content, false)) + } + }) + } } - } - Block::Break => html!("<hr>"), - 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"); + Block::Break => html!("<hr>"), + 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), } - html!("<td class='{class}'>{text}</td>"); }) - }) - }; - })) + ); + 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!("<td class='{class}'>{text}</td>"); + }) + }) + }; + })) + } } - } + ); return html; } @@ -322,7 +388,7 @@ struct ParsedLink { } fn parse_internal_link(name: &str, page: &Page, website: &Website) -> ParsedLink { - let from = &page.name; + let from = &page; 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))), @@ -352,7 +418,7 @@ fn parse_internal_link(name: &str, page: &Page, website: &Website) -> ParsedLink } fn parse_external_link(label: &str, path: &str, page: &Page, website: &Website) -> ParsedLink { - let from = &page.name; + let from = &page; let mut path = path.to_owned(); let mut label = label.to_string(); let mut is_internal = true; @@ -406,17 +472,26 @@ fn sanitize_text(text: &str, fancy: bool) -> String { }, '<' => output.push_str("<"), '>' => output.push_str(">"), - '"' if fancy => match prev.is_whitespace() { - true => output.push('“'), - false => output.push('”'), + '"' => match fancy { + true => match prev.is_whitespace() { + true => output.push('“'), + false => output.push('”'), + } + false => output.push_str("""), }, - '\'' if fancy => match prev.is_whitespace() { - true => output.push('‘'), - false => output.push('’'), + '\'' => match fancy { + true => match prev.is_whitespace() { + true => output.push('‘'), + false => output.push('’'), + } + false => output.push_str("'"), }, '-' if fancy => match prev.is_whitespace() && next.is_whitespace() { - true => output.push('—'), - false => output.push('-'), + true => match i > 0 { + true => output.push('—'), // em-dash, for mid-sentence + false => output.push('–'), // en-dash, for start of line + } + false => output.push('-'), // regular dash, for mid-word } _ => output.push(*c), } diff --git a/src/main.rs b/src/main.rs index 1ea25d2..3ee0bc6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,7 +100,7 @@ fn main() { destination.push(&page.full_url); // Convert document to different formats. if export_html { - let html = generate_html(&page.document, page, &website); + let html = generate_html(page, &website); write_file(&html, &destination, "html", page.last_modified); } // Copy original markdown file. @@ -130,12 +130,12 @@ fn main() { if export_html { if !path.contains("://") { if let Some(path) = website.has_page(redirect, &path, "html") { - write_file(&generate_html_redirect(&path), &destination, "html", redirect.last_modified); + write_file(&generate_html_redirect(&path, &website), &destination, "html", redirect.last_modified); } else { warn!("Redirect {:?} links to nonexistent page {path:?}", redirect.name); } } else { - write_file(&generate_html_redirect(&path), &destination, "html", redirect.last_modified); + write_file(&generate_html_redirect(&path, &website), &destination, "html", redirect.last_modified); } } } |
