logicaffeine_web/ui/
markdown_blocks.rs1#[derive(Clone, Debug, PartialEq)]
10pub enum Block {
11 Heading { level: usize, text: String },
13 Paragraph(String),
15 List { ordered: bool, items: Vec<String> },
17 Quote(Vec<String>),
19 Table { header: Option<Vec<String>>, rows: Vec<Vec<String>> },
21 Fence { tag: String, code: String },
24 Rule,
26}
27
28pub fn blocks(markdown: &str) -> Vec<Block> {
30 let mut out = Vec::new();
31 let mut open: Option<Block> = None;
32 let mut fence: Option<(String, Vec<&str>)> = None;
33
34 for line in markdown.lines() {
35 let trimmed = line.trim();
36
37 if let Some((tag, mut lines)) = fence.take() {
38 if trimmed.starts_with("```") {
39 out.push(fenced(tag, lines));
40 } else {
41 lines.push(line);
42 fence = Some((tag, lines));
43 }
44 continue;
45 }
46 if let Some(tag) = trimmed.strip_prefix("```") {
47 out.extend(open.take());
48 fence = Some((tag.trim().to_string(), Vec::new()));
49 continue;
50 }
51 if trimmed.is_empty() {
52 out.extend(open.take());
53 continue;
54 }
55 if let Some(block) = heading(trimmed) {
56 out.extend(open.take());
57 out.push(block);
58 continue;
59 }
60 if is_rule(trimmed) {
61 out.extend(open.take());
62 out.push(Block::Rule);
63 continue;
64 }
65 if let Some(text) = quoted(trimmed) {
66 match &mut open {
67 Some(Block::Quote(paragraphs)) if text.is_empty() => paragraphs.push(String::new()),
68 Some(Block::Quote(paragraphs)) => join(paragraphs.last_mut().expect("a quote holds a paragraph"), text),
69 _ => {
70 out.extend(open.take());
71 open = Some(Block::Quote(vec![text.to_string()]));
72 }
73 }
74 continue;
75 }
76 if let Some(cells) = table_row(trimmed) {
77 let separator = cells.iter().all(|c| !c.is_empty() && c.chars().all(|ch| matches!(ch, '-' | ':')));
78 match &mut open {
79 Some(Block::Table { header, rows }) if separator => {
80 if header.is_none() && rows.len() == 1 {
81 *header = rows.pop();
82 }
83 }
84 Some(Block::Table { rows, .. }) => rows.push(cells),
85 _ => {
86 out.extend(open.take());
87 if !separator {
88 open = Some(Block::Table { header: None, rows: vec![cells] });
89 }
90 }
91 }
92 continue;
93 }
94 if let Some((ordered, item)) = list_item(trimmed) {
95 match &mut open {
96 Some(Block::List { ordered: current, items }) if *current == ordered => items.push(item.to_string()),
97 _ => {
98 out.extend(open.take());
99 open = Some(Block::List { ordered, items: vec![item.to_string()] });
100 }
101 }
102 continue;
103 }
104 match &mut open {
105 Some(Block::Paragraph(text)) => join(text, trimmed),
106 Some(Block::List { items, .. }) => join(items.last_mut().expect("a list holds an item"), trimmed),
107 _ => {
108 out.extend(open.take());
109 open = Some(Block::Paragraph(trimmed.to_string()));
110 }
111 }
112 }
113
114 out.extend(fence.map(|(tag, lines)| fenced(tag, lines)));
115 out.extend(open);
116 out.into_iter()
117 .filter_map(|block| match block {
118 Block::Quote(paragraphs) => {
119 let paragraphs: Vec<String> = paragraphs.into_iter().filter(|p| !p.is_empty()).collect();
120 (!paragraphs.is_empty()).then_some(Block::Quote(paragraphs))
121 }
122 other => Some(other),
123 })
124 .collect()
125}
126
127fn join(text: &mut String, more: &str) {
129 if !text.is_empty() {
130 text.push(' ');
131 }
132 text.push_str(more);
133}
134
135fn fenced(tag: String, lines: Vec<&str>) -> Block {
136 Block::Fence { tag, code: lines.join("\n").trim_end_matches('\n').to_string() }
137}
138
139fn heading(line: &str) -> Option<Block> {
141 let level = line.chars().take_while(|&c| c == '#').count();
142 let text = line[level..].strip_prefix(' ')?;
143 (1..=6).contains(&level).then(|| Block::Heading { level, text: text.trim().to_string() })
144}
145
146fn is_rule(line: &str) -> bool {
148 let mut marks = line.chars().filter(|c| !c.is_whitespace());
149 let Some(mark) = marks.next().filter(|m| matches!(m, '-' | '*' | '_')) else { return false };
150 let rest: Vec<char> = marks.collect();
151 rest.len() >= 2 && rest.iter().all(|&c| c == mark)
152}
153
154fn quoted(line: &str) -> Option<&str> {
156 if line == ">" {
157 Some("")
158 } else {
159 line.strip_prefix("> ").map(str::trim)
160 }
161}
162
163fn table_row(line: &str) -> Option<Vec<String>> {
164 let inner = line.strip_prefix('|')?.strip_suffix('|')?;
165 Some(inner.split('|').map(|cell| cell.trim().to_string()).collect())
166}
167
168fn list_item(line: &str) -> Option<(bool, &str)> {
170 if let Some(item) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
171 return Some((false, item.trim()));
172 }
173 let digits = line.chars().take_while(char::is_ascii_digit).count();
174 let item = line[digits..].strip_prefix(". ")?;
175 (digits > 0).then(|| (true, item.trim()))
176}
177
178#[cfg(test)]
179#[path = "../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/markdown_blocks/tests.rs"]
180mod tests;