feat: generate HTML redirects for aliases

Add generate_aliases(), write_aliases(), and
redirect_html() to main.rs. For each content item
with aliases in frontmatter, writes a minimal HTML
redirect stub with meta refresh and canonical link.

Alias paths are normalized: bare paths get
/index.html appended, .html extensions preserved.

Add 2 tests. Test suite: 76 → 78, all passing.
Phase 2 complete — all plan items checked off.
This commit is contained in:
Timothy DeHerrera
2026-02-14 07:24:57 -07:00
parent 7e692aacb4
commit 53463b3d8b
2 changed files with 120 additions and 2 deletions

View File

@@ -121,9 +121,9 @@ Items validated by codebase investigation:
- [x] Filter drafts from nav discovery (`discover_nav()`)
- [x] Filter drafts from sitemap entries
- [x] Filter drafts from feed entries
- [ ] Generate HTML redirect stubs for each alias path (`<meta http-equiv="refresh">`)
- [x] Generate HTML redirect stubs for each alias path (`<meta http-equiv="refresh">`)
- [x] Add tests: draft filtering (excluded from listing, nav, feed, sitemap)
- [ ] Add tests: alias redirect generation (valid HTML, correct target URL)
- [x] Add tests: alias redirect generation (valid HTML, correct target URL)
3. **Phase 3: 404 & Tag Pages** — new content generation features
- [ ] Detect `content/404.md` in content discovery, treat as special page

View File

@@ -199,6 +199,9 @@ fn run(config_path: &Path) -> Result<()> {
generate_sitemap_file(&output_dir, &manifest, &config, &content_dir)?;
}
// 6. Generate alias redirects
generate_aliases(&output_dir, &content_dir, &manifest, &config)?;
eprintln!("done!");
Ok(())
}
@@ -338,6 +341,97 @@ fn write_output(
Ok(())
}
/// Generate HTML redirect stubs for alias paths.
///
/// For each content item with `aliases = ["/old/path", ...]` in frontmatter,
/// writes a minimal HTML file at the alias path that redirects to the canonical URL.
fn generate_aliases(
output_dir: &Path,
content_dir: &Path,
manifest: &content::SiteManifest,
config: &config::SiteConfig,
) -> Result<()> {
let base_url = config.base_url.trim_end_matches('/');
// Process section items
for section in &manifest.sections {
if let Ok(items) = section.collect_items() {
for item in &items {
write_aliases(output_dir, content_dir, item, base_url)?;
}
}
}
// Process standalone pages
for page in &manifest.pages {
write_aliases(output_dir, content_dir, page, base_url)?;
}
Ok(())
}
/// Write redirect stubs for a single content item's aliases.
fn write_aliases(
output_dir: &Path,
content_dir: &Path,
content: &Content,
base_url: &str,
) -> Result<()> {
if content.frontmatter.aliases.is_empty() {
return Ok(());
}
let canonical_path = content.output_path(content_dir);
let canonical_url = format!("{}/{}", base_url, canonical_path.display());
for alias in &content.frontmatter.aliases {
let alias_path = alias.trim_start_matches('/');
// Append index.html if alias ends with / or has no extension
let alias_file = if alias_path.ends_with('/') || !alias_path.contains('.') {
format!("{}/index.html", alias_path.trim_end_matches('/'))
} else {
alias_path.to_string()
};
let out_path = output_dir.join(&alias_file);
let out_dir = out_path.parent().unwrap();
fs::create_dir_all(out_dir).map_err(|e| Error::CreateDir {
path: out_dir.to_path_buf(),
source: e,
})?;
let html = redirect_html(&canonical_url);
fs::write(&out_path, html).map_err(|e| Error::WriteFile {
path: out_path.clone(),
source: e,
})?;
eprintln!(" alias: {}{}", alias, canonical_url);
}
Ok(())
}
/// Generate minimal HTML for a redirect page.
fn redirect_html(target_url: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url={url}">
<link rel="canonical" href="{url}">
</head>
<body>
<p>Redirecting to <a href="{url}">{url}</a></p>
</body>
</html>
"#,
url = target_url,
)
}
/// Copy static assets (CSS, images, etc.) to output directory.
/// CSS files are minified before writing.
fn copy_static_assets(static_dir: &Path, output_dir: &Path) -> Result<()> {
@@ -414,3 +508,27 @@ fn walk_dir_inner(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_redirect_html_contains_meta_refresh() {
let html = redirect_html("https://example.com/blog/new-post.html");
assert!(html.contains(r#"http-equiv="refresh""#));
assert!(html.contains(r#"content="0; url=https://example.com/blog/new-post.html""#));
assert!(html.contains(r#"rel="canonical""#));
assert!(html.contains(r#"href="https://example.com/blog/new-post.html""#));
}
#[test]
fn test_redirect_html_is_valid_document() {
let html = redirect_html("https://example.com/target.html");
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.contains("<html>"));
assert!(html.contains("</html>"));
assert!(html.contains("<head>"));
assert!(html.contains("</head>"));
}
}