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)