summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock183
-rw-r--r--Cargo.toml9
-rw-r--r--src/collect_files.rs154
-rw-r--r--src/generate_html.rs443
-rw-r--r--src/main.rs6
5 files changed, 574 insertions, 221 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 59defee..3f2858f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/Cargo.toml b/Cargo.toml
index 94a9ccb..a94aa66 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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("&lt;"),
'>' => output.push_str("&gt;"),
- '"' 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("&#34;"),
},
- '\'' 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("&#39;"),
},
'-' 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);
}
}
}