mirror of
https://github.com/zensical/zensical.git
synced 2026-09-27 00:35:33 +00:00
feature: support anchor redirects
Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use std::fs;
|
||||
use std::io::{BufWriter, Write};
|
||||
|
||||
use crate::path::OutputRoot;
|
||||
|
||||
@@ -70,6 +71,14 @@ pub fn write(
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
if let Some(anchors) = &snapshot.anchors {
|
||||
let path =
|
||||
output.join(&"redirect.json".parse().expect("static site path"));
|
||||
fs::create_dir_all(path.parent().expect("invariant"))?;
|
||||
let mut writer = BufWriter::new(fs::File::create(path)?);
|
||||
serde_json::to_writer(&mut writer, anchors)?;
|
||||
writer.flush()?;
|
||||
}
|
||||
for warning in &snapshot.warnings {
|
||||
eprintln!("WARNING - {warning}");
|
||||
}
|
||||
@@ -93,6 +102,8 @@ mod tests {
|
||||
use super::{render, write};
|
||||
use crate::compat::mkdocs::plugin::redirects::plan::{Redirect, Snapshot};
|
||||
use crate::path::{OutputRoot, SitePath};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn renders_upstream_document() {
|
||||
@@ -126,6 +137,7 @@ You're being redirected to a <a href="../new/">new destination</a>.
|
||||
output: output.clone(),
|
||||
target: Some("../new/".into()),
|
||||
}],
|
||||
anchors: Some(BTreeMap::new()),
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
let root = OutputRoot::prepare(directory.path()).unwrap();
|
||||
@@ -137,10 +149,31 @@ You're being redirected to a <a href="../new/">new destination</a>.
|
||||
output: output.clone(),
|
||||
target: None,
|
||||
}],
|
||||
anchors: Some(BTreeMap::new()),
|
||||
warnings: vec!["missing".into()],
|
||||
};
|
||||
write(&root, &missing, false).unwrap();
|
||||
assert!(!directory.path().join(output.as_str()).exists());
|
||||
assert!(write(&root, &missing, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_anchor_redirect_manifest() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = OutputRoot::prepare(directory.path()).unwrap();
|
||||
let snapshot = Snapshot {
|
||||
redirects: Vec::new(),
|
||||
anchors: Some(BTreeMap::from([(
|
||||
"guide/#old".into(),
|
||||
"reference/#new".into(),
|
||||
)])),
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
|
||||
write(&root, &snapshot, false).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.path().join("redirect.json")).unwrap(),
|
||||
r#"{"guide/#old":"reference/#new"}"#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,17 @@ enum Target {
|
||||
},
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Redirect source classification prepared before route settlement.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Source {
|
||||
/// Page redirect emitted as a physical HTML artifact.
|
||||
Page(SitePath),
|
||||
/// Anchor redirect emitted into the site-wide redirect manifest.
|
||||
Anchor(String),
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Structs
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -64,8 +75,12 @@ enum Target {
|
||||
/// Redirect configuration prepared independently of live page routes.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Plan {
|
||||
/// Whether the redirects plugin is enabled.
|
||||
enabled: bool,
|
||||
/// Generated paths reserved by configured redirects.
|
||||
outputs: BTreeSet<SitePath>,
|
||||
/// Anchor sources reserved by configured redirects.
|
||||
anchors: BTreeSet<String>,
|
||||
/// Internal source paths needed from the live route relation.
|
||||
targets: BTreeSet<String>,
|
||||
/// Validated redirect specifications in configuration order.
|
||||
@@ -90,8 +105,8 @@ pub struct Redirect {
|
||||
/// One validated configuration entry awaiting target resolution.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Specification {
|
||||
/// Site-relative output path.
|
||||
output: SitePath,
|
||||
/// Prepared page or anchor source.
|
||||
source: Source,
|
||||
/// Prepared internal or external target.
|
||||
target: Target,
|
||||
}
|
||||
@@ -103,6 +118,8 @@ struct Specification {
|
||||
pub struct Snapshot {
|
||||
/// Redirects ordered by configured source URI.
|
||||
pub redirects: Vec<Redirect>,
|
||||
/// Anchor redirects keyed by their resolved source URL.
|
||||
pub anchors: Option<BTreeMap<String, String>>,
|
||||
/// Compatibility warnings emitted for this snapshot.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -115,24 +132,22 @@ impl Plan {
|
||||
/// Validates route-independent configuration once for the workflow.
|
||||
pub fn new(config: &Config) -> Result<Self> {
|
||||
let plugin = &config.project.plugins.redirects.config;
|
||||
if !plugin.enabled || plugin.redirect_maps.is_empty() {
|
||||
if !plugin.enabled {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
|
||||
let mut plan = Self {
|
||||
enabled: true,
|
||||
specifications: Vec::with_capacity(plugin.redirect_maps.len()),
|
||||
..Self::default()
|
||||
};
|
||||
for (source, configured_target) in &plugin.redirect_maps {
|
||||
validate_output(
|
||||
config,
|
||||
&"redirect.json".parse().expect("static site path"),
|
||||
)?;
|
||||
for (configured_source, configured_target) in &plugin.redirect_maps {
|
||||
let (source, fragment) = split_fragment(configured_source);
|
||||
let source = normalize_source(source)?;
|
||||
let output = PageRoute::destination(
|
||||
&source,
|
||||
config.project.use_directory_urls,
|
||||
)?;
|
||||
if !plan.outputs.insert(output.clone()) {
|
||||
bail!("redirect output '{output}' is configured more than once")
|
||||
}
|
||||
validate_output(config, &output)?;
|
||||
|
||||
if !MARKDOWN_SUFFIXES
|
||||
.iter()
|
||||
@@ -143,6 +158,29 @@ impl Plan {
|
||||
));
|
||||
}
|
||||
|
||||
let source = if fragment.is_empty() {
|
||||
let output = PageRoute::destination(
|
||||
&source,
|
||||
config.project.use_directory_urls,
|
||||
)?;
|
||||
if !plan.outputs.insert(output.clone()) {
|
||||
bail!(
|
||||
"redirect output '{output}' is configured more than once"
|
||||
)
|
||||
}
|
||||
validate_output(config, &output)?;
|
||||
Source::Page(output)
|
||||
} else {
|
||||
let route = PageRoute::from_source(config, source)?;
|
||||
let source = format!("{}{fragment}", route.url);
|
||||
if !plan.anchors.insert(source.clone()) {
|
||||
bail!(
|
||||
"redirect source '{configured_source}' is configured more than once"
|
||||
)
|
||||
}
|
||||
Source::Anchor(source)
|
||||
};
|
||||
|
||||
let target = if is_external(configured_target) {
|
||||
Target::External(configured_target.clone())
|
||||
} else {
|
||||
@@ -154,7 +192,7 @@ impl Plan {
|
||||
fragment: fragment.into(),
|
||||
}
|
||||
};
|
||||
plan.specifications.push(Specification { output, target });
|
||||
plan.specifications.push(Specification { source, target });
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
@@ -168,7 +206,7 @@ impl Snapshot {
|
||||
plan: &Plan, page_routes: impl Iterator<Item = &'a PageRoute>,
|
||||
use_directory_urls: bool,
|
||||
) -> Result<Self> {
|
||||
if plan.specifications.is_empty() {
|
||||
if !plan.enabled {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
|
||||
@@ -186,18 +224,22 @@ impl Snapshot {
|
||||
}
|
||||
|
||||
let mut redirects = Vec::with_capacity(plan.specifications.len());
|
||||
let mut anchors = BTreeMap::new();
|
||||
let mut warnings = plan.warnings.clone();
|
||||
for specification in &plan.specifications {
|
||||
let target = match &specification.target {
|
||||
Target::External(target) => Some(target.clone()),
|
||||
Target::Internal { configured, source, fragment } => {
|
||||
if let Some(url) = routes.get(source) {
|
||||
Some(relative_target(
|
||||
&specification.output,
|
||||
url,
|
||||
fragment,
|
||||
use_directory_urls,
|
||||
))
|
||||
Some(match &specification.source {
|
||||
Source::Page(output) => relative_target(
|
||||
output,
|
||||
url,
|
||||
fragment,
|
||||
use_directory_urls,
|
||||
),
|
||||
Source::Anchor(_) => format!("{url}{fragment}"),
|
||||
})
|
||||
} else {
|
||||
warnings.push(format!(
|
||||
"Redirect target '{configured}' does not exist!"
|
||||
@@ -206,12 +248,22 @@ impl Snapshot {
|
||||
}
|
||||
}
|
||||
};
|
||||
redirects.push(Redirect {
|
||||
output: specification.output.clone(),
|
||||
target,
|
||||
});
|
||||
match &specification.source {
|
||||
Source::Page(output) => {
|
||||
redirects.push(Redirect { output: output.clone(), target });
|
||||
}
|
||||
Source::Anchor(source) => {
|
||||
if let Some(target) = target {
|
||||
anchors.insert(source.clone(), target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self { redirects, warnings })
|
||||
Ok(Self {
|
||||
redirects,
|
||||
anchors: Some(anchors),
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +349,7 @@ fn is_external(target: &str) -> bool {
|
||||
target.starts_with("http://") || target.starts_with("https://")
|
||||
}
|
||||
|
||||
/// Splits an internal target into source URI and hash fragment.
|
||||
/// Splits a configured URI into its path and hash fragment.
|
||||
fn split_fragment(target: &str) -> (&str, &str) {
|
||||
target
|
||||
.find('#')
|
||||
|
||||
+36
-1
@@ -25,6 +25,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -86,19 +87,53 @@ def test_redirects_generate_mkdocs_compatible_artifacts(tmp_path: Path) -> None:
|
||||
'<link rel="canonical" href="https://example.com/new?q=1">' in external
|
||||
)
|
||||
assert "noindex" not in old
|
||||
assert json.loads((tmp_path / "site" / "redirect.json").read_text()) == {}
|
||||
|
||||
|
||||
def test_anchor_redirects_generate_site_manifest(tmp_path: Path) -> None:
|
||||
"""Anchor mappings retain live pages and use resolved public URLs."""
|
||||
config = _write_project(
|
||||
tmp_path,
|
||||
"""\
|
||||
old.md: new.md
|
||||
new.md#old: guide/topic.md#details
|
||||
new.md#legacy: new.md#new
|
||||
new.md#external: https://example.com/new#there
|
||||
guide/topic.md#summary: new.md#new
|
||||
""",
|
||||
)
|
||||
zensical.build(str(config), {"clean": False, "strict": True})
|
||||
|
||||
assert (tmp_path / "site" / "old" / "index.html").is_file()
|
||||
assert "<h1" in (tmp_path / "site" / "new" / "index.html").read_text()
|
||||
assert json.loads((tmp_path / "site" / "redirect.json").read_text()) == {
|
||||
"guide/topic/#summary": "new/#new",
|
||||
"new/#external": "https://example.com/new#there",
|
||||
"new/#legacy": "new/#new",
|
||||
"new/#old": "guide/topic/#details",
|
||||
}
|
||||
|
||||
|
||||
def test_redirects_without_directory_urls_write_html_files(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""File-style URLs retain MkDocs' relative target calculation."""
|
||||
config = _write_project(tmp_path, " old.md: new.md\n")
|
||||
config = _write_project(
|
||||
tmp_path,
|
||||
"""\
|
||||
old.md: new.md
|
||||
new.md#old: guide/topic.md#details
|
||||
""",
|
||||
)
|
||||
with config.open("a", encoding="utf-8") as file:
|
||||
file.write("use_directory_urls: false\n")
|
||||
zensical.build(str(config), _BUILD_OPTIONS)
|
||||
|
||||
old = (tmp_path / "site" / "old.html").read_text()
|
||||
assert '<link rel="canonical" href="new.html">' in old
|
||||
assert json.loads((tmp_path / "site" / "redirect.json").read_text()) == {
|
||||
"new.html#old": "guide/topic.html#details"
|
||||
}
|
||||
|
||||
|
||||
def test_missing_redirect_target_warns_and_strict_mode_fails(
|
||||
|
||||
Reference in New Issue
Block a user