logicaffeine_web/ui/
inline_markup.rs1pub fn inline_markup_html(text: &str) -> String {
12 emphasis_html(text)
13}
14
15fn 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("&"),
38 '<' => out.push_str("<"),
39 '>' => out.push_str(">"),
40 '"' => out.push_str("""),
41 '\'' => out.push_str("'"),
42 _ => out.push(c),
43 }
44 }
45 out
46}
47
48enum Piece {
49 Text(String),
50 Stars(Stars),
51 Link { text: String, url: String },
53 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
164fn 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
202fn 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;