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 @@
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
+
+
+
+
+
+A simple admonition.
+
Text