diff --git a/crates/zensical-serve/src/server.rs b/crates/zensical-serve/src/server.rs index f34ce2d..7e43cac 100644 --- a/crates/zensical-serve/src/server.rs +++ b/crates/zensical-serve/src/server.rs @@ -92,8 +92,8 @@ where /// use zensical_serve::handler::Teapot; /// use zensical_serve::server::Server; /// - /// // Create server - /// let server = Server::new(Teapot, "127.0.0.1:8080")?; + /// // Create server on an available local port + /// let server = Server::new(Teapot, "127.0.0.1:0")?; /// # Ok(()) /// # } /// ``` diff --git a/crates/zensical-serve/src/server/builder.rs b/crates/zensical-serve/src/server/builder.rs index c72dd30..1ca1d08 100644 --- a/crates/zensical-serve/src/server/builder.rs +++ b/crates/zensical-serve/src/server/builder.rs @@ -127,10 +127,10 @@ where /// use zensical_serve::handler::Teapot; /// use zensical_serve::server::Builder; /// - /// // Create server builder and bind to address + /// // Create server builder and bind to an available local port /// let mut builder = Builder::new(Teapot)?; /// let server = builder - /// .bind("127.0.0.1:8080")? + /// .bind("127.0.0.1:0")? /// .listen()?; /// # Ok(()) /// # } diff --git a/crates/zensical/src/compat/mkdocs/plugin/redirects.rs b/crates/zensical/src/compat/mkdocs/plugin/redirects.rs index ae2a336..33c8116 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/redirects.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/redirects.rs @@ -24,6 +24,15 @@ // ---------------------------------------------------------------------------- //! MkDocs-compatible redirects pipeline. +//! +//! Whole-page mappings are emitted as physical HTML files. Anchor mappings +//! are emitted into `redirect.json` for browser-side resolution, and are also +//! embedded into a matching physical redirect page when one exists. +//! +//! Configuration work is retained in [`Settings`], while every settled route +//! revision produces one complete [`Snapshot`] for the output stage. This +//! keeps watch-mode updates consistent and retracts redirects whose targets +//! disappear. use anyhow::Result; use std::sync::Arc; diff --git a/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs b/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs index c4e1c54..07d0a5c 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/redirects/output.rs @@ -26,28 +26,37 @@ //! Redirect output. use anyhow::{bail, Result}; +use std::collections::BTreeMap; use std::fs; +use std::io::{BufWriter, Write}; use crate::path::OutputRoot; use super::plan::Snapshot; -/// Redirect document emitted by mkdocs-redirects 1.2.2. -const HTML_TEMPLATE: &str = r##" +/// Shared shell for physical redirect documents. +const HTML_TEMPLATE: &str = r#" Redirecting... - + You're being redirected to a new destination. -"##; +"#; + +/// Redirect script emitted by mkdocs-redirects 1.2.2. +const SCRIPT: &str = r##"var anchor=window.location.hash.substr(1);location.href="{url}"+(anchor?"#"+anchor:"")"##; + +/// Redirect script that checks configured fragment overrides before falling +/// back to the whole-page target and preserving the original fragment. +const SCRIPT_WITH_FRAGMENTS: &str = r#"var anchor=window.location.hash,redirects={redirects},target;for(var source in redirects)if(new URL(source,location.href).hash===anchor){target=redirects[source];break}location.href=target||"{url}"+anchor"#; // ---------------------------------------------------------------------------- // Functions @@ -65,11 +74,21 @@ pub fn write( let path = output.join(&redirect.output); if let Some(target) = &redirect.target { fs::create_dir_all(path.parent().expect("redirect has parent"))?; - fs::write(path, render(target))?; + fs::write(path, render(target, &redirect.overrides))?; } else if path.is_file() { fs::remove_file(path)?; } } + if let Some(manifest) = &snapshot.manifest { + // Fragments never reach the server, so anchor redirects are written + // into one manifest for the browser integration to resolve. + 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, manifest)?; + writer.flush()?; + } for warning in &snapshot.warnings { eprintln!("WARNING - {warning}"); } @@ -79,9 +98,25 @@ pub fn write( Ok(()) } -/// Renders the upstream-compatible redirect document. -fn render(target: &str) -> String { - HTML_TEMPLATE.replace("{url}", target) +/// Renders one physical redirect document. +/// +/// Redirects without fragment overrides retain the upstream script verbatim. +/// When overrides exist, JSON supplies only the fragments belonging to this +/// page. Escaping closing tags keeps that JSON inside the script. +fn render(target: &str, overrides: &BTreeMap) -> String { + let script = if overrides.is_empty() { + SCRIPT.replace("{url}", target) + } else { + let overrides = serde_json::to_string(overrides) + .expect("redirect fragments are strings") + .replace("new destination. ); } + #[test] + fn renders_fragment_specific_destinations() { + let html = render( + "../new/", + &BTreeMap::from([( + "#install".into(), + "../guides/install/#linux".into(), + )]), + ); + assert_eq!( + html, + r##" + + + + + Redirecting... + + + + + +You're being redirected to a new destination. + + +"## + ); + } + #[test] fn removes_a_stale_redirect_when_its_target_disappears() { let directory = tempfile::tempdir().unwrap(); @@ -125,7 +191,9 @@ You're being redirected to a new destination. redirects: vec![Redirect { output: output.clone(), target: Some("../new/".into()), + overrides: BTreeMap::new(), }], + manifest: Some(BTreeMap::new()), warnings: Vec::new(), }; let root = OutputRoot::prepare(directory.path()).unwrap(); @@ -136,11 +204,33 @@ You're being redirected to a new destination. redirects: vec![Redirect { output: output.clone(), target: None, + overrides: BTreeMap::new(), }], + manifest: 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(), + manifest: 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..6312ae1 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/redirects/plan.rs @@ -24,6 +24,12 @@ // ---------------------------------------------------------------------------- //! Redirect planning. +//! +//! Redirects pass through three representations: +//! +//! 1. [`Plan`] validates configuration and resolves redirect chains once. +//! 2. [`Snapshot`] resolves final internal targets against settled page routes. +//! 3. The output stage writes physical page redirects and `redirect.json`. use anyhow::{bail, Result}; use std::collections::{BTreeMap, BTreeSet}; @@ -57,6 +63,39 @@ enum Target { }, } +// ---------------------------------------------------------------------------- + +/// Final redirect target resolved against the current page routes. +enum ResolvedTarget<'a> { + /// External target that is already ready for output. + External(&'a str), + /// Internal target composed from a public page URL and fragment. + Internal { + /// Site-relative public page URL. + url: &'a str, + /// Fragment including its leading `#`, when present. + fragment: &'a str, + }, +} + +// ---------------------------------------------------------------------------- + +/// 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 manifest and matching redirect page. + Anchor { + /// Site-relative manifest key including its fragment. + manifest_key: String, + /// Physical redirect output for the source page. + output: SitePath, + /// Source fragment including its leading `#`. + fragment: String, + }, +} + // ---------------------------------------------------------------------------- // Structs // ---------------------------------------------------------------------------- @@ -64,11 +103,15 @@ 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, + /// Site-relative manifest keys reserved by configured redirects. + manifest_keys: BTreeSet, /// Internal source paths needed from the live route relation. - targets: BTreeSet, - /// Validated redirect specifications in configuration order. + route_sources: BTreeSet, + /// Validated redirects in deterministic configured-source order. specifications: Vec, /// Diagnostics that depend only on configuration. warnings: Vec, @@ -83,15 +126,19 @@ pub struct Redirect { pub output: SitePath, /// Resolved redirect target, or `None` when the target is missing. pub target: Option, + /// Fragment-specific target overrides relative to this output. + pub overrides: BTreeMap, } // ---------------------------------------------------------------------------- -/// One validated configuration entry awaiting target resolution. +/// One validated redirect mapping awaiting route resolution. #[derive(Clone, Debug)] struct Specification { - /// Site-relative output path. - output: SitePath, + /// Original configured source used to resolve redirect chains. + configured: String, + /// Prepared page or anchor source. + source: Source, /// Prepared internal or external target. target: Target, } @@ -103,6 +150,8 @@ struct Specification { pub struct Snapshot { /// Redirects ordered by configured source URI. pub redirects: Vec, + /// Anchor redirect manifest, or `None` when the plugin is disabled. + pub manifest: Option>, /// Compatibility warnings emitted for this snapshot. pub warnings: Vec, } @@ -111,51 +160,91 @@ pub struct Snapshot { // Implementations // ---------------------------------------------------------------------------- +impl Target { + /// Resolves an internal target against the current page routes. + fn resolve<'a>( + &'a self, routes: &'a BTreeMap, + ) -> Option> { + match self { + Self::External(target) => Some(ResolvedTarget::External(target)), + Self::Internal { source, fragment, .. } => routes + .get(source) + .map(|url| ResolvedTarget::Internal { url, fragment }), + } + } + + /// Returns the configured value for a missing internal target. + fn missing(&self) -> Option<&str> { + match self { + Self::External(_) => None, + Self::Internal { configured, .. } => Some(configured), + } + } +} + +// ---------------------------------------------------------------------------- + +impl ResolvedTarget<'_> { + /// Formats this target for the site-wide anchor manifest. + fn for_manifest(&self) -> String { + match self { + Self::External(target) => (*target).into(), + Self::Internal { url, fragment } => format!("{url}{fragment}"), + } + } + + /// Formats this target relative to one physical redirect page. + fn for_redirect( + &self, output: &SitePath, use_directory_urls: bool, + ) -> String { + match self { + Self::External(target) => (*target).into(), + Self::Internal { url, fragment } => { + relative_target(output, url, fragment, use_directory_urls) + } + } + } +} + +// ---------------------------------------------------------------------------- + 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 { - 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() - .any(|suffix| source.as_str().ends_with(suffix)) - { - plan.warnings.push(format!( - "redirects plugin: '{source}' is not a valid markdown file!" - )); - } - - let target = if is_external(configured_target) { - Target::External(configured_target.clone()) - } else { - let (source, fragment) = split_fragment(configured_target); - plan.targets.insert(source.into()); - Target::Internal { - configured: configured_target.clone(), - source: source.into(), - fragment: fragment.into(), - } - }; - plan.specifications.push(Specification { output, target }); + validate_output( + config, + &"redirect.json".parse().expect("static site path"), + )?; + for (configured_source, configured_target) in &plugin.redirect_maps { + let source = prepare_source(config, &mut plan, configured_source)?; + let target = prepare_target(configured_target); + plan.specifications.push(Specification { + configured: configured_source.clone(), + source, + target, + }); } + + // Chains are configuration-only. Resolve them before collecting the + // page routes needed by the remaining final internal targets. + resolve_chains(&mut plan.specifications)?; + plan.route_sources + .extend(plan.specifications.iter().filter_map(|specification| { + match &specification.target { + Target::Internal { source, .. } => Some(source.clone()), + Target::External(_) => None, + } + })); Ok(plan) } } @@ -168,13 +257,15 @@ 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()); } + // Retain only routes that can become final targets. Collision checks + // still inspect every page because redirect outputs are exclusive. let mut routes = BTreeMap::new(); for route in page_routes { - if plan.targets.contains(route.source.as_str()) { + if plan.route_sources.contains(route.source.as_str()) { routes.insert(route.source.to_string(), route.url.clone()); } if plan.outputs.contains(&route.destination) { @@ -186,32 +277,72 @@ impl Snapshot { } let mut redirects = Vec::with_capacity(plan.specifications.len()); + let mut manifest = BTreeMap::new(); + let mut overrides = + BTreeMap::>::new(); let mut warnings = plan.warnings.clone(); + + // Build the public manifest and physical redirects together. Physical + // anchor overrides are collected by output path and attached after + // this loop, because configuration order is not significant. 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, - )) - } else { - warnings.push(format!( - "Redirect target '{configured}' does not exist!" - )); - None + let target = specification.target.resolve(&routes); + if target.is_none() + && let Some(configured) = specification.target.missing() + { + warnings.push(format!( + "Redirect target '{configured}' does not exist!" + )); + } + + match &specification.source { + Source::Page(output) => { + redirects.push(Redirect { + output: output.clone(), + target: target.as_ref().map(|target| { + target.for_redirect(output, use_directory_urls) + }), + overrides: BTreeMap::new(), + }); + } + Source::Anchor { + manifest_key, + output, + fragment: source_fragment, + } => { + if let Some(target) = &target { + manifest.insert( + manifest_key.clone(), + target.for_manifest(), + ); + + // A physical redirect page loads before the UI, so it + // must resolve its own fragment-specific destinations. + if plan.outputs.contains(output) { + overrides + .entry(output.clone()) + .or_default() + .insert( + source_fragment.clone(), + target.for_redirect( + output, + use_directory_urls, + ), + ); + } } } - }; - redirects.push(Redirect { - output: specification.output.clone(), - target, - }); + } } - Ok(Self { redirects, warnings }) + for redirect in &mut redirects { + redirect.overrides = + overrides.remove(&redirect.output).unwrap_or_default(); + } + Ok(Self { + redirects, + manifest: Some(manifest), + warnings, + }) } } @@ -219,6 +350,139 @@ impl Snapshot { // Functions // ---------------------------------------------------------------------------- +/// Validates and classifies one configured redirect source. +fn prepare_source( + config: &Config, plan: &mut Plan, configured: &str, +) -> Result { + let (source, fragment) = split_fragment(configured); + let source = normalize_source(source)?; + + // Match mkdocs-redirects: warn about unusual source suffixes, but continue + // generating the configured redirect. + if !MARKDOWN_SUFFIXES + .iter() + .any(|suffix| source.as_str().ends_with(suffix)) + { + plan.warnings.push(format!( + "redirects plugin: '{source}' is not a valid markdown file!" + )); + } + + let route = PageRoute::from_source(config, source)?; + if fragment.is_empty() { + // Whole-page redirects own physical HTML outputs and must not collide + // with another configured redirect or output producer. + if !plan.outputs.insert(route.destination.clone()) { + bail!( + "redirect output '{}' is configured more than once", + route.destination + ) + } + validate_output(config, &route.destination)?; + Ok(Source::Page(route.destination)) + } else { + // Anchor redirects share their source page, so only their public URL + // must be unique. Their matching physical output is retained so page + // redirects can embed fragment-specific overrides later. + let manifest_key = format!("{}{fragment}", route.url); + if !plan.manifest_keys.insert(manifest_key.clone()) { + bail!("redirect source '{configured}' is configured more than once") + } + Ok(Source::Anchor { + manifest_key, + output: route.destination, + fragment: fragment.into(), + }) + } +} + +/// Classifies one configured redirect target. +fn prepare_target(configured: &str) -> Target { + if is_external(configured) { + Target::External(configured.into()) + } else { + let (source, fragment) = split_fragment(configured); + Target::Internal { + configured: configured.into(), + source: source.into(), + fragment: fragment.into(), + } + } +} + +// ---------------------------------------------------------------------------- + +/// Resolves configured redirect chains and rejects cycles. +fn resolve_chains(specifications: &mut [Specification]) -> Result<()> { + // Redirect targets refer to the exact source keys used in configuration. + // The index turns every chain step into a logarithmic lookup. + let sources = specifications + .iter() + .enumerate() + .map(|(index, specification)| (specification.configured.clone(), index)) + .collect::>(); + + // Cache terminal targets so shared chain tails are resolved only once. + let mut resolved = vec![None; specifications.len()]; + for index in 0..specifications.len() { + resolve_chain( + index, + specifications, + &sources, + &mut Vec::new(), + &mut resolved, + )?; + } + for (specification, target) in specifications.iter_mut().zip(resolved) { + specification.target = target.expect("every redirect was resolved"); + } + Ok(()) +} + +/// Resolves one redirect target recursively against configured sources. +fn resolve_chain( + index: usize, specifications: &[Specification], + sources: &BTreeMap, visiting: &mut Vec, + resolved: &mut [Option], +) -> Result { + if let Some(target) = &resolved[index] { + return Ok(target.clone()); + } + + // The active recursion stack identifies the complete cycle for a useful + // configuration error instead of allowing a browser redirect loop. + if let Some(position) = visiting.iter().position(|other| *other == index) { + let cycle = visiting[position..] + .iter() + .chain(std::iter::once(&index)) + .map(|index| specifications[*index].configured.as_str()) + .collect::>() + .join(" -> "); + bail!("redirect cycle detected: {cycle}") + } + + visiting.push(index); + let target = match &specifications[index].target { + Target::Internal { configured, .. } => { + if let Some(next) = sources.get(configured) { + resolve_chain( + *next, + specifications, + sources, + visiting, + resolved, + )? + } else { + specifications[index].target.clone() + } + } + Target::External(_) => specifications[index].target.clone(), + }; + visiting.pop(); + resolved[index] = Some(target.clone()); + Ok(target) +} + /// Rejects redirect sources that could escape the site directory. fn normalize_source(source: &str) -> Result { if source.is_empty() || source.contains('\\') { @@ -259,6 +523,9 @@ fn validate_output(config: &Config, output: &SitePath) -> Result<()> { .config .enabled .then_some(config.project.plugins.meta.config.meta_file.as_str()); + + // Ordinary documentation assets are copied to the site unchanged. + // Metadata files and extra templates are consumed by other producers. if docs_asset.is_file() && metadata_file != Some(output.file_name()) && !extra_templates @@ -268,6 +535,8 @@ fn validate_output(config: &Config, output: &SitePath) -> Result<()> { bail!("redirect output '{output}' collides with a documentation asset") } + // Non-HTML theme files are copied assets; HTML files are templates and + // therefore checked with the rendered template outputs below. if config.theme_dirs.iter().any(|directory| { let path = directory.join(output.as_str()); path.is_file() && path.extension().is_none_or(|ext| ext != "html") @@ -275,6 +544,7 @@ fn validate_output(config: &Config, output: &SitePath) -> Result<()> { bail!("redirect output '{output}' collides with a theme asset") } + // Static and extra templates render to the site root under their basename. let templates = config .project .theme @@ -297,7 +567,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('#') @@ -340,6 +610,9 @@ fn relative_path(target: &Path, base: &Path) -> String { .zip(&base) .take_while(|(left, right)| left == right) .count(); + + // Leave the unmatched base suffix, then append the unmatched target + // suffix. let mut parts = vec!["..".into(); base.len() - common]; parts.extend(target[common..].iter().map(|part| { part.to_str() diff --git a/python/tests/integration/test_redirects.py b/python/tests/integration/test_redirects.py index 9c38882..76e48e5 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,127 @@ 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: + """Physical redirects send configured fragments to their own targets.""" + config = _write_project( + tmp_path, + """\ + old.md: new.md + old.md#install: guide/topic.md#details + old.md#external: https://example.com/new#there +""", + ) + zensical.build(str(config), {"clean": False, "strict": True}) + + old = (tmp_path / "site" / "old" / "index.html").read_text() + assert ( + 'redirects={"#external":"https://example.com/new#there",' + '"#install":"../guide/topic/#details"}' in old + ) + assert 'location.href=target||"../new/"+anchor' in old + assert json.loads((tmp_path / "site" / "redirect.json").read_text()) == { + "old/#external": "https://example.com/new#there", + "old/#install": "guide/topic/#details", + } + + +def test_redirect_chains_resolve_to_final_targets(tmp_path: Path) -> None: + """Page, anchor, and mixed chains emit their final destinations.""" + config = _write_project( + tmp_path, + """\ + first.md: second.md + second.md: new.md + first.md#install: new.md#old + new.md#old: new.md#intermediate + new.md#intermediate: guide/topic.md#details +""", + ) + zensical.build(str(config), {"clean": False, "strict": True}) + + first = (tmp_path / "site" / "first" / "index.html").read_text() + second = (tmp_path / "site" / "second" / "index.html").read_text() + assert '' in first + assert '' in second + assert 'redirects={"#install":"../guide/topic/#details"}' in first + assert json.loads((tmp_path / "site" / "redirect.json").read_text()) == { + "first/#install": "guide/topic/#details", + "new/#intermediate": "guide/topic/#details", + "new/#old": "guide/topic/#details", + } + + +@pytest.mark.parametrize( + "redirect_maps", + [ + " old.md: old.md\n", + " first.md: second.md\n second.md: first.md\n", + ( + " new.md#first: new.md#second\n" + " new.md#second: new.md#first\n" + ), + " old.md: new.md#old\n new.md#old: old.md\n", + ], +) +def test_redirect_cycles_are_rejected( + tmp_path: Path, redirect_maps: str +) -> None: + """Self, page, anchor, and mixed redirect cycles fail planning.""" + config = _write_project(tmp_path, redirect_maps) + with pytest.raises(RuntimeError, match="redirect cycle detected"): + zensical.build(str(config), _BUILD_OPTIONS) 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 + old.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 'redirects={"#old":"guide/topic.html#details"}' in old + assert json.loads((tmp_path / "site" / "redirect.json").read_text()) == { + "old.html#old": "guide/topic.html#details" + } def test_missing_redirect_target_warns_and_strict_mode_fails(