Skip to main content

wayfinder_core/render/
content.rs

1//! Structured content IR for AON documents.
2//! Parses AON HTML/markdown into `ContentBlock` trees.
3
4use serde::Serialize;
5
6/// A block-level content element.
7#[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/// Inline content within a paragraph or list item.
38#[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
49/// Parse AON markdown/HTML content into structured blocks.
50/// `base_url` is used to resolve relative URLs (e.g. `https://2e.aonprd.com`).
51pub 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        // Check for --- hrule at start of line (after flush or start of input)
59        if c == '-' && inline_buf.is_empty() && matches_hrule(&chars) {
60            chars.next(); // -
61            chars.next(); // -
62            chars.next(); // -
63            // Consume rest of line
64            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                // Treat <br> as paragraph break if we have content
102                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                // Bare <li> without <ul> wrapper
124                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                // Bare <tr> without <table> wrapper
140                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                // closing tags -- ignored
151            }
152            // Other tags silently ignored
153        } 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            // Consume optional \r after \n
168            while chars.peek() == Some(&'\r') {
169                chars.next();
170            }
171            // Check for --- on next line (horizontal rule)
172            if matches_hrule(&chars) {
173                // Consume the ---
174                chars.next(); // -
175                chars.next(); // -
176                chars.next(); // -
177                // Consume rest of line
178                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                // Double newline = paragraph break
185                chars.next();
186                // Consume additional blank lines
187                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    // Only trim leading/trailing whitespace-only text nodes
210    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            // Trim leading whitespace from first value inline
221            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
263/// Parse inline content from an HTML string fragment.
264fn 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            // ignore other tags
296        } 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            // Find the > of the opening tag
348            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
418// --- Shared parsing helpers ---
419
420fn 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(); // consume '['
514    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}