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("", "<\\/");
+ SCRIPT_WITH_FRAGMENTS
+ .replace("{url}", target)
+ .replace("{redirects}", &overrides)
+ };
+ HTML_TEMPLATE
+ .replace("{url}", target)
+ .replace("{script}", &script)
}
// ----------------------------------------------------------------------------
@@ -93,10 +128,12 @@ mod tests {
use super::{render, write};
use crate::compat::mkdocs::plugin::redirects::plan::{Redirect, Snapshot};
use crate::path::{OutputRoot, SitePath};
+ use std::collections::BTreeMap;
+ use std::fs;
#[test]
fn renders_upstream_document() {
- let html = render("../new/");
+ let html = render("../new/", &BTreeMap::new());
assert_eq!(
html,
r##"
@@ -117,6 +154,35 @@ You're being redirected to a 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 "