use crate::*; use vagabond::*; use std::collections::HashMap; pub struct Website { pub name: String, pub config: HashMap, pub pages: Vec, pub redirects: Vec, pub static_files: Vec, pub static_dirs: Vec, } 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, // 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, // Ordered list of all headings in page } 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 struct Redirect { pub name: String, // Display name of this redirect pub full_url: String, // Safe full URL, no extension pub parents: Vec, // 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 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) => error!("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); // 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( |_| error!("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( |_| error!("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 }) } let full_url = make_url_safe(stripped); self.static_dirs.push(StaticItem { full_url, source_path }); } 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" { // 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(&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, }); } 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, }); } } 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, }); } }, "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 }); } _ => { 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 }); }, } } } // 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 { // 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()); } // 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()-1) { if segments[i] == ".." { if i == 0 { segments.remove(0); } else { segments.remove(i-1); segments.remove(i-1); } continue 'outer; } } break; } // Find page with this path in website. let path = make_url_safe(&segments.join("/")); for page in &self.pages { if page.full_url == path { if let Some(heading) = heading { if !page.headings.iter().any(|h| h.url == make_url_safe(heading)) { warn!("Page {:?} contains link to nonexistent heading {heading:?} on page {path:?}", from.name()); } } let root = from.root(); return Some(format!("{root}{path}.{ext}")); } } 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) } }