Skip to content

Commit a5fddfa

Browse files
authored
Merge pull request #1749 from tommilligan/unique-search-anchors
search: fix anchor ids for duplicate headers
2 parents 7c37dd5 + 972c61f commit a5fddfa

File tree

7 files changed

+553
-136
lines changed

7 files changed

+553
-136
lines changed

src/renderer/html_handlebars/hbs_renderer.rs

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -778,16 +778,7 @@ fn insert_link_into_header(
778778
content: &str,
779779
id_counter: &mut HashMap<String, usize>,
780780
) -> String {
781-
let raw_id = utils::id_from_content(content);
782-
783-
let id_count = id_counter.entry(raw_id.clone()).or_insert(0);
784-
785-
let id = match *id_count {
786-
0 => raw_id,
787-
other => format!("{}-{}", raw_id, other),
788-
};
789-
790-
*id_count += 1;
781+
let id = utils::unique_id_from_content(content, id_counter);
791782

792783
format!(
793784
r##"<h{level} id="{id}"><a class="header" href="#{id}">{text}</a></h{level}>"##,

src/renderer/html_handlebars/search.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ fn render_item(
9797

9898
breadcrumbs.push(chapter.name.clone());
9999

100+
let mut id_counter = HashMap::new();
100101
while let Some(event) = p.next() {
101102
match event {
102103
Event::Start(Tag::Heading(i, ..)) if i as u32 <= max_section_depth => {
@@ -120,7 +121,7 @@ fn render_item(
120121
}
121122
Event::End(Tag::Heading(i, ..)) if i as u32 <= max_section_depth => {
122123
in_heading = false;
123-
section_id = Some(utils::id_from_content(&heading));
124+
section_id = Some(utils::unique_id_from_content(&heading, &mut id_counter));
124125
breadcrumbs.push(heading.clone());
125126
}
126127
Event::Start(Tag::FootnoteDefinition(name)) => {

src/utils/mod.rs

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use regex::Regex;
99
use pulldown_cmark::{html, CodeBlockKind, CowStr, Event, Options, Parser, Tag};
1010

1111
use std::borrow::Cow;
12+
use std::collections::HashMap;
1213
use std::fmt::Write;
1314
use std::path::Path;
1415

@@ -44,6 +45,8 @@ pub fn normalize_id(content: &str) -> String {
4445

4546
/// Generate an ID for use with anchors which is derived from a "normalised"
4647
/// string.
48+
// This function should be made private when the deprecation expires.
49+
#[deprecated(since = "0.4.16", note = "use unique_id_from_content instead")]
4750
pub fn id_from_content(content: &str) -> String {
4851
let mut content = content.to_string();
4952

@@ -59,10 +62,30 @@ pub fn id_from_content(content: &str) -> String {
5962

6063
// Remove spaces and hashes indicating a header
6164
let trimmed = content.trim().trim_start_matches('#').trim();
62-
6365
normalize_id(trimmed)
6466
}
6567

68+
/// Generate an ID for use with anchors which is derived from a "normalised"
69+
/// string.
70+
///
71+
/// Each ID returned will be unique, if the same `id_counter` is provided on
72+
/// each call.
73+
pub fn unique_id_from_content(content: &str, id_counter: &mut HashMap<String, usize>) -> String {
74+
let id = {
75+
#[allow(deprecated)]
76+
id_from_content(content)
77+
};
78+
79+
// If we have headers with the same normalized id, append an incrementing counter
80+
let id_count = id_counter.entry(id.clone()).or_insert(0);
81+
let unique_id = match *id_count {
82+
0 => id,
83+
id_count => format!("{}-{}", id, id_count),
84+
};
85+
*id_count += 1;
86+
unique_id
87+
}
88+
6689
/// Fix links to the correct location.
6790
///
6891
/// This adjusts links, such as turning `.md` extensions to `.html`.
@@ -332,8 +355,9 @@ more text with spaces
332355
}
333356
}
334357

335-
mod html_munging {
336-
use super::super::{id_from_content, normalize_id};
358+
#[allow(deprecated)]
359+
mod id_from_content {
360+
use super::super::id_from_content;
337361

338362
#[test]
339363
fn it_generates_anchors() {
@@ -361,6 +385,10 @@ more text with spaces
361385
);
362386
assert_eq!(id_from_content("## Über"), "Über");
363387
}
388+
}
389+
390+
mod html_munging {
391+
use super::super::{normalize_id, unique_id_from_content};
364392

365393
#[test]
366394
fn it_normalizes_ids() {
@@ -379,5 +407,28 @@ more text with spaces
379407
assert_eq!(normalize_id("한국어"), "한국어");
380408
assert_eq!(normalize_id(""), "");
381409
}
410+
411+
#[test]
412+
fn it_generates_unique_ids_from_content() {
413+
// Same id if not given shared state
414+
assert_eq!(
415+
unique_id_from_content("## 中文標題 CJK title", &mut Default::default()),
416+
"中文標題-cjk-title"
417+
);
418+
assert_eq!(
419+
unique_id_from_content("## 中文標題 CJK title", &mut Default::default()),
420+
"中文標題-cjk-title"
421+
);
422+
423+
// Different id if given shared state
424+
let mut id_counter = Default::default();
425+
assert_eq!(unique_id_from_content("## Über", &mut id_counter), "Über");
426+
assert_eq!(
427+
unique_id_from_content("## 中文標題 CJK title", &mut id_counter),
428+
"中文標題-cjk-title"
429+
);
430+
assert_eq!(unique_id_from_content("## Über", &mut id_counter), "Über-1");
431+
assert_eq!(unique_id_from_content("## Über", &mut id_counter), "Über-2");
432+
}
382433
}
383434
}

tests/dummy_book/src/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- [Markdown](first/markdown.md)
1414
- [Unicode](first/unicode.md)
1515
- [No Headers](first/no-headers.md)
16+
- [Duplicate Headers](first/duplicate-headers.md)
1617
- [Second Chapter](second.md)
1718
- [Nested Chapter](second/nested.md)
1819

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Duplicate headers
2+
3+
This page validates behaviour of duplicate headers.
4+
5+
# Header Text
6+
7+
# Header Text
8+
9+
# header-text

tests/rendered_output.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const TOC_SECOND_LEVEL: &[&str] = &[
3636
"1.4. Markdown",
3737
"1.5. Unicode",
3838
"1.6. No Headers",
39+
"1.7. Duplicate Headers",
3940
"2.1. Nested Chapter",
4041
];
4142

@@ -653,11 +654,12 @@ mod search {
653654
let some_section = get_doc_ref("first/index.html#some-section");
654655
let summary = get_doc_ref("first/includes.html#summary");
655656
let no_headers = get_doc_ref("first/no-headers.html");
657+
let duplicate_headers_1 = get_doc_ref("first/duplicate-headers.html#header-text-1");
656658
let conclusion = get_doc_ref("conclusion.html#conclusion");
657659

658660
let bodyidx = &index["index"]["index"]["body"]["root"];
659661
let textidx = &bodyidx["t"]["e"]["x"]["t"];
660-
assert_eq!(textidx["df"], 2);
662+
assert_eq!(textidx["df"], 5);
661663
assert_eq!(textidx["docs"][&first_chapter]["tf"], 1.0);
662664
assert_eq!(textidx["docs"][&introduction]["tf"], 1.0);
663665

@@ -666,7 +668,7 @@ mod search {
666668
assert_eq!(docs[&some_section]["body"], "");
667669
assert_eq!(
668670
docs[&summary]["body"],
669-
"Dummy Book Introduction First Chapter Nested Chapter Includes Recursive Markdown Unicode No Headers Second Chapter Nested Chapter Conclusion"
671+
"Dummy Book Introduction First Chapter Nested Chapter Includes Recursive Markdown Unicode No Headers Duplicate Headers Second Chapter Nested Chapter Conclusion"
670672
);
671673
assert_eq!(
672674
docs[&summary]["breadcrumbs"],
@@ -677,6 +679,10 @@ mod search {
677679
docs[&no_headers]["breadcrumbs"],
678680
"First Chapter » No Headers"
679681
);
682+
assert_eq!(
683+
docs[&duplicate_headers_1]["breadcrumbs"],
684+
"First Chapter » Duplicate Headers » Header Text"
685+
);
680686
assert_eq!(
681687
docs[&no_headers]["body"],
682688
"Capybara capybara capybara. Capybara capybara capybara."

0 commit comments

Comments
 (0)