Skip to main content

rumdl_lib/utils/
kramdown_utils.rs

1//! Utilities for handling Kramdown-specific syntax
2//!
3//! Kramdown is a superset of Markdown that adds additional features like
4//! Inline Attribute Lists (IAL) for adding attributes to elements.
5
6use regex::Regex;
7use std::sync::LazyLock;
8
9/// Pattern for Kramdown span IAL: text{:.class #id key="value"}
10static SPAN_IAL_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{[:\.#][^}]*\}$").unwrap());
11
12/// Pattern for Kramdown extensions opening (multi-line): {::comment}, {::nomarkdown}, etc.
13/// Does NOT match self-closing blocks like {::options ... /}
14static EXTENSION_OPEN_PATTERN: LazyLock<Regex> =
15    LazyLock::new(|| Regex::new(r"^\s*\{::([a-z]+)(?:\s+[^}]*)?\}\s*$").unwrap());
16
17/// Pattern for self-closing extension blocks: {::options ... /}, {::comment /}
18static EXTENSION_SELF_CLOSING_PATTERN: LazyLock<Regex> =
19    LazyLock::new(|| Regex::new(r"^\s*\{::[a-z]+(?:\s+[^}]*)?\s*/\}\s*$").unwrap());
20
21/// Pattern for Kramdown extensions closing: {:/comment}, {:/}, etc.
22static EXTENSION_CLOSE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\{:/([a-z]+)?\}\s*$").unwrap());
23
24/// Check if a line is a Kramdown block attribute (IAL - Inline Attribute List)
25///
26/// Kramdown IAL syntax allows adding attributes to block elements:
27/// - `{:.class}` - CSS class
28/// - `{:#id}` - Element ID
29/// - `{:attribute="value"}` - Generic attributes
30/// - `{:.class #id attribute="value"}` - Combinations
31///
32/// # Examples
33///
34/// ```
35/// use rumdl_lib::utils::kramdown_utils::is_kramdown_block_attribute;
36///
37/// assert!(is_kramdown_block_attribute("{:.wrap}"));
38/// assert!(is_kramdown_block_attribute("{:#my-id}"));
39/// assert!(is_kramdown_block_attribute("{:.class #id}"));
40/// assert!(is_kramdown_block_attribute("{:style=\"color: red\"}"));
41///
42/// assert!(!is_kramdown_block_attribute("{just text}"));
43/// assert!(!is_kramdown_block_attribute("{}"));
44/// assert!(!is_kramdown_block_attribute("{"));
45/// ```
46pub fn is_kramdown_block_attribute(line: &str) -> bool {
47    let trimmed = line.trim();
48
49    // Must start with { and end with }
50    if !trimmed.starts_with('{') || !trimmed.ends_with('}') || trimmed.len() < 3 {
51        return false;
52    }
53
54    // Check if it matches Kramdown IAL patterns
55    // Valid patterns start with {: or {# or {.
56    let second_char = trimmed.chars().nth(1);
57    matches!(second_char, Some(':') | Some('#') | Some('.'))
58}
59
60/// Check if text ends with a Kramdown span IAL (inline attribute)
61///
62/// # Examples
63/// ```
64/// use rumdl_lib::utils::kramdown_utils::has_span_ial;
65///
66/// assert!(has_span_ial("*emphasized*{:.highlight}"));
67/// assert!(has_span_ial("[link](url){:target=\"_blank\"}"));
68/// assert!(!has_span_ial("regular text"));
69/// ```
70pub fn has_span_ial(text: &str) -> bool {
71    SPAN_IAL_PATTERN.is_match(text.trim())
72}
73
74/// Check if a line is a self-closing Kramdown extension: {::options ... /}, {::comment /}
75pub fn is_kramdown_extension_self_closing(line: &str) -> bool {
76    EXTENSION_SELF_CLOSING_PATTERN.is_match(line)
77}
78
79/// Check if a line is a Kramdown extension opening tag (multi-line, not self-closing)
80///
81/// Extensions include: comment, nomarkdown, options
82pub fn is_kramdown_extension_open(line: &str) -> bool {
83    EXTENSION_OPEN_PATTERN.is_match(line) && !is_kramdown_extension_self_closing(line)
84}
85
86/// Check if a line is a Kramdown extension closing tag
87pub fn is_kramdown_extension_close(line: &str) -> bool {
88    EXTENSION_CLOSE_PATTERN.is_match(line)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn test_kramdown_class_attributes() {
97        assert!(is_kramdown_block_attribute("{:.wrap}"));
98        assert!(is_kramdown_block_attribute("{:.class-name}"));
99        assert!(is_kramdown_block_attribute("{:.multiple .classes}"));
100    }
101
102    #[test]
103    fn test_kramdown_id_attributes() {
104        assert!(is_kramdown_block_attribute("{:#my-id}"));
105        assert!(is_kramdown_block_attribute("{:#section-1}"));
106    }
107
108    #[test]
109    fn test_kramdown_generic_attributes() {
110        assert!(is_kramdown_block_attribute("{:style=\"color: red\"}"));
111        assert!(is_kramdown_block_attribute("{:data-value=\"123\"}"));
112    }
113
114    #[test]
115    fn test_kramdown_combined_attributes() {
116        assert!(is_kramdown_block_attribute("{:.class #id}"));
117        assert!(is_kramdown_block_attribute("{:#id .class style=\"color: blue\"}"));
118        assert!(is_kramdown_block_attribute("{:.wrap #my-code .highlight}"));
119    }
120
121    #[test]
122    fn test_non_kramdown_braces() {
123        assert!(!is_kramdown_block_attribute("{just some text}"));
124        assert!(!is_kramdown_block_attribute("{not kramdown}"));
125        assert!(!is_kramdown_block_attribute("{ spaces }"));
126    }
127
128    #[test]
129    fn test_edge_cases() {
130        assert!(!is_kramdown_block_attribute("{}"));
131        assert!(!is_kramdown_block_attribute("{"));
132        assert!(!is_kramdown_block_attribute("}"));
133        assert!(!is_kramdown_block_attribute(""));
134        assert!(!is_kramdown_block_attribute("not braces"));
135    }
136
137    #[test]
138    fn test_whitespace_handling() {
139        assert!(is_kramdown_block_attribute("  {:.wrap}  "));
140        assert!(is_kramdown_block_attribute("\t{:#id}\t"));
141        assert!(is_kramdown_block_attribute(" {:.class #id} "));
142    }
143
144    #[test]
145    fn test_self_closing_extension_blocks() {
146        // Self-closing extension blocks end with /}
147        assert!(is_kramdown_extension_self_closing("{::options toc_levels=\"2..4\" /}"));
148        assert!(is_kramdown_extension_self_closing("{::comment /}"));
149        assert!(is_kramdown_extension_self_closing("{::nomarkdown this='is' .ignore /}"));
150        assert!(is_kramdown_extension_self_closing("  {::options key=\"val\" /}  "));
151
152        // Multi-line openers should NOT match
153        assert!(!is_kramdown_extension_self_closing("{::comment}"));
154        assert!(!is_kramdown_extension_self_closing("{::nomarkdown}"));
155        assert!(!is_kramdown_extension_self_closing("{::nomarkdown type='html'}"));
156    }
157
158    #[test]
159    fn test_extension_open_excludes_self_closing() {
160        // Multi-line openers should match
161        assert!(is_kramdown_extension_open("{::comment}"));
162        assert!(is_kramdown_extension_open("{::nomarkdown}"));
163        assert!(is_kramdown_extension_open("{::nomarkdown type='html'}"));
164
165        // Self-closing should NOT match as multi-line opener
166        assert!(!is_kramdown_extension_open("{::options toc_levels=\"2..4\" /}"));
167        assert!(!is_kramdown_extension_open("{::comment /}"));
168    }
169}