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 finish(&self) -> io::Result<()> {
fs::write(self.out.join("sitemap.xml"), self.sitemap())?;
fs::write(self.out.join("robots.txt"), self.robots())?;
Ok(())
}
fn sitemap(&self) -> String {
let mut xml = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
);
for path in &self.paths {
let loc = escape(&format!("{}{}", self.base_url, path));
xml.push_str(&format!(" <url><loc>{loc}</loc></url>\n"));
}
xml.push_str("</urlset>\n");
xml
}
fn robots(&self) -> String {
format!(
"User-agent: *\nAllow: /\n\nSitemap: {}/sitemap.xml\n",
self.base_url
)
}
}
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_assets_sitemap_and_robots() {
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();
site.finish().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());
let sitemap = fs::read_to_string(out.join("sitemap.xml")).unwrap();
assert!(sitemap.contains("<loc>https://acme.example/</loc>"));
assert!(sitemap.contains("<loc>https://acme.example/features/</loc>"));
let robots = fs::read_to_string(out.join("robots.txt")).unwrap();
assert!(robots.contains("Sitemap: https://acme.example/sitemap.xml"));
}
}