use anyhow::{anyhow, Context, Result};
use mdbook::{
book::{Book, BookItem},
errors::Result as MdbookResult,
preprocess::{Preprocessor, PreprocessorContext},
utils::unique_id_from_content,
};
use pulldown_cmark::{CodeBlockKind::*, Event, Options, Parser, Tag};
use std::{borrow::Cow, str::FromStr};
mod config;
mod parse;
mod resolve;
mod types;
use crate::{
resolve::AdmonitionMeta,
types::{AdmonitionDefaults, Directive},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OnFailure {
Bail,
Continue,
}
impl Default for OnFailure {
fn default() -> Self {
Self::Continue
}
}
impl FromStr for OnFailure {
type Err = ();
fn from_str(string: &str) -> Result<Self, ()> {
match string {
"bail" => Ok(Self::Bail),
"continue" => Ok(Self::Continue),
_ => Ok(Self::Continue),
}
}
}
impl OnFailure {
fn from_context(context: &PreprocessorContext) -> Self {
context
.config
.get("preprocessor.admonish.on_failure")
.and_then(|value| value.as_str())
.map(|value| OnFailure::from_str(value).unwrap_or_default())
.unwrap_or_default()
}
}
pub struct Admonish;
impl Preprocessor for Admonish {
fn name(&self) -> &str {
"admonish"
}
fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> MdbookResult<Book> {
ensure_compatible_assets_version(ctx)?;
let on_failure = OnFailure::from_context(ctx);
let mut res = None;
book.for_each_mut(|item: &mut BookItem| {
if let Some(Err(_)) = res {
return;
}
if let BookItem::Chapter(ref mut chapter) = *item {
res = Some(preprocess(&chapter.content, ctx, on_failure).map(|md| {
chapter.content = md;
}));
}
});
res.unwrap_or(Ok(())).map(|_| book)
}
fn supports_renderer(&self, renderer: &str) -> bool {
renderer == "html"
}
}
fn ensure_compatible_assets_version(ctx: &PreprocessorContext) -> Result<()> {
use semver::{Version, VersionReq};
const REQUIRES_ASSETS_VERSION: &str = std::include_str!("./REQUIRED_ASSETS_VERSION");
let requirement = VersionReq::parse(REQUIRES_ASSETS_VERSION.trim()).unwrap();
const USER_ACTION: &str = "Please run `mdbook-admonish install` to update installed assets.";
const DOCS_REFERENCE: &str = "For more information, see: https://github.com/tommilligan/mdbook-admonish#semantic-versioning";
let version = match ctx
.config
.get("preprocessor.admonish.assets_version")
.and_then(|value| value.as_str())
{
Some(version) => version,
None => {
return Err(anyhow!(
r#"ERROR:
Incompatible assets installed: required mdbook-admonish assets version '{requirement}', but did not find a version.
{USER_ACTION}
{DOCS_REFERENCE}"#
))
}
};
let version = Version::parse(version).unwrap();
if !requirement.matches(&version) {
return Err(anyhow!(
r#"ERROR:
Incompatible assets installed: required mdbook-admonish assets version '{requirement}', but found '{version}'.
{USER_ACTION}
{DOCS_REFERENCE}"#
));
};
Ok(())
}
impl Directive {
fn classname(&self) -> &'static str {
match self {
Directive::Note => "note",
Directive::Abstract => "abstract",
Directive::Info => "info",
Directive::Tip => "tip",
Directive::Success => "success",
Directive::Question => "question",
Directive::Warning => "warning",
Directive::Failure => "failure",
Directive::Danger => "danger",
Directive::Bug => "bug",
Directive::Example => "example",
Directive::Quote => "quote",
}
}
}
#[derive(Debug, PartialEq)]
struct Admonition<'a> {
directive: Directive,
title: String,
content: Cow<'a, str>,
additional_classnames: Vec<String>,
collapsible: bool,
}
impl<'a> Admonition<'a> {
pub fn new(info: AdmonitionMeta, content: &'a str) -> Self {
let AdmonitionMeta {
directive,
title,
additional_classnames,
collapsible,
} = info;
Self {
directive,
title,
content: Cow::Borrowed(content),
additional_classnames,
collapsible,
}
}
fn html(&self, anchor_id: &str) -> String {
let mut additional_class = Cow::Borrowed(self.directive.classname());
let title = &self.title;
let content = &self.content;
let title_block = if self.collapsible { "summary" } else { "div" };
let title_html = if !title.is_empty() {
Cow::Owned(format!(
r##"<{title_block} class="admonition-title">
{title}
<a class="admonition-anchor-link" href="#{ANCHOR_ID_PREFIX}-{anchor_id}"></a>
</{title_block}>
"##
))
} else {
Cow::Borrowed("")
};
if !self.additional_classnames.is_empty() {
let mut buffer = additional_class.into_owned();
for additional_classname in &self.additional_classnames {
buffer.push(' ');
buffer.push_str(additional_classname);
}
additional_class = Cow::Owned(buffer);
}
let admonition_block = if self.collapsible { "details" } else { "div" };
format!(
r#"<{admonition_block} id="{ANCHOR_ID_PREFIX}-{anchor_id}" class="admonition {additional_class}">
{title_html}<div>
{content}
</div>
</{admonition_block}>"#,
)
}
}
const ANCHOR_ID_PREFIX: &str = "admonition";
const ANCHOR_ID_DEFAULT: &str = "default";
fn parse_admonition<'a>(
info_string: &'a str,
admonition_defaults: &'a AdmonitionDefaults,
content: &'a str,
on_failure: OnFailure,
) -> Option<MdbookResult<Admonition<'a>>> {
let extracted = parse::extract_admonish_body(content);
let info = AdmonitionMeta::from_info_string(info_string, admonition_defaults)?;
let info = match info {
Ok(info) => info,
Err(message) => {
let fence = extracted.fence;
let enclosing_fence: String = std::iter::repeat(fence.character)
.take(fence.length + 1)
.collect();
return Some(match on_failure {
OnFailure::Continue => Ok(Admonition {
directive: Directive::Bug,
title: "Error rendering admonishment".to_owned(),
additional_classnames: Vec::new(),
collapsible: false,
content: Cow::Owned(format!(
r#"Failed with:
```
{message}
```
Original markdown input:
{enclosing_fence}
{content}
{enclosing_fence}
"#
)),
}),
OnFailure::Bail => Err(anyhow!("Error processing admonition, bailing:\n{content}")),
});
}
};
Some(Ok(Admonition::new(info, extracted.body)))
}
fn load_defaults(ctx: &PreprocessorContext) -> Result<AdmonitionDefaults> {
let table_op = ctx.config.get("preprocessor.admonish.default");
Ok(if let Some(table) = table_op {
table
.to_owned()
.try_into()
.context("preprocessor.admonish.default could not be parsed from book.toml")?
} else {
Default::default()
})
}
fn preprocess(
content: &str,
ctx: &PreprocessorContext,
on_failure: OnFailure,
) -> MdbookResult<String> {
let admonition_defaults = load_defaults(ctx)?;
let mut id_counter = Default::default();
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_FOOTNOTES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
let mut admonish_blocks = vec![];
let events = Parser::new_ext(content, opts);
for (event, span) in events.into_offset_iter() {
if let Event::Start(Tag::CodeBlock(Fenced(info_string))) = event.clone() {
let span_content = &content[span.start..span.end];
let admonition = match parse_admonition(
info_string.as_ref(),
&admonition_defaults,
span_content,
on_failure,
) {
Some(admonition) => admonition,
None => continue,
};
let admonition = admonition?;
let anchor_id = unique_id_from_content(
if !admonition.title.is_empty() {
&admonition.title
} else {
ANCHOR_ID_DEFAULT
},
&mut id_counter,
);
admonish_blocks.push((span, admonition.html(&anchor_id)));
}
}
let mut content = content.to_string();
for (span, block) in admonish_blocks.iter().rev() {
let pre_content = &content[..span.start];
let post_content = &content[span.end..];
content = format!("{}\n{}{}", pre_content, block, post_content);
}
Ok(content)
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
fn create_mock_context(admonish_ops: &str) -> PreprocessorContext {
let input_json = format!(
r##"[
{{
"root": "/path/to/book",
"config": {{
"book": {{
"authors": ["AUTHOR"],
"language": "en",
"multilingual": false,
"src": "src",
"title": "TITLE"
}},
"preprocessor": {{
"admonish": {admonish_ops}
}}
}},
"renderer": "html",
"mdbook_version": "0.4.21"
}},
{{
"sections": [
{{
"Chapter": {{
"name": "Chapter 1",
"content": "# Chapter 1\n",
"number": [1],
"sub_items": [],
"path": "chapter_1.md",
"source_path": "chapter_1.md",
"parent_names": []
}}
}}
],
"__non_exhaustive": null
}}
]"##
);
let input_json = input_json.as_bytes();
let (ctx, _) = mdbook::preprocess::CmdPreprocessor::parse_input(input_json).unwrap();
ctx
}
fn prep(content: &str) -> String {
let ctx = create_mock_context("{}");
preprocess(content, &ctx, OnFailure::Continue).unwrap()
}
#[test]
fn adds_admonish() {
let content = r#"# Chapter
```admonish
A simple admonition.
```
Text
"#;
let expected = r##"# Chapter
<div id="admonition-note" class="admonition note">
<div class="admonition-title">
Note
<a class="admonition-anchor-link" href="#admonition-note"></a>
</div>
<div>
A simple admonition.
</div>
</div>
Text
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn adds_admonish_longer_code_fence() {
let content = r#"# Chapter
````admonish
```json
{}
```
````
Text
"#;
let expected = r##"# Chapter
<div id="admonition-note" class="admonition note">
<div class="admonition-title">
Note
<a class="admonition-anchor-link" href="#admonition-note"></a>
</div>
<div>
```json
{}
```
</div>
</div>
Text
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn adds_admonish_directive() {
let content = r#"# Chapter
```admonish warning
A simple admonition.
```
Text
"#;
let expected = r##"# Chapter
<div id="admonition-warning" class="admonition warning">
<div class="admonition-title">
Warning
<a class="admonition-anchor-link" href="#admonition-warning"></a>
</div>
<div>
A simple admonition.
</div>
</div>
Text
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn adds_admonish_directive_alternate() {
let content = r#"# Chapter
```admonish caution
A warning with alternate title.
```
Text
"#;
let expected = r##"# Chapter
<div id="admonition-caution" class="admonition warning">
<div class="admonition-title">
Caution
<a class="admonition-anchor-link" href="#admonition-caution"></a>
</div>
<div>
A warning with alternate title.
</div>
</div>
Text
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn adds_admonish_directive_title() {
let content = r#"# Chapter
```admonish warning "Read **this**!"
A simple admonition.
```
Text
"#;
let expected = r##"# Chapter
<div id="admonition-read-this" class="admonition warning">
<div class="admonition-title">
Read **this**!
<a class="admonition-anchor-link" href="#admonition-read-this"></a>
</div>
<div>
A simple admonition.
</div>
</div>
Text
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn leaves_tables_untouched() {
let content = r#"# Heading
| Head 1 | Head 2 |
|--------|--------|
| Row 1 | Row 2 |
"#;
let expected = r#"# Heading
| Head 1 | Head 2 |
|--------|--------|
| Row 1 | Row 2 |
"#;
assert_eq!(expected, prep(content));
}
#[test]
fn leaves_html_untouched() {
let content = r#"# Heading
<del>
*foo*
</del>
"#;
let expected = r#"# Heading
<del>
*foo*
</del>
"#;
assert_eq!(expected, prep(content));
}
#[test]
fn html_in_list() {
let content = r#"# Heading
1. paragraph 1
```
code 1
```
2. paragraph 2
"#;
let expected = r#"# Heading
1. paragraph 1
```
code 1
```
2. paragraph 2
"#;
assert_eq!(expected, prep(content));
}
#[test]
fn info_string_that_changes_length_when_parsed() {
let content = r#"
```admonish note "And \\"<i>in</i>\\" the title"
With <b>html</b> styling.
```
hello
"#;
let expected = r##"
<div id="admonition-and-in-the-title" class="admonition note">
<div class="admonition-title">
And "<i>in</i>" the title
<a class="admonition-anchor-link" href="#admonition-and-in-the-title"></a>
</div>
<div>
With <b>html</b> styling.
</div>
</div>
hello
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn info_string_ending_in_symbol() {
let content = r#"
```admonish warning "Trademarkā¢"
Should be respected
```
hello
"#;
let expected = r##"
<div id="admonition-trademark" class="admonition warning">
<div class="admonition-title">
Trademarkā¢
<a class="admonition-anchor-link" href="#admonition-trademark"></a>
</div>
<div>
Should be respected
</div>
</div>
hello
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn block_with_additional_classname() {
let content = r#"
```admonish tip.my-style.other-style
Will have bonus classnames
```
"#;
let expected = r##"
<div id="admonition-tip" class="admonition tip my-style other-style">
<div class="admonition-title">
Tip
<a class="admonition-anchor-link" href="#admonition-tip"></a>
</div>
<div>
Will have bonus classnames
</div>
</div>
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn block_with_additional_classname_and_title() {
let content = r#"
```admonish tip.my-style.other-style "Developers don't want you to know this one weird tip!"
Will have bonus classnames
```
"#;
let expected = r##"
<div id="admonition-developers-dont-want-you-to-know-this-one-weird-tip" class="admonition tip my-style other-style">
<div class="admonition-title">
Developers don't want you to know this one weird tip!
<a class="admonition-anchor-link" href="#admonition-developers-dont-want-you-to-know-this-one-weird-tip"></a>
</div>
<div>
Will have bonus classnames
</div>
</div>
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn block_with_empty_additional_classnames_title_content() {
let content = r#"
```admonish .... ""
```
"#;
let expected = r##"
<div id="admonition-default" class="admonition note">
<div>
</div>
</div>
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn unique_ids_same_title() {
let content = r#"
```admonish note "My Note"
Content zero.
```
```admonish note "My Note"
Content one.
```
"#;
let expected = r##"
<div id="admonition-my-note" class="admonition note">
<div class="admonition-title">
My Note
<a class="admonition-anchor-link" href="#admonition-my-note"></a>
</div>
<div>
Content zero.
</div>
</div>
<div id="admonition-my-note-1" class="admonition note">
<div class="admonition-title">
My Note
<a class="admonition-anchor-link" href="#admonition-my-note-1"></a>
</div>
<div>
Content one.
</div>
</div>
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn v2_config_works() {
let content = r#"
```admonish tip class="my other-style" title="Article Heading"
Bonus content!
```
"#;
let expected = r##"
<div id="admonition-article-heading" class="admonition tip my other-style">
<div class="admonition-title">
Article Heading
<a class="admonition-anchor-link" href="#admonition-article-heading"></a>
</div>
<div>
Bonus content!
</div>
</div>
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn continue_on_error_output() {
let content = r#"
```admonish title="
Bonus content!
```
"#;
let expected = r##"
<div id="admonition-error-rendering-admonishment" class="admonition bug">
<div class="admonition-title">
Error rendering admonishment
<a class="admonition-anchor-link" href="#admonition-error-rendering-admonishment"></a>
</div>
<div>
Failed with:
```
TOML parsing error: TOML parse error at line 1, column 8
|
1 | title="
| ^
invalid basic string
```
Original markdown input:
````
```admonish title="
Bonus content!
```
````
</div>
</div>
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn bail_on_error_output() {
let content = r#"
```admonish title="
Bonus content!
```
"#;
let ctx = create_mock_context(r#"{}"#);
assert_eq!(
preprocess(content, &ctx, OnFailure::Bail)
.unwrap_err()
.to_string(),
r#"Error processing admonition, bailing:
```admonish title="
Bonus content!
```"#
.to_owned()
)
}
#[test]
fn block_collapsible() {
let content = r#"
```admonish collapsible=true
Hidden
```
"#;
let expected = r##"
<details id="admonition-note" class="admonition note">
<summary class="admonition-title">
Note
<a class="admonition-anchor-link" href="#admonition-note"></a>
</summary>
<div>
Hidden
</div>
</details>
"##;
assert_eq!(expected, prep(content));
}
#[test]
fn default_toml_title() {
let content = r#"# Chapter
```admonish
A simple admonition.
```
Text
"#;
let expected = r##"# Chapter
<div id="admonition-admonish" class="admonition note">
<div class="admonition-title">
Admonish
<a class="admonition-anchor-link" href="#admonition-admonish"></a>
</div>
<div>
A simple admonition.
</div>
</div>
Text
"##;
let ctx = create_mock_context(r#"{"default": {"title": "Admonish"}}"#);
let preprocess_result = preprocess(content, &ctx, OnFailure::Continue).unwrap();
assert_eq!(expected, preprocess_result);
}
#[test]
fn empty_explicit_title_with_default() {
let content = r#"# Chapter
```admonish title=""
A simple admonition.
```
Text
"#;
let expected = r##"# Chapter
<div id="admonition-default" class="admonition note">
<div>
A simple admonition.
</div>
</div>
Text
"##;
let ctx = create_mock_context(r#"{"default": {"title": "Admonish"}}"#);
let preprocess_result = preprocess(content, &ctx, OnFailure::Continue).unwrap();
assert_eq!(expected, preprocess_result);
}
#[test]
fn empty_explicit_title() {
let content = r#"# Chapter
```admonish title=""
A simple admonition.
```
Text
"#;
let expected = r##"# Chapter
<div id="admonition-default" class="admonition note">
<div>
A simple admonition.
</div>
</div>
Text
"##;
assert_eq!(expected, prep(content));
}
}