Skip to content

Commit 3a51abf

Browse files
authored
Merge pull request #2013 from ImUrX/heading-extension
Add heading extension support
2 parents 870e908 + 1db52ff commit 3a51abf

File tree

9 files changed

+504
-125
lines changed

9 files changed

+504
-125
lines changed

guide/src/format/markdown.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,16 @@ To enable it, see the [`output.html.curly-quotes`] config option.
220220
[tables]: https://github.github.com/gfm/#tables-extension-
221221
[task list extension]: https://github.github.com/gfm/#task-list-items-extension-
222222
[`output.html.curly-quotes`]: configuration/renderers.md#html-renderer-options
223+
224+
### Heading attributes
225+
226+
Headings can have a custom HTML ID and classes. This let's you maintain the same ID even if you change the heading's text, it also let's you add multiple classes in the heading.
227+
228+
Example:
229+
```md
230+
# Example heading { #first .class1 .class2 }
231+
```
232+
233+
This makes the level 1 heading with the content `Example heading`, ID `first`, and classes `class1` and `class2`. Note that the attributes should be space-separated.
234+
235+
More information can be found in the [heading attrs spec page](https://github.com/raphlinus/pulldown-cmark/blob/master/specs/heading_attrs.txt).

src/renderer/html_handlebars/hbs_renderer.rs

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -789,8 +789,10 @@ fn make_data(
789789
/// Goes through the rendered HTML, making sure all header tags have
790790
/// an anchor respectively so people can link to sections directly.
791791
fn build_header_links(html: &str) -> String {
792-
static BUILD_HEADER_LINKS: Lazy<Regex> =
793-
Lazy::new(|| Regex::new(r"<h(\d)>(.*?)</h\d>").unwrap());
792+
static BUILD_HEADER_LINKS: Lazy<Regex> = Lazy::new(|| {
793+
Regex::new(r#"<h(\d)(?: id="([^"]+)")?(?: class="([^"]+)")?>(.*?)</h\d>"#).unwrap()
794+
});
795+
static IGNORE_CLASS: &[&str] = &["menu-title"];
794796

795797
let mut id_counter = HashMap::new();
796798

@@ -800,7 +802,22 @@ fn build_header_links(html: &str) -> String {
800802
.parse()
801803
.expect("Regex should ensure we only ever get numbers here");
802804

803-
insert_link_into_header(level, &caps[2], &mut id_counter)
805+
// Ignore .menu-title because now it's getting detected by the regex.
806+
if let Some(classes) = caps.get(3) {
807+
for class in classes.as_str().split(" ") {
808+
if IGNORE_CLASS.contains(&class) {
809+
return caps[0].to_string();
810+
}
811+
}
812+
}
813+
814+
insert_link_into_header(
815+
level,
816+
&caps[4],
817+
caps.get(2).map(|x| x.as_str().to_string()),
818+
caps.get(3).map(|x| x.as_str().to_string()),
819+
&mut id_counter,
820+
)
804821
})
805822
.into_owned()
806823
}
@@ -810,15 +827,21 @@ fn build_header_links(html: &str) -> String {
810827
fn insert_link_into_header(
811828
level: usize,
812829
content: &str,
830+
id: Option<String>,
831+
classes: Option<String>,
813832
id_counter: &mut HashMap<String, usize>,
814833
) -> String {
815-
let id = utils::unique_id_from_content(content, id_counter);
834+
let id = id.unwrap_or_else(|| utils::unique_id_from_content(content, id_counter));
835+
let classes = classes
836+
.map(|s| format!(" class=\"{s}\""))
837+
.unwrap_or_default();
816838

817839
format!(
818-
r##"<h{level} id="{id}"><a class="header" href="#{id}">{text}</a></h{level}>"##,
840+
r##"<h{level} id="{id}"{classes}><a class="header" href="#{id}">{text}</a></h{level}>"##,
819841
level = level,
820842
id = id,
821-
text = content
843+
text = content,
844+
classes = classes
822845
)
823846
}
824847

@@ -1015,6 +1038,21 @@ mod tests {
10151038
"<h1>Foo</h1><h3>Foo</h3>",
10161039
r##"<h1 id="foo"><a class="header" href="#foo">Foo</a></h1><h3 id="foo-1"><a class="header" href="#foo-1">Foo</a></h3>"##,
10171040
),
1041+
// id only
1042+
(
1043+
r##"<h1 id="foobar">Foo</h1>"##,
1044+
r##"<h1 id="foobar"><a class="header" href="#foobar">Foo</a></h1>"##,
1045+
),
1046+
// class only
1047+
(
1048+
r##"<h1 class="class1 class2">Foo</h1>"##,
1049+
r##"<h1 id="foo" class="class1 class2"><a class="header" href="#foo">Foo</a></h1>"##,
1050+
),
1051+
// both id and class
1052+
(
1053+
r##"<h1 id="foobar" class="class1 class2">Foo</h1>"##,
1054+
r##"<h1 id="foobar" class="class1 class2"><a class="header" href="#foobar">Foo</a></h1>"##,
1055+
),
10181056
];
10191057

10201058
for (src, should_be) in inputs {

src/renderer/html_handlebars/search.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,11 @@ fn render_item(
138138

139139
in_heading = true;
140140
}
141-
Event::End(Tag::Heading(i, ..)) if i as u32 <= max_section_depth => {
141+
Event::End(Tag::Heading(i, id, _classes)) if i as u32 <= max_section_depth => {
142142
in_heading = false;
143-
section_id = Some(utils::unique_id_from_content(&heading, &mut id_counter));
143+
section_id = id
144+
.map(|id| id.to_string())
145+
.or_else(|| Some(utils::unique_id_from_content(&heading, &mut id_counter)));
144146
breadcrumbs.push(heading.clone());
145147
}
146148
Event::Start(Tag::FootnoteDefinition(name)) => {

src/utils/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ pub fn new_cmark_parser(text: &str, curly_quotes: bool) -> Parser<'_, '_> {
183183
opts.insert(Options::ENABLE_FOOTNOTES);
184184
opts.insert(Options::ENABLE_STRIKETHROUGH);
185185
opts.insert(Options::ENABLE_TASKLISTS);
186+
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
186187
if curly_quotes {
187188
opts.insert(Options::ENABLE_SMART_PUNCTUATION);
188189
}

test_book/src/individual/heading.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,9 @@
1313
##### Really Small Heading
1414

1515
###### Is it even a heading anymore - heading
16+
17+
## Custom id {#example-id}
18+
19+
## Custom class {.class1 .class2}
20+
21+
## Both id and class {#example-id2 .class1 .class2}

tests/dummy_book/src/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- [Unicode](first/unicode.md)
1515
- [No Headers](first/no-headers.md)
1616
- [Duplicate Headers](first/duplicate-headers.md)
17+
- [Heading Attributes](first/heading-attributes.md)
1718
- [Second Chapter](second.md)
1819
- [Nested Chapter](second/nested.md)
1920

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Heading Attributes {#attrs}
2+
3+
## Heading with classes {.class1 .class2}
4+
5+
## Heading with id and classes {#both .class1 .class2}

tests/rendered_output.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const TOC_SECOND_LEVEL: &[&str] = &[
3535
"1.5. Unicode",
3636
"1.6. No Headers",
3737
"1.7. Duplicate Headers",
38+
"1.8. Heading Attributes",
3839
"2.1. Nested Chapter",
3940
];
4041

@@ -754,6 +755,7 @@ mod search {
754755
let no_headers = get_doc_ref("first/no-headers.html");
755756
let duplicate_headers_1 = get_doc_ref("first/duplicate-headers.html#header-text-1");
756757
let conclusion = get_doc_ref("conclusion.html#conclusion");
758+
let heading_attrs = get_doc_ref("first/heading-attributes.html#both");
757759

758760
let bodyidx = &index["index"]["index"]["body"]["root"];
759761
let textidx = &bodyidx["t"]["e"]["x"]["t"];
@@ -766,7 +768,7 @@ mod search {
766768
assert_eq!(docs[&some_section]["body"], "");
767769
assert_eq!(
768770
docs[&summary]["body"],
769-
"Dummy Book Introduction First Chapter Nested Chapter Includes Recursive Markdown Unicode No Headers Duplicate Headers Second Chapter Nested Chapter Conclusion"
771+
"Dummy Book Introduction First Chapter Nested Chapter Includes Recursive Markdown Unicode No Headers Duplicate Headers Heading Attributes Second Chapter Nested Chapter Conclusion"
770772
);
771773
assert_eq!(
772774
docs[&summary]["breadcrumbs"],
@@ -785,6 +787,10 @@ mod search {
785787
docs[&no_headers]["body"],
786788
"Capybara capybara capybara. Capybara capybara capybara. ThisLongWordIsIncludedSoWeCanCheckThatSufficientlyLongWordsAreOmittedFromTheSearchIndex."
787789
);
790+
assert_eq!(
791+
docs[&heading_attrs]["breadcrumbs"],
792+
"First Chapter » Heading Attributes » Heading with id and classes"
793+
);
788794
}
789795

790796
// Setting this to `true` may cause issues with `cargo watch`,
@@ -946,3 +952,19 @@ fn custom_fonts() {
946952
&["fonts.css", "myfont.woff"]
947953
);
948954
}
955+
956+
#[test]
957+
fn custom_header_attributes() {
958+
let temp = DummyBook::new().build().unwrap();
959+
let md = MDBook::load(temp.path()).unwrap();
960+
md.build().unwrap();
961+
962+
let contents = temp.path().join("book/first/heading-attributes.html");
963+
964+
let summary_strings = &[
965+
r##"<h1 id="attrs"><a class="header" href="#attrs">Heading Attributes</a></h1>"##,
966+
r##"<h2 id="heading-with-classes" class="class1 class2"><a class="header" href="#heading-with-classes">Heading with classes</a></h2>"##,
967+
r##"<h2 id="both" class="class1 class2"><a class="header" href="#both">Heading with id and classes</a></h2>"##,
968+
];
969+
assert_contains_strings(&contents, summary_strings);
970+
}

0 commit comments

Comments
 (0)