From 4398d11fed04be757641107248c60a169d54c9b5 Mon Sep 17 00:00:00 2001 From: Jonathan Hult Date: Wed, 25 Feb 2026 16:32:56 -0600 Subject: [PATCH 01/12] feat(config): add no-html-extension option to HtmlConfig --- crates/mdbook-core/src/config.rs | 4 ++++ tests/testsuite/config.rs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/mdbook-core/src/config.rs b/crates/mdbook-core/src/config.rs index b2c6862c30..85e3f0fc9a 100644 --- a/crates/mdbook-core/src/config.rs +++ b/crates/mdbook-core/src/config.rs @@ -518,6 +518,9 @@ pub struct HtmlConfig { /// If enabled, the sidebar includes navigation for headers on the current /// page. Default is `true`. pub sidebar_header_nav: bool, + /// If enabled, HTML files will not have the `.html` extension in URLs. + /// Defaults to `false`. + pub no_html_extension: bool, } impl Default for HtmlConfig { @@ -548,6 +551,7 @@ impl Default for HtmlConfig { redirect: HashMap::new(), hash_files: true, sidebar_header_nav: true, + no_html_extension: false, } } } diff --git a/tests/testsuite/config.rs b/tests/testsuite/config.rs index 641243c103..3cc629efb9 100644 --- a/tests/testsuite/config.rs +++ b/tests/testsuite/config.rs @@ -342,3 +342,23 @@ preprocessor saw custom config // No HTML output .check_file_list("book", str![[""]]); } + +// Test that no-html-extension config can be parsed from TOML. +#[test] +fn config_no_html_extension_true() { + let mut test = BookTest::init(|_| {}); + test.change_file("book.toml", "[output.html]\nno-html-extension = true"); + let book = test.load_book(); + let html_config = book.config.html_config().expect("html config should exist"); + assert_eq!(html_config.no_html_extension, true); +} + +// Test that no-html-extension defaults to false when not specified. +#[test] +fn config_no_html_extension_default() { + let mut test = BookTest::init(|_| {}); + test.change_file("book.toml", "[output.html]\n"); + let book = test.load_book(); + let html_config = book.config.html_config().expect("html config should exist"); + assert_eq!(html_config.no_html_extension, false); +} From b9bf681471fc1bf168f1b1c13a21bb8b535d7938 Mon Sep 17 00:00:00 2001 From: Jonathan Hult Date: Wed, 25 Feb 2026 16:40:53 -0600 Subject: [PATCH 02/12] feat(html): add clean URL helper functions --- crates/mdbook-html/src/utils.rs | 176 ++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/crates/mdbook-html/src/utils.rs b/crates/mdbook-html/src/utils.rs index 68f42a4094..ac5ac519c6 100644 --- a/crates/mdbook-html/src/utils.rs +++ b/crates/mdbook-html/src/utils.rs @@ -1,6 +1,7 @@ //! Utilities for processing HTML. use std::collections::HashSet; +use std::ffi::OsStr; use std::path::{Component, Path, PathBuf}; /// Utility function to normalize path elements like `..`. @@ -99,6 +100,74 @@ pub(crate) fn id_from_content(content: &str) -> String { .collect() } +/// Converts a logical HTML path to a clean URL link string. +/// +/// Index pages have their `index.html` stripped, keeping a trailing slash. +/// Non-index pages have their `.html` extension replaced by `/`. +/// +/// # Examples +/// +/// - `foo/bar.html` → `"foo/bar/"` +/// - `foo/index.html` → `"foo/"` +/// - `index.html` → `"./"` +/// - `bar.html` → `"bar/"` +pub(crate) fn clean_url_link_path(logical_html_path: &Path) -> String { + let is_index = logical_html_path.file_stem() == Some(OsStr::new("index")); + + if is_index { + match logical_html_path.parent() { + Some(parent) if parent.as_os_str().is_empty() => "./".to_string(), + Some(parent) => format!("{}/", parent.to_url_path()), + None => "./".to_string(), + } + } else { + let without_ext = logical_html_path.with_extension(""); + format!("{}/", without_ext.to_url_path()) + } +} + +/// Converts a logical HTML path to the physical output path in clean URL mode. +/// +/// Index pages are left unchanged. Non-index pages are moved into a +/// subdirectory so the URL has no extension. +/// +/// # Examples +/// +/// - `foo/bar.html` → `PathBuf::from("foo/bar/index.html")` +/// - `foo/index.html` → `PathBuf::from("foo/index.html")` +/// - `index.html` → `PathBuf::from("index.html")` +/// - `bar.html` → `PathBuf::from("bar/index.html")` +pub(crate) fn clean_url_output_path(logical_html_path: &Path) -> PathBuf { + let is_index = logical_html_path.file_stem() == Some(OsStr::new("index")); + + if is_index { + logical_html_path.to_path_buf() + } else { + logical_html_path.with_extension("").join("index.html") + } +} + +/// Computes the path-to-root for a source `.md` file in clean URL mode. +/// +/// Wraps [`mdbook_core::utils::fs::path_to_root`], adding one extra `../` +/// for non-index files because those pages are served from a subdirectory. +/// +/// # Examples +/// +/// - `foo/bar.md` → `"../../"` +/// - `foo/index.md` → `"../"` +/// - `index.md` → `""` +/// - `bar.md` → `"../"` +/// - `a/b/c.md` → `"../../../"` +pub(crate) fn clean_url_path_to_root(source_md_path: &Path) -> String { + let is_index = source_md_path.file_stem() == Some(OsStr::new("index")); + let mut root = mdbook_core::utils::fs::path_to_root(source_md_path); + if !is_index { + root.push_str("../"); + } + root +} + #[cfg(test)] mod tests { use super::*; @@ -132,4 +201,111 @@ mod tests { assert_eq!(id_from_content("中文標題 CJK title"), "中文標題-cjk-title"); assert_eq!(id_from_content("Über"), "über"); } + + #[test] + fn clean_url_link_path_root_index() { + assert_eq!(clean_url_link_path(Path::new("index.html")), "./"); + } + + #[test] + fn clean_url_link_path_root_non_index() { + assert_eq!(clean_url_link_path(Path::new("bar.html")), "bar/"); + } + + #[test] + fn clean_url_link_path_nested_index() { + assert_eq!(clean_url_link_path(Path::new("foo/index.html")), "foo/"); + } + + #[test] + fn clean_url_link_path_nested_non_index() { + assert_eq!(clean_url_link_path(Path::new("foo/bar.html")), "foo/bar/"); + } + + #[test] + fn clean_url_link_path_deeply_nested() { + assert_eq!(clean_url_link_path(Path::new("a/b/c.html")), "a/b/c/"); + assert_eq!(clean_url_link_path(Path::new("a/b/index.html")), "a/b/"); + } + + #[test] + fn clean_url_output_path_root_index() { + assert_eq!( + clean_url_output_path(Path::new("index.html")), + PathBuf::from("index.html") + ); + } + + #[test] + fn clean_url_output_path_root_non_index() { + assert_eq!( + clean_url_output_path(Path::new("bar.html")), + PathBuf::from("bar/index.html") + ); + } + + #[test] + fn clean_url_output_path_nested_index() { + assert_eq!( + clean_url_output_path(Path::new("foo/index.html")), + PathBuf::from("foo/index.html") + ); + } + + #[test] + fn clean_url_output_path_nested_non_index() { + assert_eq!( + clean_url_output_path(Path::new("foo/bar.html")), + PathBuf::from("foo/bar/index.html") + ); + } + + #[test] + fn clean_url_output_path_deeply_nested() { + assert_eq!( + clean_url_output_path(Path::new("a/b/c.html")), + PathBuf::from("a/b/c/index.html") + ); + assert_eq!( + clean_url_output_path(Path::new("a/b/index.html")), + PathBuf::from("a/b/index.html") + ); + } + + #[test] + fn clean_url_path_to_root_root_index() { + // index.md at root: path_to_root = "", no extra since index + assert_eq!(clean_url_path_to_root(Path::new("index.md")), ""); + } + + #[test] + fn clean_url_path_to_root_root_non_index() { + // bar.md at root: path_to_root = "", +"../" since non-index + assert_eq!(clean_url_path_to_root(Path::new("bar.md")), "../"); + } + + #[test] + fn clean_url_path_to_root_nested_index() { + // foo/index.md: path_to_root = "../", no extra since index + assert_eq!(clean_url_path_to_root(Path::new("foo/index.md")), "../"); + } + + #[test] + fn clean_url_path_to_root_nested_non_index() { + // foo/bar.md: path_to_root = "../", +"../" since non-index + assert_eq!(clean_url_path_to_root(Path::new("foo/bar.md")), "../../"); + } + + #[test] + fn clean_url_path_to_root_deeply_nested() { + // a/b/c.md: path_to_root(parent=a/b) = "../../", +"../" since non-index + assert_eq!(clean_url_path_to_root(Path::new("a/b/c.md")), "../../../"); + // a/b/index.md: path_to_root(parent=a/b) = "../../", no extra since index + assert_eq!(clean_url_path_to_root(Path::new("a/b/index.md")), "../../"); + // a/b/c/d.md: path_to_root(parent=a/b/c) = "../../../", +"../" since non-index + assert_eq!( + clean_url_path_to_root(Path::new("a/b/c/d.md")), + "../../../../" + ); + } } From e473a4c64a5c73d58946fd7a5e1a6ad444320501 Mon Sep 17 00:00:00 2001 From: Jonathan Hult Date: Wed, 25 Feb 2026 16:49:54 -0600 Subject: [PATCH 03/12] feat(html): implement clean URL file output paths --- .../src/html_handlebars/hbs_renderer.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs index 8edac3cace..89f82e948d 100644 --- a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs +++ b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs @@ -3,7 +3,7 @@ use super::static_files::StaticFiles; use crate::html::ChapterTree; use crate::html::{build_trees, render_markdown, serialize}; use crate::theme::Theme; -use crate::utils::ToUrlPath; +use crate::utils::{clean_url_output_path, clean_url_path_to_root, ToUrlPath}; use anyhow::{Context, Result, bail}; use handlebars::Handlebars; use mdbook_core::book::{Book, BookItem, Chapter}; @@ -64,6 +64,11 @@ impl HtmlHandlebars { .to_str() .with_context(|| "Could not convert path to str")?; let filepath = Path::new(&ctx_path).with_extension("html"); + let filepath = if ctx.html_config.no_html_extension { + clean_url_output_path(&filepath) + } else { + filepath + }; let book_title = ctx .data @@ -83,8 +88,14 @@ impl HtmlHandlebars { ctx.data.insert("content".to_owned(), json!(content)); ctx.data.insert("chapter_title".to_owned(), json!(ch.name)); ctx.data.insert("title".to_owned(), json!(title)); - ctx.data - .insert("path_to_root".to_owned(), json!(fs::path_to_root(path))); + ctx.data.insert( + "path_to_root".to_owned(), + if ctx.html_config.no_html_extension { + json!(clean_url_path_to_root(path)) + } else { + json!(fs::path_to_root(path)) + }, + ); if let Some(ref section) = ch.number { ctx.data .insert("section".to_owned(), json!(section.to_string())); @@ -121,6 +132,7 @@ impl HtmlHandlebars { // Write to file let out_path = ctx.destination.join(filepath); + fs::create_dir_all(out_path.parent().unwrap())?; fs::write(&out_path, rendered)?; if prev_ch.is_none() { From 7fa083187b61c74175d1a834049dd12ffc08b44e Mon Sep 17 00:00:00 2001 From: Jonathan Hult Date: Wed, 25 Feb 2026 16:53:39 -0600 Subject: [PATCH 04/12] feat(html): update fix_link for clean URLs --- crates/mdbook-html/src/html/tree.rs | 77 +++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 10 deletions(-) diff --git a/crates/mdbook-html/src/html/tree.rs b/crates/mdbook-html/src/html/tree.rs index 5cb97ce378..123c6e6fcf 100644 --- a/crates/mdbook-html/src/html/tree.rs +++ b/crates/mdbook-html/src/html/tree.rs @@ -542,7 +542,7 @@ where let href: StrTendril = if matches!(link_type, LinkType::Email) { format!("mailto:{dest_url}").into() } else { - fix_link(dest_url).into_tendril() + fix_link(dest_url, self.options.config.no_html_extension).into_tendril() }; let mut a = Element::new("a"); a.insert_attr("href", href); @@ -558,7 +558,7 @@ where id: _, } => { let mut img = Element::new("img"); - let src = fix_link(dest_url).into_tendril(); + let src = fix_link(dest_url, self.options.config.no_html_extension).into_tendril(); img.insert_attr("src", src); if !title.is_empty() { img.insert_attr("title", title.into_tendril()); @@ -675,7 +675,7 @@ where self_closing: tag.self_closing, was_raw: true, }; - fix_html_link(&mut el); + fix_html_link(&mut el, self.options.config.no_html_extension); self.push(Node::Element(el)); if is_closed { // No end element. @@ -1089,10 +1089,11 @@ fn text_in_node(node: NodeRef<'_, Node>, output: &mut String) { /// Modifies links to work with HTML. /// -/// For local paths, this changes the `.md` extension to `.html`. -fn fix_link<'a>(link: CowStr<'a>) -> CowStr<'a> { +/// For local paths, this changes the `.md` extension to `.html` when +/// `clean_urls` is `false`, or to a trailing-slash clean URL when `true`. +fn fix_link<'a>(link: CowStr<'a>, clean_urls: bool) -> CowStr<'a> { static_regex!(SCHEME_LINK, r"^[a-z][a-z0-9+.-]*:"); - static_regex!(MD_LINK, r"(?P.*)\.md(?P#.*)?"); + static_regex!(MD_LINK, r"(?P.*)\.md(?P#[^?]*)?"); if link.starts_with('#') { // Fragment-only link. @@ -1105,8 +1106,19 @@ fn fix_link<'a>(link: CowStr<'a>) -> CowStr<'a> { // This is a relative link, adjust it as necessary. if let Some(caps) = MD_LINK.captures(&link) { - let mut fixed_link = String::from(&caps["link"]); - fixed_link.push_str(".html"); + let link_stem = &caps["link"]; + let mut fixed_link = if clean_urls { + // Convert .md links to clean URL format (trailing slash). + if link_stem == "index" { + "./".to_string() + } else if let Some(parent) = link_stem.strip_suffix("/index") { + format!("{parent}/") + } else { + format!("{link_stem}/") + } + } else { + format!("{link_stem}.html") + }; if let Some(anchor) = caps.name("anchor") { fixed_link.push_str(anchor.as_str()); } @@ -1117,13 +1129,13 @@ fn fix_link<'a>(link: CowStr<'a>) -> CowStr<'a> { } /// Calls [`fix_link`] for HTML elements. -fn fix_html_link(el: &mut Element) { +fn fix_html_link(el: &mut Element, clean_urls: bool) { if el.name() != "a" { return; } for attr in ["href", "xlink:href"] { if let Some(value) = el.attr(attr) { - let fixed = fix_link(value.into()); + let fixed = fix_link(value.into(), clean_urls); el.insert_attr(attr, fixed.into_tendril()); } } @@ -1153,3 +1165,48 @@ pub(crate) fn is_void_element(name: &str) -> bool { | "wbr" ) } + +#[cfg(test)] +mod tests { + use super::fix_link; + use pulldown_cmark::CowStr; + + fn fl(s: &str, clean: bool) -> String { + fix_link(CowStr::Borrowed(s), clean).into_string() + } + + #[test] + fn fix_link_html_default() { + assert_eq!(fl("chapter-b.md", false), "chapter-b.html"); + } + + #[test] + fn fix_link_clean_url_basic() { + assert_eq!(fl("chapter-b.md", true), "chapter-b/"); + } + + #[test] + fn fix_link_clean_url_index() { + assert_eq!(fl("index.md", true), "./"); + } + + #[test] + fn fix_link_clean_url_nested_index() { + assert_eq!(fl("foo/index.md", true), "foo/"); + } + + #[test] + fn fix_link_clean_url_with_anchor() { + assert_eq!(fl("foo/bar.md#section", true), "foo/bar/#section"); + } + + #[test] + fn fix_link_clean_url_external_unchanged() { + assert_eq!(fl("https://example.com", true), "https://example.com"); + } + + #[test] + fn fix_link_clean_url_anchor_only_unchanged() { + assert_eq!(fl("#anchor", true), "#anchor"); + } +} From 5bd4bcb2954569d5fe2081f05924dc52fd9d3a38 Mon Sep 17 00:00:00 2001 From: Jonathan Hult Date: Wed, 25 Feb 2026 16:57:55 -0600 Subject: [PATCH 05/12] feat(html): update TOC and nav links for clean URLs --- .../src/html_handlebars/hbs_renderer.rs | 18 +++++++++++------- .../src/html_handlebars/helpers/toc.rs | 9 +++++++-- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs index 89f82e948d..7c738fd1a1 100644 --- a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs +++ b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs @@ -3,7 +3,7 @@ use super::static_files::StaticFiles; use crate::html::ChapterTree; use crate::html::{build_trees, render_markdown, serialize}; use crate::theme::Theme; -use crate::utils::{clean_url_output_path, clean_url_path_to_root, ToUrlPath}; +use crate::utils::{clean_url_link_path, clean_url_output_path, clean_url_path_to_root, ToUrlPath}; use anyhow::{Context, Result, bail}; use handlebars::Handlebars; use mdbook_core::book::{Book, BookItem, Chapter}; @@ -111,12 +111,15 @@ impl HtmlHandlebars { let mut nav = |name: &str, ch: Option<&Chapter>| { let Some(ch) = ch else { return }; - let path = ch - .path - .as_ref() - .unwrap() - .with_extension("html") - .to_url_path(); + let path = if ctx.html_config.no_html_extension { + clean_url_link_path(&ch.path.as_ref().unwrap().with_extension("html")) + } else { + ch.path + .as_ref() + .unwrap() + .with_extension("html") + .to_url_path() + }; let obj = json!( { "title": ch.name, "link": path, @@ -241,6 +244,7 @@ impl HtmlHandlebars { "toc", Box::new(helpers::toc::RenderToc { no_section_label: html_config.no_section_label, + no_html_extension: html_config.no_html_extension, }), ); handlebars.register_helper("fa", Box::new(helpers::fontawesome::fa_helper)); diff --git a/crates/mdbook-html/src/html_handlebars/helpers/toc.rs b/crates/mdbook-html/src/html_handlebars/helpers/toc.rs index baee73f6d3..ef1f82549f 100644 --- a/crates/mdbook-html/src/html_handlebars/helpers/toc.rs +++ b/crates/mdbook-html/src/html_handlebars/helpers/toc.rs @@ -1,4 +1,4 @@ -use crate::utils::ToUrlPath; +use crate::utils::{ToUrlPath, clean_url_link_path}; use handlebars::{ Context, Handlebars, Helper, HelperDef, Output, RenderContext, RenderError, RenderErrorReason, }; @@ -10,6 +10,7 @@ use std::{cmp::Ordering, collections::BTreeMap}; #[derive(Clone, Copy)] pub(crate) struct RenderToc { pub no_section_label: bool, + pub no_html_extension: bool, } impl HelperDef for RenderToc { @@ -117,7 +118,11 @@ impl HelperDef for RenderToc { let path_exists = match item.get("path") { Some(path) if !path.is_empty() => { out.write(" Date: Wed, 25 Feb 2026 17:00:53 -0600 Subject: [PATCH 06/12] feat(html): update search index for clean URLs --- .../src/html_handlebars/hbs_renderer.rs | 9 +++++++-- .../mdbook-html/src/html_handlebars/search.rs | 18 +++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs index 7c738fd1a1..c2e37b9dd8 100644 --- a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs +++ b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs @@ -3,7 +3,7 @@ use super::static_files::StaticFiles; use crate::html::ChapterTree; use crate::html::{build_trees, render_markdown, serialize}; use crate::theme::Theme; -use crate::utils::{clean_url_link_path, clean_url_output_path, clean_url_path_to_root, ToUrlPath}; +use crate::utils::{ToUrlPath, clean_url_link_path, clean_url_output_path, clean_url_path_to_root}; use anyhow::{Context, Result, bail}; use handlebars::Handlebars; use mdbook_core::book::{Book, BookItem, Chapter}; @@ -386,7 +386,12 @@ impl Renderer for HtmlHandlebars { let default = mdbook_core::config::Search::default(); let search = html_config.search.as_ref().unwrap_or(&default); if search.enable { - super::search::create_files(&search, &mut static_files, &chapter_trees)?; + super::search::create_files( + &search, + &mut static_files, + &chapter_trees, + html_config.no_html_extension, + )?; } } diff --git a/crates/mdbook-html/src/html_handlebars/search.rs b/crates/mdbook-html/src/html_handlebars/search.rs index 09920d76d2..1588100077 100644 --- a/crates/mdbook-html/src/html_handlebars/search.rs +++ b/crates/mdbook-html/src/html_handlebars/search.rs @@ -1,7 +1,7 @@ use super::static_files::StaticFiles; use crate::html::{ChapterTree, Node}; use crate::theme::searcher; -use crate::utils::ToUrlPath; +use crate::utils::{ToUrlPath, clean_url_link_path}; use anyhow::{Result, bail}; use ego_tree::iter::Edge; use elasticlunr::{Index, IndexBuilder}; @@ -30,6 +30,7 @@ pub(super) fn create_files( search_config: &Search, static_files: &mut StaticFiles, chapter_trees: &[ChapterTree<'_>], + no_html_extension: bool, ) -> Result<()> { let mut index = IndexBuilder::new() .add_field_with_tokenizer("title", Box::new(&tokenize)) @@ -49,7 +50,13 @@ pub(super) fn create_files( if !chapter_settings.enable.unwrap_or(true) { continue; } - index_chapter(&mut index, search_config, &mut doc_urls, ct)?; + index_chapter( + &mut index, + search_config, + &mut doc_urls, + ct, + no_html_extension, + )?; } let index = write_to_json(index, search_config, doc_urls)?; @@ -105,8 +112,13 @@ fn index_chapter( search_config: &Search, doc_urls: &mut Vec, chapter_tree: &ChapterTree<'_>, + no_html_extension: bool, ) -> Result<()> { - let anchor_base = chapter_tree.html_path.to_url_path(); + let anchor_base = if no_html_extension { + clean_url_link_path(&chapter_tree.html_path) + } else { + chapter_tree.html_path.to_url_path() + }; let mut in_heading = false; let max_section_depth = search_config.heading_split_level; From e8e0d4a4c529976bf7fa1760ad0eb515a31f6dcd Mon Sep 17 00:00:00 2001 From: Jonathan Hult Date: Wed, 25 Feb 2026 17:09:09 -0600 Subject: [PATCH 07/12] feat(html): update frontend JS for clean URLs --- .../mdbook-html/front-end/templates/toc.js.hbs | 16 ++++++++++++++++ .../src/html_handlebars/hbs_renderer.rs | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/crates/mdbook-html/front-end/templates/toc.js.hbs b/crates/mdbook-html/front-end/templates/toc.js.hbs index 1a9f751ccf..e8b0cc48cf 100644 --- a/crates/mdbook-html/front-end/templates/toc.js.hbs +++ b/crates/mdbook-html/front-end/templates/toc.js.hbs @@ -11,9 +11,15 @@ class MDBookSidebarScrollbox extends HTMLElement { this.innerHTML = '{{#toc}}{{/toc}}'; // Set the current, active page, and reveal it if it's hidden let current_page = document.location.href.toString().split('#')[0].split('?')[0]; + {{#if no_html_extension}} + if (current_page.endsWith('index.html')) { + current_page = current_page.slice(0, -'index.html'.length); + } +{{else}} if (current_page.endsWith('/')) { current_page += 'index.html'; } +{{/if}} const links = Array.prototype.slice.call(this.querySelectorAll('a')); const l = links.length; for (let i = 0; i < l; ++i) { @@ -23,10 +29,20 @@ class MDBookSidebarScrollbox extends HTMLElement { link.href = path_to_root + href; } // The 'index' page is supposed to alias the first chapter in the book. + {{#if no_html_extension}} + let link_href = link.href; + if (link_href.endsWith('index.html')) { + link_href = link_href.slice(0, -'index.html'.length); + } + if (link_href === current_page + || i === 0 + && path_to_root === '') { +{{else}} if (link.href === current_page || i === 0 && path_to_root === '' && current_page.endsWith('/index.html')) { +{{/if}} link.classList.add('active'); let parent = link.parentElement; while (parent) { diff --git a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs index c2e37b9dd8..6e71e6a87a 100644 --- a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs +++ b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs @@ -572,6 +572,10 @@ fn make_data( "sidebar_header_nav".to_owned(), json!(html_config.sidebar_header_nav), ); + data.insert( + "no_html_extension".to_owned(), + json!(html_config.no_html_extension), + ); let search = html_config.search.clone(); if cfg!(feature = "search") { From 4179734d6b52d9657994fe0288b8e42d61e082fd Mon Sep 17 00:00:00 2001 From: Jonathan Hult Date: Wed, 25 Feb 2026 20:07:02 -0600 Subject: [PATCH 08/12] feat(html): update special page outputs for clean URLs with conflict resolution --- .../mdbook-html/front-end/templates/index.hbs | 4 +- .../src/html_handlebars/hbs_renderer.rs | 108 ++++++++++++++++-- 2 files changed, 98 insertions(+), 14 deletions(-) diff --git a/crates/mdbook-html/front-end/templates/index.hbs b/crates/mdbook-html/front-end/templates/index.hbs index b1834189f9..cbc8848864 100644 --- a/crates/mdbook-html/front-end/templates/index.hbs +++ b/crates/mdbook-html/front-end/templates/index.hbs @@ -129,7 +129,7 @@