summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock293
-rw-r--r--Cargo.toml12
-rw-r--r--rust-toolchain.toml2
-rw-r--r--src/collect_files.rs494
-rw-r--r--src/config.rs92
-rw-r--r--src/generate_html.rs617
-rw-r--r--src/generate_rss.rs72
-rw-r--r--src/main.rs192
-rw-r--r--src/string_utils.rs145
9 files changed, 1345 insertions, 574 deletions
diff --git a/Cargo.lock b/Cargo.lock
index d5d8c44..515e5c8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -12,6 +12,21 @@ dependencies = [
]
[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -27,6 +42,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "cc"
+version = "1.2.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
name = "fancy-regex"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -38,14 +94,44 @@ dependencies = [
]
[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
name = "highlight"
-version = "1.0.0"
-source = "git+git://benbridle.com/highlight?tag=v1.0.0#0e9fe32e7439a597a49f9c36c56a26726100b434"
+version = "1.0.2"
+source = "git+git://benbridle.com/highlight?tag=v1.0.2#e4802ab2a5422a87e4aadcb11b1cdb2d68f9c355"
dependencies = [
"fancy-regex",
]
[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log 0.4.29",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
name = "inked"
version = "1.0.0"
source = "git+git://benbridle.com/inked?tag=v1.0.0#2954d37b638fa2c1dd3d51ff53f08f475aea6ea3"
@@ -54,6 +140,28 @@ dependencies = [
]
[[package]]
+name = "js-sys"
+version = "0.3.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93f0862381daaec758576dcc22eb7bbf4d7efd67328553f3b45a412a51a3fb21"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.182"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
name = "log"
version = "1.1.1"
source = "git+git://benbridle.com/log?tag=v1.1.1#930f3d0e2b82df1243f423c092a38546ea7533c3"
@@ -68,8 +176,8 @@ dependencies = [
[[package]]
name = "markdown"
-version = "3.3.0"
-source = "git+git://benbridle.com/markdown?tag=v3.3.0#df45ffb3affb7cb1d53b567b70fef721353ccffe"
+version = "3.3.2"
+source = "git+git://benbridle.com/markdown?tag=v3.3.2#e8d8a1146de5e297dda3dd6264a02f9d6938c3e1"
[[package]]
name = "memchr"
@@ -78,12 +186,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
name = "recipe"
version = "1.4.0"
source = "git+git://benbridle.com/recipe?tag=v1.4.0#652aaee3130e2ee02742fdcc248ddd1bee285737"
@@ -106,15 +247,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
name = "switchboard"
-version = "1.0.0"
-source = "git+git://benbridle.com/switchboard?tag=v1.0.0#ea70fa89659e5cf1a9d4ca6ea31fb67f7a2cc633"
+version = "2.1.0"
+source = "git+git://benbridle.com/switchboard?tag=v2.1.0#e6435712ba5b3ca36e99fc8cbe7755940f8b1f3f"
dependencies = [
"log 1.1.1",
"paste",
]
[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -125,8 +289,9 @@ dependencies = [
[[package]]
name = "toaster"
-version = "1.12.0"
+version = "2.0.1"
dependencies = [
+ "chrono",
"highlight",
"log 2.0.0",
"markdown",
@@ -136,9 +301,60 @@ dependencies = [
]
[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
name = "vagabond"
-version = "1.1.1"
-source = "git+git://benbridle.com/vagabond?tag=v1.1.1#b190582517e6008ad1deff1859f15988e4efaa26"
+version = "1.1.2"
+source = "git+git://benbridle.com/vagabond?tag=v1.1.2#5ace2626e7c8eba4647250346668052071107f0f"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.110"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1de241cdc66a9d91bd84f097039eb140cdc6eec47e0cdbaf9d932a1dd6c35866"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.110"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e12fdf6649048f2e3de6d7d5ff3ced779cdedee0e0baffd7dff5cdfa3abc8a52"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.110"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e63d1795c565ac3462334c1e396fd46dbf481c40f51f5072c310717bc4fb309"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.110"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f9cdac23a5ce71f6bf9f8824898a501e511892791ea2a0c6b8568c68b9cb53"
+dependencies = [
+ "unicode-ident",
+]
[[package]]
name = "winapi-util"
@@ -150,6 +366,65 @@ dependencies = [
]
[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 6e5e084..dc80624 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,15 +1,17 @@
[package]
name = "toaster"
-version = "1.12.0"
+version = "2.0.1"
edition = "2021"
[dependencies]
-vagabond = { git = "git://benbridle.com/vagabond", tag = "v1.1.1" }
-markdown = { git = "git://benbridle.com/markdown", tag = "v3.3.0" }
+vagabond = { git = "git://benbridle.com/vagabond", tag = "v1.1.2" }
+markdown = { git = "git://benbridle.com/markdown", tag = "v3.3.2" }
recipe = { git = "git://benbridle.com/recipe", tag = "v1.4.0" }
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.0" }
+switchboard = { git = "git://benbridle.com/switchboard", tag = "v2.1.0" }
+highlight = { git = "git://benbridle.com/highlight", tag = "v1.0.2" }
+
+chrono = "0.4.43"
[profile.release]
lto=true
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 0000000..5d56faf
--- /dev/null
+++ b/rust-toolchain.toml
@@ -0,0 +1,2 @@
+[toolchain]
+channel = "nightly"
diff --git a/src/collect_files.rs b/src/collect_files.rs
index ccdfc49..cf2f307 100644
--- a/src/collect_files.rs
+++ b/src/collect_files.rs
@@ -1,59 +1,75 @@
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 name: Name,
+ pub config: Config,
pub pages: Vec<Page>,
pub redirects: Vec<Redirect>,
- pub static_files: Vec<StaticItem>, // Redirects, !-prefixed-dir contents
- pub static_dirs: Vec<StaticItem>, // Only !-prefixed static dirs
+ pub static_files: Vec<StaticFile>, // !-prefixed-dir contents
+ pub feeds: Vec<Feed>, // RSS feeds
}
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 parents: Vec<String>, // Parent directory components, unsafe
- pub parent_url: String, // Base URL for links in this page
+ pub name: Name,
+ pub url: String, // No extension
+ pub parents: Vec<Name>, // Parent names
+ pub parent_url: String, // With trailing slash
pub source_path: PathBuf, // Absolute path to source file
pub document: MarkdownDocument, // File content parsed as markdown
pub headings: Vec<Heading>, // Ordered list of all headings in page
- pub last_modified: Option<SystemTime>, // last-modified time of source file
+ pub last_modified: Option<SystemTime>, // Last-modified time of source file
}
pub struct Heading {
- pub name: String,
- pub url: String,
+ pub name: Name,
+ pub prefix: Option<Name>, // Disambiguation
pub level: Level,
+ pub block_id: usize, // Pointer to heading element in document
}
-pub struct StaticItem {
- pub full_url: String, // Safe full URL, with extension
+impl Heading {
+ pub fn slug(&self) -> String {
+ match &self.prefix {
+ Some(prefix) => format!("{}-{}", prefix.slug(), self.name.slug()),
+ None => self.name.slug(),
+ }
+ }
+}
+
+pub struct StaticFile {
+ pub url: String, // With extension
pub source_path: PathBuf, // Absolute path to source file
- pub last_modified: Option<SystemTime>, // last-modified time of source file
+ pub last_modified: Option<SystemTime>, // Last-modified time of source file
+}
+
+pub struct Feed {
+ pub name: Name, // Taken from file name
+ pub url: String, // With extension
+ pub parents: Vec<Name>, // Parent names
+ pub parent_url: String, // Base URL for feed pages
+ pub source_path: PathBuf, // Absolute path to source file
+ pub last_modified: Option<SystemTime>, // Last-modified time of source file
}
pub struct Redirect {
- pub name: String, // Display name of this redirect
- pub full_url: String, // Safe full URL, no extension
- pub parents: Vec<String>, // Parent directory components, unsafe
+ pub name: Name,
+ pub url: String, // No extension
+ pub parents: Vec<Name>, // Parent names
pub parent_url: String, // Base URL for relative redirects
- pub redirect: String, // Page to redirect to, as an internal link
- pub last_modified: Option<SystemTime>, // last-modified time of source file
+ pub target: String, // Page to redirect to, internal link
+ pub source_path: PathBuf, // Absolute path to source file, for logging
+ pub last_modified: Option<SystemTime>, // Last-modified time of source file
}
+
+/// Calculate correct relative path from this entity to a specified page.
pub trait LinkFrom: Debug {
- fn name(&self) -> &str;
+ fn name(&self) -> &Name;
fn parent_url(&self) -> &str;
- fn parents(&self) -> &[String];
+ fn parents(&self) -> &[Name];
fn root(&self) -> String {
let mut root = String::new();
for _ in self.parents() {
@@ -67,11 +83,28 @@ pub trait LinkFrom: Debug {
None => format!("/{}", self.name()),
}
}
-}
-
-pub struct Highlighters {
- pub languages: HashMap<String, usize>,
- pub highlighters: Vec<Highlighter>,
+ /// Convert an internal link to a canonical page URL and optional heading,
+ /// both as slugs.
+ ///
+ /// `path` and returned URL have no extension.
+ fn canonicalise(&self, path: &str) -> (String, Option<String>) {
+ // Remove heading fragment from path.
+ let (path, heading) = match path.rsplit_once('#') {
+ Some((path, heading)) => match heading.is_empty() {
+ true => (path, None),
+ false => (path, Some(to_slug(heading))),
+ }
+ None => (path, None),
+ };
+ let mut path = path.to_string();
+ // Attach parent URL if not an absolute path.
+ if !path.starts_with('/') {
+ path = format!("{}{path}", self.parent_url());
+ }
+ // Convert path to a canonical URL.
+ path = to_slug(&collapse_path(&path));
+ return (path, heading);
+ }
}
impl Page {
@@ -96,16 +129,33 @@ impl Debug for Redirect {
}
}
+impl Debug for Feed {
+ 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 name(&self) -> &Name { &self.name }
fn parent_url(&self) -> &str { &self.parent_url }
- fn parents(&self) -> &[String] { &self.parents }
+ fn parents(&self) -> &[Name] { &self.parents }
}
impl LinkFrom for Redirect {
- fn name(&self) -> &str { &self.name }
+ fn name(&self) -> &Name { &self.name }
+ fn parent_url(&self) -> &str { &self.parent_url }
+ fn parents(&self) -> &[Name] { &self.parents }
+}
+
+impl LinkFrom for Feed {
+ fn name(&self) -> &Name { &self.name }
fn parent_url(&self) -> &str { &self.parent_url }
- fn parents(&self) -> &[String] { &self.parents }
+ fn parents(&self) -> &[Name] { &self.parents }
+}
+
+pub struct ImagePaths {
+ pub thumb: String,
+ pub large: String,
}
@@ -115,43 +165,73 @@ impl Website {
pages: Vec::new(),
redirects: Vec::new(),
static_files: Vec::new(),
- static_dirs: Vec::new(),
+ feeds: Vec::new(),
name: match Entry::from_path(path) {
- Ok(entry) => entry.name,
+ Ok(entry) => entry.name.into(),
Err(err) => fatal!("Couldn't open {:?}: {:?}", &path, err),
},
- config: HashMap::new(),
- highlighters: Highlighters {
- languages: HashMap::new(),
- highlighters: Vec::new(),
- },
+ config: Config::new(),
};
+ // Recursively collect entire website.
new.collect_entry(path, path);
- new.parse_highlighters();
+ new.parse_hoisted_folders();
return new;
}
+ /// Read the hoisted_folders config key, make root redirects for each
+ /// child of each listed directory.
+ fn parse_hoisted_folders(&mut self) {
+ for line in self.config.get("hoisted_folders").lines() {
+ if line.is_empty() { continue }
+ // Turn line into a path
+ let path = PathBuf::from(line);
+ let prefix: Vec<Name> = path.components()
+ .filter(|c| if let std::path::Component::Normal(_) = c {true} else {false})
+ .map(|c| c.as_os_str().to_string_lossy().to_string())
+ .map(|s| strip_numeric_prefix(&s).into())
+ .collect();
+ for page in &self.pages {
+ if page.parents == prefix {
+ let name = page.name.clone();
+ let url = name.slug();
+ let parents = Vec::new();
+ let parent_url = String::new();
+ let target = page.url.clone();
+ let source_path = "<hoisted child>".into();
+ let last_modified = self.config.last_modified;
+ self.redirects.push(Redirect {
+ name,
+ url,
+ parents,
+ parent_url,
+ target,
+ source_path,
+ last_modified,
+ });
+ }
+ }
+ }
+ }
+
+ /// Recursively collect an entry and all children.
+ /// `prefix` is the base directory path for the entire website.
fn collect_entry(&mut self, path: &Path, prefix: &Path) {
let entry = Entry::from_path(path).unwrap();
// Ignore dotted entries.
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();
- }
- }
- let name_url = make_url_safe(&name);
- // Get last-modified time.
+ // Get name, extension, last-modified.
+ let (name_raw, extension) = entry.split_name();
+ let name: Name = strip_numeric_prefix(&name_raw).into();
let last_modified = entry.last_modified;
// Generate parent URL, used only for files.
let source_path = entry.original_path.clone();
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();
- parents.pop(); // Remove file segment.
+ let mut parents: Vec<Name> = relative_path.components()
+ .map(|c| c.as_os_str().to_string_lossy().to_string())
+ .map(|s| strip_numeric_prefix(&s).into())
+ .collect();
+ parents.pop(); // Remove final (non-parent) segment.
// Process each entry.
if entry.is_directory() {
@@ -162,11 +242,9 @@ impl Website {
let relative_path = source_path.strip_prefix(&entry.original_path).unwrap_or_else(
|_| fatal!("Path doesn't start with {prefix:?}: {source_path:?}"))
.as_os_str().to_string_lossy().to_string();
- let full_url = format!("{stripped}/{relative_path}");
- self.static_files.push(StaticItem { full_url, source_path, last_modified })
+ let url = format!("{stripped}/{relative_path}");
+ self.static_files.push(StaticFile { url, source_path, last_modified })
}
- let full_url = make_url_safe(stripped);
- self.static_dirs.push(StaticItem { full_url, source_path, last_modified });
} else {
for child in list_directory(entry.original_path).unwrap() {
self.collect_entry(&child.original_path, prefix);
@@ -174,57 +252,100 @@ impl Website {
}
} else if parents.is_empty() && entry.name.to_lowercase() == "toaster.conf" {
info!("Reading configuration file at {path:?}");
- // Parse the config file.
- let config = std::fs::read_to_string(&source_path).unwrap();
- let mut key = None;
- let mut value = String::new();
- for line in config.lines() {
- if line.starts_with(" ") || line.trim().is_empty() {
- value.push_str(line.trim());
- value.push('\n');
- } else {
- if let Some(key) = key {
- self.config.insert(key, std::mem::take(&mut value));
- }
- key = Some(line.trim().to_lowercase().to_string());
- }
- }
- if let Some(key) = key {
- self.config.insert(key, std::mem::take(&mut value));
- }
+ let content = std::fs::read_to_string(&source_path).unwrap();
+ self.config.parse_file(&content, last_modified);
} else {
- let full_name = match parents.last() {
+ // Used for error messages, to distinguish between pages of the same name.
+ let qualified_name = match parents.last() {
Some(parent) => format!("{parent}/{name}"),
None => name.to_string(),
};
match extension.as_str() {
+ "feed" => {
+ let mut url = String::new();
+ for parent in &parents {
+ url.push_str(&parent.slug());
+ url.push('/');
+ }
+ let parent_url = url.clone();
+ url.push_str(&name.plain());
+ self.feeds.push(Feed {
+ name,
+ url,
+ parents,
+ parent_url,
+ source_path,
+ last_modified,
+ });
+ }
+ "redirect" => {
+ let mut url = String::new();
+ for parent in &parents {
+ url.push_str(&parent.slug());
+ url.push('/');
+ }
+ let parent_url = url.clone();
+ url.push_str(&name.slug());
+ let target = std::fs::read_to_string(&source_path)
+ .unwrap().trim().to_string();
+ self.redirects.push(Redirect {
+ name,
+ url,
+ parents,
+ parent_url,
+ target,
+ source_path,
+ last_modified,
+ });
+ }
"md" => {
let markdown = std::fs::read_to_string(&source_path).unwrap();
let document = MarkdownDocument::from_str(&markdown);
- 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 name = line.to_string();
- let url = make_url_safe(strip_appendix(&name));
+ // Collect headings, check for duplicates.
+ let mut names_set = HashSet::new(); // all heading names
+ let mut duplicates = HashSet::new(); // only duplicates
+ let mut headings: Vec<Heading> = document.blocks.iter().enumerate()
+ .filter_map(|(block_id, block)| if let Block::Heading { line, level } = block {
+ let name: Name = line.to_string().into();
let level = level.to_owned();
- if !heading_set.insert(url.clone()) {
- duplicates.insert(url.clone());
+ let heading = Heading { name, prefix: None, level, block_id };
+ if !names_set.insert(heading.slug()) {
+ duplicates.insert(heading.slug());
}
- Some(Heading { name, url, level })
+ Some(heading)
} else {
None
}).collect();
- for url in duplicates {
- warn!("Page {full_name:?} contains multiple headings with ID \"#{url}\"");
+
+ // Namespace any duplicate headings to the parent h1 heading.
+ let mut prefix = None;
+ for heading in &mut headings {
+ if let Level::Heading1 = heading.level {
+ prefix = Some(heading.name.clone());
+ } else {
+ if duplicates.contains(&heading.slug()) {
+ heading.prefix = prefix.clone();
+ }
+ }
+ }
+ // Check for duplicates once more, and warn if any.
+ names_set.clear();
+ duplicates.clear();
+ for heading in &headings {
+ if !names_set.insert(heading.slug()) {
+ duplicates.insert(heading.slug());
+ }
}
- if name_url == "+index" {
+ for slug in duplicates {
+ warn!("Page {qualified_name:?} contains multiple headings with ID \"#{slug}\"");
+ }
+
+ if name.slug() == "+index" {
if parents.is_empty() {
// This is the index file for the whole site.
self.pages.push(Page {
- name: String::from("Home"),
- name_url: String::from("index"),
- full_url: String::from("index"),
+ name: self.name.clone(),
+ url: String::from("index"),
parents,
parent_url: String::from(""),
source_path,
@@ -234,20 +355,18 @@ impl Website {
});
} else {
// This is an index file for a directory.
- let name = parents[parents.len()-1].clone();
- let name_url = make_url_safe(&name);
- let mut full_url = String::new();
+ let name = parents.last().unwrap().clone();
+ let mut url = String::new();
for parent in &parents {
- full_url.push_str(&make_url_safe(parent));
- full_url.push('/');
+ url.push_str(&parent.slug());
+ url.push('/');
}
- let parent_url = full_url.clone();
- full_url.pop();
- parents.pop();
+ let parent_url = url.clone();
+ url.pop(); // Remove the trailing slash
+ parents.pop(); // Remove this directory
self.pages.push(Page {
name,
- name_url,
- full_url,
+ url,
parents,
parent_url,
source_path,
@@ -257,111 +376,72 @@ impl Website {
});
}
} else {
- let mut full_url = String::new();
+ // This is a regular page.
+ let mut url = String::new();
for parent in &parents {
- full_url.push_str(&make_url_safe(parent));
- full_url.push('/');
+ url.push_str(&parent.slug());
+ url.push('/');
}
- full_url.push_str(&name_url);
- let mut parent_url = full_url.clone();
+ // Children descend from this page, so the parent
+ // url must contain this page.
+ url.push_str(&name.slug());
+ let mut parent_url = url.clone();
parent_url.push('/');
self.pages.push(Page {
- name, name_url, full_url,
- parents, parent_url,
+ name,
+ url,
+ parents,
+ parent_url,
source_path,
- document, headings,
+ document,
+ headings,
last_modified,
});
}
},
- "redirect" => {
- let mut full_url = String::new();
- for parent in &parents {
- full_url.push_str(&make_url_safe(parent));
- full_url.push('/');
- }
- let parent_url = full_url.clone();
- full_url.push_str(&name_url);
- let redirect = std::fs::read_to_string(&source_path)
- .unwrap().trim().to_string();
- self.redirects.push(Redirect {
- name, full_url, parents, parent_url,
- redirect, last_modified,
- });
- }
_ => {
+ // This is a static file.
let mut parent_url = String::new();
for parent in &parents {
- parent_url.push_str(&make_url_safe(parent));
- parent_url.push('/');
+ parent_url.push_str(&parent.slug());
+ parent_url.push('/');
}
- let full_url = format!("{parent_url}{name_url}.{extension}");
- self.static_files.push(StaticItem { full_url, source_path, last_modified });
+ let name_slug = name.slug();
+ let url = format!("{parent_url}{name_slug}.{extension}");
+ self.static_files.push(StaticFile { url, source_path, last_modified });
},
}
}
}
- 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.
+ /// Check if the internal link `path` is valid, pointing to a real internal
+ /// page with extension `ext` and heading, relative to the current page (`from`).
+ /// Returns a resolved absolute link to the page, with extension.
pub fn has_page(&self, from: &impl LinkFrom, path: &str, ext: &str) -> Option<String> {
- // Remove heading fragment and file extension.
- let (path, heading) = match path.rsplit_once('#') {
- Some((path, heading)) => match heading.is_empty() {
- true => (path, None),
- false => (path, Some(heading)),
- }
- None => (path, None),
+ let original_path = path;
+ let (mut path, mut heading) = from.canonicalise(path);
+ if let Some(stripped) = path.strip_suffix(&format!(".{ext}")) {
+ path = stripped.to_string();
};
- let mut path = path.strip_suffix(&format!(".{ext}")).unwrap_or(path).to_string();
- // Attach parent if not an absolute path.
- if !path.starts_with('/') {
- path = format!("{}{path}", from.parent_url());
- }
- let path = make_url_safe(&collapse_path(&path));
- // Find page with this path in website.
+ // Find page with this path in website, resolving any redirect first.
+ for redirect in &self.redirects {
+ if redirect.url == path {
+ let (target_path, target_heading) = redirect.canonicalise(&redirect.target);
+ path = target_path;
+ if target_heading.is_some() && heading.is_some() {
+ warn!("Page {from:?} contains link {original_path:?} to a redirect that also links to a heading");
+ }
+ if heading.is_none() {
+ heading = target_heading;
+ }
+ }
+ }
for page in &self.pages {
- if page.full_url == path {
+ if page.url == path {
let root = from.root();
if let Some(heading) = heading {
- let heading = make_url_safe(strip_appendix(heading));
- if !page.headings.iter().any(|h| h.url == heading) {
+ if !page.headings.iter().any(|h| h.slug() == heading) {
warn!("Page {from:?} contains link to nonexistent heading {heading:?} on page {path:?}");
}
return Some(format!("{root}{path}.{ext}#{heading}"));
@@ -373,28 +453,46 @@ impl Website {
return None;
}
+ /// Check if the external link `path` points to a valid static file or
+ /// generated feed. Returns a resolved absolute link to the file.
pub fn has_static(&self, from: &impl LinkFrom, path: &str) -> Option<String> {
// Attach parent if not an absolute path.
+ // We don't want to canonicalise/sluggify the path.
let path = match !path.starts_with('/') {
true => collapse_path(&format!("{}{path}", from.parent_url())),
false => collapse_path(path),
};
+ let root = from.root();
for file in &self.static_files {
- if file.full_url == path {
- let root = from.root();
+ if file.url == path {
+ return Some(format!("{root}{path}"));
+ }
+ }
+ for feed in &self.feeds {
+ if format!("{}.rss", feed.url) == path {
return Some(format!("{root}{path}"));
}
}
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)
- }
+ /// Check if a particular image exists.
+ pub fn has_image(&self, file_name: &str, root: &str) -> Option<ImagePaths> {
+ let check = |path: String|
+ match self.static_files.iter().any(|s| s.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());
- pub fn get_config(&self, key: &str) -> String {
- self.config.get(key).map(String::to_owned).unwrap_or_else(String::new)
+ Some(ImagePaths {
+ thumb: thumb_path.or(fallback_path.clone())?,
+ large: large_path.or(fallback_path.clone())?,
+ })
}
}
@@ -419,3 +517,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/config.rs b/src/config.rs
new file mode 100644
index 0000000..1933a70
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,92 @@
+use crate::*;
+
+use highlight::*;
+
+use std::collections::HashMap;
+
+
+pub struct Config {
+ pub config: HashMap<String, String>,
+ pub last_modified: Option<SystemTime>,
+ pub languages: HashMap<String, usize>,
+ pub highlighters: Vec<Highlighter>,
+ pub root_redirects: Vec<String>,
+}
+
+impl Config {
+ pub fn new() -> Self {
+ Self {
+ config: HashMap::new(),
+ last_modified: None,
+ languages: HashMap::new(),
+ highlighters: Vec::new(),
+ root_redirects: Vec::new(),
+ }
+ }
+
+ pub fn get(&self, key: &str) -> String {
+ match self.config.get(key) {
+ Some(value) => value.to_owned(),
+ None => String::new(),
+ }
+ }
+
+ pub fn parse_file(&mut self, content: &str, last_modified: Option<SystemTime>) {
+ if self.last_modified.is_none() || self.last_modified > last_modified {
+ self.last_modified = last_modified;
+ }
+
+ let mut key = None;
+ let mut value = String::new();
+ macro_rules! bank_value {
+ () => {
+ value = value.trim().to_string();
+ if let Some(key) = key {
+ if key == "highlighters" {
+ self.parse_highlighters(&value);
+ }
+ self.config.insert(key, std::mem::take(&mut value));
+ }
+ };
+ }
+ for line in content.lines() {
+ if line.starts_with(" ") || line.trim().is_empty() {
+ value.push_str(line.trim());
+ value.push('\n');
+ } else {
+ bank_value!();
+ key = Some(line.trim().to_lowercase().to_string());
+ }
+ }
+ bank_value!();
+ }
+
+ fn parse_highlighters(&mut self, value: &str) {
+ let mut languages = Vec::new();
+ let mut source = String::new();
+ macro_rules! bank_sources {
+ () => {
+ if !languages.is_empty() {
+ let i = self.highlighters.len();
+ for language in std::mem::take(&mut languages) {
+ self.languages.insert(language, i);
+ }
+ let highlighter = Highlighter::from_str(&std::mem::take(&mut source));
+ self.highlighters.push(highlighter);
+ }
+ };
+ }
+ for line in value.lines() {
+ if let Some(line) = line.trim().strip_prefix('[') {
+ if let Some(line) = line.strip_suffix(']') {
+ bank_sources!();
+ languages = line.split('/').map(|s| s.trim().to_string()).collect();
+ continue;
+ }
+ }
+ source.push_str(line);
+ source.push('\n');
+ }
+ bank_sources!();
+ }
+}
diff --git a/src/generate_html.rs b/src/generate_html.rs
index af48d2e..3b6ee62 100644
--- a/src/generate_html.rs
+++ b/src/generate_html.rs
@@ -4,61 +4,98 @@ use markdown::*;
use recipe::*;
-pub fn generate_html(document: &MarkdownDocument, page: &Page, website: &Website) -> String {
+const DEFAULT_TEMPLATE: &str = "\
+<!DOCTYPE html>
+ <head>
+ <title>{page_name} &mdash; {site_name}</title>
+ <meta charset='UTF-8'>
+ <meta name='viewport' content='width=device-width, initial-scale=1'>
+ {head}
+ </head>
+ <body>
+ <header>
+ <nav id='up'>
+ {home_link}
+ {parent_link}
+ </nav>
+ <h1 id='title'>{page_name_html}</h1>
+ </header>
+ <main>
+ {main}
+ </main>
+ </body>
+</html>";
+
+
+pub fn generate_html(page: &Page, website: &Website) -> String {
let root = page.root();
- let page_name = sanitize_text(&page.name, true);
- let site_name = sanitize_text(&website.name, true);
+
+ // Get page name as a plain string and as an HTML fragment.
+ let mut page_name = page.name.plain();
+ let mut page_name_html = sanitize_text(&page_name, true);
+ // Find any override-title fragments.
+ for block in &page.document.blocks {
+ if let Block::Fragment { language, content } = block {
+ if language == "override-title" {
+ let line = Line::from_str(content);
+ page_name = line.to_string();
+ page_name_html = line_to_html(&line, page, website, &None);
+ }
+ }
+ }
+ page_name = sanitize_text(&page_name, true);
+
+ // Get the URL of the parent page.
+ let site_name = sanitize_text(&website.name.plain(), true);
let mut parent_url = String::new();
for segment in &page.parents {
- parent_url.push_str(&make_url_safe(segment)); parent_url.push('/');
+ parent_url.push_str(&segment.slug()); parent_url.push('/');
}
parent_url.pop();
let head = get_html_head(page, website); let head = head.trim();
let home_link = format!("<a id='home' href='{root}index.html'>{site_name}</a>");
let parent_link = match page.parents.last() {
- Some(name) => format!("<a id='parent' href='../{}.html'>{name}</a>", make_url_safe(name)),
+ Some(name) => format!("<a id='parent' href='../{}.html'>{name}</a>", name.slug()),
None => String::new(),
};
- let table_of_contents = get_table_of_contents(page);
- let main = document_to_html(document, page, website); let main = main.trim();
- format!("\
-<!DOCTYPE html>
-<head>
-<title>{page_name} &mdash; {site_name}</title>
-<meta charset='UTF-8'>
-<meta name='viewport' content='width=device-width, initial-scale=1'>
-{head}
-</head>
-<body>
-<header>
-<nav id='up'>
-{home_link}
-{parent_link}
-</nav>
-<h1 id='title'>{page_name}</h1>
-<nav id='toc'>
-{table_of_contents}
-</nav>
-</header>
-<main>
-{main}
-</main>
-</body>
-</html>")
+ // Format tables of contents and the main page.
+ let toc = get_table_of_contents(page);
+ let toc_compact = if page.headings.len() >= 3 {
+ format!("<details><summary></summary>\n{toc}</details>\n")
+ } else { String::new() };
+ let main = document_to_html(page, website); let main = main.trim();
+
+ let mut template = website.config.get("html.template");
+ if template.trim().is_empty() {
+ template = DEFAULT_TEMPLATE.to_string();
+ }
+
+ template
+ .replace("{site_name}", &site_name )
+ .replace("{page_name}", &page_name )
+ .replace("{page_name_html}", &page_name_html)
+ .replace("{home_link}", &home_link )
+ .replace("{parent_link}", &parent_link )
+ .replace("{head}", &head )
+ .replace("{toc_compact}", &toc_compact )
+ .replace("{toc}", &toc )
+ .replace("{main}", &main )
}
-pub fn generate_html_redirect(path: &str) -> String {
+pub fn generate_html_redirect(path: &str, website: &Website) -> String {
+ let head = website.config.get("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>")
}
@@ -77,7 +114,7 @@ pub fn get_html_head(page: &Page, website: &Website) -> String {
}
}
if include_default_head {
- html_head.insert_str(0, &website.get_config("html.head"));
+ html_head.insert_str(0, &website.config.get("html.head"));
}
let root = page.root();
html_head
@@ -87,16 +124,13 @@ pub fn get_html_head(page: &Page, website: &Website) -> String {
pub fn get_table_of_contents(page: &Page) -> String {
- if page.headings.len() < 3 {
- return String::new();
- }
- let mut toc = String::from("<details><summary></summary><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"));
+ let mut toc = String::from("<ul>\n");
+ let page_name = sanitize_text(&page.name.plain(), true);
+ toc.push_str(&format!("<li class='l1'><a href='#title'>{page_name}</a></li>\n"));
for heading in &page.headings {
- let name = &heading.name;
- let url = &heading.url;
+ let name = sanitize_text(&heading.name.plain(), true);
+ let url = &heading.slug();
let class = match heading.level {
Level::Heading1 => "l1",
Level::Heading2 => "l2",
@@ -104,17 +138,19 @@ 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 {
+pub fn document_to_html(page: &Page, website: &Website) -> String {
let from = &page;
+ let root = page.root();
let mut html = String::new();
+ let mut prefix = None;
macro_rules! line_to_html {
- ($l:expr) => {{ line_to_html(&$l, page, website) }}; }
+ ($l:expr) => {{ line_to_html(&$l, page, website, &prefix) }}; }
macro_rules! html {
($($arg:tt)*) => {{ html.push_str(&format!($($arg)*)); html.push('\n'); }}; }
macro_rules! tag {
@@ -124,201 +160,236 @@ 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_string()));
- 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;
+ 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>");
+ prefix = Some(to_slug(&line.to_string()));
+ };
+ // Find namespaced heading ID from headings list.
+ let url = match page.headings.iter().find(|h| h.block_id == i) {
+ Some(heading) => heading.slug(),
+ None => unreachable!("Couldn't find heading in headings list"),
+ };
+ let heading_tag = match level {
+ Level::Heading1 => "h1",
+ Level::Heading2 => "h2",
+ Level::Heading3 => "h3",
+ };
+ // Find out whether line contains a link.
+ let mut contains_link = false;
+ for token in &line.tokens {
+ if let Token::InternalLink { .. } | Token::ExternalLink { .. } = token {
+ contains_link = true;
+ break;
+ }
}
- 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>"),
+ if !contains_link {
+ wrap!(heading_tag, format!("id='{url}'"), {
+ tag!("a", line, format!("href='#{url}'"));
+ });
+ } else {
+ // Leave off the heading <a> tag if the heading already contains
+ // a link (<a> tags cannot be nested).
+ tag!(heading_tag, line, format!("id='{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 == ' ' && prev == ':' && depth == 0 {
+ output.pop(); output.pop(); output.push_str("<br>");
+ class.push_str("extended"); depth += 99;
}
+ prev = c;
}
- 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!("<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}")),
- "embed-html-head"|"override-html-head" => (),
- "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)),
+ 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 = sanitize_text(&format!("{root}images/large/{file}"), false);
- // let small = sanitize_text(&format!("{root}images/small/{file}"), false);
- let thumb = sanitize_text(&format!("{root}images/thumb/{file}"), false);
- 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 = sanitize_text(&format!("{root}images/thumb/{image}"), false);
- 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" => {
+ let mut content = content.trim().to_string();
+ if !content.contains(r"\begin") {
+ content = content.replace("\n", " \\\\\n").to_string();
+ }
+ html!("<div class='math'>{content}</div>")
+ },
+ "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}'"), {
- 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));
+ }),
+ "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, &prefix);
+ 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 {
- source.push_str(&format!("<span class='{}'>", span.tag.to_lowercase()));
- source.push_str(&sanitize_text(&span.text, false));
- source.push_str("</span>");
+ 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");
}
- html!("{source}");
- } else {
- html!("{}", sanitize_text(content, false))
- }
- })
+ }),
+ _ => wrap!("pre", format!("class='{language}'"), {
+ if let Some(i) = website.config.languages.get(language) {
+ let mut source = String::new();
+ let highlighter = &website.config.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).trim().to_string();
+ let text_cmp = text_raw.to_lowercase().to_string();
+ let text = match text_cmp.as_str() {
+ "yes" => "✓",
+ "no" => "✗",
+ _ => &text_raw,
+ };
+ 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_cmp.as_str()) {
+ class.push_str(" dim");
+ };
+ if column.border_right {
+ class.push_str(" border");
+ }
+ html!("<td class='{class}'>{text}</td>");
+ })
+ })
+ };
+ }))
+ }
}
- }
+ );
return html;
}
-fn line_to_html(line: &Line, page: &Page, website: &Website) -> String {
+fn line_to_html(line: &Line, page: &Page, website: &Website, prefix: &Option<String>) -> String {
let mut html = String::new();
for line_element in &line.tokens {
match line_element {
@@ -333,7 +404,7 @@ fn line_to_html(line: &Line, page: &Page, website: &Website) -> String {
Token::Math(text) => {
let text = &sanitize_text(text, false); html.push_str(&format!("<span class='math'>{text}</span>")) }
Token::InternalLink{ label: link_label, path } => {
- let ParsedLink { path, class, mut label } = parse_internal_link(path, page, website);
+ let ParsedLink { path, class, mut label } = parse_internal_link(path, page, website, prefix);
if !link_label.is_empty() {
label = link_label.to_string();
}
@@ -356,30 +427,56 @@ struct ParsedLink {
pub class: &'static str,
}
-fn parse_internal_link(name: &str, page: &Page, website: &Website) -> ParsedLink {
+fn parse_internal_link(name: &str, page: &Page, website: &Website, prefix: &Option<String>) -> ParsedLink {
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))),
+ Some(("", heading)) => ("heading", heading, format!("#{heading}")),
+ Some((page, heading)) => ("page", heading, format!("{page}.html#{heading}")),
_ => ("page", name, format!("{name}.html")),
};
- let mut path = make_url_safe(&path);
+ let mut path = to_slug(&path);
let label = match label.rsplit_once('/') {
Some((_, label)) => sanitize_text(label.trim(), true),
None => sanitize_text(label.trim(), true),
};
- // Check that the linked internal page exists.
+ // Check that the linked internal page with heading exists.
if class == "page" {
match website.has_page(page, &path, "html") {
Some(resolved) => path = resolved,
None => warn!("Page {from:?} contains link to nonexistent page {path:?}"),
}
}
- // Check that the heading exists.
+ // Check that the heading exists on this page.
if class == "heading" {
- let heading = path.strip_prefix('#').unwrap();
- if !page.headings.iter().any(|h| h.url == heading) {
- warn!("Page {from:?} contains link to nonexistent internal heading {heading:?}");
+ let plain_heading = path.strip_prefix('#').unwrap().to_string();
+ let prefixed_heading = match prefix {
+ Some(prefix) => format!("{prefix}-{plain_heading}"),
+ None => plain_heading.to_string(),
+ };
+ let mut matched = false;
+ for heading in &page.headings {
+ if heading.name.slug() == plain_heading {
+ if heading.prefix.is_some() {
+ // The matched heading has a prefix, so is one of many.
+ // The prefix must match, we must disambiguate the path.
+ if heading.slug() == prefixed_heading {
+ matched = true;
+ path = format!("#{prefixed_heading}");
+ break;
+ }
+ } else {
+ // The matched heading has no prefix, so is unique on the page.
+ matched = true;
+ break
+ }
+ }
+ }
+ if !matched {
+ let prefix_note = match prefix {
+ Some(prefix) => format!(" (under {prefix:?})"),
+ None => format!(""),
+ };
+ warn!("Page {from:?} contains link to nonexistent internal heading {plain_heading:?}{prefix_note}");
}
}
let path = url_encode(&path);
@@ -418,63 +515,3 @@ fn parse_external_link(label: &str, path: &str, page: &Page, website: &Website)
let label = sanitize_text(&label, true);
ParsedLink { path, class: "external", label }
}
-
-
-/// Replace each HTML-reserved character with an HTML-escaped character.
-fn sanitize_text(text: &str, fancy: bool) -> 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 fancy {
- true => match prev.is_whitespace() {
- true => output.push('“'),
- false => output.push('”'),
- }
- false => output.push_str("&#34;"),
- },
- '\'' => 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 => 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),
- }
- }
- return output;
-}
-
-
-/// Remove a 'Appendix #: ' prefix from a string.
-pub fn strip_appendix(text: &str) -> &str {
- if let Some((prefix, name)) = text.split_once(": ") {
- if prefix.starts_with("Appendix") {
- return name;
- }
- }
- return text;
-}
diff --git a/src/generate_rss.rs b/src/generate_rss.rs
new file mode 100644
index 0000000..48a6917
--- /dev/null
+++ b/src/generate_rss.rs
@@ -0,0 +1,72 @@
+use crate::*;
+
+use std::collections::VecDeque;
+
+use chrono::{DateTime, Utc, Local};
+
+
+pub fn generate_rss(feed: &Feed, website: &Website) -> String {
+ let path = &feed.source_path;
+ let content = std::fs::read_to_string(path).unwrap();
+ let mut lines: VecDeque<&str> = content.lines().collect();
+ let base_url = website.config.get("rss.base_url");
+ if base_url.is_empty() {
+ warn!("No value was given for 'rss.base_url' key in toaster.conf");
+ }
+ let (parent_url, _) = feed.url.split_once('/').unwrap();
+
+ let channel_title = lines.pop_front().unwrap_or("No title");
+ let last_build_date = match feed.last_modified {
+ Some(system_time) => system_time.into(),
+ None => Utc::now(),
+ }.to_rfc2822();
+ let mut all_entries = String::new();
+
+ for line in &lines {
+ if line.is_empty() { continue }
+ if let Some((timestamp, name)) = line.split_once("::") {
+ let mut timestamp = timestamp.to_string();
+ let entry_title = name;
+ if !timestamp.contains('T') {
+ timestamp.push_str("T00:00:00");
+ }
+ if !timestamp.contains('Z') || timestamp.contains('+') {
+ let offset = Local::now().offset().to_string();
+ timestamp.push_str(&offset);
+ }
+ let Ok(entry_time) = DateTime::parse_from_rfc3339(&timestamp) else {
+ warn!("Invalid timestamp in RSS file {path:?}: {timestamp:?}");
+ continue;
+ };
+ let entry_link = format!("{base_url}/{parent_url}/{}.html", to_slug(name));
+
+ // Check that child page exists.
+ if let None = website.has_page(feed, name, "html") {
+ warn!("Feed {feed:?} contains link to nonexistent page {name:?}");
+ }
+
+
+ let entry_string = format!("
+ <item>
+ <title>{entry_title}</title>
+ <link>{entry_link}</link>
+ <pubDate>{entry_time}</pubDate>
+ </item>
+");
+ all_entries.push_str(&entry_string);
+ } else {
+ warn!("Invalid line in RSS file {path:?}: {line:?}");
+ }
+ }
+
+ format!(
+r#"<?xml version="1.0" encoding="UTF-8" ?>
+<rss version="2.0">
+ <channel>
+ <title>{channel_title}</title>
+ <link>{base_url}</link>
+ <lastBuildDate>{last_build_date}</lastBuildDate>
+{all_entries}
+ </channel>
+</rss>"#)
+}
diff --git a/src/main.rs b/src/main.rs
index 1ea25d2..dad8377 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,9 +1,16 @@
#![feature(path_add_extension)]
mod collect_files;
-pub use collect_files::*;
+mod config;
mod generate_html;
+mod generate_rss;
+mod string_utils;
+
+pub use collect_files::*;
+pub use config::*;
pub use generate_html::*;
+pub use generate_rss::*;
+pub use string_utils::*;
use markdown::*;
use vagabond::*;
@@ -12,81 +19,97 @@ use std::collections::HashSet;
use std::time::SystemTime;
use log::{info, warn, error, fatal};
-use switchboard::{Switchboard, SwitchQuery};
-
-
-fn print_help() -> ! {
- eprintln!("\
-Usage: toaster <source> <destination>
-
-Generate a website from a structured directory of markdown files.
-
-Arguments:
- source Source directory with markdown files
- destination Path to output directory
-
-Switches:
- --delete Delete the destination directory first if it exists
- --html Generate HTML output
- --version, -v Print information as each file is parsed
- --version Print the program version and exit
- --help, -h Print help
-");
- std::process::exit(0);
-}
-
-fn print_version() -> ! {
- let version = env!("CARGO_PKG_VERSION");
- eprintln!("toaster, version {version}");
- eprintln!("written by ben bridle");
- std::process::exit(0);
-}
+use switchboard::*;
fn main() {
let mut args = Switchboard::from_env();
- if args.named("help").short('h').as_bool() {
+
+ // Informational switches.
+ args.named("help").short('h');
+ args.named("version");
+ args.named("verbose").short('v');
+ if args.get("help").as_bool() {
print_help();
+ std::process::exit(0);
}
- if args.named("version").as_bool() {
- print_version();
+ if args.get("version").as_bool() {
+ let version = env!("CARGO_PKG_VERSION");
+ eprintln!("toaster, version {version}");
+ eprintln!("written by ben bridle");
+ std::process::exit(0);
}
- if args.named("verbose").short('v').as_bool() {
+ if args.get("verbose").as_bool() {
log::set_log_level(log::LogLevel::Info);
}
- let source = args.positional("source").as_path();
- let destination = args.positional("destination").as_path();
- let delete_existing = args.named("delete").as_bool();
- let export_html = args.named("html").as_bool();
+ // Functional switches.
+ args.positional("source");
+ args.positional("destination");
+ args.named("delete");
+ args.named("html");
+ args.named("use-symlinks");
+ args.raise_errors();
+ let source = args.get("source").as_path();
+ let destination = args.get("destination").as_path();
+ let delete_existing = args.get("delete").as_bool();
+ let export_html = args.get("html").as_bool();
+ let use_symlinks = args.get("use-symlinks").as_bool();
+ #[cfg(not(target_family = "unix"))]
+ if use_symlinks {
+ fatal!("Symbolic links are only supported on Linux");
+ }
+
+ // Parse entire website directory.
let source = match source.canonicalize() {
Ok(source) => source,
Err(err) => fatal!("{source:?}: {err}"),
};
-
let website = Website::from_path(&source);
+ // ------------------------------------------------------------
+
// Check for duplicate output paths for pages.
- let mut destinations: HashSet<&str> = HashSet::new();
+ let mut urls: HashSet<&str> = HashSet::new();
let mut duplicates: HashSet<&str> = HashSet::new();
for page in &website.pages {
- if !destinations.insert(&page.full_url) {
- duplicates.insert(&page.full_url);
+ if !urls.insert(&page.url) {
+ duplicates.insert(&page.url);
+ };
+ }
+ for static_file in &website.static_files {
+ if !urls.insert(&static_file.url) {
+ duplicates.insert(&static_file.url);
+ };
+ }
+ for redirect in &website.redirects {
+ if !urls.insert(&redirect.url) {
+ duplicates.insert(&redirect.url);
};
}
if !duplicates.is_empty() {
for destination in duplicates {
- warn!("Multiple pages have the output path {destination:?}");
+ warn!("Multiple files, pages, or redirects have the output path {destination:?}");
for page in &website.pages {
- if page.full_url == destination {
+ if page.url == destination {
eprintln!(":: {:?}", page.source_path);
}
}
+ for static_file in &website.static_files {
+ if static_file.url == destination {
+ eprintln!(":: {:?}", static_file.source_path);
+ }
+ }
+ for redirect in &website.redirects {
+ if redirect.url == destination {
+ eprintln!(":: {:?}", redirect.source_path);
+ }
+ }
}
}
let mut destination = destination;
- destination.push(make_url_safe(&website.name));
+ destination.push(&website.name.slug());
if delete_existing && Entry::from_path(&destination).is_ok() {
info!("Deleting existing destination directory {destination:?}");
@@ -97,10 +120,10 @@ fn main() {
for page in &website.pages {
let mut destination = destination.clone();
- destination.push(&page.full_url);
+ destination.push(&page.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.
@@ -113,35 +136,70 @@ fn main() {
for static_file in &website.static_files {
let mut destination = destination.clone();
- destination.push(&static_file.full_url);
- info!("Copying static file to {destination:?}");
- make_parent_directory(&destination).unwrap();
- copy(&static_file.source_path, &destination).unwrap_or_else(|_|
- error!("Failed to copy static file {:?} to {:?}",
- static_file.source_path, destination));
+ destination.push(&static_file.url);
+ if use_symlinks {
+ #[cfg(target_family = "unix")]
+ {
+ info!("Linking static file to {destination:?}");
+ make_parent_directory(&destination).unwrap();
+ let _ = remove(&destination);
+ std::os::unix::fs::symlink(&static_file.source_path, &destination).unwrap_or_else(|_|
+ error!("Failed to link static file {:?} to {:?}",
+ static_file.source_path, destination));
+ }
+ } else {
+ info!("Copying static file to {destination:?}");
+ copy(&static_file.source_path, &destination).unwrap_or_else(|_|
+ error!("Failed to copy static file {:?} to {:?}",
+ static_file.source_path, destination));
+ }
+
}
// NOTE: Static dir contents are copied as part of all static files.
for redirect in &website.redirects {
let mut destination = destination.clone();
- destination.push(&redirect.full_url);
- let path = &redirect.redirect;
+ destination.push(&redirect.url);
+ let path = &redirect.target;
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);
}
}
}
+
+ for feed in &website.feeds {
+ let mut destination = destination.clone();
+ destination.push(&feed.url);
+ write_file(&generate_rss(feed, &website), &destination, "rss", feed.last_modified);
+ }
}
+fn print_help() {
+ eprintln!("\
+Usage: toaster <source> <destination>
+Generate a website from a structured directory of markdown files.
+
+Arguments:
+ source Source directory with markdown files
+ destination Path to output directory
+
+Switches:
+ --delete Delete the destination directory first if it exists
+ --html Generate HTML output
+ --version, -v Print information as each file is parsed
+ --version Print the program version and exit
+ --help, -h Print help
+");
+}
pub fn write_file(text: &str, destination: &PathBuf, ext: &str, last_modified: Option<SystemTime>) {
let mut destination = destination.clone();
@@ -158,23 +216,3 @@ pub fn write_file(text: &str, destination: &PathBuf, ext: &str, last_modified: O
}
}
}
-
-pub fn make_url_safe(text: &str) -> String {
- text.to_ascii_lowercase().chars().filter_map(|c|
- if c.is_alphanumeric() || "-_~.+/#".contains(c) { Some(c) }
- else if c == ' ' { Some('-') }
- else { None } )
- .collect()
-}
-
-pub fn url_encode(text: &str) -> String {
- let mut output = String::new();
- for c in text.chars() {
- match c {
- '"' => output.push_str("%22"),
- '\'' => output.push_str("%27"),
- _ => output.push(c),
- }
- }
- return output;
-}
diff --git a/src/string_utils.rs b/src/string_utils.rs
new file mode 100644
index 0000000..cceb575
--- /dev/null
+++ b/src/string_utils.rs
@@ -0,0 +1,145 @@
+use crate::*;
+
+
+#[derive(Clone)]
+pub struct Name {
+ raw: String,
+}
+
+impl Name {
+ /// Preserve markdown syntax, return raw string.
+ pub fn raw(&self) -> String {
+ self.raw.clone()
+ }
+ /// Parse markdown syntax, return styled line.
+ pub fn styled(&self) -> Line {
+ Line::from_str(&self.raw)
+ }
+ /// Strip out markdown syntax, return plain text.
+ pub fn plain(&self) -> String {
+ self.styled().to_string()
+ }
+ /// Strip out markdown syntax, return url-safe text.
+ pub fn slug(&self) -> String {
+ to_slug(&self.plain())
+ }
+}
+
+impl std::fmt::Display for Name {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
+ self.raw.fmt(f)
+ }
+}
+
+impl std::fmt::Debug for Name {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
+ self.raw.fmt(f)
+ }
+}
+
+impl PartialEq for Name {
+ fn eq(&self, other: &string_utils::Name) -> bool {
+ self.slug() == other.slug()
+ }
+}
+
+impl Eq for Name {}
+impl std::hash::Hash for Name {
+ fn hash<H>(&self, hasher: &mut H) where H: std::hash::Hasher {
+ self.slug().hash(hasher)
+ }
+}
+
+impl From<String> for Name {
+ fn from(raw: String) -> Self {
+ Self { raw }
+ }
+}
+
+impl From<&str> for Name {
+ fn from(raw: &str) -> Self {
+ Self { raw: raw.to_string() }
+ }
+}
+
+
+
+// Turn a string into a tidy URL slug.
+pub fn to_slug(text: &str) -> String {
+ let mut string = String::new();
+ let mut prev = ' ';
+ for c in text.to_lowercase().chars() {
+ let c = match c == ' ' {
+ true => '-',
+ false => c,
+ };
+ if c.is_alphanumeric() { string.push(c) }
+ if "_~.+/#".contains(c) { string.push(c) }
+ if c == '-' && prev != '-' { string.push(c) }
+ prev = c;
+ }
+ return string;
+}
+
+// Prevent link hrefs from breaking out of quotations.
+pub fn url_encode(text: &str) -> String {
+ let mut output = String::new();
+ for c in text.chars() {
+ match c {
+ '"' => output.push_str("%22"),
+ '\'' => output.push_str("%27"),
+ _ => output.push(c),
+ }
+ }
+ return output;
+}
+
+/// Replace each HTML-reserved character with an HTML-escaped character.
+pub fn sanitize_text(text: &str, fancy: bool) -> 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 => ' ',
+ };
+ let is_whitespace = |c: char| c.is_whitespace() || "()[].,".contains(c);
+
+ 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 fancy {
+ true => match is_whitespace(prev) {
+ true => output.push('“'),
+ false => output.push('”'),
+ }
+ false => output.push_str("&#34;"),
+ },
+ '\'' => match fancy {
+ true => match is_whitespace(prev) {
+ true => output.push('‘'),
+ false => output.push('’'),
+ }
+ false => output.push_str("&#39;"),
+ },
+ '-' if fancy => match prev.is_whitespace() && next.is_whitespace() {
+ 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),
+ }
+ }
+ return output;
+}