use crate::*; use std::fmt::Debug; pub struct Website { pub name: Name, pub config: Config, pub pages: Vec, pub redirects: Vec, pub static_files: Vec, // !-prefixed-dir contents pub feeds: Vec, // RSS feeds } pub struct Page { pub name: Name, pub url: String, // No extension pub parents: Vec, // 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, // Ordered list of all headings in page pub last_modified: Option, // Last-modified time of source file } pub struct Heading { pub name: Name, pub prefix: Option, // Disambiguation pub level: Level, pub block_id: usize, // Pointer to heading element in document } 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, // Last-modified time of source file } pub struct Feed { pub name: Name, // Taken from file name pub url: String, // With extension pub parents: Vec, // Parent names pub parent_url: String, // Base URL for feed pages pub source_path: PathBuf, // Absolute path to source file pub last_modified: Option, // Last-modified time of source file } pub struct Redirect { pub name: Name, pub url: String, // No extension pub parents: Vec, // Parent names pub parent_url: String, // Base URL for relative redirects pub target: String, // Page to redirect to, internal link pub source_path: PathBuf, // Absolute path to source file, for logging pub last_modified: Option, // Last-modified time of source file } /// Calculate correct relative path from this entity to a specified page. pub trait LinkFrom: Debug { fn name(&self) -> &Name; fn parent_url(&self) -> &str; fn parents(&self) -> &[Name]; fn root(&self) -> String { let mut root = String::new(); for _ in self.parents() { root.push_str("../"); } return root; } fn qualified_name(&self) -> String { match self.parents().last() { Some(parent) => format!("{parent}/{}", self.name()), None => format!("/{}", self.name()), } } /// 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) { // 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 { pub fn root(&self) -> String { let mut root = String::new(); for _ in &self.parents { root.push_str("../"); } return root; } } impl Debug for Page { fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> { write!(f, "\"{}\"", self.qualified_name()) } } impl Debug for Redirect { fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> { write!(f, "\"{}\"", self.qualified_name()) } } impl 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) -> &Name { &self.name } fn parent_url(&self) -> &str { &self.parent_url } fn parents(&self) -> &[Name] { &self.parents } } impl LinkFrom for Redirect { 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) -> &[Name] { &self.parents } } pub struct ImagePaths { pub thumb: String, pub large: String, } impl Website { pub fn from_path(path: &Path) -> Self { let mut new = Self { pages: Vec::new(), redirects: Vec::new(), static_files: Vec::new(), feeds: Vec::new(), name: match Entry::from_path(path) { Ok(entry) => entry.name.into(), Err(err) => fatal!("Couldn't open {:?}: {:?}", &path, err), }, config: Config::new(), }; // Recursively collect entire website. new.collect_entry(path, path); 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 = 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 = "".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, 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()) .map(|s| strip_numeric_prefix(&s).into()) .collect(); parents.pop(); // Remove final (non-parent) 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 url = format!("{stripped}/{relative_path}"); self.static_files.push(StaticFile { 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:?}"); let content = std::fs::read_to_string(&source_path).unwrap(); self.config.parse_file(&content, last_modified); } else { // 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); // 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 = 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(); let heading = Heading { name, prefix: None, level, block_id }; if !names_set.insert(heading.slug()) { duplicates.insert(heading.slug()); } Some(heading) } else { None }).collect(); // 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()); } } 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: "Home".into(), 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.last().unwrap().clone(); let mut url = String::new(); for parent in &parents { url.push_str(&parent.slug()); url.push('/'); } let parent_url = url.clone(); url.pop(); // Remove the trailing slash parents.pop(); // Remove this directory self.pages.push(Page { name, url, parents, parent_url, source_path, document, headings, last_modified, }); } } else { // This is a regular page. let mut url = String::new(); for parent in &parents { url.push_str(&parent.slug()); url.push('/'); } // 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, url, parents, parent_url, source_path, document, headings, last_modified, }); } }, _ => { // This is a static file. let mut parent_url = String::new(); for parent in &parents { parent_url.push_str(&parent.slug()); parent_url.push('/'); } let name_slug = name.slug(); let url = format!("{parent_url}{name_slug}.{extension}"); self.static_files.push(StaticFile { url, source_path, last_modified }); }, } } } /// 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 { 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(); }; // 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.url == path { let root = from.root(); if let Some(heading) = 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}")); } else { return Some(format!("{root}{path}.{ext}")); } } } return None; } /// Check if the external link `path` points to a valid static file. /// Returns a resolved absolute link to the file. pub fn has_static(&self, from: &impl LinkFrom, path: &str) -> Option { // 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), }; for file in &self.static_files { if file.url == path { let root = from.root(); return Some(format!("{root}{path}")); } } return None; } /// Check if a particular image exists. pub fn has_image(&self, file_name: &str, root: &str) -> Option { 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()); Some(ImagePaths { thumb: thumb_path.or(fallback_path.clone())?, large: large_path.or(fallback_path.clone())?, }) } } 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("/"); } } 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(); }