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(