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, Copy, 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    /// The anchor generation a flavor's renderer performs natively.
46    ///
47    /// Used when the user has not pinned `anchor-style`, so a document is
48    /// checked against the anchors its own platform emits. `per-file-flavor`
49    /// makes this a per-document answer, so resolve it from the flavor the file
50    /// is parsed with rather than from the global one.
51    pub fn for_flavor(flavor: crate::config::MarkdownFlavor) -> Self {
52        match flavor {
53            crate::config::MarkdownFlavor::MkDocs => AnchorStyle::PythonMarkdown,
54            crate::config::MarkdownFlavor::Kramdown => AnchorStyle::KramdownGfm,
55            _ => AnchorStyle::GitHub,
56        }
57    }
58
59    /// Generate an anchor fragment using the specified style
60    pub fn generate_fragment(&self, heading: &str) -> String {
61        match self {
62            AnchorStyle::GitHub => github::heading_to_fragment(heading),
63            AnchorStyle::KramdownGfm => kramdown_gfm::heading_to_fragment(heading),
64            AnchorStyle::Kramdown => kramdown::heading_to_fragment(heading),
65            AnchorStyle::PythonMarkdown => python_markdown::heading_to_fragment(heading),
66        }
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn test_anchor_style_serde() {
76        // Test serialization (uses primary names)
77        assert_eq!(serde_json::to_string(&AnchorStyle::GitHub).unwrap(), "\"github\"");
78        assert_eq!(
79            serde_json::to_string(&AnchorStyle::KramdownGfm).unwrap(),
80            "\"kramdown-gfm\""
81        );
82        assert_eq!(serde_json::to_string(&AnchorStyle::Kramdown).unwrap(), "\"kramdown\"");
83        assert_eq!(
84            serde_json::to_string(&AnchorStyle::PythonMarkdown).unwrap(),
85            "\"python-markdown\""
86        );
87
88        // Test deserialization with primary names (kebab-case)
89        assert_eq!(
90            serde_json::from_str::<AnchorStyle>("\"github\"").unwrap(),
91            AnchorStyle::GitHub
92        );
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>("\"kramdown\"").unwrap(),
99            AnchorStyle::Kramdown
100        );
101        assert_eq!(
102            serde_json::from_str::<AnchorStyle>("\"python-markdown\"").unwrap(),
103            AnchorStyle::PythonMarkdown
104        );
105
106        // Test snake_case alias
107        assert_eq!(
108            serde_json::from_str::<AnchorStyle>("\"kramdown_gfm\"").unwrap(),
109            AnchorStyle::KramdownGfm
110        );
111        assert_eq!(
112            serde_json::from_str::<AnchorStyle>("\"python_markdown\"").unwrap(),
113            AnchorStyle::PythonMarkdown
114        );
115
116        // Test backward compatibility aliases
117        assert_eq!(
118            serde_json::from_str::<AnchorStyle>("\"jekyll\"").unwrap(),
119            AnchorStyle::KramdownGfm
120        );
121        assert_eq!(
122            serde_json::from_str::<AnchorStyle>("\"mkdocs\"").unwrap(),
123            AnchorStyle::PythonMarkdown
124        );
125    }
126
127    #[test]
128    fn test_anchor_style_differences() {
129        let test_cases = [
130            "cbrown --> sbrown: --unsafe-paths",
131            "Update login_type",
132            "Test---with---multiple---hyphens",
133            "API::Response > Error--Handling",
134        ];
135
136        for case in test_cases {
137            let github = AnchorStyle::GitHub.generate_fragment(case);
138            let kramdown_gfm = AnchorStyle::KramdownGfm.generate_fragment(case);
139            let kramdown = AnchorStyle::Kramdown.generate_fragment(case);
140            let python_md = AnchorStyle::PythonMarkdown.generate_fragment(case);
141
142            // Each style should produce a valid non-empty result
143            assert!(!github.is_empty(), "GitHub style failed for: {case}");
144            assert!(!kramdown_gfm.is_empty(), "KramdownGfm style failed for: {case}");
145            assert!(!kramdown.is_empty(), "Kramdown style failed for: {case}");
146            assert!(!python_md.is_empty(), "PythonMarkdown style failed for: {case}");
147        }
148    }
149}