1
//! Minimal Markdown → [`Dom`] renderer for the `UpdateVersion` dialog's
2
//! changelog view.
3
//!
4
//! Deliberately small, line-based, and lossless where it does not
5
//! understand something (unknown syntax renders as plain text — a changelog
6
//! must never DISAPPEAR because it used a construct this renderer lacks):
7
//!
8
//! * `#` / `##` / `###` (and deeper → h3) headings
9
//! * `-` / `*` bullet lists
10
//! * fenced code blocks (verbatim, monospace)
11
//! * paragraphs separated by blank lines
12
//! * inline `**bold**` / `*em*` / `` `code` `` markers are STRIPPED (the
13
//!   text stays); links `[text](url)` render as `text (url)`.
14

            
15
use azul_core::dom::Dom;
16

            
17
/// Renders `md` into a column of heading / paragraph / list / code nodes.
18
#[must_use]
19
3
pub fn render_markdown(md: &str) -> Dom {
20
3
    let mut children: Vec<Dom> = Vec::new();
21
3
    let mut paragraph: Vec<String> = Vec::new();
22
3
    let mut bullets: Vec<String> = Vec::new();
23
3
    let mut code: Option<Vec<String>> = None;
24

            
25
13
    let flush_paragraph = |children: &mut Vec<Dom>, paragraph: &mut Vec<String>| {
26
13
        if !paragraph.is_empty() {
27
2
            children.push(Dom::create_p_with_text(strip_inline(&paragraph.join(" "))));
28
2
            paragraph.clear();
29
11
        }
30
13
    };
31
12
    let flush_bullets = |children: &mut Vec<Dom>, bullets: &mut Vec<String>| {
32
12
        if !bullets.is_empty() {
33
1
            let items: Vec<Dom> = bullets
34
1
                .drain(..)
35
3
                .map(|b| Dom::create_li_with_text(strip_inline(&b)))
36
1
                .collect();
37
1
            children.push(Dom::create_ul().with_children(items.into()));
38
11
        }
39
12
    };
40

            
41
15
    for line in md.lines() {
42
        // Fenced code blocks swallow EVERYTHING until the closing fence.
43
15
        if let Some(block) = code.as_mut() {
44
3
            if line.trim_start().starts_with("```") {
45
1
                let joined = block.join("\n");
46
1
                children.push(Dom::create_pre_with_text(joined));
47
1
                code = None;
48
2
            } else {
49
2
                block.push(line.to_owned());
50
2
            }
51
3
            continue;
52
12
        }
53
12
        let trimmed = line.trim_end();
54
12
        if trimmed.trim_start().starts_with("```") {
55
1
            flush_paragraph(&mut children, &mut paragraph);
56
1
            flush_bullets(&mut children, &mut bullets);
57
1
            code = Some(Vec::new());
58
1
            continue;
59
11
        }
60
11
        if trimmed.is_empty() {
61
2
            flush_paragraph(&mut children, &mut paragraph);
62
2
            flush_bullets(&mut children, &mut bullets);
63
2
            continue;
64
9
        }
65
9
        if let Some(rest) = heading(trimmed) {
66
4
            flush_paragraph(&mut children, &mut paragraph);
67
4
            flush_bullets(&mut children, &mut bullets);
68
4
            let (level, text) = rest;
69
4
            let text = strip_inline(text);
70
4
            children.push(match level {
71
1
                1 => Dom::create_h1_with_text(text),
72
1
                2 => Dom::create_h2_with_text(text),
73
2
                _ => Dom::create_h3_with_text(text),
74
            });
75
4
            continue;
76
5
        }
77
5
        if let Some(item) = bullet(trimmed) {
78
3
            flush_paragraph(&mut children, &mut paragraph);
79
3
            bullets.push(item.to_owned());
80
3
            continue;
81
2
        }
82
2
        flush_bullets(&mut children, &mut bullets);
83
2
        paragraph.push(trimmed.trim_start().to_owned());
84
    }
85
    // An unclosed fence still renders its content (lossless rule).
86
3
    if let Some(block) = code {
87
        children.push(Dom::create_pre_with_text(block.join("\n")));
88
3
    }
89
3
    flush_paragraph(&mut children, &mut paragraph);
90
3
    flush_bullets(&mut children, &mut bullets);
91

            
92
3
    Dom::create_div().with_children(children.into())
93
3
}
94

            
95
/// `### Title` → `(3, "Title")`; None for non-headings.
96
9
fn heading(line: &str) -> Option<(usize, &str)> {
97
19
    let hashes = line.bytes().take_while(|&b| b == b'#').count();
98
9
    if hashes == 0 || hashes > 6 {
99
5
        return None;
100
4
    }
101
4
    let rest = &line[hashes..];
102
4
    rest.strip_prefix(' ').map(|text| (hashes, text.trim()))
103
9
}
104

            
105
/// `- item` / `* item` → `item`; None otherwise.
106
5
fn bullet(line: &str) -> Option<&str> {
107
5
    let t = line.trim_start();
108
5
    t.strip_prefix("- ").or_else(|| t.strip_prefix("* ")).map(str::trim)
109
5
}
110

            
111
/// Strips `**`, `*`, `` ` `` markers and rewrites `[text](url)` as
112
/// `text (url)`. Text is never dropped, only markers.
113
12
fn strip_inline(text: &str) -> String {
114
12
    let mut out = String::with_capacity(text.len());
115
12
    let mut chars = text.chars();
116
93
    while let Some(c) = chars.next() {
117
81
        match c {
118
8
            '*' | '`' => {}
119
            '[' => {
120
                // [text](url) → text (url); a bare '[' stays.
121
2
                let rest: String = chars.clone().collect();
122
2
                if let Some((label, after)) = rest.split_once(']') {
123
1
                    if let Some(url_rest) = after.strip_prefix('(') {
124
1
                        if let Some((url, _)) = url_rest.split_once(')') {
125
1
                            out.push_str(label);
126
1
                            out.push_str(" (");
127
1
                            out.push_str(url);
128
1
                            out.push(')');
129
1
                            let consumed = label.len() + 1 + 1 + url.len() + 1;
130
27
                            for _ in 0..consumed {
131
27
                                let _ = chars.next();
132
27
                            }
133
1
                            continue;
134
                        }
135
                    }
136
1
                }
137
1
                out.push('[');
138
            }
139
71
            other => out.push(other),
140
        }
141
    }
142
12
    out
143
12
}
144

            
145
#[cfg(test)]
146
mod tests {
147
    use azul_core::dom::NodeType;
148

            
149
    use super::*;
150

            
151
3
    fn node_types(dom: &Dom) -> Vec<NodeType> {
152
3
        dom.children
153
3
            .as_ref()
154
3
            .iter()
155
8
            .map(|c| c.root.get_node_type().clone())
156
3
            .collect()
157
3
    }
158

            
159
    #[test]
160
1
    fn headings_map_to_their_levels_and_deeper_clamps_to_h3() {
161
1
        let dom = render_markdown("# One\n## Two\n### Three\n#### Four");
162
1
        let types = node_types(&dom);
163
1
        assert_eq!(types.len(), 4, "{types:?}");
164
1
        assert!(matches!(types[0], NodeType::H1), "{types:?}");
165
1
        assert!(matches!(types[1], NodeType::H2), "{types:?}");
166
1
        assert!(matches!(types[2], NodeType::H3), "{types:?}");
167
1
        assert!(matches!(types[3], NodeType::H3), "clamp: {types:?}");
168
1
    }
169

            
170
    #[test]
171
1
    fn bullets_group_into_one_list_and_paragraphs_split_on_blank_lines() {
172
1
        let dom = render_markdown("intro line\n\n- a\n- b\n- c\n\noutro");
173
1
        let types = node_types(&dom);
174
        // P, UL, P
175
1
        assert_eq!(types.len(), 3, "{types:?}");
176
1
        assert!(matches!(types[1], NodeType::Ul), "{types:?}");
177
1
        let ul = &dom.children.as_ref()[1];
178
1
        assert_eq!(ul.children.as_ref().len(), 3, "three <li>");
179
1
    }
180

            
181
    #[test]
182
1
    fn fenced_code_survives_verbatim_including_hash_lines() {
183
        // A '#' INSIDE a fence must NOT become a heading — the fence wins.
184
1
        let dom = render_markdown("```\n# not a heading\ncode line\n```");
185
1
        let types = node_types(&dom);
186
1
        assert_eq!(types.len(), 1, "{types:?}");
187
1
        assert!(matches!(types[0], NodeType::Pre), "{types:?}");
188
1
    }
189

            
190
    #[test]
191
1
    fn inline_markers_strip_but_text_and_links_survive() {
192
1
        assert_eq!(strip_inline("**bold** and *em* and `code`"), "bold and em and code");
193
1
        assert_eq!(
194
1
            strip_inline("see [the docs](https://x.test/d)"),
195
            "see the docs (https://x.test/d)"
196
        );
197
        // Unclosed constructs stay as literal text — nothing is dropped.
198
1
        assert_eq!(strip_inline("a [bare bracket"), "a [bare bracket");
199
1
    }
200
}