nightshade_api/web/
markdown.rs1use 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(¶graph)));
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#[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}