Skip to main content

lepiter_core/
render.rs

1use crate::inline_link::{LinkKind, rewrite_inline_links};
2use crate::model::{Node, Page};
3
4/// whether a render escapes block-looking line starts.
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum BlockEscaping {
7    /// for markdown `import` reads back; see [`escape_block_start`].
8    Escape,
9    /// for display; every line reaches the reader as the content has it.
10    Verbatim,
11}
12
13/// Renders a parsed page to plain text for display.
14pub fn render_page_to_text(page: &Page) -> String {
15    render_nodes_to_text(&page.content)
16}
17
18/// Renders normalized nodes to plain text for display.
19pub fn render_nodes_to_text(nodes: &[Node]) -> String {
20    render_nodes_to_text_with(nodes, &mut |_, _| None, BlockEscaping::Verbatim)
21}
22
23/// Renders normalized nodes to plain text, rewriting inline and explicit links
24/// through `rewrite`.
25///
26/// `rewrite(kind, target)` is invoked for every `[[wikilink]]` and
27/// `[label](target)` found in text-bearing nodes, and for every
28/// [`Node::Link`] url (as [`LinkKind::Markdown`]). Returning `Some(new_target)`
29/// substitutes the target; returning `None` leaves the link verbatim. A no-op
30/// rewriter with [`BlockEscaping::Verbatim`] reproduces
31/// [`render_nodes_to_text`] exactly.
32pub fn render_nodes_to_text_with(
33    nodes: &[Node],
34    rewrite: &mut impl FnMut(LinkKind, &str) -> Option<String>,
35    escaping: BlockEscaping,
36) -> String {
37    let mut out = String::new();
38    render_nodes_into(nodes, rewrite, escaping, Position::Snippet, &mut out);
39    out
40}
41
42/// whether a node is a snippet of its own or sits inside a list item.
43#[derive(Clone, Copy, PartialEq, Eq)]
44enum Position {
45    Snippet,
46    ListItem,
47}
48
49fn render_nodes_into(
50    nodes: &[Node],
51    rewrite: &mut impl FnMut(LinkKind, &str) -> Option<String>,
52    escaping: BlockEscaping,
53    position: Position,
54    out: &mut String,
55) {
56    for node in nodes {
57        match node {
58            Node::Heading { level, text } => {
59                let text = rewrite_inline_links(text, &mut *rewrite);
60                let mut lines = block_lines(&text).into_iter();
61                out.push_str(&"#".repeat((*level).max(1) as usize));
62                out.push(' ');
63                out.push_str(lines.next().unwrap_or(""));
64                out.push('\n');
65                for line in lines {
66                    push_block_line(line, escaping, out);
67                }
68                out.push('\n');
69            }
70            Node::Paragraph { text } | Node::Text { text } => {
71                let text = rewrite_inline_links(text, &mut *rewrite);
72                if escaping == BlockEscaping::Escape
73                    && position == Position::Snippet
74                    && !text.contains('\n')
75                    && is_standalone_link(&text)
76                {
77                    out.push('\\');
78                }
79                push_block_lines(&text, escaping, out);
80                out.push('\n');
81            }
82            Node::List { items } => {
83                for item in items {
84                    let mut item_out = String::new();
85                    render_nodes_into(item, rewrite, escaping, Position::ListItem, &mut item_out);
86                    let mut lines = item_out.trim().lines();
87                    if let Some(first) = lines.next() {
88                        out.push_str("- ");
89                        out.push_str(first);
90                        out.push('\n');
91                        for line in lines {
92                            out.push_str("  ");
93                            out.push_str(line);
94                            out.push('\n');
95                        }
96                    }
97                }
98                out.push('\n');
99            }
100            Node::Code { language, code } => {
101                let fence = fence_for(code);
102                out.push_str(&fence);
103                if let Some(lang) = language {
104                    out.push_str(lang);
105                }
106                out.push('\n');
107                out.push_str(code);
108                out.push('\n');
109                out.push_str(&fence);
110                out.push_str("\n\n");
111            }
112            Node::Link { text, url } => {
113                let rewritten = rewrite(LinkKind::Markdown, url).unwrap_or_else(|| url.clone());
114                out.push_str(&format!("[{text}]({rewritten})\n\n"));
115            }
116            Node::Quote { text } => {
117                let text = rewrite_inline_links(text, &mut *rewrite);
118                for line in block_lines(&text) {
119                    if line.is_empty() {
120                        out.push_str(">\n");
121                    } else {
122                        out.push_str("> ");
123                        out.push_str(line);
124                        out.push('\n');
125                    }
126                }
127                out.push('\n');
128            }
129            Node::Rewrite {
130                language,
131                search,
132                replace,
133                scope,
134                is_method_pattern,
135            } => {
136                let lang = language.clone().unwrap_or_else(|| "rewrite".to_string());
137                let mut body = String::new();
138                if let Some(scope) = scope {
139                    body.push_str(&format!("# scope: {scope}\n"));
140                }
141                if let Some(is_method_pattern) = is_method_pattern {
142                    body.push_str(&format!("# method_pattern: {is_method_pattern}\n"));
143                }
144                for line in normalize_text(search).lines() {
145                    body.push('-');
146                    body.push_str(line);
147                    body.push('\n');
148                }
149                for line in normalize_text(replace).lines() {
150                    body.push('+');
151                    body.push_str(line);
152                    body.push('\n');
153                }
154                let fence = fence_for(&body);
155                out.push_str(&format!("{fence}diff {lang}\n{body}{fence}\n\n"));
156            }
157            Node::Unknown { typ, .. } => {
158                out.push_str(&format!("[[unknown: {typ}]]\n\n"));
159            }
160        }
161    }
162}
163
164fn block_lines(text: &str) -> Vec<&str> {
165    text.split('\n').collect()
166}
167
168fn push_block_lines(text: &str, escaping: BlockEscaping, out: &mut String) {
169    for line in block_lines(text) {
170        push_block_line(line, escaping, out);
171    }
172}
173
174fn push_block_line(line: &str, escaping: BlockEscaping, out: &mut String) {
175    match escaping {
176        BlockEscaping::Escape => out.push_str(&escape_block_start(line)),
177        BlockEscaping::Verbatim => out.push_str(line),
178    }
179    out.push('\n');
180}
181
182/// a backtick fence long enough that no line of `content` can close it.
183fn fence_for(content: &str) -> String {
184    let mut longest = 0usize;
185    let mut run = 0usize;
186    for c in content.chars() {
187        if c == '`' {
188            run += 1;
189            longest = longest.max(run);
190        } else {
191            run = 0;
192        }
193    }
194    "`".repeat((longest + 1).max(3))
195}
196
197/// prefixes `line` with a backslash if a line-oriented markdown reader would
198/// take it for the start of a block.
199pub fn escape_block_start(line: &str) -> String {
200    if starts_block(line) {
201        format!("\\{line}")
202    } else {
203        line.to_string()
204    }
205}
206
207/// inverse of [`escape_block_start`].
208pub fn unescape_block_start(line: &str) -> String {
209    line.strip_prefix('\\').unwrap_or(line).to_string()
210}
211
212fn starts_block(line: &str) -> bool {
213    let trimmed = line.trim();
214    trimmed.is_empty()
215        || line.starts_with('\\')
216        || line.starts_with("- ")
217        || line.starts_with("> ")
218        || line == ">"
219        || line.starts_with("```")
220        || (trimmed.starts_with("[[unknown: ") && trimmed.ends_with("]]"))
221}
222
223/// whether `line`, ignoring surrounding whitespace, is one `[label](target)`
224/// markdown link and nothing else.
225///
226/// the markdown `import` reads such a line back as a link snippet, but only
227/// where it is a whole snippet on its own.
228pub fn is_standalone_link(line: &str) -> bool {
229    let trimmed = line.trim();
230    if !trimmed.starts_with('[') {
231        return false;
232    }
233    let Some(bracket_end) = trimmed.find("](") else {
234        return false;
235    };
236    let after = &trimmed[bracket_end + 2..];
237    after.ends_with(')') && !after[..after.len() - 1].contains(')')
238}
239
240/// Checks whether the rendered text of a page contains `needle`
241/// (case-insensitive) without allocating the full rendered string.
242///
243/// Walks nodes one at a time and returns `true` on the first match.
244pub fn page_content_contains(page: &Page, needle: &str) -> bool {
245    let needle: String = needle.chars().flat_map(char::to_lowercase).collect();
246    let mut buf = String::new();
247    nodes_contain(&page.content, &needle, &mut buf)
248}
249
250/// Substring check against the lowercased `text`, reusing `buf` to avoid
251/// allocating a new lowercased string on every call. `needle` must already
252/// be lowercased the same way by the caller.
253fn node_text_contains(text: &str, needle: &str, buf: &mut String) -> bool {
254    buf.clear();
255    buf.extend(text.chars().flat_map(char::to_lowercase));
256    buf.contains(needle)
257}
258
259fn nodes_contain(nodes: &[Node], needle: &str, buf: &mut String) -> bool {
260    for node in nodes {
261        match node {
262            Node::Heading { text, .. }
263            | Node::Paragraph { text }
264            | Node::Text { text }
265            | Node::Quote { text } => {
266                if node_text_contains(text, needle, buf) {
267                    return true;
268                }
269            }
270            Node::Code { language, code } => {
271                if node_text_contains(code, needle, buf) {
272                    return true;
273                }
274                if let Some(lang) = language
275                    && node_text_contains(lang, needle, buf)
276                {
277                    return true;
278                }
279            }
280            Node::Link { text, url } => {
281                if node_text_contains(text, needle, buf) || node_text_contains(url, needle, buf) {
282                    return true;
283                }
284            }
285            Node::List { items } => {
286                for item in items {
287                    if nodes_contain(item, needle, buf) {
288                        return true;
289                    }
290                }
291            }
292            Node::Rewrite {
293                language,
294                search,
295                replace,
296                scope,
297                ..
298            } => {
299                if node_text_contains(search, needle, buf)
300                    || node_text_contains(replace, needle, buf)
301                {
302                    return true;
303                }
304                if let Some(lang) = language
305                    && node_text_contains(lang, needle, buf)
306                {
307                    return true;
308                }
309                if let Some(s) = scope
310                    && node_text_contains(s, needle, buf)
311                {
312                    return true;
313                }
314            }
315            Node::Unknown { typ, .. } => {
316                if node_text_contains(typ, needle, buf) {
317                    return true;
318                }
319            }
320        }
321    }
322    false
323}
324
325pub fn normalize_text(input: &str) -> String {
326    input.replace("\r\n", "\n").replace('\r', "\n")
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use serde_json::json;
333
334    fn render_escaped(nodes: &[Node]) -> String {
335        render_nodes_to_text_with(nodes, &mut |_, _| None, BlockEscaping::Escape)
336    }
337
338    #[test]
339    fn render_nodes_outputs_unknown_placeholder() {
340        let text = render_nodes_to_text(&[
341            Node::Paragraph {
342                text: "para".to_string(),
343            },
344            Node::Rewrite {
345                language: Some("pharo".to_string()),
346                search: "a".to_string(),
347                replace: "b".to_string(),
348                scope: None,
349                is_method_pattern: Some(true),
350            },
351            Node::Unknown {
352                typ: "weird".to_string(),
353                raw: json!({"a":1}),
354            },
355        ]);
356        assert!(text.contains("para"));
357        assert!(text.contains("```diff pharo"));
358        assert!(text.contains("-a"));
359        assert!(text.contains("+b"));
360        assert!(text.contains("[[unknown: weird]]"));
361    }
362
363    #[test]
364    fn render_nodes_to_text_with_rewrites_inline_and_explicit_links() {
365        let mut rewrite = |kind: LinkKind, target: &str| match (kind, target) {
366            (LinkKind::Wiki, "Topic") => Some("topic.md".to_string()),
367            (LinkKind::Markdown, "page:abc") => Some("alpha.md".to_string()),
368            _ => None,
369        };
370        let text = render_nodes_to_text_with(
371            &[
372                Node::Paragraph {
373                    text: "see [[Topic]] and [x](page:abc)".to_string(),
374                },
375                Node::Link {
376                    text: "go".to_string(),
377                    url: "page:abc".to_string(),
378                },
379            ],
380            &mut rewrite,
381            BlockEscaping::Escape,
382        );
383        assert!(text.contains("see [Topic](topic.md) and [x](alpha.md)"));
384        assert!(text.contains("[go](alpha.md)"));
385    }
386
387    #[test]
388    fn render_nodes_to_text_noop_matches_plain_render() {
389        let nodes = vec![
390            Node::Heading {
391                level: 1,
392                text: "see [[Topic]]".to_string(),
393            },
394            Node::Paragraph {
395                text: "a [x](page:abc) b".to_string(),
396            },
397            Node::Link {
398                text: "go".to_string(),
399                url: "page:abc".to_string(),
400            },
401        ];
402        // The default renderer must leave every link untouched.
403        let plain = render_nodes_to_text(&nodes);
404        assert!(plain.contains("see [[Topic]]"));
405        assert!(plain.contains("a [x](page:abc) b"));
406        assert!(plain.contains("[go](page:abc)"));
407    }
408
409    #[test]
410    fn fence_widens_past_the_longest_backtick_run() {
411        assert_eq!(fence_for("plain code"), "```");
412        assert_eq!(fence_for("a ` b"), "```");
413        assert_eq!(fence_for("```"), "````");
414        assert_eq!(fence_for("outer\n`````\ninner"), "``````");
415    }
416
417    #[test]
418    fn code_block_fence_outgrows_a_nested_fence() {
419        let text = render_nodes_to_text(&[Node::Code {
420            language: Some("python".to_string()),
421            code: "doc = '''\n```\nnested\n```\n'''".to_string(),
422        }]);
423        assert_eq!(
424            text,
425            "````python\ndoc = '''\n```\nnested\n```\n'''\n````\n\n"
426        );
427    }
428
429    #[test]
430    fn escape_block_start_covers_every_block_opener() {
431        assert_eq!(escape_block_start("- item"), "\\- item");
432        assert_eq!(escape_block_start("> quote"), "\\> quote");
433        assert_eq!(escape_block_start(">"), "\\>");
434        assert_eq!(escape_block_start("```rust"), "\\```rust");
435        assert_eq!(escape_block_start("[[unknown: x]]"), "\\[[unknown: x]]");
436        assert_eq!(escape_block_start("\\already"), "\\\\already");
437        assert_eq!(escape_block_start(""), "\\");
438        assert_eq!(escape_block_start("  "), "\\  ");
439    }
440
441    #[test]
442    fn line_escaping_leaves_a_standalone_link_to_the_node_renderer() {
443        assert_eq!(escape_block_start("[label](url)"), "[label](url)");
444        assert_eq!(escape_block_start("  [pad](url)  "), "  [pad](url)  ");
445    }
446
447    #[test]
448    fn escape_block_start_leaves_ordinary_prose_alone() {
449        for line in [
450            "plain",
451            "-dash",
452            "a - b",
453            ">>chevron",
454            "``inline``",
455            "see [label](url) below",
456            "[label](url) trails off",
457            "[unclosed](url",
458            "[no target]",
459        ] {
460            assert_eq!(escape_block_start(line), line);
461        }
462    }
463
464    #[test]
465    fn escape_block_start_matches_the_importer_on_a_hand_escaped_link() {
466        assert_eq!(escape_block_start("\\[label](url)"), "\\\\[label](url)");
467        assert!(!is_standalone_link("\\[label](url)"));
468    }
469
470    #[test]
471    fn unescape_block_start_inverts_escape_block_start() {
472        for line in [
473            "plain",
474            "",
475            "- item",
476            "> quote",
477            ">",
478            "```rust",
479            "[[unknown: x]]",
480            "\\already",
481            "\\\\twice",
482            "\\- hand-escaped",
483            "  ",
484            "\\ ",
485            "[label](url)",
486            "  [pad](url)  ",
487            "\\[label](url)",
488        ] {
489            assert_eq!(unescape_block_start(&escape_block_start(line)), line);
490        }
491    }
492
493    #[test]
494    fn multi_line_nodes_escape_every_line() {
495        let text = render_escaped(&[Node::Paragraph {
496            text: "intro\n- not a list\n> not a quote".to_string(),
497        }]);
498        assert_eq!(text, "intro\n\\- not a list\n\\> not a quote\n\n");
499    }
500
501    #[test]
502    fn heading_escapes_continuation_lines_only() {
503        let text = render_escaped(&[Node::Heading {
504            level: 2,
505            text: "title\n- not a list".to_string(),
506        }]);
507        assert_eq!(text, "## title\n\\- not a list\n\n");
508    }
509
510    #[test]
511    fn quote_marks_every_line() {
512        let text = render_nodes_to_text(&[Node::Quote {
513            text: "first\n\nthird".to_string(),
514        }]);
515        assert_eq!(text, "> first\n>\n> third\n\n");
516    }
517
518    #[test]
519    fn blank_lines_inside_a_block_are_escaped() {
520        let text = render_escaped(&[Node::Paragraph {
521            text: "para one\n\npara two".to_string(),
522        }]);
523        assert_eq!(text, "para one\n\\\npara two\n\n");
524    }
525
526    #[test]
527    fn trailing_and_leading_blank_lines_are_escaped() {
528        let text = render_escaped(&[Node::Paragraph {
529            text: "\nbody\n".to_string(),
530        }]);
531        assert_eq!(text, "\\\nbody\n\\\n\n");
532    }
533
534    #[test]
535    fn whitespace_only_text_node_is_escaped_and_separated() {
536        let text = render_escaped(&[
537            Node::Text {
538                text: "  ".to_string(),
539            },
540            Node::Paragraph {
541                text: "after".to_string(),
542            },
543        ]);
544        assert_eq!(text, "\\  \n\nafter\n\n");
545    }
546
547    #[test]
548    fn text_node_that_is_only_a_link_is_escaped() {
549        let text = render_escaped(&[Node::Text {
550            text: "[label](https://example.com)".to_string(),
551        }]);
552        assert_eq!(text, "\\[label](https://example.com)\n\n");
553        let padded = render_escaped(&[Node::Text {
554            text: "  [pad](https://example.com)  ".to_string(),
555        }]);
556        assert_eq!(padded, "\\  [pad](https://example.com)  \n\n");
557    }
558
559    #[test]
560    fn a_link_line_among_others_is_left_a_working_link() {
561        let text = render_escaped(&[Node::Text {
562            text: "intro\n[label](https://example.com)\noutro".to_string(),
563        }]);
564        assert_eq!(text, "intro\n[label](https://example.com)\noutro\n\n");
565    }
566
567    #[test]
568    fn a_list_item_that_is_a_link_is_left_a_working_link() {
569        let text = render_escaped(&[Node::List {
570            items: vec![
571                vec![Node::Text {
572                    text: "[label](https://example.com)".to_string(),
573                }],
574                vec![Node::Text {
575                    text: "plain".to_string(),
576                }],
577            ],
578        }]);
579        assert_eq!(text, "- [label](https://example.com)\n- plain\n\n");
580    }
581
582    #[test]
583    fn a_heading_continuation_that_is_a_link_is_left_a_working_link() {
584        let text = render_escaped(&[Node::Heading {
585            level: 2,
586            text: "title\n[label](https://example.com)".to_string(),
587        }]);
588        assert_eq!(text, "## title\n[label](https://example.com)\n\n");
589    }
590
591    #[test]
592    fn link_node_renders_unescaped() {
593        let text = render_escaped(&[Node::Link {
594            text: "label".to_string(),
595            url: "https://example.com".to_string(),
596        }]);
597        assert_eq!(text, "[label](https://example.com)\n\n");
598    }
599
600    #[test]
601    fn render_emits_carriage_returns_verbatim_though_import_drops_them() {
602        let text = render_escaped(&[Node::Paragraph {
603            text: "one\r\ntwo".to_string(),
604        }]);
605        assert_eq!(text, "one\r\ntwo\n\n");
606    }
607
608    #[test]
609    fn display_render_leaves_block_looking_prose_alone() {
610        let text = render_nodes_to_text(&[Node::Paragraph {
611            text: "intro\n- not a list\n> not a quote\n```not a fence\n[not](a-link-snippet)"
612                .to_string(),
613        }]);
614        assert_eq!(
615            text,
616            "intro\n- not a list\n> not a quote\n```not a fence\n[not](a-link-snippet)\n\n"
617        );
618    }
619
620    #[test]
621    fn display_render_keeps_a_blank_line_blank() {
622        let text = render_nodes_to_text(&[Node::Paragraph {
623            text: "para one\n\npara two".to_string(),
624        }]);
625        assert_eq!(text, "para one\n\npara two\n\n");
626    }
627
628    #[test]
629    fn display_render_does_not_double_a_leading_backslash() {
630        let text = render_nodes_to_text(&[Node::Paragraph {
631            text: "\\newcommand{\\foo}{bar}".to_string(),
632        }]);
633        assert_eq!(text, "\\newcommand{\\foo}{bar}\n\n");
634    }
635
636    #[test]
637    fn display_render_leaves_heading_continuation_lines_alone() {
638        let text = render_nodes_to_text(&[Node::Heading {
639            level: 2,
640            text: "title\n- not a list".to_string(),
641        }]);
642        assert_eq!(text, "## title\n- not a list\n\n");
643    }
644
645    #[test]
646    fn render_list_single_line_items() {
647        let text = render_nodes_to_text(&[Node::List {
648            items: vec![
649                vec![Node::Paragraph {
650                    text: "first".to_string(),
651                }],
652                vec![Node::Paragraph {
653                    text: "second".to_string(),
654                }],
655            ],
656        }]);
657        assert_eq!(text, "- first\n- second\n\n");
658    }
659
660    #[test]
661    fn render_list_item_with_code_block() {
662        let text = render_nodes_to_text(&[Node::List {
663            items: vec![vec![Node::Code {
664                language: Some("py".to_string()),
665                code: "x = 1\ny = 2".to_string(),
666            }]],
667        }]);
668        assert_eq!(text, "- ```py\n  x = 1\n  y = 2\n  ```\n\n");
669    }
670
671    #[test]
672    fn render_list_item_with_multiple_nodes() {
673        let text = render_nodes_to_text(&[Node::List {
674            items: vec![vec![
675                Node::Paragraph {
676                    text: "intro".to_string(),
677                },
678                Node::Code {
679                    language: None,
680                    code: "code".to_string(),
681                },
682            ]],
683        }]);
684        // First line starts with "- ", continuation lines with "  "
685        let lines: Vec<&str> = text.trim().lines().collect();
686        assert_eq!(lines[0], "- intro");
687        for line in &lines[1..] {
688            assert!(
689                line.starts_with("  "),
690                "continuation line not indented: {line:?}"
691            );
692        }
693    }
694
695    fn make_page(nodes: Vec<Node>) -> Page {
696        Page {
697            id: "test".to_string(),
698            title: "Test".to_string(),
699            updated_at: None,
700            tags: Vec::new(),
701            content: nodes,
702        }
703    }
704
705    #[test]
706    fn page_content_contains_matches_paragraph() {
707        let page = make_page(vec![Node::Paragraph {
708            text: "the quick brown fox".to_string(),
709        }]);
710        assert!(page_content_contains(&page, "quick"));
711        assert!(!page_content_contains(&page, "lazy"));
712    }
713
714    #[test]
715    fn page_content_contains_case_insensitive() {
716        let page = make_page(vec![Node::Paragraph {
717            text: "Hello World".to_string(),
718        }]);
719        assert!(page_content_contains(&page, "hello world"));
720        assert!(page_content_contains(&page, "hello"));
721        assert!(page_content_contains(&page, "Hello"));
722        assert!(page_content_contains(&page, "WORLD"));
723        assert!(page_content_contains(&page, "HeLLo WoRLd"));
724        assert!(!page_content_contains(&page, "GOODBYE"));
725    }
726
727    #[test]
728    fn page_content_contains_case_insensitive_non_ascii() {
729        let page = make_page(vec![Node::Paragraph {
730            text: "Über Café".to_string(),
731        }]);
732        assert!(page_content_contains(&page, "über"));
733        assert!(page_content_contains(&page, "ÜBER"));
734        assert!(page_content_contains(&page, "CAFÉ"));
735    }
736
737    /// Haystack and needle must be lowercased identically. `str::to_lowercase`
738    /// maps a word-final sigma to `ς` where `char::to_lowercase` yields `σ`, so
739    /// using it for the needle here would break the match.
740    #[test]
741    fn page_content_contains_lowercases_needle_per_char() {
742        let page = make_page(vec![Node::Paragraph {
743            text: "ΟΔΟΣ".to_string(),
744        }]);
745        assert!(page_content_contains(&page, "ΟΔΟΣ"));
746        assert!(page_content_contains(&page, "οδοσ"));
747    }
748
749    #[test]
750    fn page_content_contains_matches_heading() {
751        let page = make_page(vec![Node::Heading {
752            level: 2,
753            text: "Important Section".to_string(),
754        }]);
755        assert!(page_content_contains(&page, "important"));
756    }
757
758    #[test]
759    fn page_content_contains_matches_code() {
760        let page = make_page(vec![Node::Code {
761            language: Some("rust".to_string()),
762            code: "fn main() {}".to_string(),
763        }]);
764        assert!(page_content_contains(&page, "fn main"));
765        assert!(page_content_contains(&page, "rust"));
766    }
767
768    #[test]
769    fn page_content_contains_matches_link() {
770        let page = make_page(vec![Node::Link {
771            text: "click here".to_string(),
772            url: "https://example.com".to_string(),
773        }]);
774        assert!(page_content_contains(&page, "click"));
775        assert!(page_content_contains(&page, "example.com"));
776    }
777
778    #[test]
779    fn page_content_contains_matches_quote() {
780        let page = make_page(vec![Node::Quote {
781            text: "to be or not to be".to_string(),
782        }]);
783        assert!(page_content_contains(&page, "not to be"));
784    }
785
786    #[test]
787    fn page_content_contains_matches_list_items() {
788        let page = make_page(vec![Node::List {
789            items: vec![
790                vec![Node::Paragraph {
791                    text: "first item".to_string(),
792                }],
793                vec![Node::Paragraph {
794                    text: "second item".to_string(),
795                }],
796            ],
797        }]);
798        assert!(page_content_contains(&page, "second"));
799        assert!(!page_content_contains(&page, "third"));
800    }
801
802    #[test]
803    fn page_content_contains_matches_rewrite() {
804        let page = make_page(vec![Node::Rewrite {
805            language: Some("pharo".to_string()),
806            search: "oldMethod".to_string(),
807            replace: "newMethod".to_string(),
808            scope: Some("MyClass".to_string()),
809            is_method_pattern: None,
810        }]);
811        assert!(page_content_contains(&page, "oldmethod"));
812        assert!(page_content_contains(&page, "newmethod"));
813        assert!(page_content_contains(&page, "pharo"));
814        assert!(page_content_contains(&page, "myclass"));
815    }
816
817    #[test]
818    fn page_content_contains_matches_unknown_type() {
819        let page = make_page(vec![Node::Unknown {
820            typ: "wardleyMap".to_string(),
821            raw: json!({}),
822        }]);
823        assert!(page_content_contains(&page, "wardley"));
824    }
825
826    #[test]
827    fn page_content_contains_early_termination() {
828        let page = make_page(vec![
829            Node::Paragraph {
830                text: "match here".to_string(),
831            },
832            Node::Paragraph {
833                text: "no match".to_string(),
834            },
835        ]);
836        assert!(page_content_contains(&page, "match here"));
837    }
838
839    #[test]
840    fn page_content_contains_empty_content() {
841        let page = make_page(vec![]);
842        assert!(!page_content_contains(&page, "anything"));
843    }
844
845    #[test]
846    fn page_content_contains_matches_text_node() {
847        let page = make_page(vec![Node::Text {
848            text: "plain text line".to_string(),
849        }]);
850        assert!(page_content_contains(&page, "plain text"));
851    }
852}