diff --git a/crates/mdbook-html/src/html/print.rs b/crates/mdbook-html/src/html/print.rs index e325396fd9..c852648d15 100644 --- a/crates/mdbook-html/src/html/print.rs +++ b/crates/mdbook-html/src/html/print.rs @@ -6,7 +6,7 @@ use super::Node; use crate::html::{ChapterTree, Element, serialize}; -use crate::utils::{ToUrlPath, id_from_content, normalize_path, unique_id}; +use crate::utils::{ToUrlPath, id_from_content_or_fallback, normalize_path, unique_id}; use mdbook_core::static_regex; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -103,8 +103,7 @@ fn make_root_id_map( // TODO: This might want to be a warning? Chapters generally // should start with an h1. let mut h1 = Element::new("h1"); - let id = id_from_content(&chapter.name); - let id = unique_id(&id, id_counter); + let id = id_from_content_or_fallback(&chapter.name, id_counter); h1.insert_attr("id", id.clone().into()); let mut root = tree.root_mut(); let mut h1 = root.prepend(Node::Element(h1)); diff --git a/crates/mdbook-html/src/html/tree.rs b/crates/mdbook-html/src/html/tree.rs index 5cb97ce378..350ea34728 100644 --- a/crates/mdbook-html/src/html/tree.rs +++ b/crates/mdbook-html/src/html/tree.rs @@ -7,7 +7,7 @@ use super::tokenizer::parse_html; use super::{HtmlRenderOptions, hide_lines, wrap_rust_main}; -use crate::utils::{id_from_content, unique_id}; +use crate::utils::id_from_content_or_fallback; use ego_tree::{NodeId, NodeRef, Tree}; use html5ever::tendril::StrTendril; use html5ever::tokenizer::{TagKind, Token}; @@ -924,8 +924,7 @@ where let node_id = node.id(); let node_ref = self.tree.get(node_id).unwrap(); text_in_node(node_ref, &mut id); - let id = id_from_content(&id); - let id = unique_id(&id, &mut id_counter); + let id = id_from_content_or_fallback(&id, &mut id_counter); let mut node = self.tree.get_mut(heading).unwrap(); let el = node.value().as_element_mut().unwrap(); let href = format!("#{id}"); diff --git a/crates/mdbook-html/src/utils.rs b/crates/mdbook-html/src/utils.rs index 68f42a4094..6401053ed2 100644 --- a/crates/mdbook-html/src/utils.rs +++ b/crates/mdbook-html/src/utils.rs @@ -99,6 +99,19 @@ pub(crate) fn id_from_content(content: &str) -> String { .collect() } +/// Returns a non-empty HTML id for heading content. +/// +/// Headings that contain only punctuation (e.g. `## ::`) produce an empty slug via +/// [`id_from_content`]; this helper falls back to a generic prefix so anchor links work. +pub(crate) fn id_from_content_or_fallback(content: &str, used: &mut HashSet) -> String { + let id = id_from_content(content); + if id.is_empty() { + unique_id("section", used) + } else { + unique_id(&id, used) + } +} + #[cfg(test)] mod tests { use super::*; @@ -129,7 +142,15 @@ mod tests { assert_eq!(id_from_content("にほんご"), "にほんご"); assert_eq!(id_from_content("한국어"), "한국어"); assert_eq!(id_from_content(""), ""); + assert_eq!(id_from_content("::"), ""); assert_eq!(id_from_content("中文標題 CJK title"), "中文標題-cjk-title"); assert_eq!(id_from_content("Über"), "über"); } + + #[test] + fn empty_heading_gets_fallback_id() { + let mut used = HashSet::new(); + assert_eq!(id_from_content_or_fallback("::", &mut used), "section"); + assert_eq!(id_from_content_or_fallback("::", &mut used), "section-1"); + } }