1use serde::Serialize;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
8#[serde(tag = "type", rename_all = "snake_case")]
9pub enum ContentBlock {
10 Title {
11 level: u8,
12 text: String,
13 right: Option<String>,
14 action: Option<String>,
15 },
16 Paragraph {
17 content: Vec<InlineContent>,
18 },
19 Table {
20 headers: Vec<String>,
21 rows: Vec<Vec<String>>,
22 },
23 List {
24 items: Vec<Vec<InlineContent>>,
25 },
26 KeyValue {
27 key: String,
28 value: Vec<InlineContent>,
29 },
30 Aside {
31 title: Option<String>,
32 content: Vec<ContentBlock>,
33 },
34 HRule,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
39#[serde(tag = "type", rename_all = "snake_case")]
40pub enum InlineContent {
41 Text { text: String },
42 Bold { text: String },
43 Italic { text: String },
44 Link { text: String, url: String },
45 Trait { label: String },
46 Action { text: String },
47}
48
49pub fn parse_content(input: &str, base_url: &str) -> Vec<ContentBlock> {
52 let mut blocks = Vec::new();
53 let mut chars = input.chars().peekable();
54 let mut inline_buf: Vec<InlineContent> = Vec::new();
55
56 while chars.peek().is_some() {
57 let c = *chars.peek().unwrap();
58 if c == '-' && inline_buf.is_empty() && matches_hrule(&chars) {
60 chars.next(); chars.next(); chars.next(); while chars.peek().is_some_and(|&ch| ch != '\n') {
65 chars.next();
66 }
67 blocks.push(ContentBlock::HRule);
68 continue;
69 }
70 if c == '<' {
71 let tag = consume_tag(&mut chars);
72 let lower = tag.to_lowercase();
73
74 if lower.starts_with("<title") {
75 flush_paragraph(&mut inline_buf, &mut blocks);
76 let content = collect_until_close(&mut chars, "title");
77 let level = extract_level(&tag);
78 let right = extract_attr(&tag, "right");
79 let action = extract_action_from_html(&content);
80 let clean = strip_inner_tags(&content);
81 if !clean.trim().is_empty() {
82 blocks.push(ContentBlock::Title {
83 level,
84 text: clean.trim().to_string(),
85 right,
86 action,
87 });
88 }
89 } else if lower.starts_with("<trait ") {
90 if let Some(label) = extract_attr(&tag, "label") {
91 inline_buf.push(InlineContent::Trait { label });
92 }
93 } else if lower.starts_with("<actions") {
94 if let Some(s) = extract_attr(&tag, "string") {
95 inline_buf.push(InlineContent::Action { text: s });
96 }
97 } else if lower == "<hr>" || lower == "<hr />" {
98 flush_paragraph(&mut inline_buf, &mut blocks);
99 blocks.push(ContentBlock::HRule);
100 } else if lower.starts_with("<br") {
101 if !inline_buf.is_empty() {
103 flush_paragraph(&mut inline_buf, &mut blocks);
104 }
105 } else if lower.starts_with("<aside") {
106 flush_paragraph(&mut inline_buf, &mut blocks);
107 let aside_content = collect_until_close(&mut chars, "aside");
108 let title = extract_attr(&tag, "title");
109 let inner = parse_content(&aside_content, base_url);
110 blocks.push(ContentBlock::Aside {
111 title,
112 content: inner,
113 });
114 } else if lower == "<ul>" || lower == "<ol>" {
115 flush_paragraph(&mut inline_buf, &mut blocks);
116 let list_tag = if lower == "<ul>" { "ul" } else { "ol" };
117 let list_content = collect_until_close(&mut chars, list_tag);
118 let items = parse_list_items(&list_content, base_url);
119 if !items.is_empty() {
120 blocks.push(ContentBlock::List { items });
121 }
122 } else if lower == "<li>" {
123 let content = collect_until_close(&mut chars, "li");
125 let inlines = parse_inline(&content, base_url);
126 if !inlines.is_empty() {
127 flush_paragraph(&mut inline_buf, &mut blocks);
128 blocks.push(ContentBlock::List {
129 items: vec![inlines],
130 });
131 }
132 } else if lower == "<table>" || lower.starts_with("<table ") {
133 flush_paragraph(&mut inline_buf, &mut blocks);
134 let table_content = collect_until_close(&mut chars, "table");
135 if let Some(table) = parse_table(&table_content) {
136 blocks.push(table);
137 }
138 } else if lower.starts_with("<tr>") || lower.starts_with("<tr ") {
139 flush_paragraph(&mut inline_buf, &mut blocks);
141 let row_content = collect_until_close(&mut chars, "tr");
142 let cells = parse_table_cells(&row_content);
143 if !cells.is_empty() {
144 blocks.push(ContentBlock::Table {
145 headers: vec![],
146 rows: vec![cells],
147 });
148 }
149 } else if lower.starts_with("</") {
150 }
152 } else if c == '[' {
154 let (text, url) = consume_link(&mut chars);
155 let url = resolve_url(&url, base_url);
156 inline_buf.push(InlineContent::Link { text, url });
157 } else if c == '*' && chars.clone().nth(1) == Some('*') {
158 let text = consume_bold(&mut chars);
159 inline_buf.push(InlineContent::Bold { text });
160 } else if c == '_' && chars.clone().nth(1).is_some_and(|n| n.is_alphabetic()) {
161 let text = consume_italic(&mut chars);
162 inline_buf.push(InlineContent::Italic { text });
163 } else if c == '\r' {
164 chars.next();
165 } else if c == '\n' {
166 chars.next();
167 while chars.peek() == Some(&'\r') {
169 chars.next();
170 }
171 if matches_hrule(&chars) {
173 chars.next(); chars.next(); chars.next(); while chars.peek().is_some_and(|&c| c != '\n') {
179 chars.next();
180 }
181 flush_paragraph(&mut inline_buf, &mut blocks);
182 blocks.push(ContentBlock::HRule);
183 } else if chars.peek() == Some(&'\n') {
184 chars.next();
186 while chars.peek() == Some(&'\r') || chars.peek() == Some(&'\n') {
188 chars.next();
189 }
190 flush_paragraph(&mut inline_buf, &mut blocks);
191 } else if !inline_buf.is_empty() {
192 push_inline_text(&mut inline_buf, " ");
193 }
194 } else {
195 push_inline_char(&mut inline_buf, c);
196 chars.next();
197 }
198 }
199
200 flush_paragraph(&mut inline_buf, &mut blocks);
201 blocks
202}
203
204fn flush_paragraph(buf: &mut Vec<InlineContent>, blocks: &mut Vec<ContentBlock>) {
205 if buf.is_empty() {
206 return;
207 }
208 let mut content = std::mem::take(buf);
209 while matches!(content.first(), Some(InlineContent::Text { text }) if text.trim().is_empty()) {
211 content.remove(0);
212 }
213 while matches!(content.last(), Some(InlineContent::Text { text }) if text.trim().is_empty()) {
214 content.pop();
215 }
216 if !content.is_empty() {
217 if let Some(InlineContent::Bold { text }) = content.first() {
218 let key = text.clone();
219 let mut value: Vec<InlineContent> = content[1..].to_vec();
220 if let Some(InlineContent::Text { text }) = value.first_mut() {
222 *text = text.trim_start().to_string();
223 if text.is_empty() {
224 value.remove(0);
225 }
226 }
227 blocks.push(ContentBlock::KeyValue { key, value });
228 } else {
229 blocks.push(ContentBlock::Paragraph { content });
230 }
231 }
232}
233
234fn push_inline_char(buf: &mut Vec<InlineContent>, c: char) {
235 if let Some(InlineContent::Text { text }) = buf.last_mut() {
236 text.push(c);
237 } else {
238 buf.push(InlineContent::Text {
239 text: c.to_string(),
240 });
241 }
242}
243
244fn push_inline_text(buf: &mut Vec<InlineContent>, s: &str) {
245 if s.is_empty() {
246 return;
247 }
248 if let Some(InlineContent::Text { text }) = buf.last_mut() {
249 text.push_str(s);
250 } else {
251 buf.push(InlineContent::Text {
252 text: s.to_string(),
253 });
254 }
255}
256
257fn extract_level(tag: &str) -> u8 {
258 extract_attr(tag, "level")
259 .and_then(|l| l.parse().ok())
260 .unwrap_or(1)
261}
262
263fn parse_inline(input: &str, base_url: &str) -> Vec<InlineContent> {
265 let mut result = Vec::new();
266 let mut chars = input.chars().peekable();
267
268 while let Some(&c) = chars.peek() {
269 if c == '<' {
270 let tag = consume_tag(&mut chars);
271 let lower = tag.to_lowercase();
272 if lower.starts_with("<trait ") {
273 if let Some(label) = extract_attr(&tag, "label") {
274 result.push(InlineContent::Trait { label });
275 }
276 } else if lower.starts_with("<actions") {
277 if let Some(s) = extract_attr(&tag, "string") {
278 result.push(InlineContent::Action { text: s });
279 }
280 } else if lower == "<b>" || lower == "<strong>" {
281 let close = if lower == "<b>" { "b" } else { "strong" };
282 let content = collect_until_close(&mut chars, close);
283 let clean = strip_inner_tags(&content);
284 if !clean.is_empty() {
285 result.push(InlineContent::Bold { text: clean });
286 }
287 } else if lower == "<i>" || lower == "<em>" {
288 let close = if lower == "<i>" { "i" } else { "em" };
289 let content = collect_until_close(&mut chars, close);
290 let clean = strip_inner_tags(&content);
291 if !clean.is_empty() {
292 result.push(InlineContent::Italic { text: clean });
293 }
294 }
295 } else if c == '[' {
297 let (text, url) = consume_link(&mut chars);
298 let url = resolve_url(&url, base_url);
299 result.push(InlineContent::Link { text, url });
300 } else if c == '*' && chars.clone().nth(1) == Some('*') {
301 let text = consume_bold(&mut chars);
302 result.push(InlineContent::Bold { text });
303 } else if c == '_' && chars.clone().nth(1).is_some_and(|n| n.is_alphabetic()) {
304 let text = consume_italic(&mut chars);
305 result.push(InlineContent::Italic { text });
306 } else {
307 push_inline_char(&mut result, c);
308 chars.next();
309 }
310 }
311 result
312}
313
314fn parse_list_items(content: &str, base_url: &str) -> Vec<Vec<InlineContent>> {
315 let mut items = Vec::new();
316 let lower = content.to_lowercase();
317 let mut pos = 0;
318
319 while pos < content.len() {
320 let search = &lower[pos..];
321 if let Some(start) = search.find("<li>") {
322 let tag_end = pos + start + 4;
323 if let Some(end) = lower[tag_end..].find("</li>") {
324 let item_html = &content[tag_end..tag_end + end];
325 items.push(parse_inline(item_html, base_url));
326 pos = tag_end + end + 5;
327 } else {
328 break;
329 }
330 } else {
331 break;
332 }
333 }
334 items
335}
336
337fn parse_table(content: &str) -> Option<ContentBlock> {
338 let mut headers = Vec::new();
339 let mut rows = Vec::new();
340 let lower = content.to_lowercase();
341 let mut pos = 0;
342
343 while pos < content.len() {
344 let search = &lower[pos..];
345 if let Some(start) = search.find("<tr>").or_else(|| search.find("<tr ")) {
346 let tr_start = pos + start;
347 let tag_end = match content[tr_start..].find('>') {
349 Some(e) => tr_start + e + 1,
350 None => break,
351 };
352 if let Some(end) = lower[tag_end..].find("</tr>") {
353 let row_html = &content[tag_end..tag_end + end];
354 let is_header = lower[tr_start..tag_end + end].contains("<th>");
355 let cells = parse_table_cells(row_html);
356 if is_header && headers.is_empty() {
357 headers = cells;
358 } else if !cells.is_empty() {
359 rows.push(cells);
360 }
361 pos = tag_end + end + 5;
362 } else {
363 break;
364 }
365 } else {
366 break;
367 }
368 }
369
370 if headers.is_empty() && rows.is_empty() {
371 None
372 } else {
373 Some(ContentBlock::Table { headers, rows })
374 }
375}
376
377fn parse_table_cells(content: &str) -> Vec<String> {
378 let mut cells = Vec::new();
379 let lower = content.to_lowercase();
380 let mut pos = 0;
381
382 while pos < content.len() {
383 let search = &lower[pos..];
384 if let Some(start) = search.find("<th>").or_else(|| search.find("<td>")) {
385 let tag_end = start + 4;
386 let close_tag = if search[start..].starts_with("<th>") {
387 "</th>"
388 } else {
389 "</td>"
390 };
391 if let Some(end) = lower[pos + tag_end..].find(close_tag) {
392 let cell = &content[pos + tag_end..pos + tag_end + end];
393 cells.push(strip_inner_tags(cell).trim().to_string());
394 pos = pos + tag_end + end + close_tag.len();
395 } else {
396 break;
397 }
398 } else {
399 break;
400 }
401 }
402 cells
403}
404
405fn extract_action_from_html(html: &str) -> Option<String> {
406 let lower = html.to_lowercase();
407 let start = lower.find("<actions ")?;
408 let tag_end = html[start..].find('>')? + start;
409 let tag = &html[start..=tag_end];
410 extract_attr(tag, "string")
411}
412
413fn matches_hrule(chars: &std::iter::Peekable<std::str::Chars<'_>>) -> bool {
414 let upcoming: String = chars.clone().take(3).collect();
415 upcoming == "---"
416}
417
418fn consume_tag(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
421 let mut tag = String::new();
422 for c in chars.by_ref() {
423 tag.push(c);
424 if c == '>' {
425 break;
426 }
427 }
428 tag
429}
430
431fn collect_until_close(
432 chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
433 tag_name: &str,
434) -> String {
435 let close = format!("</{tag_name}>");
436 let mut content = String::new();
437 let mut depth = 1u32;
438 let open_prefix = format!("<{tag_name}");
439
440 while let Some(c) = chars.next() {
441 content.push(c);
442 if c == '<' {
443 let rest: String = chars.clone().take(close.len()).collect();
444 let check = format!("<{rest}").to_lowercase();
445 if check.starts_with(&close) {
446 for _ in 0..close.len() - 1 {
447 chars.next();
448 }
449 depth -= 1;
450 if depth == 0 {
451 content.pop();
452 return content;
453 }
454 } else if check.starts_with(&open_prefix) {
455 depth += 1;
456 }
457 }
458 }
459 content
460}
461
462fn extract_attr(tag: &str, name: &str) -> Option<String> {
463 let pattern = format!("{name}=\"");
464 let start = tag.find(&pattern)? + pattern.len();
465 let rest = &tag[start..];
466 let end = rest.find('"')?;
467 Some(html_escape::decode_html_entities(&rest[..end]).to_string())
468}
469
470fn strip_inner_tags(input: &str) -> String {
471 let mut out = String::new();
472 let mut in_tag = false;
473 let mut chars = input.chars().peekable();
474
475 while let Some(c) = chars.next() {
476 if c == '<' {
477 in_tag = true;
478 } else if c == '>' {
479 in_tag = false;
480 } else if !in_tag {
481 if c == '[' {
482 let mut text = String::new();
483 for lc in chars.by_ref() {
484 if lc == ']' {
485 break;
486 }
487 text.push(lc);
488 }
489 if chars.peek() == Some(&'(') {
490 chars.next();
491 let mut depth = 1;
492 for lc in chars.by_ref() {
493 if lc == '(' {
494 depth += 1;
495 } else if lc == ')' {
496 depth -= 1;
497 if depth == 0 {
498 break;
499 }
500 }
501 }
502 }
503 out.push_str(&text);
504 } else {
505 out.push(c);
506 }
507 }
508 }
509 html_escape::decode_html_entities(&out).to_string()
510}
511
512fn consume_link(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> (String, String) {
513 chars.next(); let mut text = String::new();
515 for c in chars.by_ref() {
516 if c == ']' {
517 break;
518 }
519 text.push(c);
520 }
521 let mut url = String::new();
522 if chars.peek() == Some(&'(') {
523 chars.next();
524 let mut depth = 1;
525 for c in chars.by_ref() {
526 if c == '(' {
527 depth += 1;
528 } else if c == ')' {
529 depth -= 1;
530 if depth == 0 {
531 break;
532 }
533 }
534 url.push(c);
535 }
536 }
537 (text, url)
538}
539
540fn consume_bold(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
541 chars.next();
542 chars.next();
543 let mut text = String::new();
544 while let Some(&c) = chars.peek() {
545 if c == '*' {
546 chars.next();
547 if chars.peek() == Some(&'*') {
548 chars.next();
549 break;
550 }
551 text.push('*');
552 } else {
553 text.push(c);
554 chars.next();
555 }
556 }
557 text
558}
559
560fn resolve_url(url: &str, base_url: &str) -> String {
561 if url.starts_with('/') {
562 format!("{base_url}{url}")
563 } else {
564 url.to_string()
565 }
566}
567
568fn consume_italic(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
569 chars.next();
570 let mut text = String::new();
571 for c in chars.by_ref() {
572 if c == '_' {
573 break;
574 }
575 text.push(c);
576 }
577 text
578}