Merge pull request #2013 from ImUrX/heading-extension

Add heading extension support
This commit is contained in:
Eric Huss 2023-05-28 12:16:26 -07:00 committed by GitHub
commit 3a51abfcad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 506 additions and 127 deletions

View File

@ -220,3 +220,16 @@ To enable it, see the [`output.html.curly-quotes`] config option.
[tables]: https://github.github.com/gfm/#tables-extension- [tables]: https://github.github.com/gfm/#tables-extension-
[task list extension]: https://github.github.com/gfm/#task-list-items-extension- [task list extension]: https://github.github.com/gfm/#task-list-items-extension-
[`output.html.curly-quotes`]: configuration/renderers.md#html-renderer-options [`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).

View File

@ -789,8 +789,10 @@ fn make_data(
/// Goes through the rendered HTML, making sure all header tags have /// Goes through the rendered HTML, making sure all header tags have
/// an anchor respectively so people can link to sections directly. /// an anchor respectively so people can link to sections directly.
fn build_header_links(html: &str) -> String { fn build_header_links(html: &str) -> String {
static BUILD_HEADER_LINKS: Lazy<Regex> = static BUILD_HEADER_LINKS: Lazy<Regex> = Lazy::new(|| {
Lazy::new(|| Regex::new(r"<h(\d)>(.*?)</h\d>").unwrap()); Regex::new(r#"<h(\d)(?: id="([^"]+)")?(?: class="([^"]+)")?>(.*?)</h\d>"#).unwrap()
});
static IGNORE_CLASS: &[&str] = &["menu-title"];
let mut id_counter = HashMap::new(); let mut id_counter = HashMap::new();
@ -800,7 +802,22 @@ fn build_header_links(html: &str) -> String {
.parse() .parse()
.expect("Regex should ensure we only ever get numbers here"); .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() .into_owned()
} }
@ -810,15 +827,21 @@ fn build_header_links(html: &str) -> String {
fn insert_link_into_header( fn insert_link_into_header(
level: usize, level: usize,
content: &str, content: &str,
id: Option<String>,
classes: Option<String>,
id_counter: &mut HashMap<String, usize>, id_counter: &mut HashMap<String, usize>,
) -> String { ) -> 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!( 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, level = level,
id = id, id = id,
text = content text = content,
classes = classes
) )
} }
@ -1015,6 +1038,21 @@ mod tests {
"<h1>Foo</h1><h3>Foo</h3>", "<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>"##, 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 { for (src, should_be) in inputs {

View File

@ -138,9 +138,11 @@ fn render_item(
in_heading = true; 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; 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()); breadcrumbs.push(heading.clone());
} }
Event::Start(Tag::FootnoteDefinition(name)) => { Event::Start(Tag::FootnoteDefinition(name)) => {

View File

@ -183,6 +183,7 @@ pub fn new_cmark_parser(text: &str, curly_quotes: bool) -> Parser<'_, '_> {
opts.insert(Options::ENABLE_FOOTNOTES); opts.insert(Options::ENABLE_FOOTNOTES);
opts.insert(Options::ENABLE_STRIKETHROUGH); opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS); opts.insert(Options::ENABLE_TASKLISTS);
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
if curly_quotes { if curly_quotes {
opts.insert(Options::ENABLE_SMART_PUNCTUATION); opts.insert(Options::ENABLE_SMART_PUNCTUATION);
} }

View File

@ -13,3 +13,9 @@
##### Really Small Heading ##### Really Small Heading
###### Is it even a heading anymore - heading ###### Is it even a heading anymore - heading
## Custom id {#example-id}
## Custom class {.class1 .class2}
## Both id and class {#example-id2 .class1 .class2}

View File

@ -14,6 +14,7 @@
- [Unicode](first/unicode.md) - [Unicode](first/unicode.md)
- [No Headers](first/no-headers.md) - [No Headers](first/no-headers.md)
- [Duplicate Headers](first/duplicate-headers.md) - [Duplicate Headers](first/duplicate-headers.md)
- [Heading Attributes](first/heading-attributes.md)
- [Second Chapter](second.md) - [Second Chapter](second.md)
- [Nested Chapter](second/nested.md) - [Nested Chapter](second/nested.md)

View File

@ -0,0 +1,5 @@
# Heading Attributes {#attrs}
## Heading with classes {.class1 .class2}
## Heading with id and classes {#both .class1 .class2}

View File

@ -35,6 +35,7 @@ const TOC_SECOND_LEVEL: &[&str] = &[
"1.5. Unicode", "1.5. Unicode",
"1.6. No Headers", "1.6. No Headers",
"1.7. Duplicate Headers", "1.7. Duplicate Headers",
"1.8. Heading Attributes",
"2.1. Nested Chapter", "2.1. Nested Chapter",
]; ];
@ -754,6 +755,7 @@ mod search {
let no_headers = get_doc_ref("first/no-headers.html"); 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 duplicate_headers_1 = get_doc_ref("first/duplicate-headers.html#header-text-1");
let conclusion = get_doc_ref("conclusion.html#conclusion"); 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 bodyidx = &index["index"]["index"]["body"]["root"];
let textidx = &bodyidx["t"]["e"]["x"]["t"]; let textidx = &bodyidx["t"]["e"]["x"]["t"];
@ -766,7 +768,7 @@ mod search {
assert_eq!(docs[&some_section]["body"], ""); assert_eq!(docs[&some_section]["body"], "");
assert_eq!( assert_eq!(
docs[&summary]["body"], 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!( assert_eq!(
docs[&summary]["breadcrumbs"], docs[&summary]["breadcrumbs"],
@ -785,6 +787,10 @@ mod search {
docs[&no_headers]["body"], docs[&no_headers]["body"],
"Capybara capybara capybara. Capybara capybara capybara. ThisLongWordIsIncludedSoWeCanCheckThatSufficientlyLongWordsAreOmittedFromTheSearchIndex." "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`, // Setting this to `true` may cause issues with `cargo watch`,
@ -946,3 +952,19 @@ fn custom_fonts() {
&["fonts.css", "myfont.woff"] &["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);
}

File diff suppressed because it is too large Load Diff