Skip to content

Commit

Permalink
Merge pull request #2013 from ImUrX/heading-extension
Browse files Browse the repository at this point in the history
Add heading extension support
  • Loading branch information
ehuss authored May 28, 2023
2 parents 870e908 + 1db52ff commit 3a51abf
Show file tree
Hide file tree
Showing 9 changed files with 504 additions and 125 deletions.
13 changes: 13 additions & 0 deletions guide/src/format/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,16 @@ To enable it, see the [`output.html.curly-quotes`] config option.
[tables]: https://github.github.com/gfm/#tables-extension-
[task list extension]: https://github.github.com/gfm/#task-list-items-extension-
[`output.html.curly-quotes`]: configuration/renderers.md#html-renderer-options

### Heading attributes

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.

Example:
```md
# Example heading { #first .class1 .class2 }
```

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.

More information can be found in the [heading attrs spec page](https://github.com/raphlinus/pulldown-cmark/blob/master/specs/heading_attrs.txt).
50 changes: 44 additions & 6 deletions src/renderer/html_handlebars/hbs_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -789,8 +789,10 @@ fn make_data(
/// Goes through the rendered HTML, making sure all header tags have
/// an anchor respectively so people can link to sections directly.
fn build_header_links(html: &str) -> String {
static BUILD_HEADER_LINKS: Lazy<Regex> =
Lazy::new(|| Regex::new(r"<h(\d)>(.*?)</h\d>").unwrap());
static BUILD_HEADER_LINKS: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"<h(\d)(?: id="([^"]+)")?(?: class="([^"]+)")?>(.*?)</h\d>"#).unwrap()
});
static IGNORE_CLASS: &[&str] = &["menu-title"];

let mut id_counter = HashMap::new();

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

insert_link_into_header(level, &caps[2], &mut id_counter)
// Ignore .menu-title because now it's getting detected by the regex.
if let Some(classes) = caps.get(3) {
for class in classes.as_str().split(" ") {
if IGNORE_CLASS.contains(&class) {
return caps[0].to_string();
}
}
}

insert_link_into_header(
level,
&caps[4],
caps.get(2).map(|x| x.as_str().to_string()),
caps.get(3).map(|x| x.as_str().to_string()),
&mut id_counter,
)
})
.into_owned()
}
Expand All @@ -810,15 +827,21 @@ fn build_header_links(html: &str) -> String {
fn insert_link_into_header(
level: usize,
content: &str,
id: Option<String>,
classes: Option<String>,
id_counter: &mut HashMap<String, usize>,
) -> String {
let id = utils::unique_id_from_content(content, id_counter);
let id = id.unwrap_or_else(|| utils::unique_id_from_content(content, id_counter));
let classes = classes
.map(|s| format!(" class=\"{s}\""))
.unwrap_or_default();

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

Expand Down Expand Up @@ -1015,6 +1038,21 @@ mod tests {
"<h1>Foo</h1><h3>Foo</h3>",
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>"##,
),
// id only
(
r##"<h1 id="foobar">Foo</h1>"##,
r##"<h1 id="foobar"><a class="header" href="#foobar">Foo</a></h1>"##,
),
// class only
(
r##"<h1 class="class1 class2">Foo</h1>"##,
r##"<h1 id="foo" class="class1 class2"><a class="header" href="#foo">Foo</a></h1>"##,
),
// both id and class
(
r##"<h1 id="foobar" class="class1 class2">Foo</h1>"##,
r##"<h1 id="foobar" class="class1 class2"><a class="header" href="#foobar">Foo</a></h1>"##,
),
];

for (src, should_be) in inputs {
Expand Down
6 changes: 4 additions & 2 deletions src/renderer/html_handlebars/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,11 @@ fn render_item(

in_heading = true;
}
Event::End(Tag::Heading(i, ..)) if i as u32 <= max_section_depth => {
Event::End(Tag::Heading(i, id, _classes)) if i as u32 <= max_section_depth => {
in_heading = false;
section_id = Some(utils::unique_id_from_content(&heading, &mut id_counter));
section_id = id
.map(|id| id.to_string())
.or_else(|| Some(utils::unique_id_from_content(&heading, &mut id_counter)));
breadcrumbs.push(heading.clone());
}
Event::Start(Tag::FootnoteDefinition(name)) => {
Expand Down
1 change: 1 addition & 0 deletions src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ pub fn new_cmark_parser(text: &str, curly_quotes: bool) -> Parser<'_, '_> {
opts.insert(Options::ENABLE_FOOTNOTES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
if curly_quotes {
opts.insert(Options::ENABLE_SMART_PUNCTUATION);
}
Expand Down
6 changes: 6 additions & 0 deletions test_book/src/individual/heading.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@
##### Really Small Heading

###### Is it even a heading anymore - heading

## Custom id {#example-id}

## Custom class {.class1 .class2}

## Both id and class {#example-id2 .class1 .class2}
1 change: 1 addition & 0 deletions tests/dummy_book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- [Unicode](first/unicode.md)
- [No Headers](first/no-headers.md)
- [Duplicate Headers](first/duplicate-headers.md)
- [Heading Attributes](first/heading-attributes.md)
- [Second Chapter](second.md)
- [Nested Chapter](second/nested.md)

Expand Down
5 changes: 5 additions & 0 deletions tests/dummy_book/src/first/heading-attributes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Heading Attributes {#attrs}

## Heading with classes {.class1 .class2}

## Heading with id and classes {#both .class1 .class2}
24 changes: 23 additions & 1 deletion tests/rendered_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const TOC_SECOND_LEVEL: &[&str] = &[
"1.5. Unicode",
"1.6. No Headers",
"1.7. Duplicate Headers",
"1.8. Heading Attributes",
"2.1. Nested Chapter",
];

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

let bodyidx = &index["index"]["index"]["body"]["root"];
let textidx = &bodyidx["t"]["e"]["x"]["t"];
Expand All @@ -766,7 +768,7 @@ mod search {
assert_eq!(docs[&some_section]["body"], "");
assert_eq!(
docs[&summary]["body"],
"Dummy Book Introduction First Chapter Nested Chapter Includes Recursive Markdown Unicode No Headers Duplicate Headers Second Chapter Nested Chapter Conclusion"
"Dummy Book Introduction First Chapter Nested Chapter Includes Recursive Markdown Unicode No Headers Duplicate Headers Heading Attributes Second Chapter Nested Chapter Conclusion"
);
assert_eq!(
docs[&summary]["breadcrumbs"],
Expand All @@ -785,6 +787,10 @@ mod search {
docs[&no_headers]["body"],
"Capybara capybara capybara. Capybara capybara capybara. ThisLongWordIsIncludedSoWeCanCheckThatSufficientlyLongWordsAreOmittedFromTheSearchIndex."
);
assert_eq!(
docs[&heading_attrs]["breadcrumbs"],
"First Chapter » Heading Attributes » Heading with id and classes"
);
}

// Setting this to `true` may cause issues with `cargo watch`,
Expand Down Expand Up @@ -946,3 +952,19 @@ fn custom_fonts() {
&["fonts.css", "myfont.woff"]
);
}

#[test]
fn custom_header_attributes() {
let temp = DummyBook::new().build().unwrap();
let md = MDBook::load(temp.path()).unwrap();
md.build().unwrap();

let contents = temp.path().join("book/first/heading-attributes.html");

let summary_strings = &[
r##"<h1 id="attrs"><a class="header" href="#attrs">Heading Attributes</a></h1>"##,
r##"<h2 id="heading-with-classes" class="class1 class2"><a class="header" href="#heading-with-classes">Heading with classes</a></h2>"##,
r##"<h2 id="both" class="class1 class2"><a class="header" href="#both">Heading with id and classes</a></h2>"##,
];
assert_contains_strings(&contents, summary_strings);
}
Loading

0 comments on commit 3a51abf

Please sign in to comment.