Skip to main content

nightshade_api/web/
markdown.rs

1//! A tiny self-contained Markdown renderer: parses a subset of Markdown and emits Leptos views.
2
3use leptos::prelude::*;
4
5#[derive(Clone, PartialEq)]
6enum Inline {
7    Text(String),
8    Bold(String),
9    Italic(String),
10    Code(String),
11    Link { text: String, url: String },
12}
13
14#[derive(Clone, PartialEq)]
15enum Block {
16    Heading(u8, Vec<Inline>),
17    Paragraph(Vec<Inline>),
18    Code(String),
19    List {
20        ordered: bool,
21        items: Vec<Vec<Inline>>,
22    },
23    Quote(Vec<Inline>),
24    Rule,
25}
26
27fn parse_inline(source: &str) -> Vec<Inline> {
28    let chars: Vec<char> = source.chars().collect();
29    let count = chars.len();
30    let mut runs: Vec<Inline> = Vec::new();
31    let mut plain = String::new();
32    let mut index = 0;
33
34    let flush = |plain: &mut String, runs: &mut Vec<Inline>| {
35        if !plain.is_empty() {
36            runs.push(Inline::Text(std::mem::take(plain)));
37        }
38    };
39
40    while index < count {
41        let rest: String = chars[index..].iter().collect();
42        if rest.starts_with("**")
43            && let Some(end) = find_delim(&chars, index + 2, "**")
44        {
45            flush(&mut plain, &mut runs);
46            runs.push(Inline::Bold(chars[index + 2..end].iter().collect()));
47            index = end + 2;
48        } else if (chars[index] == '*' || chars[index] == '_')
49            && let Some(end) = find_char(&chars, index + 1, chars[index])
50        {
51            flush(&mut plain, &mut runs);
52            runs.push(Inline::Italic(chars[index + 1..end].iter().collect()));
53            index = end + 1;
54        } else if chars[index] == '`'
55            && let Some(end) = find_char(&chars, index + 1, '`')
56        {
57            flush(&mut plain, &mut runs);
58            runs.push(Inline::Code(chars[index + 1..end].iter().collect()));
59            index = end + 1;
60        } else if chars[index] == '['
61            && let Some((text, url, next)) = parse_link(&chars, index)
62        {
63            flush(&mut plain, &mut runs);
64            runs.push(Inline::Link { text, url });
65            index = next;
66        } else {
67            plain.push(chars[index]);
68            index += 1;
69        }
70    }
71    flush(&mut plain, &mut runs);
72    runs
73}
74
75fn find_char(chars: &[char], start: usize, target: char) -> Option<usize> {
76    (start..chars.len()).find(|&index| chars[index] == target)
77}
78
79fn find_delim(chars: &[char], start: usize, delim: &str) -> Option<usize> {
80    let delim: Vec<char> = delim.chars().collect();
81    let mut index = start;
82    while index + delim.len() <= chars.len() {
83        if chars[index..index + delim.len()] == delim[..] {
84            return Some(index);
85        }
86        index += 1;
87    }
88    None
89}
90
91fn parse_link(chars: &[char], start: usize) -> Option<(String, String, usize)> {
92    let text_end = find_char(chars, start + 1, ']')?;
93    if text_end + 1 >= chars.len() || chars[text_end + 1] != '(' {
94        return None;
95    }
96    let url_end = find_char(chars, text_end + 2, ')')?;
97    let text: String = chars[start + 1..text_end].iter().collect();
98    let url: String = chars[text_end + 2..url_end].iter().collect();
99    Some((text, url, url_end + 1))
100}
101
102fn parse(source: &str) -> Vec<Block> {
103    let mut blocks: Vec<Block> = Vec::new();
104    let lines: Vec<&str> = source.lines().collect();
105    let mut index = 0;
106    while index < lines.len() {
107        let line = lines[index];
108        let trimmed = line.trim();
109
110        if trimmed.is_empty() {
111            index += 1;
112        } else if let Some(fence) = trimmed.strip_prefix("```") {
113            let _ = fence;
114            index += 1;
115            let mut code = String::new();
116            while index < lines.len() && !lines[index].trim_start().starts_with("```") {
117                code.push_str(lines[index]);
118                code.push('\n');
119                index += 1;
120            }
121            index += 1;
122            blocks.push(Block::Code(code));
123        } else if trimmed == "---" || trimmed == "***" || trimmed == "___" {
124            blocks.push(Block::Rule);
125            index += 1;
126        } else if let Some(level) = heading_level(trimmed) {
127            let text = trimmed[level as usize..].trim();
128            blocks.push(Block::Heading(level, parse_inline(text)));
129            index += 1;
130        } else if is_unordered(trimmed) || is_ordered(trimmed) {
131            let ordered = is_ordered(trimmed);
132            let mut items = Vec::new();
133            while index < lines.len() {
134                let item = lines[index].trim();
135                if ordered && is_ordered(item) {
136                    items.push(parse_inline(strip_ordered(item)));
137                } else if !ordered && is_unordered(item) {
138                    items.push(parse_inline(item[2..].trim()));
139                } else {
140                    break;
141                }
142                index += 1;
143            }
144            blocks.push(Block::List { ordered, items });
145        } else if let Some(quote) = trimmed.strip_prefix("> ") {
146            blocks.push(Block::Quote(parse_inline(quote)));
147            index += 1;
148        } else {
149            let mut paragraph = String::new();
150            while index < lines.len() && !lines[index].trim().is_empty() {
151                let current = lines[index].trim();
152                if heading_level(current).is_some()
153                    || current.starts_with("```")
154                    || is_unordered(current)
155                    || is_ordered(current)
156                {
157                    break;
158                }
159                if !paragraph.is_empty() {
160                    paragraph.push(' ');
161                }
162                paragraph.push_str(current);
163                index += 1;
164            }
165            blocks.push(Block::Paragraph(parse_inline(&paragraph)));
166        }
167    }
168    blocks
169}
170
171fn heading_level(line: &str) -> Option<u8> {
172    let hashes = line
173        .chars()
174        .take_while(|character| *character == '#')
175        .count();
176    if (1..=6).contains(&hashes) && line.chars().nth(hashes) == Some(' ') {
177        Some(hashes as u8)
178    } else {
179        None
180    }
181}
182
183fn is_unordered(line: &str) -> bool {
184    line.starts_with("- ") || line.starts_with("* ") || line.starts_with("+ ")
185}
186
187fn is_ordered(line: &str) -> bool {
188    strip_ordered_prefix(line).is_some()
189}
190
191fn strip_ordered_prefix(line: &str) -> Option<usize> {
192    let digits = line.chars().take_while(char::is_ascii_digit).count();
193    if digits == 0 {
194        return None;
195    }
196    let after = &line[digits..];
197    if after.starts_with(". ") {
198        Some(digits + 2)
199    } else {
200        None
201    }
202}
203
204fn strip_ordered(line: &str) -> &str {
205    match strip_ordered_prefix(line) {
206        Some(offset) => line[offset..].trim(),
207        None => line,
208    }
209}
210
211fn render_inline(runs: Vec<Inline>) -> AnyView {
212    runs.into_iter()
213        .map(|run| match run {
214            Inline::Text(text) => text.into_any(),
215            Inline::Bold(text) => view! { <strong>{text}</strong> }.into_any(),
216            Inline::Italic(text) => view! { <em>{text}</em> }.into_any(),
217            Inline::Code(text) => {
218                view! { <code class="nightshade-md-code">{text}</code> }.into_any()
219            }
220            Inline::Link { text, url } => view! {
221                <a class="nightshade-md-link" href=url target="_blank" rel="noreferrer">
222                    {text}
223                </a>
224            }
225            .into_any(),
226        })
227        .collect_view()
228        .into_any()
229}
230
231fn render_block(block: Block) -> AnyView {
232    match block {
233        Block::Heading(level, runs) => {
234            let inline = render_inline(runs);
235            match level {
236                1 => view! { <h1 class="nightshade-md-h1">{inline}</h1> }.into_any(),
237                2 => view! { <h2 class="nightshade-md-h2">{inline}</h2> }.into_any(),
238                3 => view! { <h3 class="nightshade-md-h3">{inline}</h3> }.into_any(),
239                _ => view! { <h4 class="nightshade-md-h4">{inline}</h4> }.into_any(),
240            }
241        }
242        Block::Paragraph(runs) => {
243            view! { <p class="nightshade-md-p">{render_inline(runs)}</p> }.into_any()
244        }
245        Block::Code(code) => {
246            view! { <pre class="nightshade-md-pre"><code>{code}</code></pre> }.into_any()
247        }
248        Block::List { ordered, items } => {
249            let entries = items
250                .into_iter()
251                .map(|item| view! { <li>{render_inline(item)}</li> })
252                .collect_view();
253            if ordered {
254                view! { <ol class="nightshade-md-list">{entries}</ol> }.into_any()
255            } else {
256                view! { <ul class="nightshade-md-list">{entries}</ul> }.into_any()
257            }
258        }
259        Block::Quote(runs) => {
260            view! { <blockquote class="nightshade-md-quote">{render_inline(runs)}</blockquote> }
261                .into_any()
262        }
263        Block::Rule => view! { <hr class="nightshade-md-rule" /> }.into_any(),
264    }
265}
266
267/// Parses the `source` signal as Markdown and renders it reactively. Supports
268/// headings, paragraphs, fenced code, ordered and unordered lists, blockquotes,
269/// horizontal rules, and inline bold, italic, code, and links.
270#[component]
271pub fn Markdown(#[prop(into)] source: Signal<String>) -> impl IntoView {
272    view! {
273        <div class="nightshade-markdown">
274            {move || {
275                parse(&source.get())
276                    .into_iter()
277                    .map(render_block)
278                    .collect_view()
279            }}
280        </div>
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::{Block, Inline, parse, parse_inline};
287
288    #[test]
289    fn parses_headings_and_paragraphs() {
290        let blocks = parse("# Title\n\nA paragraph line\nwrapped here.");
291        assert!(matches!(blocks[0], Block::Heading(1, _)));
292        assert!(matches!(blocks[1], Block::Paragraph(_)));
293    }
294
295    #[test]
296    fn parses_lists_and_code_fences() {
297        let blocks = parse("- one\n- two\n\n```\ncode\n```\n");
298        assert!(matches!(blocks[0], Block::List { ordered: false, .. }));
299        assert!(matches!(&blocks[1], Block::Code(code) if code.contains("code")));
300    }
301
302    #[test]
303    fn parses_inline_emphasis_code_and_links() {
304        let runs = parse_inline("a **b** and *c* and `d` and [e](http://x)");
305        assert!(
306            runs.iter()
307                .any(|run| matches!(run, Inline::Bold(text) if text == "b"))
308        );
309        assert!(
310            runs.iter()
311                .any(|run| matches!(run, Inline::Italic(text) if text == "c"))
312        );
313        assert!(
314            runs.iter()
315                .any(|run| matches!(run, Inline::Code(text) if text == "d"))
316        );
317        assert!(
318            runs.iter()
319                .any(|run| matches!(run, Inline::Link { url, .. } if url == "http://x"))
320        );
321    }
322
323    #[test]
324    fn parses_ordered_lists() {
325        let blocks = parse("1. first\n2. second\n");
326        match &blocks[0] {
327            Block::List { ordered, items } => {
328                assert!(ordered);
329                assert_eq!(items.len(), 2);
330            }
331            _ => panic!("expected ordered list"),
332        }
333    }
334}