diff --git a/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs b/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs index c4e1c54..e0593fd 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs @@ -27,6 +27,7 @@ use anyhow::{bail, Result}; use std::fs; +use std::io::{BufWriter, Write}; use crate::path::OutputRoot; @@ -70,6 +71,14 @@ pub fn write( fs::remove_file(path)?; } } + if let Some(anchors) = &snapshot.anchors { + let path = + output.join(&"redirect.json".parse().expect("static site path")); + fs::create_dir_all(path.parent().expect("invariant"))?; + let mut writer = BufWriter::new(fs::File::create(path)?); + serde_json::to_writer(&mut writer, anchors)?; + writer.flush()?; + } for warning in &snapshot.warnings { eprintln!("WARNING - {warning}"); } @@ -93,6 +102,8 @@ mod tests { use super::{render, write}; use crate::compat::mkdocs::plugin::redirects::plan::{Redirect, Snapshot}; use crate::path::{OutputRoot, SitePath}; + use std::collections::BTreeMap; + use std::fs; #[test] fn renders_upstream_document() { @@ -126,6 +137,7 @@ You're being redirected to a new destination. output: output.clone(), target: Some("../new/".into()), }], + anchors: Some(BTreeMap::new()), warnings: Vec::new(), }; let root = OutputRoot::prepare(directory.path()).unwrap(); @@ -137,10 +149,31 @@ You're being redirected to a new destination. output: output.clone(), target: None, }], + anchors: Some(BTreeMap::new()), warnings: vec!["missing".into()], }; write(&root, &missing, false).unwrap(); assert!(!directory.path().join(output.as_str()).exists()); assert!(write(&root, &missing, true).is_err()); } + + #[test] + fn writes_anchor_redirect_manifest() { + let directory = tempfile::tempdir().unwrap(); + let root = OutputRoot::prepare(directory.path()).unwrap(); + let snapshot = Snapshot { + redirects: Vec::new(), + anchors: Some(BTreeMap::from([( + "guide/#old".into(), + "reference/#new".into(), + )])), + warnings: Vec::new(), + }; + + write(&root, &snapshot, false).unwrap(); + assert_eq!( + fs::read_to_string(directory.path().join("redirect.json")).unwrap(), + r#"{"guide/#old":"reference/#new"}"# + ); + } } diff --git a/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs b/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs index 92ffd3b..054efcd 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs @@ -57,6 +57,17 @@ enum Target { }, } +// ---------------------------------------------------------------------------- + +/// Redirect source classification prepared before route settlement. +#[derive(Clone, Debug)] +enum Source { + /// Page redirect emitted as a physical HTML artifact. + Page(SitePath), + /// Anchor redirect emitted into the site-wide redirect manifest. + Anchor(String), +} + // ---------------------------------------------------------------------------- // Structs // ---------------------------------------------------------------------------- @@ -64,8 +75,12 @@ enum Target { /// Redirect configuration prepared independently of live page routes. #[derive(Clone, Debug, Default)] pub struct Plan { + /// Whether the redirects plugin is enabled. + enabled: bool, /// Generated paths reserved by configured redirects. outputs: BTreeSet, + /// Anchor sources reserved by configured redirects. + anchors: BTreeSet, /// Internal source paths needed from the live route relation. targets: BTreeSet, /// Validated redirect specifications in configuration order. @@ -90,8 +105,8 @@ pub struct Redirect { /// One validated configuration entry awaiting target resolution. #[derive(Clone, Debug)] struct Specification { - /// Site-relative output path. - output: SitePath, + /// Prepared page or anchor source. + source: Source, /// Prepared internal or external target. target: Target, } @@ -103,6 +118,8 @@ struct Specification { pub struct Snapshot { /// Redirects ordered by configured source URI. pub redirects: Vec, + /// Anchor redirects keyed by their resolved source URL. + pub anchors: Option>, /// Compatibility warnings emitted for this snapshot. pub warnings: Vec, } @@ -115,24 +132,22 @@ impl Plan { /// Validates route-independent configuration once for the workflow. pub fn new(config: &Config) -> Result { let plugin = &config.project.plugins.redirects.config; - if !plugin.enabled || plugin.redirect_maps.is_empty() { + if !plugin.enabled { return Ok(Self::default()); } let mut plan = Self { + enabled: true, specifications: Vec::with_capacity(plugin.redirect_maps.len()), ..Self::default() }; - for (source, configured_target) in &plugin.redirect_maps { + validate_output( + config, + &"redirect.json".parse().expect("static site path"), + )?; + for (configured_source, configured_target) in &plugin.redirect_maps { + let (source, fragment) = split_fragment(configured_source); let source = normalize_source(source)?; - let output = PageRoute::destination( - &source, - config.project.use_directory_urls, - )?; - if !plan.outputs.insert(output.clone()) { - bail!("redirect output '{output}' is configured more than once") - } - validate_output(config, &output)?; if !MARKDOWN_SUFFIXES .iter() @@ -143,6 +158,29 @@ impl Plan { )); } + let source = if fragment.is_empty() { + let output = PageRoute::destination( + &source, + config.project.use_directory_urls, + )?; + if !plan.outputs.insert(output.clone()) { + bail!( + "redirect output '{output}' is configured more than once" + ) + } + validate_output(config, &output)?; + Source::Page(output) + } else { + let route = PageRoute::from_source(config, source)?; + let source = format!("{}{fragment}", route.url); + if !plan.anchors.insert(source.clone()) { + bail!( + "redirect source '{configured_source}' is configured more than once" + ) + } + Source::Anchor(source) + }; + let target = if is_external(configured_target) { Target::External(configured_target.clone()) } else { @@ -154,7 +192,7 @@ impl Plan { fragment: fragment.into(), } }; - plan.specifications.push(Specification { output, target }); + plan.specifications.push(Specification { source, target }); } Ok(plan) } @@ -168,7 +206,7 @@ impl Snapshot { plan: &Plan, page_routes: impl Iterator, use_directory_urls: bool, ) -> Result { - if plan.specifications.is_empty() { + if !plan.enabled { return Ok(Self::default()); } @@ -186,18 +224,22 @@ impl Snapshot { } let mut redirects = Vec::with_capacity(plan.specifications.len()); + let mut anchors = BTreeMap::new(); let mut warnings = plan.warnings.clone(); for specification in &plan.specifications { let target = match &specification.target { Target::External(target) => Some(target.clone()), Target::Internal { configured, source, fragment } => { if let Some(url) = routes.get(source) { - Some(relative_target( - &specification.output, - url, - fragment, - use_directory_urls, - )) + Some(match &specification.source { + Source::Page(output) => relative_target( + output, + url, + fragment, + use_directory_urls, + ), + Source::Anchor(_) => format!("{url}{fragment}"), + }) } else { warnings.push(format!( "Redirect target '{configured}' does not exist!" @@ -206,12 +248,22 @@ impl Snapshot { } } }; - redirects.push(Redirect { - output: specification.output.clone(), - target, - }); + match &specification.source { + Source::Page(output) => { + redirects.push(Redirect { output: output.clone(), target }); + } + Source::Anchor(source) => { + if let Some(target) = target { + anchors.insert(source.clone(), target); + } + } + } } - Ok(Self { redirects, warnings }) + Ok(Self { + redirects, + anchors: Some(anchors), + warnings, + }) } } @@ -297,7 +349,7 @@ fn is_external(target: &str) -> bool { target.starts_with("http://") || target.starts_with("https://") } -/// Splits an internal target into source URI and hash fragment. +/// Splits a configured URI into its path and hash fragment. fn split_fragment(target: &str) -> (&str, &str) { target .find('#') diff --git a/python/tests/integration/test_redirects.py b/python/tests/integration/test_redirects.py index 9c38882..cc4755d 100644 --- a/python/tests/integration/test_redirects.py +++ b/python/tests/integration/test_redirects.py @@ -25,6 +25,7 @@ from __future__ import annotations +import json import subprocess import sys import time @@ -86,19 +87,53 @@ def test_redirects_generate_mkdocs_compatible_artifacts(tmp_path: Path) -> None: '' in external ) assert "noindex" not in old + assert json.loads((tmp_path / "site" / "redirect.json").read_text()) == {} + + +def test_anchor_redirects_generate_site_manifest(tmp_path: Path) -> None: + """Anchor mappings retain live pages and use resolved public URLs.""" + config = _write_project( + tmp_path, + """\ + old.md: new.md + new.md#old: guide/topic.md#details + new.md#legacy: new.md#new + new.md#external: https://example.com/new#there + guide/topic.md#summary: new.md#new +""", + ) + zensical.build(str(config), {"clean": False, "strict": True}) + + assert (tmp_path / "site" / "old" / "index.html").is_file() + assert " None: """File-style URLs retain MkDocs' relative target calculation.""" - config = _write_project(tmp_path, " old.md: new.md\n") + config = _write_project( + tmp_path, + """\ + old.md: new.md + new.md#old: guide/topic.md#details +""", + ) with config.open("a", encoding="utf-8") as file: file.write("use_directory_urls: false\n") zensical.build(str(config), _BUILD_OPTIONS) old = (tmp_path / "site" / "old.html").read_text() assert '' in old + assert json.loads((tmp_path / "site" / "redirect.json").read_text()) == { + "new.html#old": "guide/topic.html#details" + } def test_missing_redirect_target_warns_and_strict_mode_fails(