Skip to main content

logicaffeine_web/ui/
markdown_blocks.rs

1//! The block structure of the Markdown the site writes by hand: news articles and the Syntax
2//! Guide's prose. Lines group the way CommonMark groups them. Consecutive lines of text are one
3//! paragraph, so a line break in the source is a space on the page and a blank line ends the
4//! paragraph; a list item and a quote continue across their wrapped lines; a line of `---` is a
5//! rule. Each block's text is marked up inline by `ui::inline_markup`, the one renderer every
6//! page's prose goes through.
7
8/// One block of a Markdown document, in document order.
9#[derive(Clone, Debug, PartialEq)]
10pub enum Block {
11    /// `#` to `######`, and the heading's text.
12    Heading { level: usize, text: String },
13    /// Consecutive lines of text, joined by spaces.
14    Paragraph(String),
15    /// `-` or `*` items, or `1.`-numbered items when `ordered`, each item's wrapped lines joined.
16    List { ordered: bool, items: Vec<String> },
17    /// Consecutive `>` lines, as the quote's paragraphs: a bare `>` separates them.
18    Quote(Vec<String>),
19    /// `| a | b |` rows; `header` is the row above a `|---|` separator, when there is one.
20    Table { header: Option<Vec<String>>, rows: Vec<Vec<String>> },
21    /// A fenced block: the language its opening fence names (empty when it names none) and its
22    /// lines exactly as written.
23    Fence { tag: String, code: String },
24    /// A thematic break: a line of three or more `-`, `*` or `_`, and nothing else.
25    Rule,
26}
27
28/// The blocks `markdown` is made of.
29pub 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
127/// A wrapped line continues the text before it after a space.
128fn 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
139/// `### Title` as a level-3 heading; seven or more `#`, or none followed by a space, is text.
140fn 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
146/// `---`, `***` or `___`, three or more of one mark with any spaces between, and nothing else.
147fn 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
154/// The text of a quote line: `> text`, or a bare `>` between the quote's paragraphs.
155fn 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
168/// `- item` and `* item` are bullets; `12. item` is numbered.
169fn 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;