1use crate::inline_link::{LinkKind, rewrite_inline_links};
2use crate::model::{Node, Page};
3
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum BlockEscaping {
7 Escape,
9 Verbatim,
11}
12
13pub fn render_page_to_text(page: &Page) -> String {
15 render_nodes_to_text(&page.content)
16}
17
18pub fn render_nodes_to_text(nodes: &[Node]) -> String {
20 render_nodes_to_text_with(nodes, &mut |_, _| None, BlockEscaping::Verbatim)
21}
22
23pub 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#[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
182fn 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
197pub fn escape_block_start(line: &str) -> String {
200 if starts_block(line) {
201 format!("\\{line}")
202 } else {
203 line.to_string()
204 }
205}
206
207pub 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
223pub 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
240pub 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
250fn 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 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".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 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 #[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}