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");
+ }
}