Skip to main content

logicaffeine_web/ui/
inline_markup.rs

1//! Inline markup for the site's prose — curriculum, Syntax Guide and news: `**strong**`,
2//! `*emphasis*`, `` `code` `` and `[links](/path)`.
3//!
4//! Text is HTML-escaped before any markup is recognised, so lesson prose can say `x < y` or
5//! `A & B` and never inject markup. Stars follow CommonMark's flanking rules: a run of `*`
6//! opens emphasis only at the start of a word and closes it only at the end of one, so the
7//! star test's literal stars (`A* is B*`, `S*-D`) and arithmetic (`2 * 3`) stay text, and an
8//! unmatched marker is printed as written instead of swallowing the rest of the paragraph.
9
10/// Render one paragraph of the site's prose to HTML.
11pub fn inline_markup_html(text: &str) -> String {
12    emphasis_html(text)
13}
14
15/// The end of the backtick run of exactly `ticks` that closes a code span opened just before
16/// `chars`, as an index into `chars`.
17fn closing_ticks(chars: &[char], ticks: usize) -> Option<usize> {
18    let mut i = 0;
19    while i < chars.len() {
20        if chars[i] == '`' {
21            let run = chars[i..].iter().take_while(|&&c| c == '`').count();
22            if run == ticks {
23                return Some(i);
24            }
25            i += run;
26        } else {
27            i += 1;
28        }
29    }
30    None
31}
32
33pub(crate) fn escape(text: &str) -> String {
34    let mut out = String::with_capacity(text.len());
35    for c in text.chars() {
36        match c {
37            '&' => out.push_str("&amp;"),
38            '<' => out.push_str("&lt;"),
39            '>' => out.push_str("&gt;"),
40            '"' => out.push_str("&quot;"),
41            '\'' => out.push_str("&#39;"),
42            _ => out.push(c),
43        }
44    }
45    out
46}
47
48enum Piece {
49    Text(String),
50    Stars(Stars),
51    /// `[text](url)`: one unit to the emphasis around it, its text marked up on its own.
52    Link { text: String, url: String },
53    /// A code span: one unit to the emphasis around it, its text shown as written.
54    Code(String),
55}
56
57struct Stars {
58    left: usize,
59    original: usize,
60    can_open: bool,
61    can_close: bool,
62    opens: Vec<&'static str>,
63    closes: Vec<&'static str>,
64}
65
66pub(crate) fn emphasis_html(text: &str) -> String {
67    let mut pieces = split_star_runs(text);
68    match_emphasis(&mut pieces);
69
70    let mut out = String::with_capacity(text.len());
71    for piece in &pieces {
72        match piece {
73            Piece::Text(t) => out.push_str(&escape(t)),
74            Piece::Code(code) => {
75                out.push_str("<code>");
76                out.push_str(&escape(code.trim()));
77                out.push_str("</code>");
78            }
79            Piece::Link { text, url } => {
80                out.push_str("<a href=\"");
81                out.push_str(&escape(url));
82                out.push_str("\">");
83                out.push_str(&emphasis_html(text));
84                out.push_str("</a>");
85            }
86            Piece::Stars(s) => {
87                for tag in &s.closes {
88                    out.push_str("</");
89                    out.push_str(tag);
90                    out.push('>');
91                }
92                out.extend(std::iter::repeat('*').take(s.left));
93                for tag in s.opens.iter().rev() {
94                    out.push('<');
95                    out.push_str(tag);
96                    out.push('>');
97                }
98            }
99        }
100    }
101    out
102}
103
104fn split_star_runs(text: &str) -> Vec<Piece> {
105    let chars: Vec<char> = text.chars().collect();
106    let mut pieces = Vec::new();
107    let mut literal = String::new();
108    let mut i = 0;
109    while i < chars.len() {
110        if chars[i] == '`' {
111            let ticks = chars[i..].iter().take_while(|&&c| c == '`').count();
112            match closing_ticks(&chars[i + ticks..], ticks) {
113                Some(end) => {
114                    if !literal.is_empty() {
115                        pieces.push(Piece::Text(std::mem::take(&mut literal)));
116                    }
117                    pieces.push(Piece::Code(chars[i + ticks..i + ticks + end].iter().collect()));
118                    i += ticks + end + ticks;
119                }
120                None => {
121                    literal.extend(&chars[i..i + ticks]);
122                    i += ticks;
123                }
124            }
125            continue;
126        }
127        if chars[i] == '[' {
128            if let Some((text, url, len)) = link_at(&chars[i..]) {
129                if !literal.is_empty() {
130                    pieces.push(Piece::Text(std::mem::take(&mut literal)));
131                }
132                pieces.push(Piece::Link { text, url });
133                i += len;
134                continue;
135            }
136        }
137        if chars[i] != '*' {
138            literal.push(chars[i]);
139            i += 1;
140            continue;
141        }
142        let run = chars[i..].iter().take_while(|&&c| c == '*').count();
143        let before = if i == 0 { None } else { Some(chars[i - 1]) };
144        let after = chars.get(i + run).copied();
145        if !literal.is_empty() {
146            pieces.push(Piece::Text(std::mem::take(&mut literal)));
147        }
148        pieces.push(Piece::Stars(Stars {
149            left: run,
150            original: run,
151            can_open: left_flanking(before, after),
152            can_close: right_flanking(before, after),
153            opens: Vec::new(),
154            closes: Vec::new(),
155        }));
156        i += run;
157    }
158    if !literal.is_empty() {
159        pieces.push(Piece::Text(literal));
160    }
161    pieces
162}
163
164/// `[text](url)` at the start of `chars`: its text, its address and how many chars it spans. A
165/// link needs text, and an address the site links to — the web, mail, or a path on the site;
166/// anything else (a `javascript:` or `data:` URL, brackets with no address) stays text.
167fn link_at(chars: &[char]) -> Option<(String, String, usize)> {
168    let close = chars.iter().position(|&c| c == ']')?;
169    let text: String = chars[1..close].iter().collect();
170    if text.is_empty() || text.contains('[') || chars.get(close + 1) != Some(&'(') {
171        return None;
172    }
173    let end = close + 2 + chars[close + 2..].iter().position(|&c| c == ')')?;
174    let url: String = chars[close + 2..end].iter().collect();
175    let linkable = ["https://", "http://", "mailto:", "/", "#"].iter().any(|scheme| url.starts_with(scheme));
176    (linkable && !url.chars().any(char::is_whitespace)).then_some((text, url, end + 1))
177}
178
179fn is_space(c: Option<char>) -> bool {
180    c.map_or(true, char::is_whitespace)
181}
182
183fn is_punct(c: Option<char>) -> bool {
184    c.is_some_and(|c| !c.is_alphanumeric() && !c.is_whitespace())
185}
186
187fn left_flanking(before: Option<char>, after: Option<char>) -> bool {
188    !is_space(after) && (!is_punct(after) || is_space(before) || is_punct(before))
189}
190
191fn right_flanking(before: Option<char>, after: Option<char>) -> bool {
192    !is_space(before) && (!is_punct(before) || is_space(after) || is_punct(after))
193}
194
195fn stars(pieces: &mut [Piece], i: usize) -> &mut Stars {
196    match &mut pieces[i] {
197        Piece::Stars(s) => s,
198        Piece::Text(_) | Piece::Link { .. } | Piece::Code(_) => unreachable!("index points at a star run"),
199    }
200}
201
202/// CommonMark's "process emphasis", for `*` only: each closer pairs with the nearest usable
203/// opener before it; runs strictly between a matched pair can no longer pair with anything
204/// outside it.
205fn match_emphasis(pieces: &mut [Piece]) {
206    let runs: Vec<usize> = pieces
207        .iter()
208        .enumerate()
209        .filter_map(|(i, p)| matches!(p, Piece::Stars(_)).then_some(i))
210        .collect();
211
212    for (closer_pos, &closer) in runs.iter().enumerate() {
213        loop {
214            let (closer_left, closer_original, closer_can_open, closer_can_close) = {
215                let c = stars(pieces, closer);
216                (c.left, c.original, c.can_open, c.can_close)
217            };
218            if closer_left == 0 || !closer_can_close {
219                break;
220            }
221            let opener = runs[..closer_pos].iter().rev().copied().find(|&o| {
222                let s = stars(pieces, o);
223                if s.left == 0 || !s.can_open {
224                    return false;
225                }
226                let odd_match = (s.can_close || closer_can_open)
227                    && (s.original + closer_original) % 3 == 0
228                    && !(s.original % 3 == 0 && closer_original % 3 == 0);
229                !odd_match
230            });
231            let Some(opener) = opener else { break };
232
233            let width = if stars(pieces, opener).left >= 2 && closer_left >= 2 { 2 } else { 1 };
234            let tag = if width == 2 { "strong" } else { "em" };
235            {
236                let o = stars(pieces, opener);
237                o.left -= width;
238                o.opens.push(tag);
239            }
240            {
241                let c = stars(pieces, closer);
242                c.left -= width;
243                c.closes.push(tag);
244            }
245            for &between in runs.iter().filter(|&&r| r > opener && r < closer) {
246                let s = stars(pieces, between);
247                s.can_open = false;
248                s.can_close = false;
249            }
250        }
251    }
252}
253
254#[cfg(test)]
255#[path = "../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/inline_markup/tests.rs"]
256mod tests;