Skip to main content

rumdl_lib/utils/anchor_styles/
mod.rs

1//! Anchor generation styles for different Markdown platforms
2//!
3//! This module provides different anchor generation implementations that match
4//! the behavior of various Markdown platforms:
5//!
6//! - **GitHub**: GitHub.com's official anchor generation algorithm
7//! - **KramdownGfm**: Kramdown with GFM input (used by Jekyll/GitHub Pages)
8//! - **Kramdown**: Pure kramdown without GFM extensions
9//!
10//! Each style is implemented in a separate module with comprehensive tests
11//! verified against the official tools/platforms.
12//!
13//! Common utilities are shared via the `common` module to avoid duplication.
14
15pub mod common;
16pub mod github;
17pub mod kramdown;
18pub mod kramdown_gfm; // Renamed from jekyll for clarity
19pub mod python_markdown;
20
21use serde::{Deserialize, Serialize};
22
23/// Anchor generation style for heading fragments
24#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
25#[serde(rename_all = "kebab-case")]
26#[derive(Default)]
27pub enum AnchorStyle {
28    /// GitHub/GFM style (default): preserves underscores, removes punctuation
29    #[default]
30    #[serde(rename = "github")]
31    GitHub,
32    /// Kramdown with GFM input: matches Jekyll/GitHub Pages behavior
33    /// Accepts "kramdown-gfm", "kramdown_gfm", and "jekyll" (for backward compatibility)
34    #[serde(rename = "kramdown-gfm", alias = "kramdown_gfm", alias = "jekyll")]
35    KramdownGfm,
36    /// Pure kramdown style: removes underscores and punctuation
37    #[serde(rename = "kramdown")]
38    Kramdown,
39    /// Python-Markdown style: used by MkDocs (NFKD → ASCII, collapse separators)
40    #[serde(rename = "python-markdown", alias = "python_markdown", alias = "mkdocs")]
41    PythonMarkdown,
42}
43
44impl AnchorStyle {
45    /// Generate an anchor fragment using the specified style
46    pub fn generate_fragment(&self, heading: &str) -> String {
47        match self {
48            AnchorStyle::GitHub => github::heading_to_fragment(heading),
49            AnchorStyle::KramdownGfm => kramdown_gfm::heading_to_fragment(heading),
50            AnchorStyle::Kramdown => kramdown::heading_to_fragment(heading),
51            AnchorStyle::PythonMarkdown => python_markdown::heading_to_fragment(heading),
52        }
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn test_anchor_style_serde() {
62        // Test serialization (uses primary names)
63        assert_eq!(serde_json::to_string(&AnchorStyle::GitHub).unwrap(), "\"github\"");
64        assert_eq!(
65            serde_json::to_string(&AnchorStyle::KramdownGfm).unwrap(),
66            "\"kramdown-gfm\""
67        );
68        assert_eq!(serde_json::to_string(&AnchorStyle::Kramdown).unwrap(), "\"kramdown\"");
69        assert_eq!(
70            serde_json::to_string(&AnchorStyle::PythonMarkdown).unwrap(),
71            "\"python-markdown\""
72        );
73
74        // Test deserialization with primary names (kebab-case)
75        assert_eq!(
76            serde_json::from_str::<AnchorStyle>("\"github\"").unwrap(),
77            AnchorStyle::GitHub
78        );
79        assert_eq!(
80            serde_json::from_str::<AnchorStyle>("\"kramdown-gfm\"").unwrap(),
81            AnchorStyle::KramdownGfm
82        );
83        assert_eq!(
84            serde_json::from_str::<AnchorStyle>("\"kramdown\"").unwrap(),
85            AnchorStyle::Kramdown
86        );
87        assert_eq!(
88            serde_json::from_str::<AnchorStyle>("\"python-markdown\"").unwrap(),
89            AnchorStyle::PythonMarkdown
90        );
91
92        // Test snake_case alias
93        assert_eq!(
94            serde_json::from_str::<AnchorStyle>("\"kramdown_gfm\"").unwrap(),
95            AnchorStyle::KramdownGfm
96        );
97        assert_eq!(
98            serde_json::from_str::<AnchorStyle>("\"python_markdown\"").unwrap(),
99            AnchorStyle::PythonMarkdown
100        );
101
102        // Test backward compatibility aliases
103        assert_eq!(
104            serde_json::from_str::<AnchorStyle>("\"jekyll\"").unwrap(),
105            AnchorStyle::KramdownGfm
106        );
107        assert_eq!(
108            serde_json::from_str::<AnchorStyle>("\"mkdocs\"").unwrap(),
109            AnchorStyle::PythonMarkdown
110        );
111    }
112
113    #[test]
114    fn test_anchor_style_differences() {
115        let test_cases = [
116            "cbrown --> sbrown: --unsafe-paths",
117            "Update login_type",
118            "Test---with---multiple---hyphens",
119            "API::Response > Error--Handling",
120        ];
121
122        for case in test_cases {
123            let github = AnchorStyle::GitHub.generate_fragment(case);
124            let kramdown_gfm = AnchorStyle::KramdownGfm.generate_fragment(case);
125            let kramdown = AnchorStyle::Kramdown.generate_fragment(case);
126            let python_md = AnchorStyle::PythonMarkdown.generate_fragment(case);
127
128            // Each style should produce a valid non-empty result
129            assert!(!github.is_empty(), "GitHub style failed for: {case}");
130            assert!(!kramdown_gfm.is_empty(), "KramdownGfm style failed for: {case}");
131            assert!(!kramdown.is_empty(), "Kramdown style failed for: {case}");
132            assert!(!python_md.is_empty(), "PythonMarkdown style failed for: {case}");
133        }
134    }
135}