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 `}`
43static 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-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-Z_][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 if a line is a MkDocs anchor line (empty link with attr_list)
138///
139/// MkDocs anchor lines are used to create invisible anchor points in documentation.
140/// They consist of an empty link `[]()` followed by an attr_list containing an ID
141/// or class. These are rendered as `<a id="anchor"></a>` in the HTML output.
142///
143/// # Syntax
144///
145/// ```markdown
146/// [](){ #anchor-id }              <!-- Basic anchor -->
147/// [](){#anchor-id}                <!-- No spaces -->
148/// [](){ #id .class }              <!-- Anchor with class -->
149/// [](){: #id }                    <!-- Kramdown-style with colon -->
150/// [](){ .highlight }              <!-- Class-only (styling hook) -->
151/// ```
152///
153/// # Use Cases
154///
155/// 1. **Deep linking**: Create anchor points for linking to specific paragraphs
156/// 2. **Cross-references**: Target for mkdocs-autorefs links
157/// 3. **Styling hooks**: Apply CSS classes to following content
158///
159/// # Examples
160///
161/// ```
162/// use rumdl_lib::utils::mkdocs_attr_list::is_mkdocs_anchor_line;
163///
164/// // Valid anchor lines
165/// assert!(is_mkdocs_anchor_line("[](){ #example }"));
166/// assert!(is_mkdocs_anchor_line("[](){#example}"));
167/// assert!(is_mkdocs_anchor_line("[](){ #id .class }"));
168/// assert!(is_mkdocs_anchor_line("[](){: #anchor }"));
169///
170/// // NOT anchor lines
171/// assert!(!is_mkdocs_anchor_line("[link](url)"));           // Has URL
172/// assert!(!is_mkdocs_anchor_line("[](){ #id } text"));      // Has trailing content
173/// assert!(!is_mkdocs_anchor_line("[]()"));                  // No attr_list
174/// assert!(!is_mkdocs_anchor_line("[](){ }"));               // Empty attr_list
175/// ```
176///
177/// # References
178///
179/// - [Python-Markdown attr_list](https://python-markdown.github.io/extensions/attr_list/)
180/// - [MkDocs Material - Anchor Links](https://squidfunk.github.io/mkdocs-material/reference/annotations/#anchor-links)
181/// - [MkDocs discussions on paragraph anchors](https://github.com/mkdocs/mkdocs/discussions/3754)
182#[inline]
183pub fn is_mkdocs_anchor_line(line: &str) -> bool {
184    let trimmed = line.trim();
185
186    // Fast path: must contain the empty link pattern
187    if !trimmed.starts_with("[]()") {
188        return false;
189    }
190
191    // Extract the part after []()
192    let after_link = &trimmed[4..];
193
194    // Fast path: must contain opening brace for attr_list
195    if !after_link.contains('{') {
196        return false;
197    }
198
199    // Skip optional whitespace between []() and {
200    let attr_start = after_link.trim_start();
201
202    // Must start with { or {:
203    if !attr_start.starts_with('{') {
204        return false;
205    }
206
207    // Find the closing brace
208    let Some(close_idx) = attr_start.find('}') else {
209        return false;
210    };
211
212    // Nothing meaningful should follow the closing brace
213    if !attr_start[close_idx + 1..].trim().is_empty() {
214        return false;
215    }
216
217    // Extract and validate the attr_list content
218    let attr_content = &attr_start[..=close_idx];
219
220    // Use the existing attr_list validation - must be a valid attr_list
221    if !ATTR_LIST_PATTERN.is_match(attr_content) {
222        return false;
223    }
224
225    // Parse the attr_list to ensure it has meaningful content (ID or class)
226    let attrs = find_attr_lists(attr_content);
227    attrs.iter().any(|a| a.has_id() || a.has_classes())
228}
229
230/// Extract all attr_lists from a line
231pub fn find_attr_lists(line: &str) -> Vec<AttrList> {
232    if !line.contains('{') {
233        return Vec::new();
234    }
235
236    let mut results = Vec::new();
237
238    for m in ATTR_LIST_PATTERN.find_iter(line) {
239        let attr_text = m.as_str();
240        let mut attr_list = AttrList {
241            start: m.start(),
242            end: m.end(),
243            ..Default::default()
244        };
245
246        // Extract custom ID (first one wins per HTML spec)
247        if let Some(caps) = CUSTOM_ID_PATTERN.captures(attr_text)
248            && let Some(id_match) = caps.get(1)
249        {
250            attr_list.id = Some(id_match.as_str().to_string());
251        }
252
253        // Extract all classes
254        for caps in CLASS_PATTERN.captures_iter(attr_text) {
255            if let Some(class_match) = caps.get(1) {
256                attr_list.classes.push(class_match.as_str().to_string());
257            }
258        }
259
260        // Extract key-value pairs
261        for caps in KEY_VALUE_PATTERN.captures_iter(attr_text) {
262            if let Some(key) = caps.get(1)
263                && let Some(value) = caps.get(2)
264            {
265                attr_list
266                    .attributes
267                    .push((key.as_str().to_string(), value.as_str().to_string()));
268            }
269        }
270
271        if !attr_list.is_empty() {
272            results.push(attr_list);
273        }
274    }
275
276    results
277}
278
279/// Extract custom ID from a heading line with attr_list syntax
280///
281/// Returns the custom ID if found, or None if no custom ID is present.
282///
283/// # Examples
284/// ```
285/// use rumdl_lib::utils::mkdocs_attr_list::extract_heading_custom_id;
286///
287/// assert_eq!(extract_heading_custom_id("# Heading {#my-id}"), Some("my-id".to_string()));
288/// assert_eq!(extract_heading_custom_id("## Title {#custom .class}"), Some("custom".to_string()));
289/// assert_eq!(extract_heading_custom_id("# No ID here"), None);
290/// ```
291pub fn extract_heading_custom_id(line: &str) -> Option<String> {
292    let attrs = find_attr_lists(line);
293    attrs.into_iter().find_map(|a| a.id)
294}
295
296/// Strip attr_list syntax from a heading text
297///
298/// Returns the heading text without the trailing attr_list.
299///
300/// # Examples
301/// ```
302/// use rumdl_lib::utils::mkdocs_attr_list::strip_attr_list_from_heading;
303///
304/// assert_eq!(strip_attr_list_from_heading("Heading {#my-id}"), "Heading");
305/// assert_eq!(strip_attr_list_from_heading("Title {#id .class}"), "Title");
306/// assert_eq!(strip_attr_list_from_heading("No attributes"), "No attributes");
307/// ```
308pub fn strip_attr_list_from_heading(text: &str) -> String {
309    if let Some(m) = ATTR_LIST_PATTERN.find(text) {
310        // Only strip if at the end of the text (with optional whitespace)
311        let after = &text[m.end()..];
312        if after.trim().is_empty() {
313            return text[..m.start()].trim_end().to_string();
314        }
315    }
316    text.to_string()
317}
318
319/// Check if a position in a line is within an attr_list
320pub fn is_in_attr_list(line: &str, position: usize) -> bool {
321    for m in ATTR_LIST_PATTERN.find_iter(line) {
322        if m.start() <= position && position < m.end() {
323            return true;
324        }
325    }
326    false
327}
328
329/// Extract all custom anchor IDs from a document
330///
331/// This function finds all custom IDs defined using attr_list syntax throughout
332/// the document. These IDs can be used as fragment link targets.
333///
334/// # Arguments
335/// * `content` - The full document content
336///
337/// # Returns
338/// A vector of (custom_id, line_number) tuples, where line_number is 1-indexed
339pub fn extract_all_custom_anchors(content: &str) -> Vec<(String, usize)> {
340    let mut anchors = Vec::new();
341
342    for (line_idx, line) in content.lines().enumerate() {
343        let line_num = line_idx + 1;
344
345        for attr_list in find_attr_lists(line) {
346            if let Some(id) = attr_list.id {
347                anchors.push((id, line_num));
348            }
349        }
350    }
351
352    anchors
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn test_contains_attr_list() {
361        // Valid attr_list syntax
362        assert!(contains_attr_list("# Heading {#custom-id}"));
363        assert!(contains_attr_list("# Heading {.my-class}"));
364        assert!(contains_attr_list("# Heading {#id .class}"));
365        assert!(contains_attr_list("Text {: #id}"));
366        assert!(contains_attr_list("Link {target=\"_blank\"}"));
367
368        // Not attr_list
369        assert!(!contains_attr_list("# Regular heading"));
370        assert!(!contains_attr_list("Code with {braces}"));
371        assert!(!contains_attr_list("Empty {}"));
372        assert!(!contains_attr_list("Just text"));
373    }
374
375    #[test]
376    fn test_find_attr_lists_basic() {
377        let attrs = find_attr_lists("# Heading {#custom-id}");
378        assert_eq!(attrs.len(), 1);
379        assert_eq!(attrs[0].id, Some("custom-id".to_string()));
380        assert!(attrs[0].classes.is_empty());
381    }
382
383    #[test]
384    fn test_find_attr_lists_with_class() {
385        let attrs = find_attr_lists("# Heading {.highlight}");
386        assert_eq!(attrs.len(), 1);
387        assert!(attrs[0].id.is_none());
388        assert_eq!(attrs[0].classes, vec!["highlight"]);
389    }
390
391    #[test]
392    fn test_find_attr_lists_complex() {
393        let attrs = find_attr_lists("# Heading {#my-id .class1 .class2 data-value=\"test\"}");
394        assert_eq!(attrs.len(), 1);
395        assert_eq!(attrs[0].id, Some("my-id".to_string()));
396        assert_eq!(attrs[0].classes, vec!["class1", "class2"]);
397        assert_eq!(
398            attrs[0].attributes,
399            vec![("data-value".to_string(), "test".to_string())]
400        );
401    }
402
403    #[test]
404    fn test_find_attr_lists_kramdown_style() {
405        // With colon prefix (kramdown style)
406        let attrs = find_attr_lists("Paragraph {: #para-id .special }");
407        assert_eq!(attrs.len(), 1);
408        assert_eq!(attrs[0].id, Some("para-id".to_string()));
409        assert_eq!(attrs[0].classes, vec!["special"]);
410    }
411
412    #[test]
413    fn test_extract_heading_custom_id() {
414        assert_eq!(
415            extract_heading_custom_id("# Heading {#my-anchor}"),
416            Some("my-anchor".to_string())
417        );
418        assert_eq!(
419            extract_heading_custom_id("## Title {#title .class}"),
420            Some("title".to_string())
421        );
422        assert_eq!(extract_heading_custom_id("# No ID {.class-only}"), None);
423        assert_eq!(extract_heading_custom_id("# Plain heading"), None);
424    }
425
426    #[test]
427    fn test_strip_attr_list_from_heading() {
428        assert_eq!(strip_attr_list_from_heading("Heading {#my-id}"), "Heading");
429        assert_eq!(strip_attr_list_from_heading("Title {#id .class}"), "Title");
430        assert_eq!(
431            strip_attr_list_from_heading("Multi Word Title {#anchor}"),
432            "Multi Word Title"
433        );
434        assert_eq!(strip_attr_list_from_heading("No attributes"), "No attributes");
435        // Attr list in middle should not be stripped
436        assert_eq!(strip_attr_list_from_heading("Before {#id} after"), "Before {#id} after");
437    }
438
439    #[test]
440    fn test_is_in_attr_list() {
441        let line = "Some text {#my-id} more text";
442        assert!(!is_in_attr_list(line, 0)); // "S"
443        assert!(!is_in_attr_list(line, 8)); // " "
444        assert!(is_in_attr_list(line, 10)); // "{"
445        assert!(is_in_attr_list(line, 15)); // "i"
446        assert!(!is_in_attr_list(line, 19)); // " "
447    }
448
449    #[test]
450    fn test_extract_all_custom_anchors() {
451        let content = r#"# First Heading {#first}
452
453Some paragraph {: #para-id}
454
455## Second {#second .class}
456
457No ID here.
458
459### Third {.class-only}
460
461{#standalone-id}
462"#;
463        let anchors = extract_all_custom_anchors(content);
464
465        assert_eq!(anchors.len(), 4);
466        assert_eq!(anchors[0], ("first".to_string(), 1));
467        assert_eq!(anchors[1], ("para-id".to_string(), 3));
468        assert_eq!(anchors[2], ("second".to_string(), 5));
469        assert_eq!(anchors[3], ("standalone-id".to_string(), 11));
470    }
471
472    #[test]
473    fn test_multiple_attr_lists_same_line() {
474        let attrs = find_attr_lists("[link]{#link-id} and [other]{#other-id}");
475        assert_eq!(attrs.len(), 2);
476        assert_eq!(attrs[0].id, Some("link-id".to_string()));
477        assert_eq!(attrs[1].id, Some("other-id".to_string()));
478    }
479
480    #[test]
481    fn test_attr_list_positions() {
482        let line = "Text {#my-id} more";
483        let attrs = find_attr_lists(line);
484        assert_eq!(attrs.len(), 1);
485        assert_eq!(attrs[0].start, 5);
486        assert_eq!(attrs[0].end, 13);
487        assert_eq!(&line[attrs[0].start..attrs[0].end], "{#my-id}");
488    }
489
490    #[test]
491    fn test_underscore_in_identifiers() {
492        let attrs = find_attr_lists("# Heading {#my_custom_id .my_class}");
493        assert_eq!(attrs.len(), 1);
494        assert_eq!(attrs[0].id, Some("my_custom_id".to_string()));
495        assert_eq!(attrs[0].classes, vec!["my_class"]);
496    }
497
498    /// Test for issue #337: Standalone attr_lists should be detected
499    /// These should be treated as paragraph boundaries in reflow
500    #[test]
501    fn test_is_standalone_attr_list() {
502        // Valid standalone attr_lists (on their own line)
503        assert!(is_standalone_attr_list("{ .class-name }"));
504        assert!(is_standalone_attr_list("{: .class-name }"));
505        assert!(is_standalone_attr_list("{#custom-id}"));
506        assert!(is_standalone_attr_list("{: #custom-id .class }"));
507        assert!(is_standalone_attr_list("  { .indented }  ")); // With whitespace
508
509        // Not standalone (part of other content)
510        assert!(!is_standalone_attr_list("Some text {#id}"));
511        assert!(!is_standalone_attr_list("{#id} more text"));
512        assert!(!is_standalone_attr_list("# Heading {#id}"));
513
514        // Not valid attr_lists (just braces)
515        assert!(!is_standalone_attr_list("{ }"));
516        assert!(!is_standalone_attr_list("{}"));
517        assert!(!is_standalone_attr_list("{ random text }"));
518
519        // Empty line
520        assert!(!is_standalone_attr_list(""));
521        assert!(!is_standalone_attr_list("   "));
522    }
523
524    /// Test for issue #365: MkDocs anchor lines should be detected
525    /// Pattern: `[](){ #anchor }` creates invisible anchor points
526    #[test]
527    fn test_is_mkdocs_anchor_line_basic() {
528        // Valid anchor lines with ID
529        assert!(is_mkdocs_anchor_line("[](){ #example }"));
530        assert!(is_mkdocs_anchor_line("[](){#example}"));
531        assert!(is_mkdocs_anchor_line("[](){ #my-anchor }"));
532        assert!(is_mkdocs_anchor_line("[](){ #anchor_with_underscore }"));
533
534        // Valid anchor lines with class
535        assert!(is_mkdocs_anchor_line("[](){ .highlight }"));
536        assert!(is_mkdocs_anchor_line("[](){.my-class}"));
537
538        // Valid anchor lines with both ID and class
539        assert!(is_mkdocs_anchor_line("[](){ #anchor .class }"));
540        assert!(is_mkdocs_anchor_line("[](){ .class #anchor }"));
541        assert!(is_mkdocs_anchor_line("[](){ #id .class1 .class2 }"));
542    }
543
544    #[test]
545    fn test_is_mkdocs_anchor_line_kramdown_style() {
546        // Kramdown-style with colon prefix
547        assert!(is_mkdocs_anchor_line("[](){: #anchor }"));
548        assert!(is_mkdocs_anchor_line("[](){:#anchor}"));
549        assert!(is_mkdocs_anchor_line("[](){: .class }"));
550        assert!(is_mkdocs_anchor_line("[](){: #id .class }"));
551    }
552
553    #[test]
554    fn test_is_mkdocs_anchor_line_whitespace_variations() {
555        // Leading/trailing whitespace on line
556        assert!(is_mkdocs_anchor_line("  [](){ #example }"));
557        assert!(is_mkdocs_anchor_line("[](){ #example }  "));
558        assert!(is_mkdocs_anchor_line("  [](){ #example }  "));
559        assert!(is_mkdocs_anchor_line("\t[](){ #example }\t"));
560
561        // Whitespace between []() and {
562        assert!(is_mkdocs_anchor_line("[]()  { #example }"));
563        assert!(is_mkdocs_anchor_line("[]()\t{ #example }"));
564
565        // No whitespace (compact form)
566        assert!(is_mkdocs_anchor_line("[](){#example}"));
567    }
568
569    #[test]
570    fn test_is_mkdocs_anchor_line_not_anchor_lines() {
571        // Empty link without attr_list
572        assert!(!is_mkdocs_anchor_line("[]()"));
573
574        // Empty attr_list (no ID or class)
575        assert!(!is_mkdocs_anchor_line("[](){ }"));
576        assert!(!is_mkdocs_anchor_line("[](){}"));
577
578        // Regular link with URL
579        assert!(!is_mkdocs_anchor_line("[](url)"));
580        assert!(!is_mkdocs_anchor_line("[text](url)"));
581        assert!(!is_mkdocs_anchor_line("[text](url){ #id }"));
582
583        // Trailing content after attr_list
584        assert!(!is_mkdocs_anchor_line("[](){ #anchor } extra text"));
585        assert!(!is_mkdocs_anchor_line("[](){ #anchor } <!-- comment -->"));
586
587        // Leading content before link
588        assert!(!is_mkdocs_anchor_line("text [](){ #anchor }"));
589        assert!(!is_mkdocs_anchor_line("# Heading [](){ #anchor }"));
590
591        // Not a link at all
592        assert!(!is_mkdocs_anchor_line("# Heading"));
593        assert!(!is_mkdocs_anchor_line("Some paragraph text"));
594        assert!(!is_mkdocs_anchor_line("{ #standalone-attr }"));
595
596        // Malformed patterns
597        assert!(!is_mkdocs_anchor_line("[]{#anchor}")); // Missing ()
598        assert!(!is_mkdocs_anchor_line("[](#anchor)")); // ID in URL position
599        assert!(!is_mkdocs_anchor_line("[](){ #anchor")); // Unclosed brace
600    }
601
602    #[test]
603    fn test_is_mkdocs_anchor_line_edge_cases() {
604        // Empty line
605        assert!(!is_mkdocs_anchor_line(""));
606        assert!(!is_mkdocs_anchor_line("   "));
607        assert!(!is_mkdocs_anchor_line("\t"));
608
609        // Only braces
610        assert!(!is_mkdocs_anchor_line("{}"));
611        assert!(!is_mkdocs_anchor_line("{ }"));
612
613        // Key-value attributes (valid in MkDocs but unusual for anchors)
614        assert!(is_mkdocs_anchor_line("[](){ #id data-value=\"test\" }"));
615
616        // Multiple IDs (first one wins per HTML spec, but pattern is valid)
617        assert!(is_mkdocs_anchor_line("[](){ #first #second }"));
618
619        // Unicode in ID (should work per attr_list spec)
620        // Note: depends on regex pattern supporting unicode identifiers
621    }
622
623    #[test]
624    fn test_is_mkdocs_anchor_line_real_world_examples() {
625        // Examples from MkDocs Material documentation
626        assert!(is_mkdocs_anchor_line("[](){ #installation }"));
627        assert!(is_mkdocs_anchor_line("[](){ #getting-started }"));
628        assert!(is_mkdocs_anchor_line("[](){ #api-reference }"));
629
630        // Examples with styling classes
631        assert!(is_mkdocs_anchor_line("[](){ .annotate }"));
632        assert!(is_mkdocs_anchor_line("[](){ #note .warning }"));
633    }
634}