From 6267748ba7d13b6eabe44d0531c00304cc93cf8c Mon Sep 17 00:00:00 2001 From: ychampion Date: Wed, 15 Jul 2026 19:25:46 +0000 Subject: [PATCH] Support percent-encoded chapter paths Decode valid UTF-8 escapes for source lookup, then encode generated chapter URL surfaces so path delimiters remain filename characters. Constraint: Chapter filenames may contain URL delimiters that must be decoded for disk access and re-encoded in generated links. Rejected: Decode without renderer encoding | would turn question marks and hashes into URL delimiters. Confidence: high Scope-risk: moderate Directive: Keep filesystem paths decoded internally and encode only generated chapter URL surfaces. Tested: summary/html crate suites with and without defaults; targeted TOC regression in both feature modes; workspace packages; 109/111 root tests; strict Clippy; rustdoc; rustfmt; guide build; CLI reproduction Not-tested: Native Windows filesystem behavior for question-mark filenames; two nested-Cargo harness cases blocked by the external target location --- Cargo.lock | 2 + Cargo.toml | 1 + crates/mdbook-html/Cargo.toml | 1 + .../src/html_handlebars/hbs_renderer.rs | 4 +- .../src/html_handlebars/helpers/toc.rs | 2 +- .../mdbook-html/src/html_handlebars/search.rs | 2 +- crates/mdbook-html/src/utils.rs | 28 ++++++++++ crates/mdbook-summary/Cargo.toml | 1 + crates/mdbook-summary/src/lib.rs | 26 ++++++++-- guide/src/format/summary.md | 5 ++ tests/testsuite/toc.rs | 52 +++++++++++++++++++ .../percent_encoded_chapter_paths/book.toml | 2 + .../src/SUMMARY.md | 5 ++ .../src/number#one.md | 1 + .../src/spati\303\253ring.md" | 1 + 15 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 tests/testsuite/toc/percent_encoded_chapter_paths/book.toml create mode 100644 tests/testsuite/toc/percent_encoded_chapter_paths/src/SUMMARY.md create mode 100644 tests/testsuite/toc/percent_encoded_chapter_paths/src/number#one.md create mode 100644 "tests/testsuite/toc/percent_encoded_chapter_paths/src/spati\303\253ring.md" diff --git a/Cargo.lock b/Cargo.lock index 5ffcf6687b..790b8d405f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1070,6 +1070,7 @@ dependencies = [ "mdbook-core", "mdbook-markdown", "mdbook-renderer", + "percent-encoding", "pulldown-cmark", "regex", "serde", @@ -1126,6 +1127,7 @@ dependencies = [ "anyhow", "mdbook-core", "memchr", + "percent-encoding", "pulldown-cmark", "serde", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 95254fe02a..f20ba5e7b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ notify = "8.2.0" notify-debouncer-mini = "0.7.0" opener = "0.8.5" pathdiff = "0.2.3" +percent-encoding = "2.3.2" pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] } # Do not update, part of the public api. regex = "1.12.4" select = "0.6.1" diff --git a/crates/mdbook-html/Cargo.toml b/crates/mdbook-html/Cargo.toml index f04d3acb54..38fbaed568 100644 --- a/crates/mdbook-html/Cargo.toml +++ b/crates/mdbook-html/Cargo.toml @@ -19,6 +19,7 @@ indexmap.workspace = true mdbook-core.workspace = true mdbook-markdown.workspace = true mdbook-renderer.workspace = true +percent-encoding.workspace = true pulldown-cmark.workspace = true regex.workspace = true serde.workspace = true diff --git a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs index 8edac3cace..8cdaa8e83e 100644 --- a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs +++ b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs @@ -105,7 +105,7 @@ impl HtmlHandlebars { .as_ref() .unwrap() .with_extension("html") - .to_url_path(); + .to_encoded_url_path(); let obj = json!( { "title": ch.name, "link": path, @@ -675,7 +675,7 @@ fn collect_redirects_for_path( path: &Path, redirects: &HashMap, ) -> Result> { - let path = format!("/{}", path.to_url_path()); + let path = format!("/{}", path.to_encoded_url_path()); if redirects.contains_key(&path) { bail!( "redirect found for existing chapter at `{path}`\n\ diff --git a/crates/mdbook-html/src/html_handlebars/helpers/toc.rs b/crates/mdbook-html/src/html_handlebars/helpers/toc.rs index baee73f6d3..c2146bd7f1 100644 --- a/crates/mdbook-html/src/html_handlebars/helpers/toc.rs +++ b/crates/mdbook-html/src/html_handlebars/helpers/toc.rs @@ -117,7 +117,7 @@ impl HelperDef for RenderToc { let path_exists = match item.get("path") { Some(path) if !path.is_empty() => { out.write(", chapter_tree: &ChapterTree<'_>, ) -> Result<()> { - let anchor_base = chapter_tree.html_path.to_url_path(); + let anchor_base = chapter_tree.html_path.to_encoded_url_path(); let mut in_heading = false; let max_section_depth = search_config.heading_split_level; diff --git a/crates/mdbook-html/src/utils.rs b/crates/mdbook-html/src/utils.rs index 68f42a4094..314d33d66e 100644 --- a/crates/mdbook-html/src/utils.rs +++ b/crates/mdbook-html/src/utils.rs @@ -1,8 +1,22 @@ //! Utilities for processing HTML. +use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode}; use std::collections::HashSet; use std::path::{Component, Path, PathBuf}; +const URL_PATH_ENCODE_SET: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'%') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'^') + .add(b'`') + .add(b'{') + .add(b'}'); + /// Utility function to normalize path elements like `..`. pub(crate) fn normalize_path(path: &Path) -> PathBuf { let mut components = path.components().peekable(); @@ -41,6 +55,8 @@ pub(crate) fn normalize_path(path: &Path) -> PathBuf { /// Helper trait for converting a [`Path`] to a string suitable for an HTML path. pub(crate) trait ToUrlPath { fn to_url_path(&self) -> String; + + fn to_encoded_url_path(&self) -> String; } impl ToUrlPath for Path { @@ -49,6 +65,10 @@ impl ToUrlPath for Path { // The replace here is to handle Windows paths. self.to_str().unwrap().replace('\\', "/") } + + fn to_encoded_url_path(&self) -> String { + utf8_percent_encode(&self.to_url_path(), URL_PATH_ENCODE_SET).to_string() + } } /// Make sure an HTML id is unique. @@ -113,6 +133,14 @@ mod tests { assert_eq!(unique_id("Über", &mut id_counter), "Über-2"); } + #[test] + fn percent_encodes_url_paths() { + assert_eq!( + Path::new("nested/spatiëring ?#%^.html").to_encoded_url_path(), + "nested/spati%C3%ABring%20%3F%23%25%5E.html" + ); + } + #[test] fn it_normalizes_ids() { assert_eq!( diff --git a/crates/mdbook-summary/Cargo.toml b/crates/mdbook-summary/Cargo.toml index 0b40808f92..c156744157 100644 --- a/crates/mdbook-summary/Cargo.toml +++ b/crates/mdbook-summary/Cargo.toml @@ -11,6 +11,7 @@ rust-version.workspace = true anyhow.workspace = true mdbook-core.workspace = true memchr.workspace = true +percent-encoding.workspace = true pulldown-cmark.workspace = true serde.workspace = true tracing.workspace = true diff --git a/crates/mdbook-summary/src/lib.rs b/crates/mdbook-summary/src/lib.rs index 0a9402ab23..39e2fabcd8 100644 --- a/crates/mdbook-summary/src/lib.rs +++ b/crates/mdbook-summary/src/lib.rs @@ -7,6 +7,7 @@ use anyhow::{Context, Error, Result, bail}; pub use mdbook_core::book::SectionNumber; use memchr::Memchr; +use percent_encoding::percent_decode_str; use pulldown_cmark::{DefaultBrokenLinkCallback, Event, HeadingLevel, Tag, TagEnd}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -379,7 +380,11 @@ impl<'a> SummaryParser<'a> { /// Finishes parsing a link once the `Event::Start(Tag::Link(..))` has been opened. fn parse_link(&mut self, href: String) -> Link { - let href = href.replace("%20", " "); + let decoded_href = percent_decode_str(&href) + .decode_utf8() + .ok() + .map(|href| href.into_owned()); + let href = decoded_href.unwrap_or(href); let link_content = collect_events!(self.stream, end TagEnd::Link); let name = stringify_events(link_content); @@ -997,8 +1002,11 @@ mod tests { } #[test] - fn allow_space_in_link_destination() { - let src = "- [test1](./test%20link1.md)\n- [test2](<./test link2.md>)"; + fn allow_percent_encoding_in_link_destination() { + let src = "- [test1](./test%20link1.md)\n\ + - [test2](<./test link2.md>)\n\ + - [test3](./question%3F.md)\n\ + - [test4](./spati%C3%ABring.md)"; let should_be = vec![ SummaryItem::Link(Link { name: String::from("test1"), @@ -1012,6 +1020,18 @@ mod tests { number: Some(SectionNumber::new([2])), nested_items: Vec::new(), }), + SummaryItem::Link(Link { + name: String::from("test3"), + location: Some(PathBuf::from("./question?.md")), + number: Some(SectionNumber::new([3])), + nested_items: Vec::new(), + }), + SummaryItem::Link(Link { + name: String::from("test4"), + location: Some(PathBuf::from("./spatiëring.md")), + number: Some(SectionNumber::new([4])), + nested_items: Vec::new(), + }), ]; let mut parser = SummaryParser::new(src); let got = parser diff --git a/guide/src/format/summary.md b/guide/src/format/summary.md index 85d3b7406c..54ab7280e4 100644 --- a/guide/src/format/summary.md +++ b/guide/src/format/summary.md @@ -56,6 +56,11 @@ to be ignored at best, or may cause an error when attempting to build the book. - [Another Chapter](relative/path/to/markdown4.md) ``` Numbered chapters can be denoted with either `-` or `*` (do not mix delimiters). + + Chapter paths may use percent encoding for characters that cannot safely appear in a + Markdown link destination. For example, `question%3F.md` refers to a file named + `question?.md`. mdBook decodes the path when reading the source file and percent-encodes + it again in generated links. 1. ***Suffix Chapter*** - Like prefix chapters, suffix chapters are unnumbered, but they come after numbered chapters. diff --git a/tests/testsuite/toc.rs b/tests/testsuite/toc.rs index 40bb0001e2..aeb9dbdd73 100644 --- a/tests/testsuite/toc.rs +++ b/tests/testsuite/toc.rs @@ -189,3 +189,55 @@ fn summary_with_markdown_formatting() { "#]], ); } + +#[test] +#[cfg(not(windows))] +fn percent_encoded_chapter_paths() { + let mut test = BookTest::from_dir("toc/percent_encoded_chapter_paths"); + // `?` cannot appear in Windows filenames, so create this source at runtime. + std::fs::write( + test.dir.join("src/question?.md"), + "# Question mark\n\n[Query string](asset.png?raw=1)\n", + ) + .unwrap(); + test.build(); + + test.check_toc_js(str![[r#" +
    +
  1. + + + Question mark + +
  2. +
  3. + + + Unicode + +
  4. +
  5. + + + Fragment delimiter + +
  6. +
+"#]]); + + assert!(test.dir.join("book/question?.html").is_file()); + assert!(test.dir.join("book/spatiëring.html").is_file()); + assert!(test.dir.join("book/number#one.html").is_file()); + + let question_html = read_to_string(test.dir.join("book/question?.html")); + assert!(question_html.contains(r#"href="spati%C3%ABring.html""#)); + assert!(question_html.contains(r#"href="asset.png?raw=1""#)); + + #[cfg(feature = "search")] + { + let search_index = read_to_string(glob_one(&test.dir, "book/searchindex*.js")); + assert!(search_index.contains("question%3F.html#question-mark")); + assert!(search_index.contains("spati%C3%ABring.html#unicode")); + assert!(search_index.contains("number%23one.html#fragment-delimiter")); + } +} diff --git a/tests/testsuite/toc/percent_encoded_chapter_paths/book.toml b/tests/testsuite/toc/percent_encoded_chapter_paths/book.toml new file mode 100644 index 0000000000..2efb562a61 --- /dev/null +++ b/tests/testsuite/toc/percent_encoded_chapter_paths/book.toml @@ -0,0 +1,2 @@ +[build] +create-missing = false diff --git a/tests/testsuite/toc/percent_encoded_chapter_paths/src/SUMMARY.md b/tests/testsuite/toc/percent_encoded_chapter_paths/src/SUMMARY.md new file mode 100644 index 0000000000..00a96080e7 --- /dev/null +++ b/tests/testsuite/toc/percent_encoded_chapter_paths/src/SUMMARY.md @@ -0,0 +1,5 @@ +# Percent-encoded chapter paths + +- [Question mark](question%3F.md) +- [Unicode](spati%C3%ABring.md) +- [Fragment delimiter](number%23one.md) diff --git a/tests/testsuite/toc/percent_encoded_chapter_paths/src/number#one.md b/tests/testsuite/toc/percent_encoded_chapter_paths/src/number#one.md new file mode 100644 index 0000000000..8a3a929ca5 --- /dev/null +++ b/tests/testsuite/toc/percent_encoded_chapter_paths/src/number#one.md @@ -0,0 +1 @@ +# Fragment delimiter diff --git "a/tests/testsuite/toc/percent_encoded_chapter_paths/src/spati\303\253ring.md" "b/tests/testsuite/toc/percent_encoded_chapter_paths/src/spati\303\253ring.md" new file mode 100644 index 0000000000..7f28fc1704 --- /dev/null +++ "b/tests/testsuite/toc/percent_encoded_chapter_paths/src/spati\303\253ring.md" @@ -0,0 +1 @@ +# Unicode