From ea53b0d844f60798db2bf37234f8b84e8b4c1171 Mon Sep 17 00:00:00 2001 From: Sky Date: Tue, 24 Oct 2023 15:38:11 -0400 Subject: [PATCH] Custom ids - You can now set custom CSS ids for admonishment blocks with the `id` field. - You can now customize the default CSS id prefix (default is `"admonition-"`). --- .gitignore | 3 +- CHANGELOG.md | 3 + book/src/overview.md | 18 ++++ book/src/reference.md | 1 + src/config/mod.rs | 11 ++- src/config/v1.rs | 6 ++ src/config/v2.rs | 20 ++++ src/markdown.rs | 217 +++++++++++++++++++++++++++++++++++++++++- src/parse.rs | 1 + src/render.rs | 46 +++++---- src/resolve.rs | 47 ++++++++- src/types.rs | 16 ++++ 12 files changed, 366 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index ea8c4bf..bba7b53 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -/target +/target/ +/.idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e775d7e..b21ab71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- You can now set custom CSS ids for admonishment blocks with the `id` field. +- You can now customize the default CSS id prefix (default is `"admonition-"`). + ## 1.13.1 ### Changed diff --git a/book/src/overview.md b/book/src/overview.md index c5984a5..fc84713 100644 --- a/book/src/overview.md +++ b/book/src/overview.md @@ -161,6 +161,23 @@ Will yield something like the following HTML, which you can then apply styles to ``` +#### Custom CSS ID + +If you want to customize the CSS `id` field, set `id="custom-id"`. +This will ignore [`default.css-id-prefix`](reference.md#default). + +The default id is a normalized version of the admonishment's title, +prefixed with the `default.css-id-prefix`, +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" +Link to this block with `#my-special-info` instead of the default `#admonition-my-info`. +``` +```` + #### Collapsible For a block to be initially collapsible, and then be openable, set `collapsible=true`: @@ -176,3 +193,4 @@ Will yield something like the following HTML, which you can then apply styles to ```admonish collapsible=true Content will be hidden initially. ``` + diff --git a/book/src/reference.md b/book/src/reference.md index 2228425..ab31e21 100644 --- a/book/src/reference.md +++ b/book/src/reference.md @@ -38,6 +38,7 @@ Subfields: - `default.title` (optional): Title to use for blocks. Defaults to the directive used in titlecase. - `default.collapsible` (optional, default: `false`): Make blocks collapsible by default when set to `true`. +- `default.css-id-prefix` (optional, default: `"admonition-"`): The default css id prefix to add to the id of all blocks. Ignored on blocks with an `id` field. ### `renderer` diff --git a/src/config/mod.rs b/src/config/mod.rs index 4029426..b931f36 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -9,6 +9,7 @@ mod v2; pub(crate) struct InstanceConfig { pub(crate) directive: String, pub(crate) title: Option, + pub(crate) id: Option, pub(crate) additional_classnames: Vec, pub(crate) collapsible: Option, } @@ -69,18 +70,22 @@ mod test { InstanceConfig { directive: "note".to_owned(), title: None, + id: None, additional_classnames: vec!["additional-classname".to_owned()], collapsible: None, } ); // v2 syntax is supported assert_eq!( - InstanceConfig::from_info_string(r#"admonish title="Custom Title" type="question""#) - .unwrap() - .unwrap(), + 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/v1.rs b/src/config/v1.rs index 20c9645..9a37b41 100644 --- a/src/config/v1.rs +++ b/src/config/v1.rs @@ -52,6 +52,7 @@ pub(crate) fn from_config_string(config_string: &str) -> Result, #[serde(default)] + id: Option, + #[serde(default)] class: Option, #[serde(default)] collapsible: Option, @@ -88,6 +90,7 @@ pub(crate) fn from_config_string(config_string: &str) -> Result admonition.html_with_unique_ids(&mut id_counter), + RenderTextMode::Html => admonition.html(&mut id_counter), RenderTextMode::Strip => admonition.strip(), }; @@ -732,6 +732,7 @@ Text OnFailure::Continue, &AdmonitionDefaults { title: Some("Admonish".to_owned()), + css_id_prefix: None, collapsible: false, }, RenderTextMode::Html, @@ -766,6 +767,7 @@ Text OnFailure::Continue, &AdmonitionDefaults { title: Some("Admonish".to_owned()), + css_id_prefix: None, collapsible: false, }, RenderTextMode::Html, @@ -798,6 +800,219 @@ Text assert_eq!(expected, prep(content)); } + #[test] + fn standard_custom_id() { + let content = r#"# Chapter +```admonish check id="yay-custom-id" +A simple admonition. +``` +Text +"#; + + let expected = r##"# Chapter + +
+
+ +Check + + +
+
+ +A simple admonition. + +
+
+Text +"##; + + assert_eq!(expected, prep(content)); + } + + #[test] + fn no_custom_id_default_prefix() { + let content = r#"# Chapter +```admonish check +A simple admonition. +``` +Text +"#; + + let expected = r##"# Chapter + +
+
+ +Check + + +
+
+ +A simple admonition. + +
+
+Text +"##; + + assert_eq!(expected, prep(content)); + } + + #[test] + fn no_custom_id_default_prefix_custom_title() { + let content = r#"# Chapter +```admonish check title="Check Mark" +A simple admonition. +``` +Text +"#; + + let expected = r##"# Chapter + +
+
+ +Check Mark + + +
+
+ +A simple admonition. + +
+
+Text +"##; + + assert_eq!(expected, prep(content)); + } + + #[test] + fn empty_default_id_prefix() { + let content = r#"# Chapter +```admonish info +A simple admonition. +``` +Text +"#; + + let expected = r##"# Chapter + +
+
+ +Info + + +
+
+ +A simple admonition. + +
+
+Text +"##; + + let preprocess_result = preprocess( + content, + OnFailure::Continue, + &AdmonitionDefaults { + title: Some("Info".to_owned()), + css_id_prefix: Some("".to_owned()), + collapsible: false, + }, + RenderTextMode::Html, + ) + .unwrap(); + assert_eq!(expected, preprocess_result); + } + + #[test] + fn custom_id_prefix_custom_title() { + let content = r#"# Chapter +```admonish info title="My Title" +A simple admonition. +``` +Text +"#; + + let expected = r##"# Chapter + +
+
+ +My Title + + +
+
+ +A simple admonition. + +
+
+Text +"##; + + let preprocess_result = preprocess( + content, + OnFailure::Continue, + &AdmonitionDefaults { + title: Some("Info".to_owned()), + css_id_prefix: Some("prefix-".to_owned()), + collapsible: false, + }, + RenderTextMode::Html, + ) + .unwrap(); + assert_eq!(expected, preprocess_result); + } + + #[test] + fn custom_id_custom_title() { + let content = r#"# Chapter +```admonish info title="My Title" id="my-section-id" +A simple admonition. +``` +Text +"#; + + let expected = r##"# Chapter + +
+
+ +My Title + + +
+
+ +A simple admonition. + +
+
+Text +"##; + + let preprocess_result = preprocess( + content, + OnFailure::Continue, + &AdmonitionDefaults { + title: Some("Info".to_owned()), + css_id_prefix: Some("ignored-prefix-".to_owned()), + collapsible: false, + }, + RenderTextMode::Html, + ) + .unwrap(); + assert_eq!(expected, preprocess_result); + } + #[test] fn list_embed() { let content = r#"# Chapter diff --git a/src/parse.rs b/src/parse.rs index e06b234..d499f6c 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -46,6 +46,7 @@ pub(crate) fn parse_admonition<'a>( Ok(Admonition { directive: Directive::Bug, title: "Error rendering admonishment".to_owned(), + css_id: None, additional_classnames: Vec::new(), collapsible: false, content: Cow::Owned(format!( diff --git a/src/render.rs b/src/render.rs index 61d384f..cf4d6d9 100644 --- a/src/render.rs +++ b/src/render.rs @@ -3,7 +3,10 @@ use std::borrow::Cow; use std::collections::HashMap; pub use crate::preprocessor::Admonish; -use crate::{resolve::AdmonitionMeta, types::Directive}; +use crate::{ + resolve::AdmonitionMeta, + types::{CssIdType, Directive}, +}; impl Directive { fn classname(&self) -> &'static str { @@ -29,6 +32,7 @@ pub(crate) struct Admonition<'a> { pub(crate) directive: Directive, pub(crate) title: String, pub(crate) content: Cow<'a, str>, + pub(crate) css_id: Option, pub(crate) additional_classnames: Vec, pub(crate) collapsible: bool, pub(crate) indent: usize, @@ -39,6 +43,7 @@ impl<'a> Admonition<'a> { let AdmonitionMeta { directive, title, + css_id, additional_classnames, collapsible, } = info; @@ -46,25 +51,34 @@ impl<'a> Admonition<'a> { directive, title, content: Cow::Borrowed(content), + css_id, additional_classnames, collapsible, indent, } } - pub(crate) fn html_with_unique_ids(&self, id_counter: &mut HashMap) -> String { - let anchor_id = unique_id_from_content( - if !self.title.is_empty() { - &self.title - } else { - ANCHOR_ID_DEFAULT - }, - id_counter, - ); - self.html(&anchor_id) - } + pub(crate) fn html(&self, id_counter: &mut HashMap) -> String { + let css_id_type = self + .css_id + .clone() + .unwrap_or_else(|| CssIdType::Prefix(ANCHOR_DEFAULT_ID_PREFIX.to_owned())); + let anchor_id = match css_id_type { + CssIdType::Verbatim(id) => id, + CssIdType::Prefix(prefix) => { + let id = unique_id_from_content( + if !self.title.is_empty() { + &self.title + } else { + ANCHOR_ID_DEFAULT + }, + id_counter, + ); + + prefix + &id + } + }; - fn html(&self, anchor_id: &str) -> String { let mut additional_class = Cow::Borrowed(self.directive.classname()); let title = &self.title; let content = &self.content; @@ -78,7 +92,7 @@ impl<'a> Admonition<'a> { {indent} {indent}{title} {indent} -{indent} +{indent} {indent} "## )) @@ -103,7 +117,7 @@ impl<'a> Admonition<'a> { // rendered as markdown paragraphs. format!( r#" -{indent}<{admonition_block} id="{ANCHOR_ID_PREFIX}-{anchor_id}" class="admonition {additional_class}"> +{indent}<{admonition_block} id="{anchor_id}" class="admonition {additional_class}"> {title_html}{indent}
{indent} {indent}{content} @@ -121,5 +135,5 @@ impl<'a> Admonition<'a> { } } -const ANCHOR_ID_PREFIX: &str = "admonition"; +const ANCHOR_DEFAULT_ID_PREFIX: &str = "admonition-"; const ANCHOR_ID_DEFAULT: &str = "default"; diff --git a/src/resolve.rs b/src/resolve.rs index ac77556..25edff4 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -1,5 +1,5 @@ use crate::config::InstanceConfig; -use crate::types::{AdmonitionDefaults, Directive}; +use crate::types::{AdmonitionDefaults, CssIdType, Directive}; use std::str::FromStr; /// All information required to render an admonition. @@ -9,6 +9,7 @@ use std::str::FromStr; pub(crate) struct AdmonitionMeta { pub directive: Directive, pub title: String, + pub css_id: Option, pub additional_classnames: Vec, pub collapsible: bool, } @@ -28,6 +29,7 @@ impl AdmonitionMeta { let InstanceConfig { directive: raw_directive, title, + id, additional_classnames, collapsible, } = raw; @@ -44,16 +46,25 @@ impl AdmonitionMeta { (Err(_), Some(title)) => (Directive::Note, title), }; + let css_id = if let Some(verbatim) = id { + Some(CssIdType::Verbatim(verbatim)) + } else if let Some(prefix) = &defaults.css_id_prefix { + Some(CssIdType::Prefix(prefix.clone())) + } else { + None + }; + Self { directive, title, + css_id, additional_classnames, collapsible, } } } -/// Make the first letter of `input` upppercase. +/// Make the first letter of `input` uppercase. /// /// source: https://stackoverflow.com/a/38406885 fn ucfirst(input: &str) -> String { @@ -76,6 +87,7 @@ mod test { InstanceConfig { directive: " ".to_owned(), title: None, + id: None, additional_classnames: Vec::new(), collapsible: None, }, @@ -84,6 +96,7 @@ mod test { AdmonitionMeta { directive: Directive::Note, title: "Note".to_owned(), + css_id: None, additional_classnames: Vec::new(), collapsible: false, } @@ -97,17 +110,47 @@ mod test { InstanceConfig { directive: " ".to_owned(), title: None, + id: None, + additional_classnames: Vec::new(), + collapsible: None, + }, + &AdmonitionDefaults { + title: Some("Important!!!".to_owned()), + css_id_prefix: Some("custom-prefix-".to_owned()), + collapsible: true, + }, + ), + AdmonitionMeta { + directive: Directive::Note, + title: "Important!!!".to_owned(), + css_id: Some(CssIdType::Prefix("custom-prefix-".to_owned())), + additional_classnames: Vec::new(), + collapsible: true, + } + ); + } + + #[test] + fn test_admonition_info_from_raw_with_defaults_and_custom_id() { + assert_eq!( + AdmonitionMeta::resolve( + InstanceConfig { + directive: " ".to_owned(), + title: None, + id: Some("my-custom-id".to_owned()), additional_classnames: Vec::new(), collapsible: None, }, &AdmonitionDefaults { title: Some("Important!!!".to_owned()), + css_id_prefix: Some("ignored-custom-prefix-".to_owned()), collapsible: true, }, ), AdmonitionMeta { directive: Directive::Note, title: "Important!!!".to_owned(), + css_id: Some(CssIdType::Verbatim("my-custom-id".to_owned())), additional_classnames: Vec::new(), collapsible: true, } diff --git a/src/types.rs b/src/types.rs index 669eab5..587535b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -3,12 +3,16 @@ use std::str::FromStr; /// Book wide defaults that may be provided by the user. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] pub(crate) struct AdmonitionDefaults { #[serde(default)] pub(crate) title: Option, #[serde(default)] pub(crate) collapsible: bool, + + #[serde(default)] + pub(crate) css_id_prefix: Option, } #[derive(Debug, PartialEq)] @@ -54,3 +58,15 @@ pub(crate) enum RenderTextMode { Strip, Html, } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CssIdType { + /// id="my-id" in the admonishment + /// + /// used directly for the id field + Verbatim(String), + /// the prefix from default.css-id-prefix + /// + /// will generate the rest of the id based on the title + Prefix(String), +}