feature: support anchor redirects

Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
squidfunk
2026-09-09 21:24:33 +02:00
parent 82ece74436
commit 0b1a3780bc
3 changed files with 147 additions and 27 deletions
@@ -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 <a href="../new/">new destination</a>.
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 <a href="../new/">new destination</a>.
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"}"#
);
}
}
@@ -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<SitePath>,
/// Anchor sources reserved by configured redirects.
anchors: BTreeSet<String>,
/// Internal source paths needed from the live route relation.
targets: BTreeSet<String>,
/// 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<Redirect>,
/// Anchor redirects keyed by their resolved source URL.
pub anchors: Option<BTreeMap<String, String>>,
/// Compatibility warnings emitted for this snapshot.
pub warnings: Vec<String>,
}
@@ -115,24 +132,22 @@ impl Plan {
/// Validates route-independent configuration once for the workflow.
pub fn new(config: &Config) -> Result<Self> {
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<Item = &'a PageRoute>,
use_directory_urls: bool,
) -> Result<Self> {
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('#')
+36 -1
View File
@@ -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:
'<link rel="canonical" href="https://example.com/new?q=1">' 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 "<h1" in (tmp_path / "site" / "new" / "index.html").read_text()
assert json.loads((tmp_path / "site" / "redirect.json").read_text()) == {
"guide/topic/#summary": "new/#new",
"new/#external": "https://example.com/new#there",
"new/#legacy": "new/#new",
"new/#old": "guide/topic/#details",
}
def test_redirects_without_directory_urls_write_html_files(
tmp_path: Path,
) -> 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 '<link rel="canonical" href="new.html">' 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(