From a6fdc5f030cf53e3f76087064ce6ad33c63e2c52 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Wed, 9 Sep 2026 21:44:06 +0200 Subject: [PATCH] feature: resolve anchor overrides in page redirects Signed-off-by: squidfunk --- .../compat/mkdocs/plugin/redirects/output.rs | 64 +++++++++++++++-- .../compat/mkdocs/plugin/redirects/plan.rs | 71 ++++++++++++++++--- python/tests/integration/test_redirects.py | 31 +++++++- 3 files changed, 148 insertions(+), 18 deletions(-) diff --git a/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs b/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs index e0593fd..9ccfc63 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs @@ -26,6 +26,7 @@ //! Redirect output. use anyhow::{bail, Result}; +use std::collections::BTreeMap; use std::fs; use std::io::{BufWriter, Write}; @@ -34,21 +35,27 @@ use crate::path::OutputRoot; use super::plan::Snapshot; /// Redirect document emitted by mkdocs-redirects 1.2.2. -const HTML_TEMPLATE: &str = r##" +const HTML_TEMPLATE: &str = r#" Redirecting... - + You're being redirected to a new destination. -"##; +"#; + +/// Upstream-compatible redirect script. +const SCRIPT: &str = r##"var anchor=window.location.hash.substr(1);location.href="{url}"+(anchor?"#"+anchor:"")"##; + +/// Redirect script with fragment-specific destinations. +const SCRIPT_WITH_FRAGMENTS: &str = r#"var anchor=window.location.hash,redirects={redirects},target;for(var source in redirects)if(new URL(source,location.href).hash===anchor){target=redirects[source];break}location.href=target||"{url}"+anchor"#; // ---------------------------------------------------------------------------- // Functions @@ -66,7 +73,7 @@ pub fn write( let path = output.join(&redirect.output); if let Some(target) = &redirect.target { fs::create_dir_all(path.parent().expect("redirect has parent"))?; - fs::write(path, render(target))?; + fs::write(path, render(target, &redirect.fragments))?; } else if path.is_file() { fs::remove_file(path)?; } @@ -89,8 +96,20 @@ pub fn write( } /// Renders the upstream-compatible redirect document. -fn render(target: &str) -> String { - HTML_TEMPLATE.replace("{url}", target) +fn render(target: &str, fragments: &BTreeMap) -> String { + let script = if fragments.is_empty() { + SCRIPT.replace("{url}", target) + } else { + let fragments = serde_json::to_string(fragments) + .expect("redirect fragments are strings") + .replace("new destination. ); } + #[test] + fn renders_fragment_specific_destinations() { + let html = render( + "../new/", + &BTreeMap::from([( + "#install".into(), + "../guides/install/#linux".into(), + )]), + ); + assert_eq!( + html, + r##" + + + + + Redirecting... + + + + + +You're being redirected to a new destination. + + +"## + ); + } + #[test] fn removes_a_stale_redirect_when_its_target_disappears() { let directory = tempfile::tempdir().unwrap(); @@ -136,6 +184,7 @@ You're being redirected to a new destination. redirects: vec![Redirect { output: output.clone(), target: Some("../new/".into()), + fragments: BTreeMap::new(), }], anchors: Some(BTreeMap::new()), warnings: Vec::new(), @@ -148,6 +197,7 @@ You're being redirected to a new destination. redirects: vec![Redirect { output: output.clone(), target: None, + fragments: BTreeMap::new(), }], anchors: Some(BTreeMap::new()), warnings: vec!["missing".into()], diff --git a/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs b/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs index 054efcd..7056f1d 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs @@ -64,8 +64,15 @@ enum Target { enum Source { /// Page redirect emitted as a physical HTML artifact. Page(SitePath), - /// Anchor redirect emitted into the site-wide redirect manifest. - Anchor(String), + /// Anchor redirect emitted into the manifest and matching redirect page. + Anchor { + /// Site-relative source URL including its fragment. + url: String, + /// Physical redirect output for the source page. + output: SitePath, + /// Source fragment including its leading `#`. + fragment: String, + }, } // ---------------------------------------------------------------------------- @@ -98,6 +105,8 @@ pub struct Redirect { pub output: SitePath, /// Resolved redirect target, or `None` when the target is missing. pub target: Option, + /// Fragment-specific targets relative to this redirect output. + pub fragments: BTreeMap, } // ---------------------------------------------------------------------------- @@ -172,13 +181,17 @@ impl Plan { Source::Page(output) } else { let route = PageRoute::from_source(config, source)?; - let source = format!("{}{fragment}", route.url); - if !plan.anchors.insert(source.clone()) { + let url = format!("{}{fragment}", route.url); + if !plan.anchors.insert(url.clone()) { bail!( "redirect source '{configured_source}' is configured more than once" ) } - Source::Anchor(source) + Source::Anchor { + url, + output: route.destination, + fragment: fragment.into(), + } }; let target = if is_external(configured_target) { @@ -225,6 +238,8 @@ impl Snapshot { let mut redirects = Vec::with_capacity(plan.specifications.len()); let mut anchors = BTreeMap::new(); + let mut fragments = + BTreeMap::>::new(); let mut warnings = plan.warnings.clone(); for specification in &plan.specifications { let target = match &specification.target { @@ -238,7 +253,9 @@ impl Snapshot { fragment, use_directory_urls, ), - Source::Anchor(_) => format!("{url}{fragment}"), + Source::Anchor { .. } => { + format!("{url}{fragment}") + } }) } else { warnings.push(format!( @@ -250,15 +267,51 @@ impl Snapshot { }; match &specification.source { Source::Page(output) => { - redirects.push(Redirect { output: output.clone(), target }); + redirects.push(Redirect { + output: output.clone(), + target, + fragments: BTreeMap::new(), + }); } - Source::Anchor(source) => { + Source::Anchor { + url, + output, + fragment: source_fragment, + } => { if let Some(target) = target { - anchors.insert(source.clone(), target); + anchors.insert(url.clone(), target); + + // A physical redirect page loads before the UI, so it + // must resolve its own fragment-specific destinations. + if plan.outputs.contains(output) { + let target = match &specification.target { + Target::External(target) => target.clone(), + Target::Internal { + source, + fragment: target_fragment, + .. + } => relative_target( + output, + routes + .get(source) + .expect("resolved target"), + target_fragment, + use_directory_urls, + ), + }; + fragments + .entry(output.clone()) + .or_default() + .insert(source_fragment.clone(), target); + } } } } } + for redirect in &mut redirects { + redirect.fragments = + fragments.remove(&redirect.output).unwrap_or_default(); + } Ok(Self { redirects, anchors: Some(anchors), diff --git a/python/tests/integration/test_redirects.py b/python/tests/integration/test_redirects.py index cc4755d..77b2a0f 100644 --- a/python/tests/integration/test_redirects.py +++ b/python/tests/integration/test_redirects.py @@ -114,6 +114,32 @@ def test_anchor_redirects_generate_site_manifest(tmp_path: Path) -> None: } +def test_page_redirects_override_configured_anchor_targets( + tmp_path: Path, +) -> None: + """Physical redirects send configured fragments to their own targets.""" + config = _write_project( + tmp_path, + """\ + old.md: new.md + old.md#install: guide/topic.md#details + old.md#external: https://example.com/new#there +""", + ) + zensical.build(str(config), {"clean": False, "strict": True}) + + old = (tmp_path / "site" / "old" / "index.html").read_text() + assert ( + 'redirects={"#external":"https://example.com/new#there",' + '"#install":"../guide/topic/#details"}' in old + ) + assert 'location.href=target||"../new/"+anchor' in old + assert json.loads((tmp_path / "site" / "redirect.json").read_text()) == { + "old/#external": "https://example.com/new#there", + "old/#install": "guide/topic/#details", + } + + def test_redirects_without_directory_urls_write_html_files( tmp_path: Path, ) -> None: @@ -122,7 +148,7 @@ def test_redirects_without_directory_urls_write_html_files( tmp_path, """\ old.md: new.md - new.md#old: guide/topic.md#details + old.md#old: guide/topic.md#details """, ) with config.open("a", encoding="utf-8") as file: @@ -131,8 +157,9 @@ def test_redirects_without_directory_urls_write_html_files( old = (tmp_path / "site" / "old.html").read_text() assert '' in old + assert 'redirects={"#old":"guide/topic.html#details"}' in old assert json.loads((tmp_path / "site" / "redirect.json").read_text()) == { - "new.html#old": "guide/topic.html#details" + "old.html#old": "guide/topic.html#details" }