summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/generate_html.rs88
-rw-r--r--src/main.rs114
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