diff --git a/crates/zensical/src/compat/mkdocs/plugin.rs b/crates/zensical/src/compat/mkdocs/plugin.rs
index 7a0d333..1cd698f 100644
--- a/crates/zensical/src/compat/mkdocs/plugin.rs
+++ b/crates/zensical/src/compat/mkdocs/plugin.rs
@@ -15,6 +15,7 @@ use crate::structure::markdown::Markdown;
pub mod autorefs;
pub mod meta;
pub mod mkdocstrings;
+pub mod redirects;
pub mod search;
// ----------------------------------------------------------------------------
diff --git a/crates/zensical/src/compat/mkdocs/plugin/redirects.rs b/crates/zensical/src/compat/mkdocs/plugin/redirects.rs
new file mode 100644
index 0000000..6710eb5
--- /dev/null
+++ b/crates/zensical/src/compat/mkdocs/plugin/redirects.rs
@@ -0,0 +1,485 @@
+// Copyright (c) 2025-2026 Zensical and contributors
+
+// SPDX-License-Identifier: MIT
+// All contributions are certified under the DCO
+
+//! MkDocs-compatible redirects plugin.
+
+use anyhow::{bail, Result};
+use std::collections::{BTreeMap, BTreeSet};
+use std::fs;
+use std::path::{Component, Path};
+use zrx::id::Id;
+use zrx::stream::{Key, Signal, Value};
+
+use crate::compat::mkdocs::plugin::meta;
+use crate::config::Config;
+use crate::structure::page::PageRoute;
+
+// ----------------------------------------------------------------------------
+// Constants
+// ----------------------------------------------------------------------------
+
+/// Redirect document emitted by mkdocs-redirects 1.2.2.
+const HTML_TEMPLATE: &str = r##"
+
+
+
+
+ Redirecting...
+
+
+
+
+
+You're being redirected to a new destination.
+
+
+"##;
+
+/// Source suffixes recognized by MkDocs as Markdown.
+const MARKDOWN_SUFFIXES: &[&str] =
+ &[".markdown", ".mdown", ".mkdn", ".mkd", ".md"];
+
+// ----------------------------------------------------------------------------
+// Structs
+// ----------------------------------------------------------------------------
+
+/// One resolved redirect output.
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct Redirect {
+ /// Site-relative output path.
+ output: String,
+ /// Resolved redirect target, or `None` when the target is missing.
+ target: Option,
+}
+
+/// One validated configuration entry awaiting target resolution.
+struct Specification<'a> {
+ /// Site-relative output path.
+ output: String,
+ /// Configured internal or external target.
+ target: &'a str,
+}
+
+/// Revision-settled redirect outputs and warnings.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub(crate) struct Snapshot {
+ /// Redirects ordered by configured source URI.
+ redirects: Vec,
+ /// Compatibility warnings emitted for this snapshot.
+ warnings: Vec,
+}
+
+impl Value for Snapshot {}
+
+// ----------------------------------------------------------------------------
+// Implementations
+// ----------------------------------------------------------------------------
+
+impl Snapshot {
+ /// Resolves configured redirects against the current page relation.
+ pub(crate) fn new<'a>(
+ config: &Config,
+ page_routes: impl Iterator- , &'a PageRoute)>,
+ ) -> Result {
+ let settings = &config.project.plugins.redirects.config;
+ if !settings.enabled || settings.redirect_maps.is_empty() {
+ return Ok(Self::default());
+ }
+
+ let mut outputs = BTreeSet::new();
+ let mut targets = BTreeSet::new();
+ let mut specifications =
+ Vec::with_capacity(settings.redirect_maps.len());
+ let mut warnings = Vec::new();
+ for (source, configured_target) in &settings.redirect_maps {
+ let source = normalize_source(source)?;
+ let output = PageRoute::destination(
+ &source,
+ config.project.use_directory_urls,
+ );
+ if !outputs.insert(output.clone()) {
+ bail!("redirect output '{output}' is configured more than once")
+ }
+ validate_output(config, &output)?;
+
+ if !MARKDOWN_SUFFIXES
+ .iter()
+ .any(|suffix| source.ends_with(suffix))
+ {
+ warnings.push(format!(
+ "redirects plugin: '{source}' is not a valid markdown file!"
+ ));
+ }
+
+ if !is_external(configured_target) {
+ targets.insert(split_fragment(configured_target).0);
+ }
+ specifications.push(Specification {
+ output,
+ target: configured_target,
+ });
+ }
+
+ let mut routes = BTreeMap::new();
+ for (_, route) in page_routes {
+ if targets.contains(route.source.as_str()) {
+ routes.insert(route.source.clone(), route.url.clone());
+ }
+ if outputs.contains(&route.destination) {
+ bail!(
+ "redirect output '{}' collides with a page",
+ route.destination
+ )
+ }
+ }
+
+ let mut redirects = Vec::with_capacity(specifications.len());
+ for specification in specifications {
+ let target = if is_external(specification.target) {
+ Some(specification.target.into())
+ } else {
+ let (target_source, fragment) =
+ split_fragment(specification.target);
+ if let Some(url) = routes.get(target_source) {
+ Some(relative_target(
+ &specification.output,
+ url,
+ fragment,
+ config.project.use_directory_urls,
+ ))
+ } else {
+ warnings.push(format!(
+ "Redirect target '{}' does not exist!",
+ specification.target
+ ));
+ None
+ }
+ };
+ redirects.push(Redirect {
+ output: specification.output,
+ target,
+ });
+ }
+ Ok(Self { redirects, warnings })
+ }
+}
+
+// ----------------------------------------------------------------------------
+// Functions
+// ----------------------------------------------------------------------------
+
+/// Attaches redirect artifact generation to the settled site graph.
+pub(crate) fn attach(
+ config: &Config, strict: bool, snapshot: &Signal,
+) {
+ let settings = &config.project.plugins.redirects.config;
+ if !settings.enabled || settings.redirect_maps.is_empty() {
+ return;
+ }
+ let site_dir = config.get_site_dir();
+ let _ = snapshot
+ .map(move |snapshot: &Snapshot| write(&site_dir, snapshot, strict));
+}
+
+/// Reconciles one redirect snapshot with the site directory.
+fn write(site_dir: &Path, snapshot: &Snapshot, strict: bool) -> Result<()> {
+ for redirect in &snapshot.redirects {
+ let path = site_dir.join(&redirect.output);
+ if let Some(target) = &redirect.target {
+ fs::create_dir_all(path.parent().expect("redirect has parent"))?;
+ fs::write(path, redirect_html(target))?;
+ } else if path.is_file() {
+ fs::remove_file(path)?;
+ }
+ }
+ for warning in &snapshot.warnings {
+ eprintln!("WARNING - {warning}");
+ }
+ if strict && !snapshot.warnings.is_empty() {
+ bail!("Aborted because --strict flag is set")
+ }
+ Ok(())
+}
+
+/// Rejects redirect sources that could escape the site directory.
+fn normalize_source(source: &str) -> Result {
+ if source.is_empty() || source.contains('\\') {
+ bail!("redirect source '{source}' is not a safe relative path")
+ }
+ let mut parts = Vec::new();
+ for component in Path::new(source).components() {
+ match component {
+ Component::Normal(part) => {
+ parts.push(part.to_string_lossy().into_owned());
+ }
+ Component::CurDir => {}
+ Component::ParentDir
+ | Component::RootDir
+ | Component::Prefix(_) => {
+ bail!("redirect source '{source}' is not a safe relative path")
+ }
+ }
+ }
+ if parts.is_empty() {
+ bail!("redirect source '{source}' is not a safe relative path")
+ }
+ Ok(parts.join("/"))
+}
+
+/// Rejects redirect paths already owned by another output producer.
+fn validate_output(config: &Config, output: &str) -> Result<()> {
+ let extra_templates = &config.project.extra_templates;
+ let meta = meta::Settings::new(config);
+ let docs_asset = config.get_docs_dir().join(output);
+ if docs_asset.is_file()
+ && !meta::claims(output, &meta)
+ && !extra_templates.iter().any(|template| template == output)
+ {
+ bail!("redirect output '{output}' collides with a documentation asset")
+ }
+
+ if config.theme_dirs.iter().any(|directory| {
+ let path = directory.join(output);
+ path.is_file() && path.extension().is_none_or(|ext| ext != "html")
+ }) {
+ bail!("redirect output '{output}' collides with a theme asset")
+ }
+
+ let templates = config
+ .project
+ .theme
+ .static_templates
+ .iter()
+ .chain(extra_templates);
+ if templates
+ .filter_map(|template| Path::new(template).file_name())
+ .any(|name| {
+ Path::new(output).file_name().is_some_and(|out| out == name)
+ && Path::new(output)
+ .parent()
+ .is_none_or(|parent| parent.as_os_str().is_empty())
+ })
+ {
+ bail!("redirect output '{output}' collides with a rendered template")
+ }
+ Ok(())
+}
+
+/// Returns whether a configured target is an external HTTP(S) URL.
+fn is_external(target: &str) -> bool {
+ let target = target.to_ascii_lowercase();
+ target.starts_with("http://") || target.starts_with("https://")
+}
+
+/// Splits an internal target into source URI and hash fragment.
+fn split_fragment(target: &str) -> (&str, &str) {
+ target
+ .find('#')
+ .map_or((target, ""), |index| (&target[..index], &target[index..]))
+}
+
+/// Makes a final page URL relative to one redirect output.
+fn relative_target(
+ output: &str, target: &str, fragment: &str, use_directory_urls: bool,
+) -> String {
+ let parent = Path::new(output).parent().unwrap_or_else(|| Path::new(""));
+ let mut relative = relative_path(Path::new(target), parent);
+ if use_directory_urls {
+ relative.push('/');
+ }
+ relative.push_str(fragment);
+ relative
+}
+
+/// Computes a lexical POSIX-style relative path.
+fn relative_path(target: &Path, base: &Path) -> String {
+ let target = target
+ .components()
+ .filter_map(|component| match component {
+ Component::Normal(part) => Some(part),
+ _ => None,
+ })
+ .collect::>();
+ let base = base
+ .components()
+ .filter_map(|component| match component {
+ Component::Normal(part) => Some(part),
+ _ => None,
+ })
+ .collect::>();
+ let common = target
+ .iter()
+ .zip(&base)
+ .take_while(|(left, right)| left == right)
+ .count();
+ let mut parts = vec!["..".into(); base.len() - common];
+ parts.extend(
+ target[common..]
+ .iter()
+ .map(|part| part.to_string_lossy().into_owned()),
+ );
+ if parts.is_empty() {
+ ".".into()
+ } else {
+ parts.join("/")
+ }
+}
+
+/// Renders the upstream redirect document.
+fn redirect_html(target: &str) -> String {
+ HTML_TEMPLATE.replace("{url}", target)
+}
+
+// ----------------------------------------------------------------------------
+// Tests
+// ----------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn matches_upstream_relative_targets() {
+ let directory_cases = [
+ ("old/index.html", "", "", "../"),
+ ("old/index.html", "new/", "", "../new/"),
+ ("foo/old/index.html", "foo/new/", "", "../new/"),
+ (
+ "foo/fizz/old/index.html",
+ "foo/bar/new/",
+ "",
+ "../../bar/new/",
+ ),
+ (
+ "fizz/old/index.html",
+ "foo/bar/new/",
+ "",
+ "../../foo/bar/new/",
+ ),
+ ("foo/index.html", "foo/", "", "./"),
+ (
+ "foo/index.html",
+ "fake/destination/",
+ "",
+ "../fake/destination/",
+ ),
+ ("old/index.html", "new/", "#hash", "../new/#hash"),
+ ("foo/index.html", "foo/", "#hash", "./#hash"),
+ ("old/index.html", "100%25/", "", "../100%25/"),
+ ];
+ for (output, target, fragment, expected) in directory_cases {
+ assert_eq!(
+ relative_target(output, target, fragment, true),
+ expected
+ );
+ }
+
+ let file_cases = [
+ ("old.html", "index.html", "", "index.html"),
+ ("old.html", "new.html", "", "new.html"),
+ ("foo/old.html", "foo/new.html", "", "new.html"),
+ (
+ "foo/fizz/old.html",
+ "foo/bar/new.html",
+ "",
+ "../bar/new.html",
+ ),
+ (
+ "fizz/old.html",
+ "foo/bar/new.html",
+ "",
+ "../foo/bar/new.html",
+ ),
+ ("foo.html", "foo/index.html", "", "foo/index.html"),
+ ("old.html", "new.html", "#hash", "new.html#hash"),
+ ];
+ for (output, target, fragment, expected) in file_cases {
+ assert_eq!(
+ relative_target(output, target, fragment, false),
+ expected
+ );
+ }
+ }
+
+ #[test]
+ fn matches_upstream_redirect_output_paths() {
+ let cases = [
+ ("old.md", "old.html", "old/index.html"),
+ ("README.md", "index.html", "index.html"),
+ ("100%.md", "100%.html", "100%/index.html"),
+ (
+ "foo/fizz/old.md",
+ "foo/fizz/old.html",
+ "foo/fizz/old/index.html",
+ ),
+ (
+ "foo/fizz/index.md",
+ "foo/fizz/index.html",
+ "foo/fizz/index.html",
+ ),
+ ];
+ for (source, file, directory) in cases {
+ assert_eq!(PageRoute::destination(source, false), file);
+ assert_eq!(PageRoute::destination(source, true), directory);
+ }
+ }
+
+ #[test]
+ fn rejects_unsafe_sources() {
+ for source in ["", "../old.md", "/old.md", "old\\page.md"] {
+ assert!(normalize_source(source).is_err(), "{source}");
+ }
+ assert_eq!(normalize_source("./old/page.md").unwrap(), "old/page.md");
+ }
+
+ #[test]
+ fn renders_upstream_document() {
+ let html = redirect_html("../new/");
+ 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();
+ let output = String::from("old/index.html");
+ let valid = Snapshot {
+ redirects: vec![Redirect {
+ output: output.clone(),
+ target: Some("../new/".into()),
+ }],
+ warnings: Vec::new(),
+ };
+ write(directory.path(), &valid, false).unwrap();
+ assert!(directory.path().join(&output).is_file());
+
+ let missing = Snapshot {
+ redirects: vec![Redirect {
+ output: output.clone(),
+ target: None,
+ }],
+ warnings: vec!["missing".into()],
+ };
+ write(directory.path(), &missing, false).unwrap();
+ assert!(!directory.path().join(output).exists());
+ assert!(write(directory.path(), &missing, true).is_err());
+ }
+}
diff --git a/crates/zensical/src/config/plugins.rs b/crates/zensical/src/config/plugins.rs
index 9406b91..9d5e92b 100644
--- a/crates/zensical/src/config/plugins.rs
+++ b/crates/zensical/src/config/plugins.rs
@@ -27,6 +27,7 @@
use pyo3::FromPyObject;
use serde::Serialize;
+use std::collections::BTreeMap;
// ----------------------------------------------------------------------------
// Structs
@@ -48,6 +49,8 @@ pub struct Plugins {
pub search: SearchPlugin,
/// Material meta plugin.
pub meta: MetaPlugin,
+ /// Redirects plugin.
+ pub redirects: RedirectsPlugin,
/// Offline plugin.
pub offline: OfflinePlugin,
}
@@ -74,6 +77,26 @@ pub struct MetaPluginConfig {
// ----------------------------------------------------------------------------
+/// Redirects plugin.
+#[derive(Clone, Debug, Hash, FromPyObject, Serialize)]
+#[pyo3(from_item_all)]
+pub struct RedirectsPlugin {
+ /// Plugin configuration.
+ pub config: RedirectsPluginConfig,
+}
+
+/// Redirects plugin configuration.
+#[derive(Clone, Debug, Hash, FromPyObject, Serialize)]
+#[pyo3(from_item_all)]
+pub struct RedirectsPluginConfig {
+ /// Whether redirects are enabled.
+ pub enabled: bool,
+ /// Source-to-target redirect mappings.
+ pub redirect_maps: BTreeMap,
+}
+
+// ----------------------------------------------------------------------------
+
/// Search plugin.
#[derive(Clone, Debug, Hash, FromPyObject, Serialize)]
#[pyo3(from_item_all)]
diff --git a/crates/zensical/src/structure/page.rs b/crates/zensical/src/structure/page.rs
index 15e1bac..f02e46d 100644
--- a/crates/zensical/src/structure/page.rs
+++ b/crates/zensical/src/structure/page.rs
@@ -26,7 +26,7 @@
//! Page.
use minijinja::{context, Error, Value as TemplateValue};
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::ops::Deref;
use std::path::PathBuf;
@@ -47,6 +47,23 @@ use super::tag::Tag;
// Structs
// ----------------------------------------------------------------------------
+/// Stable route facts derived from one Markdown source.
+#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)]
+pub(crate) struct PageRoute {
+ /// Documentation-relative source URI.
+ pub source: String,
+ /// Site-relative destination URI.
+ pub destination: String,
+ /// Encoded page URL.
+ pub url: String,
+ /// Absolute destination path.
+ pub path: String,
+}
+
+impl Value for PageRoute {}
+
+// ----------------------------------------------------------------------------
+
/// Immutable page data shared between scheduler branches.
///
/// Page values are cloned by the scheduler as they fan out into navigation,
@@ -91,70 +108,51 @@ pub struct Page {
// Implementations
// ----------------------------------------------------------------------------
+impl PageRoute {
+ /// Computes route facts for a source identifier.
+ pub(crate) fn new(config: &Config, id: &Id) -> Self {
+ Self::from_source(config, &id.location())
+ }
+
+ /// Computes route facts for a documentation-relative source URI.
+ pub(crate) fn from_source(config: &Config, source: &str) -> Self {
+ let destination =
+ Self::destination(source, config.project.use_directory_urls);
+ let url = route_url(&destination, config.project.use_directory_urls);
+ let path = config.get_site_dir().join(&destination);
+ Self {
+ source: source.into(),
+ destination,
+ url,
+ path: path.to_string_lossy().into_owned(),
+ }
+ }
+
+ /// Computes the site-relative destination for a Markdown source.
+ pub(crate) fn destination(
+ source: &str, use_directory_urls: bool,
+ ) -> String {
+ destination(source, use_directory_urls)
+ }
+}
+
+// ----------------------------------------------------------------------------
+
impl Page {
/// Creates a page.
#[allow(clippy::similar_names)]
- pub fn new(config: &Config, id: &Id, markdown: Markdown) -> Page {
- let root_dir = config.get_root_dir();
-
- // Retrieve site directory and URL
- let site_dir = config.project.site_dir.clone();
+ pub(crate) fn new(
+ config: &Config, route: PageRoute, markdown: Markdown,
+ ) -> Page {
+ // Retrieve site URL
let site_url = config.project.site_url.clone();
// Retrieve repository URL and edit URI
let repo_url = config.project.repo_url.clone();
let edit_uri = config.project.edit_uri.clone();
- // Determine whether to use directory URLs
- let use_directory_urls = config.project.use_directory_urls;
- let file_uri = id.location().into_owned();
-
- // Create identifier builder, as we need to change the context in order
- // to copy the file over to the site directory
- let builder = id.to_builder().context(&site_dir);
- let id = builder.clone().build().expect("invariant");
-
- // Next, obtain the path, and check whether it is an index file, which
- // is true for index.md, as well as README.md, as MkDocs handles both
- let mut path: PathBuf = id.location().to_string().into();
- let is_index =
- path.ends_with("index.md") || path.ends_with("README.md");
-
- // Ensure that README.md files are treated as index files
- if path.ends_with("README.md") {
- path.pop();
- path = path.join("index.md");
- }
-
- // If directory URLs should not be used, and the page is an index page,
- // we need to adjust the path accordingly
- if !use_directory_urls || is_index {
- path.set_extension("html");
- } else {
- path.set_extension("");
- path.push("index.html");
- }
-
- // Set computed path in id, and compute final target path - once we add
- // more convenience function to the id crate, we can make this shorter
- let path = path.to_string_lossy().into_owned();
- let id = builder
- .location(path.replace('\\', "/"))
- .build()
- .expect("invariant");
-
- // Compute URL of page, and strip the index.html suffix in case
- // directory URLs should be used. The URL is relative.
- let url = id.as_uri().to_string();
- let url = if use_directory_urls {
- url.trim_end_matches("index.html").to_string()
- } else {
- url
- };
-
- // Ensure path encoding, and compute canonical URL. Note that we should
- // definitely rethink this interface, it's a little inconvenient
- let url = Uri::from(url.as_ref()).to_string();
+ // Compute canonical URL
+ let url = route.url;
let canonical_url = site_url.as_ref().map(|base| {
let base = base.trim_end_matches('/');
format!("{base}/{url}")
@@ -165,9 +163,9 @@ impl Page {
let edit_url = repo_url.clone().and_then(|repo_url| {
edit_uri.clone().map(|uri| {
if uri.starts_with("https://") {
- format!("{uri}/{file_uri}")
+ format!("{uri}/{}", route.source)
} else {
- format!("{repo_url}/{uri}/{file_uri}")
+ format!("{repo_url}/{uri}/{}", route.source)
}
})
});
@@ -176,13 +174,12 @@ impl Page {
// pages are populated when the navigation is created. This is also a
// hint that it's not a good idea to centralize all propeties in a
// single struct, but to split up the page as necessary later on.
- let path = root_dir.join(id.to_path());
Page {
data: Arc::new(PageData {
url,
canonical_url,
edit_url,
- path: path.to_string_lossy().into_owned(),
+ path: route.path,
markdown,
}),
ancestors: Vec::new(),
@@ -240,6 +237,35 @@ impl Page {
}
}
+// ----------------------------------------------------------------------------
+
+/// Computes the site-relative destination for a Markdown source.
+fn destination(source: &str, use_directory_urls: bool) -> String {
+ let mut path = PathBuf::from(source);
+ let is_index = path.ends_with("index.md") || path.ends_with("README.md");
+ if path.ends_with("README.md") {
+ path.pop();
+ path.push("index.md");
+ }
+ if !use_directory_urls || is_index {
+ path.set_extension("html");
+ } else {
+ path.set_extension("");
+ path.push("index.html");
+ }
+ path.to_string_lossy().replace('\\', "/")
+}
+
+/// Computes the encoded URL for a site-relative destination.
+fn route_url(destination: &str, use_directory_urls: bool) -> String {
+ let url = if use_directory_urls {
+ destination.trim_end_matches("index.html")
+ } else {
+ destination
+ };
+ Uri::from(url).to_string()
+}
+
// ----------------------------------------------------------------------------
// Trait implementations
// ----------------------------------------------------------------------------
@@ -334,6 +360,21 @@ mod tests {
assert!(Arc::ptr_eq(&page.data, &clone.data));
}
+ #[test]
+ fn computes_mkdocs_destinations() {
+ assert_eq!(destination("index.md", true), "index.html");
+ assert_eq!(destination("README.md", true), "index.html");
+ assert_eq!(destination("guide/README.md", true), "guide/index.html");
+ assert_eq!(destination("guide/page.md", true), "guide/page/index.html");
+ assert_eq!(destination("guide/page.md", false), "guide/page.html");
+ }
+
+ #[test]
+ fn computes_encoded_urls() {
+ assert_eq!(route_url("100%/index.html", true), "100%25/");
+ assert_eq!(route_url("100%.html", false), "100%25.html");
+ }
+
#[test]
fn serialization_keeps_flat_page_shape() {
let value = serde_json::to_value(page()).unwrap();
diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs
index 0966e8e..272048e 100644
--- a/crates/zensical/src/workflow.rs
+++ b/crates/zensical/src/workflow.rs
@@ -29,7 +29,7 @@ use regex::Regex;
use serde::{Deserialize, Serialize};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::ops::Deref;
-use std::path::{Path, PathBuf};
+use std::path::Path;
use std::str::FromStr;
use std::sync::{Arc, LazyLock, OnceLock};
use std::{fs, io};
@@ -42,12 +42,12 @@ use zrx::stream::{
};
use super::compat::mkdocs::plugin::{
- self, autorefs, meta, mkdocstrings, search,
+ self, autorefs, meta, mkdocstrings, redirects, search,
};
use super::config::Config;
use super::structure::markdown::Markdown;
use super::structure::nav::Navigation;
-use super::structure::page::Page;
+use super::structure::page::{Page, PageRoute};
use super::template::Template;
use super::watcher::Source;
@@ -145,9 +145,24 @@ impl Value for SitePage {}
// ----------------------------------------------------------------------------
+/// Markdown source paired with route facts available before rendering.
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct RoutedMarkdown {
+ /// Source and revision-local facts supplied by the provider.
+ input: Input,
+ /// Route derived without parsing or rendering Markdown.
+ route: PageRoute,
+}
+
+impl Value for RoutedMarkdown {}
+
+// ----------------------------------------------------------------------------
+
/// Cached output of rendering one Markdown source.
#[derive(Clone, Debug, Serialize, Deserialize)]
struct RenderedMarkdown {
+ /// Route computed once before Markdown rendering.
+ route: PageRoute,
/// Rendered Markdown consumed by page construction.
markdown: Markdown,
/// Page-local registrations consumed during site settlement.
@@ -191,7 +206,16 @@ impl Main {
// Set up workflow to process static assets and Markdown files.
process_theme_assets(&self.config, &files);
process_assets(&self.config, &files, &meta);
- let rendered = process_markdown(&self.config, &files);
+ let markdown = route_markdown(&self.config, &files);
+
+ // Redirects depend on routes, not rendered Markdown. Settle their
+ // compact input independently so they can proceed concurrently with
+ // the Python rendering branch.
+ let routes = markdown.map(|input: &RoutedMarkdown| input.route.clone());
+ let redirects = generate_redirects(&self.config, &routes);
+ redirects::attach(&self.config, self.strict, &redirects);
+
+ let rendered = process_markdown(&self.config, &markdown);
// Cross the one global settlement boundary, derive all site-wide
// state, then expand the resulting batch into independent page work.
@@ -388,10 +412,10 @@ fn copy_file(
io::copy(&mut from, &mut to).map(|_| ())
}
-/// Create a stream to process Markdown files.
-fn process_markdown(
+/// Select Markdown sources and derive their routes before rendering.
+fn route_markdown(
config: &Config, files: &Stream,
-) -> Stream {
+) -> Stream {
let matcher = Arc::new(
Matcher::from_str(&format!(
"zrs::::{}:**/*.md:",
@@ -399,63 +423,40 @@ fn process_markdown(
))
.expect("invariant"),
);
-
- // Create pipeline to render Markdown files
- let plugins = plugin::Settings::new(config);
let config = config.clone();
files
.filter(move |id: &Id| matcher.is_match(id).expect("invariant"))
+ .map(move |id: &Id, input: &Input| RoutedMarkdown {
+ input: input.clone(),
+ route: PageRoute::new(&config, id),
+ })
+}
+
+/// Create a stream to process routed Markdown files.
+fn process_markdown(
+ config: &Config, routed: &Stream,
+) -> Stream {
+ // Create pipeline to render Markdown files
+ let plugins = plugin::Settings::new(config);
+ let config = config.clone();
+ routed
// Render Markdown if we don't have a recent cached version at our own
// disposal. Otherwise, just return that if the content did not change.
// Note that we need to limit concurrency here, or we'll overwhelm the
// Python interpreter with all tasks competing for the GIL.
- .map(concurrent(1, move |id: &Id, source: &Input| {
+ .map(concurrent(1, move |id: &Id, routed: &RoutedMarkdown| {
let location = id.location().into_owned();
- let data = fs::read_to_string(&*source.path)?;
+ let data = fs::read_to_string(&*routed.input.path)?;
+ let route = routed.route.clone();
let (data, page_meta) = meta::front_matter(&location, &data)?;
- let resolved = source.metadata.resolve(&location, page_meta)?;
-
- // Compute URL using same logic as Page::new()
- let site_dir = config.project.site_dir.clone();
- let use_directory_urls = config.project.use_directory_urls;
-
- let builder = id.to_builder().context(&site_dir);
- let url_id = builder.clone().build().expect("invariant");
-
- let mut url_path: PathBuf = url_id.location().to_string().into();
- let is_index = url_path.ends_with("index.md")
- || url_path.ends_with("README.md");
-
- if url_path.ends_with("README.md") {
- url_path.pop();
- url_path = url_path.join("index.md");
- }
-
- if !use_directory_urls || is_index {
- url_path.set_extension("html");
- } else {
- url_path.set_extension("");
- url_path.push("index.html");
- }
-
- let url_path = url_path.to_string_lossy().into_owned();
- let url_id = builder
- .location(url_path.replace('\\', "/"))
- .build()
- .expect("invariant");
-
- let url = url_id.as_uri().to_string();
- let url = if use_directory_urls {
- url.trim_end_matches("index.html").to_string()
- } else {
- url
- };
+ let resolved =
+ routed.input.metadata.resolve(&location, page_meta)?;
// Don't cache page if it inserts (pymdownx) snippets.
// This is a hack while waiting for CommonMark (AST) and components,
// as well as topic-based authoring functionality.
if SNIPPET_RE.is_match(&data) {
- render_markdown(id, url, data, plugins, resolved)
+ render_markdown(id, route, data, plugins, resolved)
} else {
cached(
&config,
@@ -464,11 +465,11 @@ fn process_markdown(
1_u8,
config.hash,
data.clone(),
- url.clone(),
+ route.clone(),
resolved.clone(),
),
- |(_, _, data, url, resolved)| {
- render_markdown(id, url, data, plugins, resolved)
+ |(_, _, data, route, resolved)| {
+ render_markdown(id, route, data, plugins, resolved)
},
)
}
@@ -477,18 +478,20 @@ fn process_markdown(
/// Render Markdown and collect the page-local facts produced alongside it.
fn render_markdown(
- id: &Id, url: String, content: String, plugins: plugin::Settings,
+ id: &Id, route: PageRoute, content: String, plugins: plugin::Settings,
meta: meta::Resolved,
) -> anyhow::Result {
- let mut markdown = Markdown::new(id, url.clone(), content, meta.values())?;
+ let mut markdown =
+ Markdown::new(id, route.url.clone(), content, meta.values())?;
let html = plugin::prepare(&mut markdown, plugins);
let registrations = if plugins.autorefs {
- autorefs::take_page(&url)
+ autorefs::take_page(&route.url)
} else {
Arc::default()
};
let meta = Arc::new(meta.reconcile(markdown.meta.clone()));
Ok(RenderedMarkdown {
+ route,
markdown,
registrations,
html,
@@ -501,8 +504,12 @@ fn generate_page(
config: &Config, markdown: &Stream,
) -> Stream {
let config = config.clone();
- markdown.map(move |id: &Id, markdown: &RenderedMarkdown| RenderedPage {
- page: Page::new(&config, id, markdown.markdown.clone()),
+ markdown.map(move |markdown: &RenderedMarkdown| RenderedPage {
+ page: Page::new(
+ &config,
+ markdown.route.clone(),
+ markdown.markdown.clone(),
+ ),
registrations: markdown.registrations.clone(),
html: markdown.html.clone(),
meta: markdown.meta.clone(),
@@ -543,12 +550,22 @@ fn generate_site(
let nav = Navigation::new(config.project.nav.clone(), nav_pages);
let autorefs = autorefs::assemble(&config, facts);
let search = search::Snapshot::new(documents, nav.clone());
- Some(Site {
+ Ok::<_, anyhow::Error>(Some(Site {
pages: Arc::new(site_pages),
nav,
autorefs,
search,
- })
+ }))
+ })
+}
+
+/// Resolve redirects from the compact route relation.
+fn generate_redirects(
+ config: &Config, routes: &Stream,
+) -> Signal {
+ let config = config.clone();
+ routes.reduce(move |routes: &dyn Collection, PageRoute>| {
+ redirects::Snapshot::new(&config, routes.iter()).map(Some)
})
}
diff --git a/python/tests/integration/test_redirects.py b/python/tests/integration/test_redirects.py
new file mode 100644
index 0000000..c6cd311
--- /dev/null
+++ b/python/tests/integration/test_redirects.py
@@ -0,0 +1,165 @@
+# Copyright (c) 2025-2026 Zensical and contributors
+
+# SPDX-License-Identifier: MIT
+# All contributions are certified under the DCO
+
+"""Integration tests for MkDocs-compatible redirect artifacts."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+import pytest
+
+import zensical
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+_BUILD_OPTIONS: dict[str, Any] = {"clean": False, "strict": False}
+
+
+def _write_project(root: Path, redirect_maps: str) -> Path:
+ """Create a small project with internal and external redirect targets."""
+ docs = root / "docs"
+ (docs / "guide").mkdir(parents=True)
+ (docs / "index.md").write_text("# Home\n", encoding="utf-8")
+ (docs / "new.md").write_text("# New\n", encoding="utf-8")
+ (docs / "guide" / "topic.md").write_text(
+ "# Topic\n\n## Details\n", encoding="utf-8"
+ )
+ config = root / "mkdocs.yml"
+ config.write_text(
+ f"""\
+site_name: Redirects
+plugins:
+ - redirects:
+ redirect_maps:
+{redirect_maps}
+""",
+ encoding="utf-8",
+ )
+ return config
+
+
+def test_redirects_generate_mkdocs_compatible_artifacts(tmp_path: Path) -> None:
+ """Internal, fragment, and external targets use the upstream paths."""
+ config = _write_project(
+ tmp_path,
+ """\
+ old.md: new.md
+ legacy/deep.md: guide/topic.md#details
+ external.md: https://example.com/new?q=1
+""",
+ )
+ zensical.build(str(config), _BUILD_OPTIONS)
+
+ old = (tmp_path / "site" / "old" / "index.html").read_text()
+ nested = (
+ tmp_path / "site" / "legacy" / "deep" / "index.html"
+ ).read_text()
+ external = (
+ tmp_path / "site" / "external" / "index.html"
+ ).read_text()
+ assert '' in old
+ assert (
+ '' in nested
+ )
+ assert (
+ ''
+ in external
+ )
+ assert "noindex" not in old
+
+
+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")
+ 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
+
+
+def test_missing_redirect_target_warns_and_strict_mode_fails(
+ tmp_path: Path, capfd: pytest.CaptureFixture[str]
+) -> None:
+ """Missing targets are omitted and retain MkDocs strict semantics."""
+ config = _write_project(tmp_path, " old.md: missing.md\n")
+ zensical.build(str(config), _BUILD_OPTIONS)
+ assert not (tmp_path / "site" / "old" / "index.html").exists()
+ assert (
+ "Redirect target 'missing.md' does not exist!"
+ in capfd.readouterr().err
+ )
+
+ with pytest.raises(RuntimeError, match="strict flag is set"):
+ zensical.build(str(config), {"clean": False, "strict": True})
+
+
+@pytest.mark.parametrize(
+ ("kind", "message"),
+ [("page", "collides with a page"), ("asset", "documentation asset")],
+)
+def test_redirect_output_collisions_are_rejected(
+ tmp_path: Path, kind: str, message: str
+) -> None:
+ """No concurrent producer may own a configured redirect output."""
+ config = _write_project(tmp_path, " old.md: new.md\n")
+ if kind == "page":
+ (tmp_path / "docs" / "old.md").write_text("# Existing\n")
+ else:
+ asset = tmp_path / "docs" / "old" / "index.html"
+ asset.parent.mkdir()
+ asset.write_text("existing asset", encoding="utf-8")
+
+ with pytest.raises(RuntimeError, match=message):
+ zensical.build(str(config), _BUILD_OPTIONS)
+
+
+def test_unsafe_redirect_source_is_rejected(tmp_path: Path) -> None:
+ """Redirect outputs cannot escape the site directory."""
+ config = _write_project(tmp_path, " ../old.md: new.md\n")
+ with pytest.raises(RuntimeError, match="not a safe relative path"):
+ zensical.build(str(config), _BUILD_OPTIONS)
+
+
+def test_invalid_source_suffix_warns_but_still_generates(
+ tmp_path: Path, capfd: pytest.CaptureFixture[str]
+) -> None:
+ """Upstream's source warning does not suppress a valid redirect."""
+ config = _write_project(
+ tmp_path, " old.txt: https://example.com/new\n"
+ )
+ zensical.build(str(config), _BUILD_OPTIONS)
+ assert (tmp_path / "site" / "old" / "index.html").is_file()
+ assert "'old.txt' is not a valid markdown file" in capfd.readouterr().err
+
+
+def test_duplicate_redirect_outputs_are_rejected(tmp_path: Path) -> None:
+ """Different source names cannot resolve to one generated file."""
+ config = _write_project(
+ tmp_path,
+ """\
+ foo.md: new.md
+ foo/index.md: new.md
+""",
+ )
+ with pytest.raises(RuntimeError, match="configured more than once"):
+ zensical.build(str(config), _BUILD_OPTIONS)
+
+
+def test_redirect_output_cannot_replace_a_static_template(
+ tmp_path: Path,
+) -> None:
+ """The upstream post-build overwrite becomes a deterministic error."""
+ config = _write_project(tmp_path, " 404.md: new.md\n")
+ with config.open("a", encoding="utf-8") as file:
+ file.write("use_directory_urls: false\n")
+ with pytest.raises(RuntimeError, match="rendered template"):
+ zensical.build(str(config), _BUILD_OPTIONS)
diff --git a/python/tests/unit/test_config.py b/python/tests/unit/test_config.py
index 7e501a4..c49b373 100644
--- a/python/tests/unit/test_config.py
+++ b/python/tests/unit/test_config.py
@@ -181,6 +181,25 @@ class TestPluginShimming:
"meta_file": "defaults.yml",
}
+ def test_redirects_plugin_is_normalized(self, tmp_path: Path) -> None:
+ config = self._parse_yaml(
+ tmp_path,
+ plugins={"redirects": {"redirect_maps": {"old.md": "new.md"}}},
+ )
+ assert config["plugins"]["redirects"]["config"] == {
+ "enabled": True,
+ "redirect_maps": {"old.md": "new.md"},
+ }
+
+ def test_redirects_plugin_is_disabled_by_default(
+ self, tmp_path: Path
+ ) -> None:
+ config = self._parse_yaml(tmp_path, plugins=[])
+ assert config["plugins"]["redirects"]["config"] == {
+ "enabled": False,
+ "redirect_maps": {},
+ }
+
def test_mike_plugin_defaults_with_versioned_build(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
diff --git a/python/zensical/config.py b/python/zensical/config.py
index 65a6d68..cab8202 100644
--- a/python/zensical/config.py
+++ b/python/zensical/config.py
@@ -1282,6 +1282,16 @@ def _convert_plugins(value: Any, config: dict) -> dict:
set_default(meta, "meta_file", ".meta.yml", str)
plugins["meta"] = meta
+ # Normalize redirects into typed native configuration. The enabled flag is
+ # internal; plugin presence retains MkDocs' activation semantics.
+ if "redirects" not in plugins:
+ redirects = {"enabled": False, "redirect_maps": {}}
+ else:
+ redirects = dict(plugins["redirects"] or {})
+ set_default(redirects, "enabled", True, bool)
+ set_default(redirects, "redirect_maps", {}, dict)
+ plugins["redirects"] = redirects
+
# Define defaults for offline plugin
offline = set_default(plugins, "offline", {"enabled": False}, dict)
set_default(offline, "enabled", True, bool)