pulldown_html_ext/html/
config.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
use serde::Deserialize;
use std::collections::HashMap;

/// Main configuration struct for the HTML renderer
#[derive(Debug, Clone, Deserialize)]
pub struct HtmlConfig {
    /// HTML-specific rendering options
    pub html: HtmlOptions,
    /// Options for different Markdown elements
    pub elements: ElementOptions,
    /// Custom attribute mappings
    pub attributes: AttributeMappings,
    /// Syntect syntax highlighting configuration (style only)
    pub syntect: Option<crate::html::syntect::SyntectConfigStyle>,
}
/// Configuration options for HTML output
#[derive(Debug, Clone, Deserialize)]
pub struct HtmlOptions {
    /// Whether to escape HTML in the input
    pub escape_html: bool,
    /// Whether to convert newlines to <br> tags
    pub break_on_newline: bool,
    /// Whether to use XHTML-style self-closing tags
    pub xhtml_style: bool,
    /// Whether to add newlines after block elements for prettier output
    pub pretty_print: bool,
}

/// Configuration options for different Markdown elements
#[derive(Debug, Clone, Deserialize)]
pub struct ElementOptions {
    /// Options for heading elements
    pub headings: HeadingOptions,
    /// Options for link elements
    pub links: LinkOptions,
    /// Options for code blocks
    pub code_blocks: CodeBlockOptions,
}

/// Configuration options for headings
#[derive(Debug, Clone, Deserialize)]
pub struct HeadingOptions {
    /// Whether to add IDs to headings
    pub add_ids: bool,
    /// Prefix to use for heading IDs
    pub id_prefix: String,
    /// CSS classes to add to different heading levels
    #[serde(deserialize_with = "deserialize_heading_map")]
    pub level_classes: HashMap<u8, String>,
}

/// Configuration options for links
#[derive(Debug, Clone, Deserialize)]
pub struct LinkOptions {
    /// Whether to add rel="nofollow" to external links
    pub nofollow_external: bool,
    /// Whether to add target="_blank" to external links
    pub open_external_blank: bool,
}

/// Configuration options for code blocks
#[derive(Debug, Clone, Deserialize)]
pub struct CodeBlockOptions {
    /// Default language for code blocks that don't specify one
    pub default_language: Option<String>,
    /// Whether to add line numbers to code blocks
    pub line_numbers: bool,
}

/// Custom attribute mappings for HTML elements
#[derive(Debug, Clone, Deserialize)]
pub struct AttributeMappings {
    /// Mapping of element names to their attributes
    #[serde(deserialize_with = "deserialize_nested_string_map")]
    pub element_attributes: HashMap<String, HashMap<String, String>>,
}

impl Default for HtmlConfig {
    fn default() -> Self {
        HtmlConfig {
            html: HtmlOptions {
                escape_html: false,
                break_on_newline: true,
                xhtml_style: false,
                pretty_print: true,
            },
            elements: ElementOptions {
                headings: HeadingOptions {
                    add_ids: true,
                    id_prefix: "heading-".to_string(),
                    level_classes: HashMap::new(),
                },
                links: LinkOptions {
                    nofollow_external: true,
                    open_external_blank: true,
                },
                code_blocks: CodeBlockOptions {
                    default_language: None,
                    line_numbers: false,
                },
            },
            attributes: AttributeMappings {
                element_attributes: HashMap::new(),
            },
            #[cfg(feature = "syntect")]
            syntect: None,
        }
    }
}

fn deserialize_heading_map<'de, D>(deserializer: D) -> Result<HashMap<u8, String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::de::Error;
    use serde_json::Value;

    let value = Value::deserialize(deserializer)?;

    match value {
        Value::Object(map) => {
            let mut result = HashMap::new();
            for (k, v) in map {
                let level = k.parse::<u8>().map_err(D::Error::custom)?;
                if !(1..=6).contains(&level) {
                    return Err(D::Error::custom(format!(
                        "heading level must be between 1 and 6, got {}",
                        level
                    )));
                }
                let class = v
                    .as_str()
                    .ok_or_else(|| D::Error::custom("value must be a string"))?
                    .to_string();
                result.insert(level, class);
            }
            Ok(result)
        }
        _ => Err(D::Error::custom("expected a map")),
    }
}

fn deserialize_nested_string_map<'de, D>(
    deserializer: D,
) -> Result<HashMap<String, HashMap<String, String>>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::de::Error;
    use serde_json::Value;

    let value = Value::deserialize(deserializer)?;

    match value {
        Value::Object(outer_map) => {
            let mut result = HashMap::new();
            for (outer_key, inner_value) in outer_map {
                match inner_value {
                    Value::Object(inner_map) => {
                        let mut inner_result = HashMap::new();
                        for (inner_key, value) in inner_map {
                            let str_value = value
                                .as_str()
                                .ok_or_else(|| D::Error::custom("value must be a string"))?
                                .to_string();
                            inner_result.insert(inner_key, str_value);
                        }
                        result.insert(outer_key, inner_result);
                    }
                    _ => return Err(D::Error::custom("expected a nested map")),
                }
            }
            Ok(result)
        }
        _ => Err(D::Error::custom("expected a map")),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn test_default_config() {
        let config = HtmlConfig::default();
        assert!(!config.html.escape_html);
        assert!(config.html.break_on_newline);
        assert!(!config.html.xhtml_style);
        assert!(config.html.pretty_print);
    }

    #[test]
    fn test_heading_map_deserialization() {
        let json = json!({
            "1": "title",
            "2": "subtitle",
            "6": "small-title"
        });

        let map: HashMap<u8, String> = deserialize_heading_map(json).unwrap();
        assert_eq!(map.get(&1).unwrap(), "title");
        assert_eq!(map.get(&2).unwrap(), "subtitle");
        assert_eq!(map.get(&6).unwrap(), "small-title");
    }

    #[test]
    fn test_invalid_heading_level() {
        let json = json!({
            "7": "invalid"
        });

        let result: Result<HashMap<u8, String>, _> = deserialize_heading_map(json);
        assert!(result.is_err());
    }

    #[test]
    fn test_attribute_map_deserialization() {
        let json = json!({
            "h1": {
                "class": "title",
                "data-level": "1"
            },
            "pre": {
                "class": "code-block"
            }
        });

        let map: HashMap<String, HashMap<String, String>> =
            deserialize_nested_string_map(json).unwrap();
        assert_eq!(map.get("h1").unwrap().get("class").unwrap(), "title");
        assert_eq!(map.get("h1").unwrap().get("data-level").unwrap(), "1");
        assert_eq!(map.get("pre").unwrap().get("class").unwrap(), "code-block");
    }
}