use crate::*;
use vagabond::*;
use std::collections::HashMap;
pub struct Website {
pub name: String,
pub config: HashMap<String, String>,
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 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 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 struct Heading {
pub name: String,
pub url: String,
pub level: Level,
}
pub struct StaticItem {
pub full_url: String, // Safe full URL, with extension
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 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 trait LinkFrom {
fn name(&self) -> &str;
fn parent_url(&self) -> &str;
fn parents(&self) -> &[String];
fn root(&self) -> String {
let mut root = String::new();
for _ in self.parents() {
root.push_str("../");
}
return root;
}
}
impl Page {
pub fn root(&self) -> String {
let mut root = String::new();
for _ in &self.parents {
root.push_str("../");
}
return root;
}
}
impl LinkFrom for Page {
fn name(&self) -> &str { &self.name }
fn parent_url(&self) -> &str { &self.parent_url }
fn parents(&self) -> &[String] { &self.parents }
}
impl LinkFrom for Redirect {
fn name(&self) -> &str { &self.name }
fn parent_url(&self) -> &str { &self.parent_url }
fn parents(&self) -> &[String] { &self.parents }
}
impl Website {
pub fn from_path(path: &Path) -> Self {
let mut new = Self {
pages: Vec::new(),
redirects: Vec::new(),
static_files: Vec::new(),
static_dirs: Vec::new(),
name: match Entry::from_path(path) {
Ok(entry) => entry.name,
Err(err) => fatal!("Couldn't open {:?}: {:?}", &path, err),
},
config: HashMap::new(),
};
new.collect_entry(path, path);
return new;
}
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.
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.
// Process each entry.
if entry.is_directory() {
if let Some(stripped) = entry.name.strip_prefix("!") {
// Track all files inside the static directory.
for child in traverse_directory(&entry).unwrap() {
let source_path = child.original_path;
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 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);
}
}
} 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(" ") {
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));
}
} else {
match extension.as_str() {
"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));
let level = level.to_owned();
if !heading_set.insert(url.clone()) {
duplicates.insert(url.clone());
}
Some(Heading { name, url, level })
} else {
None
}).collect();
for url in duplicates {
warn!("Page {name:?} contains multiple headings with ID \"#{url}\"");
}
if name_url == "+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"),
parents,
parent_url: String::from(""),
source_path,
document,
headings,
last_modified,
});
} 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();
for parent in &parents {
full_url.push_str(&make_url_safe(parent));
full_url.push('/');
}
let parent_url = full_url.clone();
full_url.pop();
parents.pop();
self.pages.push(Page {
name,
name_url,
full_url,
parents,
parent_url,
source_path,
document,
headings,
last_modified,
});
}
} else {
let mut full_url = String::new();
for parent in &parents {
full_url.push_str(&make_url_safe(parent));
full_url.push('/');
}
full_url.push_str(&name_url);
let mut parent_url = full_url.clone();
parent_url.push('/');
self.pages.push(Page {
name, name_url, full_url,
parents, parent_url,
source_path,
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,
});
}
_ => {
let mut parent_url = String::new();
for parent in &parents {
parent_url.push_str(&make_url_safe(parent));
parent_url.push('/');
}
let full_url = format!("{parent_url}{name_url}.{extension}");
self.static_files.push(StaticItem { full_url, source_path, last_modified });
},
}
}
}
// 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.
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 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.
for page in &self.pages {
if page.full_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) {
warn!("Page {:?} contains link to nonexistent heading {heading:?} on page {path:?}", from.name());
}
return Some(format!("{root}{path}.{ext}#{heading}"));
} else {
return Some(format!("{root}{path}.{ext}"));
}
}
}
return None;
}
pub fn has_static(&self, from: &impl LinkFrom, path: &str) -> Option<String> {
// Attach parent if not an absolute path.
let path = match !path.starts_with('/') {
true => collapse_path(&format!("{}{path}", from.parent_url())),
false => collapse_path(path),
};
for file in &self.static_files {
if file.full_url == path {
let root = from.root();
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)
}
pub fn get_config(&self, key: &str) -> String {
self.config.get(key).map(String::to_owned).unwrap_or_else(String::new)
}
}
fn collapse_path(path: &str) -> String {
// Iteratively collapse ".." segments.
let mut segments: Vec<&str> = path.split('/')
.filter(|s| !s.is_empty() && *s != ".")
.collect();
'outer: loop {
for i in 0..(segments.len().saturating_sub(1)) {
if segments[i] == ".." {
if i == 0 {
segments.remove(0);
} else {
segments.remove(i-1);
segments.remove(i-1);
}
continue 'outer;
}
}
return segments.join("/");
}
}