Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/mdbook-html/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/mdbook-html/src/html_handlebars/hbs_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -675,7 +675,7 @@ fn collect_redirects_for_path(
path: &Path,
redirects: &HashMap<String, String>,
) -> Result<BTreeMap<String, String>> {
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\
Expand Down
2 changes: 1 addition & 1 deletion crates/mdbook-html/src/html_handlebars/helpers/toc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ impl HelperDef for RenderToc {
let path_exists = match item.get("path") {
Some(path) if !path.is_empty() => {
out.write("<a href=\"")?;
let tmp = Path::new(path).with_extension("html").to_url_path();
let tmp = Path::new(path).with_extension("html").to_encoded_url_path();

// Add link
out.write(&tmp)?;
Expand Down
2 changes: 1 addition & 1 deletion crates/mdbook-html/src/html_handlebars/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ fn index_chapter(
doc_urls: &mut Vec<String>,
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;
Expand Down
28 changes: 28 additions & 0 deletions crates/mdbook-html/src/utils.rs
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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!(
Expand Down
1 change: 1 addition & 0 deletions crates/mdbook-summary/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions crates/mdbook-summary/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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"),
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions guide/src/format/summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions tests/testsuite/toc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"
<ol class="chapter">
<li class="chapter-item expanded ">
<span class="chapter-link-wrapper">
<a href="question%3F.html">
<strong aria-hidden="true">1.</strong> Question mark</a>
</span>
</li>
<li class="chapter-item expanded ">
<span class="chapter-link-wrapper">
<a href="spati%C3%ABring.html">
<strong aria-hidden="true">2.</strong> Unicode</a>
</span>
</li>
<li class="chapter-item expanded ">
<span class="chapter-link-wrapper">
<a href="number%23one.html">
<strong aria-hidden="true">3.</strong> Fragment delimiter</a>
</span>
</li>
</ol>
"#]]);

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"));
}
}
2 changes: 2 additions & 0 deletions tests/testsuite/toc/percent_encoded_chapter_paths/book.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
create-missing = false
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Percent-encoded chapter paths

- [Question mark](question%3F.md)
- [Unicode](spati%C3%ABring.md)
- [Fragment delimiter](number%23one.md)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Fragment delimiter
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Unicode