use chrono::{DateTime, Datelike, Utc};
use std::path::{Path, PathBuf};
pub fn default_for(id_path: &Path) -> PathBuf {
id_path.with_extension("html")
}
pub fn expand(pattern: &str, id_path: &Path, date: &DateTime<Utc>, term: Option<&str>) -> PathBuf {
let stem = id_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default();
let slug = slug::slugify(stem);
let trailing_slash = pattern.ends_with('/');
let mut expanded = pattern
.trim_start_matches('/')
.replace(":slug", &slug)
.replace(":yyyy", &format!("{:04}", date.year()))
.replace(":mm", &format!("{:02}", date.month()))
.replace(":dd", &format!("{:02}", date.day()));
if let Some(term) = term {
expanded = expanded.replace(":term", term);
}
let mut path = PathBuf::from(&expanded);
if trailing_slash {
path.push("index.html");
}
path
}
pub fn paginate_pattern(permalink: &str, page: usize) -> String {
if page <= 1 {
permalink.to_string()
} else {
format!("{}/page/{}/", permalink.trim_end_matches('/'), page)
}
}
pub fn to_url(output_path: &Path) -> String {
let s = output_path.to_string_lossy();
if let Some(stripped) = s.strip_suffix("index.html") {
let trimmed = stripped.trim_end_matches('/');
if trimmed.is_empty() {
"/".to_string()
} else {
format!("/{}/", trimmed)
}
} else {
format!("/{}", s)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn date(y: i32, m: u32, d: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(y, m, d, 0, 0, 0).unwrap()
}
#[test]
fn default_for_swaps_extension_to_html() {
assert_eq!(
default_for(Path::new("blog/post.md")),
PathBuf::from("blog/post.html"),
);
}
#[test]
fn expand_substitutes_slug_from_stem() {
let p = expand(
"/blog/:slug/",
Path::new("posts/Hello World.md"),
&date(2025, 1, 1),
None,
);
assert_eq!(p, PathBuf::from("blog/hello-world/index.html"));
}
#[test]
fn expand_zero_pads_date_components() {
let p = expand(
"/:yyyy/:mm/:dd/:slug.html",
Path::new("hi.md"),
&date(2025, 3, 7),
None,
);
assert_eq!(p, PathBuf::from("2025/03/07/hi.html"));
}
#[test]
fn expand_trailing_slash_appends_index_html() {
let p = expand(
"/blog/:slug/",
Path::new("hello.md"),
&date(2025, 10, 31),
None,
);
assert_eq!(p, PathBuf::from("blog/hello/index.html"));
}
#[test]
fn expand_no_trailing_slash_is_verbatim() {
let p = expand("/feed.xml", Path::new("rss.md"), &date(2025, 1, 1), None);
assert_eq!(p, PathBuf::from("feed.xml"));
}
#[test]
fn expand_strips_leading_slash() {
let p = expand("/:slug.html", Path::new("hi.md"), &date(2025, 1, 1), None);
assert_eq!(p, PathBuf::from("hi.html"));
}
#[test]
fn expand_handles_no_leading_slash() {
let p = expand(":slug.html", Path::new("hi.md"), &date(2025, 1, 1), None);
assert_eq!(p, PathBuf::from("hi.html"));
}
#[test]
fn expand_substitutes_term() {
let p = expand(
"/tags/:term/",
Path::new("tags.html"),
&date(1970, 1, 1),
Some("rust"),
);
assert_eq!(p, PathBuf::from("tags/rust/index.html"));
}
#[test]
fn expand_leaves_term_literal_when_none() {
let p = expand(
"/tags/:term/",
Path::new("tags.html"),
&date(1970, 1, 1),
None,
);
assert_eq!(p, PathBuf::from("tags/:term/index.html"));
}
#[test]
fn paginate_pattern_page_one_is_verbatim() {
assert_eq!(paginate_pattern("/blog/", 1), "/blog/");
}
#[test]
fn paginate_pattern_appends_page_segment() {
assert_eq!(paginate_pattern("/blog/", 2), "/blog/page/2/");
assert_eq!(paginate_pattern("/tags/:term/", 3), "/tags/:term/page/3/");
}
#[test]
fn to_url_index_html_root() {
assert_eq!(to_url(Path::new("index.html")), "/");
}
#[test]
fn to_url_index_html_nested() {
assert_eq!(to_url(Path::new("blog/page-1/index.html")), "/blog/page-1/");
}
#[test]
fn to_url_non_index() {
assert_eq!(to_url(Path::new("posts/p1.html")), "/posts/p1.html");
}
#[test]
fn to_url_top_level_xml() {
assert_eq!(to_url(Path::new("sitemap.xml")), "/sitemap.xml");
}
}