diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/generate_html.rs | 88 | ||||
-rw-r--r-- | src/main.rs | 114 |
2 files changed, 144 insertions, 58 deletions
diff --git a/src/generate_html.rs b/src/generate_html.rs index 3f22d3b..1e3acc7 100644 --- a/src/generate_html.rs +++ b/src/generate_html.rs @@ -20,8 +20,8 @@ pub fn generate_html(document: &MarkdownDocument, page: &SourceFile, website: &W </html> \ ", page.name, website.name, - get_html_head(document), - document_to_html(document, page, website) + get_html_head(document).trim(), + document_to_html(document, page, website).trim() ) } @@ -34,9 +34,9 @@ pub fn get_html_head(document: &MarkdownDocument) -> String { } } String::from("\ -<link rel='stylesheet' type='text/css' media='screen' href='static/screen.css'> -<link rel='stylesheet' type='text/css' media='print' href='static/print.css'> -<script src='static/render_math.js' defer></script> \ +<link rel='stylesheet' type='text/css' media='screen' href='/static/screen.css'> +<link rel='stylesheet' type='text/css' media='print' href='/static/print.css'> +<script src='/static/render_math.js' defer></script> \ ") } @@ -50,7 +50,8 @@ pub fn document_to_html(document: &MarkdownDocument, page: &SourceFile, website: macro_rules! html { ($($arg:tt)*) => {{ html.push_str(&format!($($arg)*)); html.push('\n'); }}; } macro_rules! tag { - ($t:expr,$l:expr) => { html!("<{}>{}</{}>", $t, line_to_html!($l), $t) }; } + ($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); }}; } @@ -58,11 +59,19 @@ pub fn document_to_html(document: &MarkdownDocument, page: &SourceFile, website: for block in &document.blocks { match block { Block::Heading { level, line } => match level { - Level::Heading1 => tag!("h1", line), - Level::Heading2 => tag!("h2", line), - Level::Heading3 => tag!("h3", line), + Level::Heading1 => tag!("h1", line, format!("id='{}'", make_url_safe(&line_to_html!(line)))), + Level::Heading2 => tag!("h2", line, format!("id='{}'", make_url_safe(&line_to_html!(line)))), + Level::Heading3 => tag!("h3", line, format!("id='{}'", make_url_safe(&line_to_html!(line)))), + } + Block::Paragraph(line) => { + if let Some(stripped) = line.to_string().strip_prefix("$$ ") { + if let Some(stripped) = stripped.strip_suffix(" $$") { + html!("<div class='math'>{stripped}</div>"); + continue; + } + } + tag!("p", line); } - Block::Paragraph(line) => tag!("p", line), Block::List(lines) => wrap!("ul", for line in lines { // Insert a <br> tag directly after the first untagged colon. let mut depth = 0; @@ -88,19 +97,18 @@ pub fn document_to_html(document: &MarkdownDocument, page: &SourceFile, website: 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='{}'><img src='{}' alt='{}' title='{}'></a></figure>", - path, path, label, label - ), + "<figure><a href='{path}'><img src='{path}' alt='{label}' title='{label}'></a></figure>"), "mp3"|"wav"|"m4a" => html!("<audio src='{path}' controls>"), - ext @ _ => error!("Unrecognised extension for embedded file '{path}' with extension '{ext}'"), + ext @ _ => warn!("Unrecognised extension for embedded file {path:?} with extension {ext:?} in page {:?}", page.name), } - _ => error!("Cannot embed file '{path}' with no file extension"), + _ => warn!("Cannot embed file {path:?} with no file extension in page {:?}", page.name), } Block::Fragment { language, content } => { match language.as_str() { - "embed-html" => html!("{}", content), - "embed-css" => wrap!("style", html!("{}", content)), - "embed-javascript"|"embed-js" => wrap!("script", html!("{}", content)), + "math" => html!("<div class='math'>{content}</div>"), + "embed-html" => html!("{content}"), + "embed-css" => wrap!("style", html!("{content}")), + "embed-javascript"|"embed-js" => wrap!("script", html!("{content}")), "hidden"|"todo"|"embed-html-head" => (), _ => { html!("<pre class='{}'>", language); @@ -165,37 +173,41 @@ fn line_to_html(line: &Line, page: &SourceFile, website: &Website) -> String { let text = &sanitize_text(text); html.push_str(&format!("<span class='math'>{text}</span>")) } Token::InternalLink(path) => { let (label, class, path) = match path.split_once('#') { - Some(("", section)) => (section, "heading", format!("#{}", make_url_safe(path))), - Some((page, section)) => (section, "page", format!("{}.html#{}", make_url_safe(page), make_url_safe(section))), - _ => (path.as_str(), "page", format!("{}.html", make_url_safe(path))), + Some(("", section)) => (section, "heading", format!("#{section}")), + Some((page, section)) => (section, "page", format!("{page}.html#{section}")), + _ => (path.as_str(), "page", format!("{path}.html")), }; + let mut path = make_url_safe(&path); let full_label = sanitize_text(label); - let label = match full_label.split_once('/') { + let label = match full_label.rsplit_once('/') { Some((_parent, label)) => label.trim(), None => &full_label, }; // Check that the linked internal page exists. if class == "page" { - let path_no_ext = path.strip_suffix(".html").unwrap(); - if !website.has_page(&path_no_ext) { - error!("Page {:?} contains invalid link to {:?}", page.name, path_no_ext); + match website.has_page(page, &path, "html") { + Some(resolved_path) => path = resolved_path, + None => warn!("Page {:?} contains link to nonexistent page {path:?}", page.name), + } + } + // Check that the heading exists. + if class == "heading" { + let heading = path.strip_prefix('#').unwrap().to_string(); + if !page.headings.contains(&heading) { + warn!("Page {:?} contains link to nonexistent internal heading {heading:?}", page.name); } } - // Return to the site root before descending into a link. - let mut back = String::new(); - let levels = page.full_url.chars().filter(|c| *c == '/').count(); - for _ in 0..levels { back.push_str("../") } - html.push_str(&format!("<a href='{back}{path}' class='{class}'>{label}</a>")) + html.push_str(&format!("<a href='{path}' class='{class}'>{label}</a>")) } Token::ExternalLink { label, path } => { - let is_internal = path.find("/").is_none(); - let (new_label, class, path) = match (is_internal, path.split_once("#")) { - (true, Some(("", frag))) => (sanitize_text(frag), "heading", format!("#{}", make_url_safe(frag)) ), - (true, Some((page, frag))) => (sanitize_text(frag), "page", format!("{}.html#{}", make_url_safe(page), make_url_safe(frag)) ), - (true, None) => (sanitize_text(path), "page", if path.contains(".") { path.clone() } else { format!("{}.html", make_url_safe(path)) } ), - (false, _) => (sanitize_text(path), "external", path.clone() ) }; - let label = match label.is_empty() { true => new_label, false => sanitize_text(label) }; - html.push_str(&format!("<a href='{path}' class='{class}'>{label}</a>")); + let mut label = label.to_string(); + // Strip the protocol from the path when using the path as a label. + if label.is_empty() { + for protocol in ["mailto://", "http://", "https://"] { + if let Some(stripped) = path.strip_prefix(protocol) { + label = stripped.to_string(); } } } + let label = sanitize_text(&label); + html.push_str(&format!("<a href='{path}' class='external'>{label}</a>")); } } } diff --git a/src/main.rs b/src/main.rs index d8c9274..a1b38f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ const NORMAL: &str = "\x1b[0m"; const BOLD: &str = "\x1b[1m"; const WHITE: &str = "\x1b[37m"; const RED: &str = "\x1b[31m"; +const YELLOW: &str = "\x1b[33m"; const BLUE: &str = "\x1b[34m"; static mut VERBOSE: bool = false; @@ -20,6 +21,12 @@ static mut VERBOSE: bool = false; eprintln!("{NORMAL}"); } }; } +#[macro_export] macro_rules! warn { + ($($tokens:tt)*) => {{ + eprint!("{BOLD}{YELLOW}[WARNING]{NORMAL}{WHITE}: "); eprint!($($tokens)*); + eprintln!("{NORMAL}"); + }}; +} #[macro_export] macro_rules! error { ($($tokens:tt)*) => {{ eprint!("{BOLD}{RED}[ERROR]{WHITE}: "); eprint!($($tokens)*); @@ -31,26 +38,36 @@ fn main() { let args = Arguments::from_env_or_exit(); if args.version { let version = env!("CARGO_PKG_VERSION"); - eprintln!("Markdown website generator, version {version}"); + eprintln!("toaster, version {version}"); std::process::exit(0); } if args.verbose { unsafe { VERBOSE = true; } } + if args.source.is_none() || args.destination.is_none() { + error!("Provide a source directory and a destination directory.") + } + let source_directory = args.source.unwrap().canonicalize().unwrap(); + let destination_directory = args.destination.unwrap(); let mut website = Website { source_files: Vec::new(), static_files: Vec::new(), - name: match Entry::from_path(&args.source) { + name: match Entry::from_path(&source_directory) { Ok(entry) => entry.name, - Err(err) => error!("Couldn't open {:?}: {:?}", args.source, err), + Err(err) => error!("Couldn't open {:?}: {:?}", &source_directory, err), }, error: false, }; + // Collect all website files. - match traverse_directory(&args.source) { + match traverse_directory(&source_directory) { Ok(entries) => for entry in entries { + // Ignore dot files. + if entry.name.starts_with(".") { + continue; + } // Generate name, stripping any leading digit sequence. let (mut name, extension) = entry.split_name(); if let Some((prefix, suffix)) = name.split_once(' ') { @@ -60,9 +77,9 @@ fn main() { } // Generate full URL with stripped name, no extension. let source_path = entry.original_path; - let relative_path = source_path.strip_prefix(&args.source).unwrap_or_else( + let relative_path = source_path.strip_prefix(&source_directory).unwrap_or_else( // Probably unreachable. - |_| error!("Path doesn't start with {:?}: {:?}", args.source, source_path)); + |_| error!("Path doesn't start with {:?}: {:?}", source_directory, source_path)); let mut full_url = String::new(); let mut components: Vec<_> = relative_path.components().collect(); components.pop(); // Remove file segment, use the stripped name instead. @@ -70,10 +87,12 @@ fn main() { full_url.push_str(&make_url_safe(&c.as_os_str().to_string_lossy())); full_url.push('/') }; + let parent_url = full_url.clone(); full_url.push_str(&make_url_safe(&name)); if extension == "md" { + // Rename and relocate index files. let mut file_url = make_url_safe(&name); if file_url == "+index" { let components: Vec<_> = relative_path.components().collect(); @@ -94,7 +113,15 @@ fn main() { full_url.push_str(&file_url); } } - website.source_files.push(SourceFile { name, file_url, full_url, source_path }); + // Load and parse the markdown. + let markdown = std::fs::read_to_string(&source_path).unwrap(); + let document = MarkdownDocument::from_str(&markdown); + let headings = document.blocks.iter().filter_map(|block| match block { + Block::Heading { line, .. } => Some(make_url_safe(&line.to_string())), + _ => None, + }).collect(); + website.source_files.push( + SourceFile { name, parent_url, file_url, full_url, source_path, document, headings }); } else { full_url.push('.'); full_url.push_str(&extension); website.static_files.push(StaticFile { full_url, source_path }); @@ -103,21 +130,21 @@ fn main() { Err(err) => error!("Could not read from source directory: {:?}", err), } - let mut destination = args.destination.clone(); + let mut destination = destination_directory.clone(); destination.push(make_url_safe(&website.name)); for source_file in &website.source_files { - let markdown = std::fs::read_to_string(&source_file.source_path).unwrap(); - let document = MarkdownDocument::from_str(&markdown); let mut destination = destination.clone(); destination.push(&source_file.full_url); // Convert document to different formats. if args.html { - let html = generate_html(&document, source_file, &website); + let html = generate_html(&source_file.document, source_file, &website); write_file(&html, &destination, "html"); } // Copy original markdown file. - write_file(&markdown, &destination, "md"); + destination.add_extension("md"); + verbose!("Copying original markdown file to {destination:?}"); + copy(&source_file.source_path, &destination).unwrap(); } for static_file in &website.static_files { @@ -141,7 +168,7 @@ pub fn write_file(text: &str, destination: &PathBuf, ext: &str) { pub fn make_url_safe(text: &str) -> String { text.to_ascii_lowercase().chars().filter_map(|c| - if c.is_alphanumeric() || "-_~.+/".contains(c) { Some(c) } + if c.is_alphanumeric() || "-_~.+/#".contains(c) { Some(c) } else if c == ' ' { Some('-') } else { None } ) .collect() @@ -156,21 +183,68 @@ pub struct Website { } impl Website { - pub fn has_page(&self, path: &str) -> bool { + // 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: &SourceFile, 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 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}"), + }; + + // Remove relative portions of path. + let segments: Vec<&str> = full_path.split("/") + .filter(|seg| !seg.is_empty() && *seg != ".") + .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]); + } + } + let path = reduced_segments.join("/"); + for source_file in &self.source_files { if source_file.full_url == path { - return true; + if let Some(heading) = heading { + if !source_file.headings.contains(&make_url_safe(heading)) { + warn!("Page {:?} contains link to nonexistent heading {heading:?} on page {path:?}", from.name); + } + } + return Some(format!("{path}.{ext}")); } } - return false; + return None; } } pub struct SourceFile { pub name: String, - pub file_url: String, // URL file segment, no extension - pub full_url: String, // URL full path, no extension + pub parent_url: String, // URL base of child pages + pub file_url: String, // URL file segment, no extension + pub full_url: String, // URL full path, no extension pub source_path: PathBuf, + pub document: MarkdownDocument, + pub headings: Vec<String>, } pub struct StaticFile { @@ -182,9 +256,9 @@ xflags::xflags! { /// Generate a website from a structured directory of markdown files. cmd arguments { /// Source directory with markdown files - required source: PathBuf + optional source: PathBuf /// Path to output directory - required destination: PathBuf + optional destination: PathBuf /// Generate HTML output optional --html /// Generate Gemtext output |