Merge pull request #2013 from ImUrX/heading-extension
Add heading extension support
This commit is contained in:
commit
3a51abfcad
|
@ -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).
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)) => {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Heading Attributes {#attrs}
|
||||||
|
|
||||||
|
## Heading with classes {.class1 .class2}
|
||||||
|
|
||||||
|
## Heading with id and classes {#both .class1 .class2}
|
|
@ -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
Loading…
Reference in New Issue