use std::{
fs::{self, File},
io::Write,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use clap::Parser;
use fs_extra::dir::{self, CopyOptions};
use handlebars::Handlebars;
use pulldown_cmark::{html::push_html, CowStr, Event, Options, Tag};
use serde_json::Value;
use shellexpand::tilde;
use walkdir::WalkDir;
fn main() {
let args = Args::parse();
generate_site(args)
}
#[derive(clap::Parser, Debug)]
#[command(version, about, long_about=None)]
struct Args {
content: String,
#[arg(long, default_value = "header.md")]
header: String,
#[arg(long, default_value = "footer.md")]
footer: String,
#[arg(long, default_value = "style.css")]
style: String,
#[arg(short, long, default_value = "_site")]
output: String,
#[arg(long)]
domain: Option<String>,
#[arg(long)]
base_url: Option<String>,
}
fn generate_site(args: Args) {
let content_path = get_content_path(args.content)
.map_err(|e| eprintln!("Error extracting the content path: {}", e))
.unwrap_or_else(|_| std::process::exit(1));
let output_path = get_output_path(args.output)
.map_err(|e| eprintln!("Error preparing the output path: {}", e))
.unwrap_or_else(|_| std::process::exit(1));
let base_url = args.base_url.as_deref();
copy_static_assets(&content_path, &output_path)
.map_err(|e| eprintln!("Error copying static assets: {}", e))
.unwrap_or_else(|_| std::process::exit(1));
let style = load_style(&args.style);
let header = load_header(&args.header, &content_path, base_url);
let footer = load_footer(&args.footer, &content_path, base_url);
let mut sitemap_entries = Vec::new();
for entry in WalkDir::new(&content_path)
.into_iter()
.filter_entry(|e| !e.path().starts_with(&content_path.join("static")))
.filter_map(|e| e.ok())
{
if entry.file_type().is_file() && entry.path().extension().map_or(false, |e| e == "md") {
let html_content = load_html_from_md_file(entry.path(), &content_path, base_url)
.map_err(|e| eprintln!("Error rendering markdown to HTML: {}", e))
.unwrap_or_else(|_| std::process::exit(1));
let relative_path = entry
.path()
.strip_prefix(&content_path)
.unwrap()
.with_extension("html");
let output_path = output_path.join(&relative_path);
if let Some(ref domain) = args.domain {
let full_url = if let Some(base) = base_url {
format!(
"{}/{}/{}",
domain.trim_end_matches("/"),
base.trim_start_matches("/").trim_end_matches("/"),
relative_path.to_string_lossy()
)
} else {
format!(
"{}/{}",
domain.trim_end_matches("/"),
relative_path.to_string_lossy()
)
};
sitemap_entries.push(full_url);
};
let final_html = render_template(
style.as_deref(),
header.as_ref().map(|content| content.html.as_str()),
footer.as_ref().map(|content| content.html.as_str()),
html_content,
)
.map_err(|e| eprintln!("Error rendering template: {}", e))
.unwrap_or_else(|_| std::process::exit(1));
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| eprintln!("Error creating content directory: {}", e))
.unwrap_or_else(|_| std::process::exit(1));
}
fs::write(output_path, final_html)
.map_err(|e| eprintln!("Error writing generated HTML to file: {}", e))
.unwrap_or_else(|_| std::process::exit(1));
}
}
if let Some(_) = args.domain {
generate_sitemap(&output_path, &sitemap_entries)
.map_err(|e| eprintln!("Error generating sitemap: {}", e))
.unwrap_or_else(|_| std::process::exit(1));
}
}
fn get_content_path(content_path: impl AsRef<str>) -> Result<PathBuf> {
get_absolute_path(content_path)
}
fn get_output_path(output_path: impl AsRef<str>) -> Result<PathBuf> {
match get_absolute_path(&output_path) {
Ok(path) => {
fs::remove_dir_all(&path)?;
fs::create_dir_all(&path)?;
Ok(path)
}
Err(_) => {
let output_path = expand_path(output_path);
fs::create_dir_all(&output_path)?;
Ok(fs::canonicalize(&output_path)?)
}
}
}
fn copy_static_assets(content_path: &Path, output_path: &Path) -> Result<()> {
let static_dir = content_path.join("static");
let output_static_dir = output_path.join("static");
if static_dir.exists() {
fs::create_dir_all(&output_static_dir)?;
let mut options = CopyOptions::new();
options.overwrite = true;
options.content_only = true;
dir::copy(&static_dir, &output_static_dir, &options)?;
}
Ok(())
}
fn load_style(style_path: impl AsRef<str>) -> Option<String> {
get_absolute_path(style_path)
.ok()
.and_then(|path| fs::read_to_string(&path).ok())
}
fn load_header(
header_path: impl AsRef<str>,
content_path: &Path,
base_url: Option<&str>,
) -> Option<HTMLContent> {
let header_path = get_absolute_path(header_path).ok()?;
load_html_from_md_file(&header_path, content_path, base_url).ok()
}
fn load_footer(
footer_path: impl AsRef<str>,
content_path: &Path,
base_url: Option<&str>,
) -> Option<HTMLContent> {
let footer_path = get_absolute_path(footer_path).ok()?;
load_html_from_md_file(&footer_path, content_path, base_url).ok()
}
fn load_html_from_md_file(
path: &Path,
content_path: &Path,
base_url: Option<&str>,
) -> Result<HTMLContent> {
fs::read_to_string(&path)
.with_context(|| format!("Failed to read from markdown file: {:?}", path))
.and_then(|file_content| process_markdown(&file_content))
.with_context(|| "Failed to process markdown file.")
.and_then(|markdown_content| {
let html = markdown_to_html(&markdown_content.markdown, content_path, base_url)
.with_context(|| "Failed to convert markdown to HTML.")?;
Ok(HTMLContent {
front_matter: markdown_content.front_matter,
html,
})
})
}
fn render_template(
style: Option<&str>,
header: Option<&str>,
footer: Option<&str>,
content: HTMLContent,
) -> Result<String> {
let mut handlebars = Handlebars::new();
handlebars.register_template_string("template", include_str!("./template.html"))?;
let data = serde_json::json!({
"title": content.front_matter.as_ref().map_or("", |fm| fm.title.as_deref().unwrap_or("")),
"description": content.front_matter.as_ref().map_or("", |fm| fm.description.as_deref().unwrap_or("")) ,
"style": style.as_deref().unwrap_or(""),
"header": header.as_deref().unwrap_or(""),
"footer": footer.as_deref().unwrap_or(""),
"content": content.html
});
Ok(handlebars.render("template", &data)?)
}
fn get_absolute_path(path: impl AsRef<str>) -> Result<PathBuf> {
Ok(fs::canonicalize(expand_path(path))?)
}
fn expand_path(path: impl AsRef<str>) -> String {
tilde(path.as_ref()).into_owned()
}
struct FrontMatter {
title: Option<String>,
description: Option<String>,
}
struct MarkdownContent {
front_matter: Option<FrontMatter>,
markdown: String,
}
struct HTMLContent {
front_matter: Option<FrontMatter>,
html: String,
}
fn process_markdown(content: &str) -> Result<MarkdownContent> {
if content.starts_with("---") {
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() == 3 {
let front_matter_str = parts[1];
let rest_content = parts[2];
let front_matter: Value = serde_yaml::from_str(front_matter_str)
.with_context(|| "Failed to parse YAML front matter.")?;
let title = front_matter
.get("title")
.and_then(Value::as_str)
.map(|s| s.to_owned());
let meta_description = front_matter
.get("meta_description")
.and_then(Value::as_str)
.map(|s| s.to_owned());
Ok(MarkdownContent {
front_matter: Some(FrontMatter {
title,
description: meta_description,
}),
markdown: rest_content.trim_start().to_string(),
})
} else {
Ok(MarkdownContent {
front_matter: None,
markdown: content.to_string(),
})
}
} else {
Ok(MarkdownContent {
front_matter: None,
markdown: content.to_string(),
})
}
}
fn markdown_to_html(
markdown_input: &str,
content_dir: &Path,
base_url: Option<&str>,
) -> Result<String> {
let parser = pulldown_cmark::Parser::new_ext(markdown_input, Options::all());
let mut events: Vec<Event> = Vec::new();
let content_dir_name = content_dir
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or("");
let content_dir_with_slash = format!("/{}", content_dir_name);
for event in parser {
match event {
Event::Start(Tag::Link {
link_type,
dest_url,
title,
id,
}) => {
let mut new_dest = dest_url.to_string();
if dest_url.starts_with(&content_dir_with_slash)
|| dest_url.starts_with(content_dir_name)
{
let stripped_url = dest_url
.strip_prefix(&content_dir_with_slash)
.or_else(|| dest_url.strip_prefix(content_dir_name))
.unwrap_or(&dest_url);
new_dest = if let Some(base) = base_url {
format!(
"/{}/{}",
base.trim_start_matches("/").trim_end_matches("/"),
stripped_url.trim_start_matches("/")
)
.to_string()
} else {
stripped_url.to_string()
}
}
if new_dest.ends_with(".md") {
new_dest = new_dest
.trim_end_matches("index.md")
.replace(".md", ".html");
}
events.push(Event::Start(Tag::Link {
link_type,
dest_url: CowStr::Boxed(new_dest.into_boxed_str()),
title,
id,
}));
}
_ => events.push(event),
}
}
let mut html_output = String::new();
push_html(&mut html_output, events.into_iter());
Ok(html_output)
}
fn generate_sitemap(output_path: &Path, entries: &[String]) -> Result<()> {
let sitemap_path = output_path.join("sitemap.xml");
let mut file = File::create(&sitemap_path)?;
writeln!(file, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")?;
writeln!(
file,
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">"
)?;
for entry in entries {
let url = format!("<url><loc>{}</loc></url>", entry);
writeln!(file, "{}", url)?;
}
writeln!(file, "</urlset>")?;
Ok(())
}