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("", "<\\/");
+ SCRIPT_WITH_FRAGMENTS
+ .replace("{url}", target)
+ .replace("{redirects}", &fragments)
+ };
+ HTML_TEMPLATE
+ .replace("{url}", target)
+ .replace("{script}", &script)
}
// ----------------------------------------------------------------------------
@@ -107,7 +126,7 @@ mod tests {
#[test]
fn renders_upstream_document() {
- let html = render("../new/");
+ let html = render("../new/", &BTreeMap::new());
assert_eq!(
html,
r##"
@@ -128,6 +147,35 @@ You're being redirected to a 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"
}