pub mod theme;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Meta {
title: String,
description: String,
canonical: Option<String>,
image: Option<String>,
}
impl Meta {
pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
Self {
title: title.into(),
description: description.into(),
canonical: None,
image: None,
}
}
pub fn canonical(mut self, url: impl Into<String>) -> Self {
self.canonical = Some(url.into());
self
}
pub fn image(mut self, url: impl Into<String>) -> Self {
self.image = Some(url.into());
self
}
pub fn head_tags(&self) -> String {
let title = escape(&self.title);
let description = escape(&self.description);
let mut out = format!(
"<title>{title}</title>\n\
<meta name=\"description\" content=\"{description}\">\n\
<meta property=\"og:title\" content=\"{title}\">\n\
<meta property=\"og:description\" content=\"{description}\">\n\
<meta property=\"og:type\" content=\"website\">\n\
<meta name=\"twitter:card\" content=\"summary_large_image\">"
);
if let Some(url) = &self.canonical {
let url = escape(url);
out.push_str(&format!(
"\n<link rel=\"canonical\" href=\"{url}\">\n\
<meta property=\"og:url\" content=\"{url}\">"
));
}
if let Some(image) = &self.image {
let image = escape(image);
out.push_str(&format!(
"\n<meta property=\"og:image\" content=\"{image}\">"
));
}
out
}
}
pub struct StaticSite {
out: PathBuf,
base_url: String,
paths: Vec<String>,
}
impl StaticSite {
pub fn new(out_dir: impl Into<PathBuf>, base_url: impl Into<String>) -> io::Result<Self> {
let out = out_dir.into();
fs::create_dir_all(&out)?;
Ok(Self {
out,
base_url: base_url.into().trim_end_matches('/').to_string(),
paths: Vec::new(),
})
}
pub fn page(&mut self, path: &str, html: &str) -> io::Result<&mut Self> {
let file = self.out.join(path_to_file(path));
if let Some(parent) = file.parent() {
fs::create_dir_all(parent)?;
}
fs::write(file, html)?;
self.paths.push(normalise_path(path));
Ok(self)
}
pub fn assets(&self, from: impl AsRef<Path>, to: &str) -> io::Result<()> {
copy_dir(from.as_ref(), &self.out.join(to))
}
pub fn file(&self, path: &str, contents: &str) -> io::Result<()> {
let relative = path.trim_start_matches('/');
if relative.split('/').any(|part| part == "..") {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("`{path}` climbs out of the output directory"),
));
}
let target = self.out.join(relative);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(target, contents)
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn paths(&self) -> &[String] {
&self.paths
}
}
fn path_to_file(path: &str) -> PathBuf {
let trimmed = path.trim_matches('/');
if trimmed.is_empty() {
PathBuf::from("index.html")
} else {
PathBuf::from(trimmed).join("index.html")
}
}
fn normalise_path(path: &str) -> String {
let trimmed = path.trim_matches('/');
if trimmed.is_empty() {
"/".to_string()
} else {
format!("/{trimmed}/")
}
}
fn escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn copy_dir(from: &Path, to: &Path) -> io::Result<()> {
fs::create_dir_all(to)?;
for entry in fs::read_dir(from)? {
let entry = entry?;
let src = entry.path();
let dst = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir(&src, &dst)?;
} else {
fs::copy(&src, &dst)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_map_to_clean_url_files() {
assert_eq!(path_to_file("/"), PathBuf::from("index.html"));
assert_eq!(path_to_file(""), PathBuf::from("index.html"));
assert_eq!(
path_to_file("/features/"),
PathBuf::from("features/index.html")
);
assert_eq!(
path_to_file("/get-started"),
PathBuf::from("get-started/index.html")
);
}
#[test]
fn head_tags_include_and_escape_metadata() {
let meta = Meta::new("Acme & Co", "A \"great\" site")
.canonical("https://acme.example/")
.image("https://acme.example/card.png");
let tags = meta.head_tags();
assert!(tags.contains("<title>Acme & Co</title>"));
assert!(tags.contains("content=\"A "great" site\""));
assert!(tags.contains("rel=\"canonical\" href=\"https://acme.example/\""));
assert!(tags.contains("og:image"));
}
#[test]
fn builds_pages_and_copies_assets() {
let tmp = tempfile::tempdir().unwrap();
let assets = tmp.path().join("static");
fs::create_dir_all(&assets).unwrap();
fs::write(assets.join("site.css"), "body{}").unwrap();
let out = tmp.path().join("dist");
let mut site = StaticSite::new(&out, "https://acme.example/").unwrap();
site.page("/", "<h1>Home</h1>").unwrap();
site.page("/features/", "<h1>Features</h1>").unwrap();
site.assets(&assets, "static").unwrap();
assert_eq!(
fs::read_to_string(out.join("index.html")).unwrap(),
"<h1>Home</h1>"
);
assert!(out.join("features/index.html").exists());
assert!(out.join("static/site.css").exists());
assert!(!out.join("sitemap.xml").exists());
assert!(!out.join("robots.txt").exists());
assert_eq!(site.paths(), ["/", "/features/"]);
assert_eq!(site.base_url(), "https://acme.example");
}
#[test]
fn a_site_writes_its_own_root_files() {
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join("dist");
let site = StaticSite::new(&out, "https://acme.example").unwrap();
site.file("robots.txt", "User-agent: *\nDisallow: /private/\n")
.unwrap();
site.file(
"/.well-known/security.txt",
"Contact: mailto:x@acme.example\n",
)
.unwrap();
let robots = fs::read_to_string(out.join("robots.txt")).unwrap();
assert!(robots.contains("Disallow: /private/"));
assert!(out.join(".well-known/security.txt").exists());
assert!(site.paths().is_empty());
}
#[test]
fn a_root_file_cannot_escape_the_output_directory() {
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join("dist");
let site = StaticSite::new(&out, "https://acme.example").unwrap();
assert!(site.file("../escaped.txt", "no").is_err());
assert!(site.file("a/../../escaped.txt", "no").is_err());
assert!(!tmp.path().join("escaped.txt").exists());
}
}