pulldown_html_ext/html/
config.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3
4/// Main configuration struct for the HTML renderer
5#[derive(Debug, Clone, Deserialize)]
6pub struct HtmlConfig {
7    /// HTML-specific rendering options
8    pub html: HtmlOptions,
9    /// Options for different Markdown elements
10    pub elements: ElementOptions,
11    /// Custom attribute mappings
12    pub attributes: AttributeMappings,
13    /// Syntect syntax highlighting configuration (style only)
14    pub syntect: Option<crate::html::syntect::SyntectConfigStyle>,
15}
16/// Configuration options for HTML output
17#[derive(Debug, Clone, Deserialize)]
18pub struct HtmlOptions {
19    /// Whether to escape HTML in the input
20    pub escape_html: bool,
21    /// Whether to convert newlines to <br> tags
22    pub break_on_newline: bool,
23    /// Whether to use XHTML-style self-closing tags
24    pub xhtml_style: bool,
25    /// Whether to add newlines after block elements for prettier output
26    pub pretty_print: bool,
27}
28
29/// Configuration options for different Markdown elements
30#[derive(Debug, Clone, Deserialize)]
31pub struct ElementOptions {
32    /// Options for heading elements
33    pub headings: HeadingOptions,
34    /// Options for link elements
35    pub links: LinkOptions,
36    /// Options for code blocks
37    pub code_blocks: CodeBlockOptions,
38}
39
40/// Configuration options for headings
41#[derive(Debug, Clone, Deserialize)]
42pub struct HeadingOptions {
43    /// Whether to add IDs to headings
44    pub add_ids: bool,
45    /// Prefix to use for heading IDs
46    pub id_prefix: String,
47    /// CSS classes to add to different heading levels
48    #[serde(deserialize_with = "deserialize_heading_map")]
49    pub level_classes: HashMap<u8, String>,
50}
51
52/// Configuration options for links
53#[derive(Debug, Clone, Deserialize)]
54pub struct LinkOptions {
55    /// Whether to add rel="nofollow" to external links
56    pub nofollow_external: bool,
57    /// Whether to add target="_blank" to external links
58    pub open_external_blank: bool,
59}
60
61/// Configuration options for code blocks
62#[derive(Debug, Clone, Deserialize)]
63pub struct CodeBlockOptions {
64    /// Default language for code blocks that don't specify one
65    pub default_language: Option<String>,
66    /// Whether to add line numbers to code blocks
67    pub line_numbers: bool,
68}
69
70/// Custom attribute mappings for HTML elements
71#[derive(Debug, Clone, Deserialize)]
72pub struct AttributeMappings {
73    /// Mapping of element names to their attributes
74    #[serde(deserialize_with = "deserialize_nested_string_map")]
75    pub element_attributes: HashMap<String, HashMap<String, String>>,
76}
77
78impl Default for HtmlConfig {
79    fn default() -> Self {
80        HtmlConfig {
81            html: HtmlOptions {
82                escape_html: false,
83                break_on_newline: true,
84                xhtml_style: false,
85                pretty_print: true,
86            },
87            elements: ElementOptions {
88                headings: HeadingOptions {
89                    add_ids: true,
90                    id_prefix: "heading-".to_string(),
91                    level_classes: HashMap::new(),
92                },
93                links: LinkOptions {
94                    nofollow_external: true,
95                    open_external_blank: true,
96                },
97                code_blocks: CodeBlockOptions {
98                    default_language: None,
99                    line_numbers: false,
100                },
101            },
102            attributes: AttributeMappings {
103                element_attributes: HashMap::new(),
104            },
105            #[cfg(feature = "syntect")]
106            syntect: None,
107        }
108    }
109}
110
111fn deserialize_heading_map<'de, D>(deserializer: D) -> Result<HashMap<u8, String>, D::Error>
112where
113    D: serde::Deserializer<'de>,
114{
115    use serde::de::Error;
116    use serde_json::Value;
117
118    let value = Value::deserialize(deserializer)?;
119
120    match value {
121        Value::Object(map) => {
122            let mut result = HashMap::new();
123            for (k, v) in map {
124                let level = k.parse::<u8>().map_err(D::Error::custom)?;
125                if !(1..=6).contains(&level) {
126                    return Err(D::Error::custom(format!(
127                        "heading level must be between 1 and 6, got {}",
128                        level
129                    )));
130                }
131                let class = v
132                    .as_str()
133                    .ok_or_else(|| D::Error::custom("value must be a string"))?
134                    .to_string();
135                result.insert(level, class);
136            }
137            Ok(result)
138        }
139        _ => Err(D::Error::custom("expected a map")),
140    }
141}
142
143fn deserialize_nested_string_map<'de, D>(
144    deserializer: D,
145) -> Result<HashMap<String, HashMap<String, String>>, D::Error>
146where
147    D: serde::Deserializer<'de>,
148{
149    use serde::de::Error;
150    use serde_json::Value;
151
152    let value = Value::deserialize(deserializer)?;
153
154    match value {
155        Value::Object(outer_map) => {
156            let mut result = HashMap::new();
157            for (outer_key, inner_value) in outer_map {
158                match inner_value {
159                    Value::Object(inner_map) => {
160                        let mut inner_result = HashMap::new();
161                        for (inner_key, value) in inner_map {
162                            let str_value = value
163                                .as_str()
164                                .ok_or_else(|| D::Error::custom("value must be a string"))?
165                                .to_string();
166                            inner_result.insert(inner_key, str_value);
167                        }
168                        result.insert(outer_key, inner_result);
169                    }
170                    _ => return Err(D::Error::custom("expected a nested map")),
171                }
172            }
173            Ok(result)
174        }
175        _ => Err(D::Error::custom("expected a map")),
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use serde_json::json;
183
184    #[test]
185    fn test_default_config() {
186        let config = HtmlConfig::default();
187        assert!(!config.html.escape_html);
188        assert!(config.html.break_on_newline);
189        assert!(!config.html.xhtml_style);
190        assert!(config.html.pretty_print);
191    }
192
193    #[test]
194    fn test_heading_map_deserialization() {
195        let json = json!({
196            "1": "title",
197            "2": "subtitle",
198            "6": "small-title"
199        });
200
201        let map: HashMap<u8, String> = deserialize_heading_map(json).unwrap();
202        assert_eq!(map.get(&1).unwrap(), "title");
203        assert_eq!(map.get(&2).unwrap(), "subtitle");
204        assert_eq!(map.get(&6).unwrap(), "small-title");
205    }
206
207    #[test]
208    fn test_invalid_heading_level() {
209        let json = json!({
210            "7": "invalid"
211        });
212
213        let result: Result<HashMap<u8, String>, _> = deserialize_heading_map(json);
214        assert!(result.is_err());
215    }
216
217    #[test]
218    fn test_attribute_map_deserialization() {
219        let json = json!({
220            "h1": {
221                "class": "title",
222                "data-level": "1"
223            },
224            "pre": {
225                "class": "code-block"
226            }
227        });
228
229        let map: HashMap<String, HashMap<String, String>> =
230            deserialize_nested_string_map(json).unwrap();
231        assert_eq!(map.get("h1").unwrap().get("class").unwrap(), "title");
232        assert_eq!(map.get("h1").unwrap().get("data-level").unwrap(), "1");
233        assert_eq!(map.get("pre").unwrap().get("class").unwrap(), "code-block");
234    }
235}