diff options
| -rw-r--r-- | Cargo.lock | 4 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | src/collect_files.rs | 174 | ||||
| -rw-r--r-- | src/generate_html.rs | 105 | ||||
| -rw-r--r-- | src/main.rs | 22 | 
5 files changed, 212 insertions, 95 deletions
@@ -4,8 +4,8 @@ version = 4  [[package]]  name = "markdown" -version = "2.1.0" -source = "git+git://benbridle.com/markdown?tag=v2.1.0#36daed5dc398697905ac579f636fbcbc56d30efb" +version = "2.1.1" +source = "git+git://benbridle.com/markdown?tag=v2.1.1#259eeb0094b70d80cb8300707fe89f5adf554b1d"  [[package]]  name = "toaster" @@ -5,7 +5,7 @@ edition = "2021"  [dependencies]  vagabond = { git = "git://benbridle.com/vagabond", tag = "v1.0.2" } -markdown = { git = "git://benbridle.com/markdown", tag = "v2.1.0" } +markdown = { git = "git://benbridle.com/markdown", tag = "v2.1.1" }  xflags = "0.4.0-pre.1"  [profile.release] diff --git a/src/collect_files.rs b/src/collect_files.rs index 88d065f..d9cfb37 100644 --- a/src/collect_files.rs +++ b/src/collect_files.rs @@ -11,13 +11,20 @@ pub struct Website {  }  pub struct Page { -    pub name: String,                 // Display name -    pub parent_url: String,           // URL base for relative links -    pub file_url: String,             // Safe file name, no extension -    pub full_url: String,             // Safe full URL, no extension +    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<String>,        // Safe name of each document heading +    pub headings: Vec<Heading>,       // Ordered list of all headings in page +} + +pub struct Heading { +    pub name: String, +    pub url: String, +    pub level: Level,  }  pub struct StaticItem { @@ -28,14 +35,12 @@ pub struct StaticItem {  impl Page { -    pub fn back_string(&self) -> String { -        let mut back = String::new(); -        for c in self.full_url.chars() { -            if c == '/' { -                back.push_str("../"); -            } +    pub fn root(&self) -> String { +        let mut root = String::new(); +        for _ in &self.parents { +            root.push_str("../");          } -        return back; +        return root;      }  } @@ -59,9 +64,7 @@ impl 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; -        } +        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(' ') { @@ -69,18 +72,14 @@ impl Website {                  name = suffix.to_string();              }          } -        let file_url = make_url_safe(&name); +        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 parent_url = String::new(); -        let mut components: Vec<_> = relative_path.components().collect(); -        components.pop();  // Remove file segment. -        for c in &components { -            let segment = &make_url_safe(&c.as_os_str().to_string_lossy()); -            parent_url.push_str(segment); parent_url.push('/') -        }; +            |_| 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() { @@ -98,34 +97,74 @@ impl Website {                      let markdown = std::fs::read_to_string(&source_path).unwrap();                      let document = MarkdownDocument::from_str(&markdown);                      let headings = document.blocks.iter() -                        .filter_map(|block| if let Block::Heading { line, .. } = block { -                            Some(make_url_safe(&line.to_string())) +                        .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(); +                            Some(Heading { name, url, level })                          } else {                              None                          }).collect(); -                    // Change name and path if this is an index file. -                    let mut name = name; -                    let mut file_url = file_url; -                    let mut full_url = format!("{parent_url}{file_url}"); -                    if file_url == "+index" { -                        if components.is_empty() { +                    if name_url == "+index" { +                        if parents.is_empty() {                              // This is the index file for the whole site. -                            name = String::from("Home"); -                            file_url = String::from("index"); -                            full_url = String::from("index"); +                            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. -                            name = components[components.len()-1] -                                .as_os_str().to_string_lossy().to_string(); -                            file_url = make_url_safe(&name); -                            full_url = parent_url.strip_suffix('/').unwrap_or(&parent_url).to_string(); +                            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, +                        });                      } -                    self.pages.push( -                        Page { name, parent_url, file_url, full_url, source_path, document, headings });                  },                  _ => { -                    let full_url = format!("{parent_url}{file_url}.{extension}"); +                    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 });                  },              } @@ -144,42 +183,41 @@ impl Website {              }              None => (path, None),          }; -        let path = path.strip_suffix(&format!(".{ext}")).unwrap_or(path); - -        // Attach parent of current page to given path. -        let directory = match from.parent_url.rsplit_once('/') { -            Some((parent, _)) => parent, -            None => &from.parent_url, -        }; -        let full_path = match path.starts_with("/") { -            true => path.to_string(), -            false => format!("{directory}/{path}"), -        }; +        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); +        } -        // Remove relative portions of path. -        let segments: Vec<&str> = full_path.split("/") -            .filter(|seg| !seg.is_empty() && *seg != ".") +        // Iteratively collapse ".." segments. +        let mut segments: Vec<&str> = path.split('/') +            .filter(|s| !s.is_empty() && *s != ".")              .collect(); -        let mut reduced_segments: Vec<&str> = segments.windows(2) -            .filter(|w| w[1] != "..") -            .map(|w| w[1]) -            .collect(); -        // The first segment is always skipped by the previous step. -        if !segments.is_empty() && segments.get(1) != Some(&"..") { -            if segments[0] != ".." { -                reduced_segments.insert(0, segments[0]); +        '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;          } -        let path = reduced_segments.join("/"); - +        // Find page with this path in website. +        let path = segments.join("/");          for page in &self.pages {              if page.full_url == path {                  if let Some(heading) = heading { -                    if !page.headings.contains(&make_url_safe(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);                      }                  } -                return Some(format!("{path}.{ext}")); +                let root = from.root(); +                return Some(format!("{root}{path}.{ext}"));              }          }          return None; diff --git a/src/generate_html.rs b/src/generate_html.rs index 84c3bdb..dd08885 100644 --- a/src/generate_html.rs +++ b/src/generate_html.rs @@ -4,44 +4,94 @@ use markdown::*;  pub fn generate_html(document: &MarkdownDocument, page: &Page, website: &Website) -> String { +    let root = page.root(); +    let page_name = &page.name; +    let site_name = &website.name; +    let mut parent_url = String::new(); +    for segment in &page.parents { +        parent_url.push_str(&make_url_safe(segment)); parent_url.push('/'); +    } +    parent_url.pop(); +    let parent_name = match page.parents.get(page.parents.len()-1) { +        Some(parent) => parent.to_string(), +        None => String::new(), +    }; + +    let head = get_html_head(document, page); let head = head.trim(); +    let mut home = format!("<a id='home' href='{root}index.html'>{site_name}</a>"); +    let mut parent = format!("<a id='parent' href='../{parent_url}.html'>{parent_name}</a>"); +    let mut title = format!("<h1 id='title'>{page_name}</h1>"); +    let mut toc = get_table_of_contents(page); +    let main = document_to_html(document, page, website); let main = main.trim(); + +    if page.parents.is_empty() { +        parent.clear(); +        if page.name_url == "index" { +            home.clear(); +            title.clear(); +            toc.clear(); +        } +    } +      format!("\  <!DOCTYPE html>  <head> -<title>{} — {}</title> +<title>{page_name} — {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} +{parent} +</nav> +{title} +{toc} +</header>  <main> -{} +{main}  </main>  </body> -</html> \ -", -        page.name, website.name, -        get_html_head(document, page).trim(), -        document_to_html(document, page, website).trim() -    ) +</html>")  } -  pub fn get_html_head(document: &MarkdownDocument, page: &Page) -> String {      if let Some(Block::Fragment { language, content }) = document.blocks.first() {          if language == "embed-html-head" {              return content.to_string();          }      } -    let back = page.back_string(); +    let root = page.root();      format!("\ -<link rel='stylesheet' type='text/css' media='screen' href='{back}static/screen.css'> -<link rel='stylesheet' type='text/css' media='print' href='{back}static/print.css'> -<script src='{back}static/render_math.js' defer></script> \ +<link rel='stylesheet' type='text/css' media='screen' href='{root}static/screen.css'> +<link rel='stylesheet' type='text/css' media='print' href='{root}static/print.css'> +<script src='{root}static/render_math.js' defer></script> \      ")  } +pub fn get_table_of_contents(page: &Page) -> String { +    if page.headings.len() < 3 { +        return String::new(); +    } +    let mut toc = String::from("<nav id='toc'><details><summary></summary><ul>\n"); +    for heading in &page.headings { +        let name = &heading.name; +        let url = &heading.url; +        let class = match heading.level { +            Level::Heading1 => "l1", +            Level::Heading2 => "l2", +            Level::Heading3 => "l3", +        }; +        toc.push_str(&format!("<li><a href='#{url}' class='{class}'>{name}</a></li>\n")); +    } +    toc.push_str("</ul></details></nav>\n"); +    return toc; +} +  pub fn document_to_html(document: &MarkdownDocument, page: &Page, website: &Website) -> String {      let mut html = String::new(); @@ -54,9 +104,10 @@ pub fn document_to_html(document: &MarkdownDocument, page: &Page, website: &Webs          ($t:expr,$l:expr,$c:expr) => { html!("<{} {}>{}</{}>", $t, $c, line_to_html!($l), $t) };          ($t:expr,$l:expr)         => { html!("<{}>{}</{}>",    $t,     line_to_html!($l), $t) }; }      macro_rules! wrap { -        ($t:expr,$f:expr) => {{ html!("<{}>", $t); $f; html!("</{}>", $t); }}; -    } +        ($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 } => match level { @@ -96,11 +147,17 @@ pub fn document_to_html(document: &MarkdownDocument, page: &Page, website: &Webs              }),              Block::Note(lines) => wrap!("aside", for line in lines { tag!("p", line) }),              Block::Embedded { label, path } => match path.rsplit_once('.') { -                Some((_, extension)) => 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 src='{path}' controls>"), -                    ext @ _ => warn!("Unrecognised extension for embedded file {path:?} with extension {ext:?} in page {:?}", page.name), +                Some((_, extension)) => { +                    let path = match path.strip_prefix('/') { +                        Some(stripped) => format!("{root}{stripped}"), +                        None => path.to_string(), +                    }; +                    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 src='{path}' controls>"), +                        ext @ _ => warn!("Unrecognised extension for embedded file {path:?} with extension {ext:?} in page {:?}", page.name), +                    }                  }                  _ => warn!("Cannot embed file {path:?} with no file extension in page {:?}", page.name),              } @@ -119,7 +176,7 @@ pub fn document_to_html(document: &MarkdownDocument, page: &Page, website: &Webs                  }              }              Block::Break => html!("<hr>"), -            Block::Table(table) => wrap!("table", { +            Block::Table(table) => wrap!("div", "class='table'", wrap!("table", {                  wrap!("thead",                      wrap!("tr", for column in &table.columns {                          tag!("th", column.name); @@ -150,7 +207,7 @@ pub fn document_to_html(document: &MarkdownDocument, page: &Page, website: &Webs                          })                      })                  }; -            }) +            }))          }      }      return html; @@ -194,7 +251,7 @@ fn line_to_html(line: &Line, page: &Page, website: &Website) -> String {                  // Check that the heading exists.                  if class == "heading" {                      let heading = path.strip_prefix('#').unwrap().to_string(); -                    if !page.headings.contains(&heading) { +                    if !page.headings.iter().any(|h| h.url == heading) {                          warn!("Page {:?} contains link to nonexistent internal heading {heading:?}", page.name);                      }                  } diff --git a/src/main.rs b/src/main.rs index 2950ee9..a41f801 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,8 @@ pub use generate_html::*;  use markdown::*;  use vagabond::*; +use std::collections::HashSet; +  const NORMAL: &str = "\x1b[0m";  const BOLD:   &str = "\x1b[1m"; @@ -55,6 +57,25 @@ fn main() {      let website = Website::from_path(&source_directory); +    // Check for duplicate output paths for pages. +    let mut destinations: 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 !duplicates.is_empty() { +        for destination in duplicates { +            warn!("Multiple pages have the output path {destination:?}"); +            for page in &website.pages { +                if page.full_url == destination { +                    eprintln!(":: {:?}", page.source_path); +                } +            } +        } +    } +      let mut destination = destination_directory.clone();      destination.push(make_url_safe(&website.name)); @@ -64,6 +85,7 @@ fn main() {              error!("Failed to delete existing destination directory {destination:?}"));      } +      for page in &website.pages {          let mut destination = destination.clone();          destination.push(&page.full_url);  | 
