diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c680e2..05addc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Changed + +- Blocks should have key-value options separated by commas. Existing syntax remains is supported for back-compatibility. See [the documentation on Additional Options](https://tommilligan.github.io/mdbook-admonish/#additional-options) for more details ([#181](https://github.com/tommilligan/mdbook-admonish/pull/181)) + +### Fixed + +- Titles contining `=` will now render correctly. Thanks to [@s00500](https://github.com/s00500) for the bug report! ([#181](https://github.com/tommilligan/mdbook-admonish/pull/181)) + ## v1.16.0 ### Changed diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index b841d92..348bbac 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -2,3 +2,4 @@ - [Overview](./overview.md) - [Reference](./reference.md) +- [Examples](./examples.md) diff --git a/book/src/examples.md b/book/src/examples.md new file mode 100644 index 0000000..597aaa8 --- /dev/null +++ b/book/src/examples.md @@ -0,0 +1,15 @@ +# Examples + +## Combining multiple custom properties + +Note that the comma `,` is used to seperate custom options. + +```` +```admonish quote collapsible=true, title='A title that really pops' +To really grab your reader's attention. +``` +```` + +```admonish quote collapsible=true, title='A title that really pops' +To really grab your reader's attention. +``` diff --git a/book/src/overview.md b/book/src/overview.md index 823ab00..4d9cec4 100644 --- a/book/src/overview.md +++ b/book/src/overview.md @@ -78,14 +78,19 @@ You can also configure the build to fail loudly, by setting `on_failure = "bail" ### Additional Options -You can pass additional options to each block. The options are structured as TOML key-value pairs. +You can pass additional options to each block. Options are given like a [TOML Inline Table](https://toml.io/en/v1.0.0#inline-table), as key-value pairs separated by commas. + +`mdbook-admonish` parses options by wrapping your options in an inline table before parsing them, so please consult [The TOML Reference](https://toml.io) if you run into any syntax errors. Be aware that: + +- Key-value pairs must be separated with a comma `,` +- TOML escapes must be escaped again - for instance, write `\"` as `\\"`. +- For complex strings such as HTML, you may want to use a [literal string](https://toml.io/en/v1.0.0#string) to avoid complex escape sequences Note that some options can be passed globally, through the `default` section in `book.toml`. See the [configuration reference](./reference.md#booktoml-configuration) for more details. #### Custom title -A custom title can be provided, contained in a double quoted TOML string. -Note that TOML escapes must be escaped again - for instance, write `\"` as `\\"`. +A custom title can be provided: ```` ```admonish warning title="Data loss" @@ -114,13 +119,13 @@ This will take a while, go and grab a drink of water. Markdown and HTML can be used in the inner content, as you'd expect: ```` -```admonish tip title="_Referencing_ and dereferencing" +```admonish tip title='_Referencing_ and dereferencing' The opposite of *referencing* by using `&` is *dereferencing*, which is accomplished with the dereference operator, `*`. ``` ```` -```admonish tip title="_Referencing_ and dereferencing" +```admonish tip title='_Referencing_ and dereferencing' The opposite of *referencing* by using `&` is *dereferencing*, which is accomplished with the dereference operator, `*`. ``` @@ -148,7 +153,7 @@ print "Hello, world!" If you want to provide custom styling to a specific admonition, you can attach one or more custom classnames: ```` -```admonish note class="custom-0 custom-1" +```admonish note title="Stylish", class="custom-0 custom-1" Styled with my custom CSS class. ``` ```` @@ -173,7 +178,7 @@ with an appended number if multiple blocks would have the same id. Setting the `id` field will _ignore_ all other ids and the duplicate counter. ```` -```admonish info title="My Info" id="my-special-info" +```admonish info title="My Info", id="my-special-info" Link to this block with `#my-special-info` instead of the default `#admonition-my-info`. ``` ```` @@ -183,14 +188,14 @@ Link to this block with `#my-special-info` instead of the default `#admonition-m For a block to be initially collapsible, and then be openable, set `collapsible=true`: ```` -```admonish collapsible=true +```admonish title="Sneaky", collapsible=true Content will be hidden initially. ``` ```` Will yield something like the following HTML, which you can then apply styles to: -```admonish collapsible=true +```admonish title="Sneaky", collapsible=true Content will be hidden initially. ``` diff --git a/integration/expected/chapter_1_main.html b/integration/expected/chapter_1_main.html index 6b7c099..e23e431 100644 --- a/integration/expected/chapter_1_main.html +++ b/integration/expected/chapter_1_main.html @@ -41,10 +41,10 @@

Chapter 1

Failed with:

'title="' is not a valid directive or TOML key-value pair.
 
-TOML parsing error: TOML parse error at line 1, column 8
+TOML parsing error: TOML parse error at line 1, column 21
   |
-1 | title="
-  |        ^
+1 | config = { title=" }
+  |                     ^
 invalid basic string
 
 
diff --git a/src/config/mod.rs b/src/config/mod.rs index b931f36..0d9e3f2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,11 +1,13 @@ +mod toml_wrangling; mod v1; mod v2; +mod v3; /// Configuration as described by the instance of an admonition in markdown. /// /// This structure represents the configuration the user must provide in each /// instance. -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Default)] pub(crate) struct InstanceConfig { pub(crate) directive: String, pub(crate) title: Option, @@ -35,20 +37,29 @@ impl InstanceConfig { /// - `Some(InstanceConfig)` if this is an `admonish` block pub fn from_info_string(info_string: &str) -> Option> { let config_string = admonition_config_string(info_string)?; + Some(Self::from_admonish_config_string(config_string)) + } + + /// Parse an info string that is known to be for `admonish`. + fn from_admonish_config_string(config_string: &str) -> Result { + // If we succeed at parsing v3, return that. Otherwise hold onto the error + let config_v3_error = match v3::from_config_string(config_string) { + Ok(config) => return Ok(config), + Err(error) => error, + }; - // If we succeed at parsing v2, return that. Otherwise hold onto the error - let config_v2_error = match v2::from_config_string(config_string) { - Ok(config) => return Some(Ok(config)), - Err(config) => config, + // If we succeed at parsing v2, return that + if let Ok(config) = v2::from_config_string(config_string) { + return Ok(config); }; - Some(if let Ok(config) = v1::from_config_string(config_string) { - // If we succeed at parsing v1, return that. - Ok(config) - } else { - // Otherwise return our v2 error. - Err(config_v2_error) - }) + // If we succeed at parsing v1, return that. + if let Ok(config) = v1::from_config_string(config_string) { + return Ok(config); + } + + // Otherwise return our v3 error. + Err(config_v3_error) } } @@ -90,5 +101,20 @@ mod test { collapsible: None, } ); + // v3 syntax is supported + assert_eq!( + InstanceConfig::from_info_string( + r#"admonish title="Custom Title", type="question", id="my-id""# + ) + .unwrap() + .unwrap(), + InstanceConfig { + directive: "question".to_owned(), + title: Some("Custom Title".to_owned()), + id: Some("my-id".to_owned()), + additional_classnames: Vec::new(), + collapsible: None, + } + ); } } diff --git a/src/config/toml_wrangling.rs b/src/config/toml_wrangling.rs new file mode 100644 index 0000000..57439ec --- /dev/null +++ b/src/config/toml_wrangling.rs @@ -0,0 +1,44 @@ +use once_cell::sync::Lazy; +use regex::Regex; +use serde::Deserialize; +use std::fmt::Display; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct UserInput { + #[serde(default)] + pub r#type: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub id: Option, + #[serde(default)] + pub class: Option, + #[serde(default)] + pub collapsible: Option, +} + +impl UserInput { + pub fn classnames(&self) -> Vec { + self.class + .as_ref() + .map(|class| { + class + .split(' ') + .filter(|classname| !classname.is_empty()) + .map(|classname| classname.to_owned()) + .collect() + }) + .unwrap_or_default() + } +} + +pub(crate) static RX_DIRECTIVE: Lazy = + Lazy::new(|| Regex::new(r#"^[A-Za-z0-9_-]+$"#).expect("directive regex")); + +pub(crate) fn format_toml_parsing_error(error: impl Display) -> String { + format!("TOML parsing error: {error}") +} + +pub(crate) fn format_invalid_directive(directive: &str, original_error: impl Display) -> String { + format!("'{directive}' is not a valid directive or TOML key-value pair.\n\n{original_error}") +} diff --git a/src/config/v2.rs b/src/config/v2.rs index 33cc67e..c4a5400 100644 --- a/src/config/v2.rs +++ b/src/config/v2.rs @@ -1,21 +1,9 @@ +use super::toml_wrangling::{ + format_invalid_directive, format_toml_parsing_error, UserInput, RX_DIRECTIVE, +}; use super::InstanceConfig; use once_cell::sync::Lazy; use regex::Regex; -use serde::Deserialize; - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -struct UserInput { - #[serde(default)] - r#type: Option, - #[serde(default)] - title: Option, - #[serde(default)] - id: Option, - #[serde(default)] - class: Option, - #[serde(default)] - collapsible: Option, -} /// Transform our config string into valid toml fn bare_key_value_pairs_to_toml(pairs: &str) -> String { @@ -39,10 +27,18 @@ fn bare_key_value_pairs_to_toml(pairs: &str) -> String { .into_owned() } +fn user_input_from_config_toml(config_toml: &str) -> Result { + toml::from_str(config_toml).map_err(format_toml_parsing_error) +} + /// Parse and return the config assuming v2 format. /// /// Note that if an error occurs, a parsed struct that can be returned to /// show the error message will be returned. +/// +/// The basic idea here is to accept space separated key-value pairs, break them +/// onto separate lines, and then parse them as a TOML document. +/// This breaks when values contain a literal '=' sign, for which v3 syntax should be used. pub(crate) fn from_config_string(config_string: &str) -> Result { let config_toml = bare_key_value_pairs_to_toml(config_string); let config_toml = config_toml.trim(); @@ -50,7 +46,7 @@ pub(crate) fn from_config_string(config_string: &str) -> Result config, Err(error) => { - let original_error = format!("TOML parsing error: {error}"); + let original_error = format_toml_parsing_error(error); // For ergonomic reasons, we allow users to specify the directive without // a key. So if parsing fails initially, take the first line, @@ -60,19 +56,11 @@ pub(crate) fn from_config_string(config_string: &str) -> Result (config_toml, ""), }; - static RX_DIRECTIVE: Lazy = - Lazy::new(|| Regex::new(r#"^[A-Za-z0-9_-]+$"#).expect("directive regex")); - if !RX_DIRECTIVE.is_match(directive) { - return Err(format!("'{directive}' is not a valid directive or TOML key-value pair.\n\n{original_error}")); + return Err(format_invalid_directive(directive, original_error)); } - let mut config: UserInput = match toml::from_str(config_toml) { - Ok(config) => config, - Err(error) => { - return Err(format!("TOML parsing error: {error}")); - } - }; + let mut config = user_input_from_config_toml(dbg!(config_toml))?; config.r#type = Some(directive.to_owned()); config } @@ -188,6 +176,7 @@ mod test { )?; // Directive after toml config is an error assert!(from_config_string(r#"title="Information" info"#).is_err()); + Ok(()) } diff --git a/src/config/v3.rs b/src/config/v3.rs new file mode 100644 index 0000000..985a57d --- /dev/null +++ b/src/config/v3.rs @@ -0,0 +1,202 @@ +use super::toml_wrangling::{ + format_invalid_directive, format_toml_parsing_error, UserInput, RX_DIRECTIVE, +}; +use super::InstanceConfig; +use serde::Deserialize; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct Wrapper { + config: T, +} + +/// Transform our config string into valid toml +fn bare_inline_table_to_toml(pairs: &str) -> String { + format!("config = {{ {pairs} }}") +} + +fn user_input_from_config_string(config_string: &str) -> Result { + match toml::from_str::>(&bare_inline_table_to_toml(config_string)) { + Ok(wrapper) => Ok(wrapper.config), + Err(error) => Err(format_toml_parsing_error(error)), + } +} + +/// Parse and return the config assuming v3 format. +/// +/// Note that if an error occurs, a parsed struct that can be returned to +/// show the error message will be returned. +/// +/// The basic idea here is to accept the inside of an inline table, wrap it, +/// parse it, and then use the toml values. +pub(crate) fn from_config_string(config_string: &str) -> Result { + let config_string = config_string.trim(); + + let config = match user_input_from_config_string(config_string) { + Ok(config) => config, + Err(error) => { + // For ergonomic reasons, we allow users to specify the directive without + // a key. So if parsing fails initially, take the first word, + // use that as the directive, and reparse. + let (directive, config_string) = match config_string.split_once(' ') { + Some((directive, config_string)) => (directive.trim(), config_string.trim()), + None => (config_string, ""), + }; + + if !RX_DIRECTIVE.is_match(directive) { + return Err(format_invalid_directive(directive, error)); + } + + let mut config = user_input_from_config_string(config_string)?; + config.r#type = Some(directive.to_owned()); + config + } + }; + + let additional_classnames = config.classnames(); + Ok(InstanceConfig { + directive: config.r#type.unwrap_or_default(), + title: config.title, + id: config.id, + additional_classnames, + collapsible: config.collapsible, + }) +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_from_config_string_v3() -> Result<(), ()> { + fn check(config_string: &str, expected: InstanceConfig) -> Result<(), ()> { + let actual = match from_config_string(config_string) { + Ok(config) => config, + Err(error) => { + panic!("Expected config '{config_string}' to be valid, got error:\n\n{error}") + } + }; + assert_eq!(actual, expected); + Ok(()) + } + + check( + "", + InstanceConfig { + directive: "".to_owned(), + title: None, + id: None, + additional_classnames: Vec::new(), + collapsible: None, + }, + )?; + check( + " ", + InstanceConfig { + directive: "".to_owned(), + title: None, + id: None, + additional_classnames: Vec::new(), + collapsible: None, + }, + )?; + check( + r#"type="note", class="additional classname", title="Никита", collapsible=true"#, + InstanceConfig { + directive: "note".to_owned(), + title: Some("Никита".to_owned()), + id: None, + additional_classnames: vec!["additional".to_owned(), "classname".to_owned()], + collapsible: Some(true), + }, + )?; + // Specifying unknown keys is okay, as long as they're valid + check( + r#"unkonwn="but valid toml""#, + InstanceConfig { + directive: "".to_owned(), + title: None, + id: None, + additional_classnames: Vec::new(), + collapsible: None, + }, + )?; + // Just directive is fine + check( + r#"info"#, + InstanceConfig { + directive: "info".to_owned(), + title: None, + id: None, + additional_classnames: Vec::new(), + collapsible: None, + }, + )?; + // Directive plus toml config + check( + r#"info title="Information", collapsible=false"#, + InstanceConfig { + directive: "info".to_owned(), + title: Some("Information".to_owned()), + id: None, + additional_classnames: Vec::new(), + collapsible: Some(false), + }, + )?; + // Test custom id + check( + r#"info title="My Info", id="my-info-custom-id""#, + InstanceConfig { + directive: "info".to_owned(), + title: Some("My Info".to_owned()), + id: Some("my-info-custom-id".to_owned()), + additional_classnames: Vec::new(), + collapsible: None, + }, + )?; + // Directive after toml config is an error + assert!(from_config_string(r#"title="Information" info"#).is_err()); + // HTML with quotes inside content + // Note that we use toml literal (single quoted) strings here + check( + r#"info title='My Title'"#, + InstanceConfig { + directive: "info".to_owned(), + title: Some(r#"My Title"#.to_owned()), + id: None, + additional_classnames: Vec::new(), + collapsible: None, + }, + )?; + + Ok(()) + } + + #[test] + fn test_from_config_string_invalid_directive() { + assert_eq!( + from_config_string(r#"oh!wow titlel=""#).unwrap_err(), + r#"'oh!wow' is not a valid directive or TOML key-value pair. + +TOML parsing error: TOML parse error at line 1, column 14 + | +1 | config = { oh!wow titlel=" } + | ^ +expected `.`, `=` +"# + ); + } + + #[test] + fn test_from_config_string_invalid_toml_value() { + assert_eq!( + from_config_string(r#"note titlel=""#).unwrap_err(), + r#"TOML parsing error: TOML parse error at line 1, column 22 + | +1 | config = { titlel=" } + | ^ +invalid basic string +"# + ); + } +} diff --git a/src/markdown.rs b/src/markdown.rs index f263ea5..9bb4611 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -598,10 +598,10 @@ Failed with: ```log 'title="' is not a valid directive or TOML key-value pair. -TOML parsing error: TOML parse error at line 1, column 8 +TOML parsing error: TOML parse error at line 1, column 21 | -1 | title=" - | ^ +1 | config = { title=" } + | ^ invalid basic string ``` @@ -892,6 +892,39 @@ Check Mark A simple admonition. + + +Text +"##; + + assert_eq!(expected, prep(content)); + } + + #[test] + fn title_and_content_with_html() { + // Note that we use toml literal (single quoted) strings here + // and the fact we have an equals sign in the value does not cause + // us to break (because we're using v3 syntax, not v2) + let content = r#"# Chapter +```admonish success title='Check Mark' +A simple admonition. +``` +Text +"#; + + let expected = r##"# Chapter + +
+
+ +Check Mark + + +
+
+ +A simple admonition. +
Text