Skip to main content

rumdl_lib/utils/
mkdocs_attr_list.rs

1/// MkDocs attr_list extension support
2///
3/// This module provides support for the Python-Markdown attr_list extension,
4/// which allows adding custom attributes to Markdown elements including:
5/// - Custom IDs: `{#custom-id}`
6/// - Classes: `{.my-class}`
7/// - Key-value pairs: `{key="value"}`
8///
9/// ## Syntax
10///
11/// ### Headings with custom anchors
12/// ```markdown
13/// # Heading {#custom-anchor}
14/// # Heading {.class-name}
15/// # Heading {#id .class key=value}
16/// ```
17///
18/// ### Block attributes (on separate line)
19/// ```markdown
20/// Paragraph text here.
21/// {: #id .class }
22/// ```
23///
24/// ### Inline attributes
25/// ```markdown
26/// [link text](url){: .external target="_blank" }
27/// *emphasis*{: .special }
28/// ```
29///
30/// ## References
31///
32/// - [Python-Markdown attr_list](https://python-markdown.github.io/extensions/attr_list/)
33/// - [MkDocs Material - Anchor Links](https://squidfunk.github.io/mkdocs-material/reference/annotations/#anchor-links)
34use regex::Regex;
35use std::sync::LazyLock;
36
37/// Pattern to match attr_list syntax: `{: #id .class key="value" }`
38/// The `:` prefix is optional (kramdown style uses it, but attr_list accepts both)
39/// Requirements for valid attr_list:
40/// - Must start with `{` and optional `:` with optional whitespace
41/// - Must contain at least one of: #id, .class, or key="value"
42/// - Must end with `}`
43pub static ATTR_LIST_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
44    // Pattern requires at least one attribute (id, class, or key=value)
45    // to avoid matching plain text in braces like {word}
46    Regex::new(r#"\{:?\s*(?:(?:#[a-zA-Z0-9_][a-zA-Z0-9_-]*|\.[a-zA-Z_][a-zA-Z0-9_-]*|[a-zA-Z_][a-zA-Z0-9_-]*=["'][^"']*["'])\s*)+\}"#).unwrap()
47});
48
49/// Pattern to extract custom ID from attr_list: `#id`
50static CUSTOM_ID_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"#([a-zA-Z0-9_][a-zA-Z0-9_-]*)").unwrap());
51
52/// Pattern to extract classes from attr_list: `.class`
53static CLASS_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\.([a-zA-Z_][a-zA-Z0-9_-]*)").unwrap());
54
55/// Pattern to extract key-value pairs: `key="value"` or `key='value'`
56static KEY_VALUE_PATTERN: LazyLock<Regex> =
57    LazyLock::new(|| Regex::new(r#"([a-zA-Z_][a-zA-Z0-9_-]*)=["']([^"']*)["']"#).unwrap());
58
59/// Parsed attribute list containing IDs, classes, and key-value pairs
60#[derive(Debug, Clone, Default, PartialEq)]
61pub struct AttrList {
62    /// Custom ID (e.g., `custom-id` from `{#custom-id}`)
63    pub id: Option<String>,
64    /// CSS classes (e.g., `["class1", "class2"]` from `{.class1 .class2}`)
65    pub classes: Vec<String>,
66    /// Key-value attributes (e.g., `[("target", "_blank")]`)
67    pub attributes: Vec<(String, String)>,
68    /// Start position in the line (0-indexed)
69    pub start: usize,
70    /// End position in the line (0-indexed, exclusive)
71    pub end: usize,
72}
73
74impl AttrList {
75    /// Create a new empty AttrList
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    /// Check if this attr_list has a custom ID
81    #[inline]
82    pub fn has_id(&self) -> bool {
83        self.id.is_some()
84    }
85
86    /// Check if this attr_list has any classes
87    #[inline]
88    pub fn has_classes(&self) -> bool {
89        !self.classes.is_empty()
90    }
91
92    /// Check if this attr_list has any attributes
93    #[inline]
94    pub fn has_attributes(&self) -> bool {
95        !self.attributes.is_empty()
96    }
97
98    /// Check if this attr_list is empty (no id, classes, or attributes)
99    #[inline]
100    pub fn is_empty(&self) -> bool {
101        self.id.is_none() && self.classes.is_empty() && self.attributes.is_empty()
102    }
103}
104
105/// Check if a line contains attr_list syntax
106#[inline]
107pub fn contains_attr_list(line: &str) -> bool {
108    // Fast path: check for opening brace first
109    if !line.contains('{') {
110        return false;
111    }
112    ATTR_LIST_PATTERN.is_match(line)
113}
114
115/// Check if a line is a standalone block attr_list (on its own line)
116/// This is used for block-level attributes like:
117/// ```markdown
118/// Paragraph text.
119/// { .class-name }
120/// ```
121/// or with colon:
122/// ```markdown
123/// Paragraph text.
124/// {: .class-name }
125/// ```
126#[inline]
127pub fn is_standalone_attr_list(line: &str) -> bool {
128    let trimmed = line.trim();
129    // Must start with { and end with }
130    if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
131        return false;
132    }
133    // Must be a valid attr_list (not just random braces)
134    ATTR_LIST_PATTERN.is_match(trimmed)
135}
136
137/// Check whether a line is a block attribute list attached to the preceding block.
138///
139/// Block attribute lists sit on their own line directly under a block (heading,
140/// table, fenced code, list) and describe that block, so the blanks-around rules
141/// (MD022, MD031, MD032, MD058) must not treat them as separate content needing a
142/// blank line between them and the block.
143///
144/// Two forms are recognized:
145/// - Kramdown IALs (`{:.class}`, `{:#id}`) in every flavor, matching long-standing
146///   behavior.
147/// - Bare attribute lists such as Hugo/Goldmark's `{class="a" id="b"}` only when
148///   the flavor enables attribute lists, because in plain CommonMark that text is
149///   literal content that legitimately needs surrounding blanks.
150#[inline]
151pub fn is_block_attribute_line(line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
152    crate::utils::kramdown_utils::is_kramdown_block_attribute(line)
153        || (flavor.supports_attr_lists() && is_standalone_attr_list(line))
154}
155
156/// Check if a line is a MkDocs anchor line (empty link with attr_list)
157///
158/// MkDocs anchor lines are used to create invisible anchor points in documentation.
159/// They consist of an empty link `[]()` followed by an attr_list containing an ID
160/// or class. These are rendered as `<a id="anchor"></a>` in the HTML output.
161///
162/// # Syntax
163///
164/// ```markdown
165/// [](){ #anchor-id }              <!-- Basic anchor -->
166/// [](){#anchor-id}                <!-- No spaces -->
167/// [](){ #id .class }              <!-- Anchor with class -->
168/// [](){: #id }                    <!-- Kramdown-style with colon -->
169/// [](){ .highlight }              <!-- Class-only (styling hook) -->
170/// ```
171///
172/// # Use Cases
173///
174/// 1. **Deep linking**: Create anchor points for linking to specific paragraphs
175/// 2. **Cross-references**: Target for mkdocs-autorefs links
176/// 3. **Styling hooks**: Apply CSS classes to following content
177///
178/// # Examples
179///
180/// ```
181/// use rumdl_lib::utils::mkdocs_attr_list::is_mkdocs_anchor_line;
182///
183/// // Valid anchor lines
184/// assert!(is_mkdocs_anchor_line("[](){ #example }"));
185/// assert!(is_mkdocs_anchor_line("[](){#example}"));
186/// assert!(is_mkdocs_anchor_line("[](){ #id .class }"));
187/// assert!(is_mkdocs_anchor_line("[](){: #anchor }"));
188///
189/// // NOT anchor lines
190/// assert!(!is_mkdocs_anchor_line("[link](url)"));           // Has URL
191/// assert!(!is_mkdocs_anchor_line("[](){ #id } text"));      // Has trailing content
192/// assert!(!is_mkdocs_anchor_line("[]()"));                  // No attr_list
193/// assert!(!is_mkdocs_anchor_line("[](){ }"));               // Empty attr_list
194/// ```
195///
196/// # References
197///
198/// - [Python-Markdown attr_list](https://python-markdown.github.io/extensions/attr_list/)
199/// - [MkDocs Material - Anchor Links](https://squidfunk.github.io/mkdocs-material/reference/annotations/#anchor-links)
200/// - [MkDocs discussions on paragraph anchors](https://github.com/mkdocs/mkdocs/discussions/3754)
201#[inline]
202pub fn is_mkdocs_anchor_line(line: &str) -> bool {
203    let trimmed = line.trim();
204
205    // Fast path: must contain the empty link pattern
206    if !trimmed.starts_with("[]()") {
207        return false;
208    }
209
210    // Extract the part after []()
211    let after_link = &trimmed[4..];
212
213    // Fast path: must contain opening brace for attr_list
214    if !after_link.contains('{') {
215        return false;
216    }
217
218    // Skip optional whitespace between []() and {
219    let attr_start = after_link.trim_start();
220
221    // Must start with { or {:
222    if !attr_start.starts_with('{') {
223        return false;
224    }
225
226    // Find the closing brace
227    let Some(close_idx) = attr_start.find('}') else {
228        return false;
229    };
230
231    // Nothing meaningful should follow the closing brace
232    if !attr_start[close_idx + 1..].trim().is_empty() {
233        return false;
234    }
235
236    // Extract and validate the attr_list content
237    let attr_content = &attr_start[..=close_idx];
238
239    // Use the existing attr_list validation - must be a valid attr_list
240    if !ATTR_LIST_PATTERN.is_match(attr_content) {
241        return false;
242    }
243
244    // Parse the attr_list to ensure it has meaningful content (ID or class)
245    let attrs = find_attr_lists(attr_content);
246    attrs.iter().any(|a| a.has_id() || a.has_classes())
247}
248
249/// Extract all attr_lists from a line
250pub fn find_attr_lists(line: &str) -> Vec<AttrList> {
251    if !line.contains('{') {
252        return Vec::new();
253    }
254
255    let mut results = Vec::new();
256
257    for m in ATTR_LIST_PATTERN.find_iter(line) {
258        let attr_text = m.as_str();
259        let mut attr_list = AttrList {
260            start: m.start(),
261            end: m.end(),
262            ..Default::default()
263        };
264
265        // Extract custom ID (first one wins per HTML spec)
266        if let Some(caps) = CUSTOM_ID_PATTERN.captures(attr_text)
267            && let Some(id_match) = caps.get(1)
268        {
269            attr_list.id = Some(id_match.as_str().to_string());
270        }
271
272        // Extract all classes
273        for caps in CLASS_PATTERN.captures_iter(attr_text) {
274            if let Some(class_match) = caps.get(1) {
275                attr_list.classes.push(class_match.as_str().to_string());
276            }
277        }
278
279        // Extract key-value pairs
280        for caps in KEY_VALUE_PATTERN.captures_iter(attr_text) {
281            if let Some(key) = caps.get(1)
282                && let Some(value) = caps.get(2)
283            {
284                attr_list
285                    .attributes
286                    .push((key.as_str().to_string(), value.as_str().to_string()));
287            }
288        }
289
290        if !attr_list.is_empty() {
291            results.push(attr_list);
292        }
293    }
294
295    results
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn test_contains_attr_list() {
304        // Valid attr_list syntax
305        assert!(contains_attr_list("# Heading {#custom-id}"));
306        assert!(contains_attr_list("# Heading {.my-class}"));
307        assert!(contains_attr_list("# Heading {#id .class}"));
308        assert!(contains_attr_list("Text {: #id}"));
309        assert!(contains_attr_list("Link {target=\"_blank\"}"));
310
311        // Not attr_list
312        assert!(!contains_attr_list("# Regular heading"));
313        assert!(!contains_attr_list("Code with {braces}"));
314        assert!(!contains_attr_list("Empty {}"));
315        assert!(!contains_attr_list("Just text"));
316    }
317
318    #[test]
319    fn test_find_attr_lists_basic() {
320        let attrs = find_attr_lists("# Heading {#custom-id}");
321        assert_eq!(attrs.len(), 1);
322        assert_eq!(attrs[0].id, Some("custom-id".to_string()));
323        assert!(attrs[0].classes.is_empty());
324    }
325
326    #[test]
327    fn test_find_attr_lists_with_class() {
328        let attrs = find_attr_lists("# Heading {.highlight}");
329        assert_eq!(attrs.len(), 1);
330        assert!(attrs[0].id.is_none());
331        assert_eq!(attrs[0].classes, vec!["highlight"]);
332    }
333
334    #[test]
335    fn test_find_attr_lists_complex() {
336        let attrs = find_attr_lists("# Heading {#my-id .class1 .class2 data-value=\"test\"}");
337        assert_eq!(attrs.len(), 1);
338        assert_eq!(attrs[0].id, Some("my-id".to_string()));
339        assert_eq!(attrs[0].classes, vec!["class1", "class2"]);
340        assert_eq!(
341            attrs[0].attributes,
342            vec![("data-value".to_string(), "test".to_string())]
343        );
344    }
345
346    #[test]
347    fn test_find_attr_lists_kramdown_style() {
348        // With colon prefix (kramdown style)
349        let attrs = find_attr_lists("Paragraph {: #para-id .special }");
350        assert_eq!(attrs.len(), 1);
351        assert_eq!(attrs[0].id, Some("para-id".to_string()));
352        assert_eq!(attrs[0].classes, vec!["special"]);
353    }
354
355    #[test]
356    fn test_multiple_attr_lists_same_line() {
357        let attrs = find_attr_lists("[link]{#link-id} and [other]{#other-id}");
358        assert_eq!(attrs.len(), 2);
359        assert_eq!(attrs[0].id, Some("link-id".to_string()));
360        assert_eq!(attrs[1].id, Some("other-id".to_string()));
361    }
362
363    #[test]
364    fn test_attr_list_positions() {
365        let line = "Text {#my-id} more";
366        let attrs = find_attr_lists(line);
367        assert_eq!(attrs.len(), 1);
368        assert_eq!(attrs[0].start, 5);
369        assert_eq!(attrs[0].end, 13);
370        assert_eq!(&line[attrs[0].start..attrs[0].end], "{#my-id}");
371    }
372
373    #[test]
374    fn test_underscore_in_identifiers() {
375        let attrs = find_attr_lists("# Heading {#my_custom_id .my_class}");
376        assert_eq!(attrs.len(), 1);
377        assert_eq!(attrs[0].id, Some("my_custom_id".to_string()));
378        assert_eq!(attrs[0].classes, vec!["my_class"]);
379    }
380
381    /// Test for issue #337: Standalone attr_lists should be detected
382    /// These should be treated as paragraph boundaries in reflow
383    #[test]
384    fn test_is_standalone_attr_list() {
385        // Valid standalone attr_lists (on their own line)
386        assert!(is_standalone_attr_list("{ .class-name }"));
387        assert!(is_standalone_attr_list("{: .class-name }"));
388        assert!(is_standalone_attr_list("{#custom-id}"));
389        assert!(is_standalone_attr_list("{: #custom-id .class }"));
390        assert!(is_standalone_attr_list("  { .indented }  ")); // With whitespace
391
392        // Not standalone (part of other content)
393        assert!(!is_standalone_attr_list("Some text {#id}"));
394        assert!(!is_standalone_attr_list("{#id} more text"));
395        assert!(!is_standalone_attr_list("# Heading {#id}"));
396
397        // Not valid attr_lists (just braces)
398        assert!(!is_standalone_attr_list("{ }"));
399        assert!(!is_standalone_attr_list("{}"));
400        assert!(!is_standalone_attr_list("{ random text }"));
401
402        // Empty line
403        assert!(!is_standalone_attr_list(""));
404        assert!(!is_standalone_attr_list("   "));
405    }
406
407    /// Test for issue #365: MkDocs anchor lines should be detected
408    /// Pattern: `[](){ #anchor }` creates invisible anchor points
409    #[test]
410    fn test_is_mkdocs_anchor_line_basic() {
411        // Valid anchor lines with ID
412        assert!(is_mkdocs_anchor_line("[](){ #example }"));
413        assert!(is_mkdocs_anchor_line("[](){#example}"));
414        assert!(is_mkdocs_anchor_line("[](){ #my-anchor }"));
415        assert!(is_mkdocs_anchor_line("[](){ #anchor_with_underscore }"));
416
417        // Valid anchor lines with class
418        assert!(is_mkdocs_anchor_line("[](){ .highlight }"));
419        assert!(is_mkdocs_anchor_line("[](){.my-class}"));
420
421        // Valid anchor lines with both ID and class
422        assert!(is_mkdocs_anchor_line("[](){ #anchor .class }"));
423        assert!(is_mkdocs_anchor_line("[](){ .class #anchor }"));
424        assert!(is_mkdocs_anchor_line("[](){ #id .class1 .class2 }"));
425    }
426
427    #[test]
428    fn test_is_mkdocs_anchor_line_kramdown_style() {
429        // Kramdown-style with colon prefix
430        assert!(is_mkdocs_anchor_line("[](){: #anchor }"));
431        assert!(is_mkdocs_anchor_line("[](){:#anchor}"));
432        assert!(is_mkdocs_anchor_line("[](){: .class }"));
433        assert!(is_mkdocs_anchor_line("[](){: #id .class }"));
434    }
435
436    #[test]
437    fn test_is_mkdocs_anchor_line_whitespace_variations() {
438        // Leading/trailing whitespace on line
439        assert!(is_mkdocs_anchor_line("  [](){ #example }"));
440        assert!(is_mkdocs_anchor_line("[](){ #example }  "));
441        assert!(is_mkdocs_anchor_line("  [](){ #example }  "));
442        assert!(is_mkdocs_anchor_line("\t[](){ #example }\t"));
443
444        // Whitespace between []() and {
445        assert!(is_mkdocs_anchor_line("[]()  { #example }"));
446        assert!(is_mkdocs_anchor_line("[]()\t{ #example }"));
447
448        // No whitespace (compact form)
449        assert!(is_mkdocs_anchor_line("[](){#example}"));
450    }
451
452    #[test]
453    fn test_is_mkdocs_anchor_line_not_anchor_lines() {
454        // Empty link without attr_list
455        assert!(!is_mkdocs_anchor_line("[]()"));
456
457        // Empty attr_list (no ID or class)
458        assert!(!is_mkdocs_anchor_line("[](){ }"));
459        assert!(!is_mkdocs_anchor_line("[](){}"));
460
461        // Regular link with URL
462        assert!(!is_mkdocs_anchor_line("[](url)"));
463        assert!(!is_mkdocs_anchor_line("[text](url)"));
464        assert!(!is_mkdocs_anchor_line("[text](url){ #id }"));
465
466        // Trailing content after attr_list
467        assert!(!is_mkdocs_anchor_line("[](){ #anchor } extra text"));
468        assert!(!is_mkdocs_anchor_line("[](){ #anchor } <!-- comment -->"));
469
470        // Leading content before link
471        assert!(!is_mkdocs_anchor_line("text [](){ #anchor }"));
472        assert!(!is_mkdocs_anchor_line("# Heading [](){ #anchor }"));
473
474        // Not a link at all
475        assert!(!is_mkdocs_anchor_line("# Heading"));
476        assert!(!is_mkdocs_anchor_line("Some paragraph text"));
477        assert!(!is_mkdocs_anchor_line("{ #standalone-attr }"));
478
479        // Malformed patterns
480        assert!(!is_mkdocs_anchor_line("[]{#anchor}")); // Missing ()
481        assert!(!is_mkdocs_anchor_line("[](#anchor)")); // ID in URL position
482        assert!(!is_mkdocs_anchor_line("[](){ #anchor")); // Unclosed brace
483    }
484
485    #[test]
486    fn test_is_mkdocs_anchor_line_edge_cases() {
487        // Empty line
488        assert!(!is_mkdocs_anchor_line(""));
489        assert!(!is_mkdocs_anchor_line("   "));
490        assert!(!is_mkdocs_anchor_line("\t"));
491
492        // Only braces
493        assert!(!is_mkdocs_anchor_line("{}"));
494        assert!(!is_mkdocs_anchor_line("{ }"));
495
496        // Key-value attributes (valid in MkDocs but unusual for anchors)
497        assert!(is_mkdocs_anchor_line("[](){ #id data-value=\"test\" }"));
498
499        // Multiple IDs (first one wins per HTML spec, but pattern is valid)
500        assert!(is_mkdocs_anchor_line("[](){ #first #second }"));
501
502        // Unicode in ID (should work per attr_list spec)
503        // Note: depends on regex pattern supporting unicode identifiers
504    }
505
506    #[test]
507    fn test_is_mkdocs_anchor_line_real_world_examples() {
508        // Examples from MkDocs Material documentation
509        assert!(is_mkdocs_anchor_line("[](){ #installation }"));
510        assert!(is_mkdocs_anchor_line("[](){ #getting-started }"));
511        assert!(is_mkdocs_anchor_line("[](){ #api-reference }"));
512
513        // Examples with styling classes
514        assert!(is_mkdocs_anchor_line("[](){ .annotate }"));
515        assert!(is_mkdocs_anchor_line("[](){ #note .warning }"));
516    }
517
518    #[test]
519    fn test_attr_list_pattern_digit_starting_ids() {
520        // HTML5 allows IDs starting with digits
521        assert!(contains_attr_list("{#3rd-party}"));
522        assert!(contains_attr_list("{ #3rd-party }"));
523        assert!(contains_attr_list("{#1}"));
524        assert!(contains_attr_list("{#123-foo}"));
525        assert!(contains_attr_list("{#1st-section}"));
526        assert!(contains_attr_list("{#2nd_item}"));
527
528        // Digit-starting ID combined with class
529        assert!(contains_attr_list("{#3rd-party .glossary}"));
530
531        // Kramdown style with colon
532        assert!(contains_attr_list("{: #3rd-party}"));
533    }
534
535    #[test]
536    fn test_custom_id_extraction_digit_starting() {
537        // extract_custom_id should extract IDs starting with digits
538        let attrs = find_attr_lists("{#3rd-party}");
539        assert_eq!(attrs.len(), 1);
540        assert_eq!(attrs[0].id, Some("3rd-party".to_string()));
541
542        let attrs = find_attr_lists("{#1}");
543        assert_eq!(attrs.len(), 1);
544        assert_eq!(attrs[0].id, Some("1".to_string()));
545
546        let attrs = find_attr_lists("{#123-foo}");
547        assert_eq!(attrs.len(), 1);
548        assert_eq!(attrs[0].id, Some("123-foo".to_string()));
549
550        let attrs = find_attr_lists("{#1st-section}");
551        assert_eq!(attrs.len(), 1);
552        assert_eq!(attrs[0].id, Some("1st-section".to_string()));
553
554        let attrs = find_attr_lists("{#2nd_item}");
555        assert_eq!(attrs.len(), 1);
556        assert_eq!(attrs[0].id, Some("2nd_item".to_string()));
557    }
558
559    #[test]
560    fn test_class_pattern_still_rejects_digit_starting() {
561        // CSS class names starting with digits are invalid, should not match
562        let attrs = find_attr_lists("{.3invalid}");
563        assert_eq!(attrs.len(), 0, "Digit-starting class names should not be matched");
564    }
565
566    #[test]
567    fn test_mkdocs_anchor_line_digit_starting_id() {
568        // Anchor lines with digit-starting IDs
569        assert!(is_mkdocs_anchor_line("[](){ #3rd-party }"));
570        assert!(is_mkdocs_anchor_line("[](){ #1 }"));
571        assert!(is_mkdocs_anchor_line("[](){ #123-section }"));
572    }
573}