1use docgen_core::headings::Heading;
2use docgen_core::model::{Backlink, TreeNode};
3use minijinja::{context, Environment};
4use serde::Serialize;
5
6pub const DEFAULT_PAGE_TEMPLATE: &str = include_str!("../templates/page.html");
8
9#[deprecated(note = "use docgen-assets::core_assets() / emit()")]
15pub const SEARCH_JS: &str = include_str!("../assets/search.js");
16
17pub const DEFAULT_HISTORY_TEMPLATE: &str = include_str!("../templates/history.html");
25
26pub const DEFAULT_GRAPH_TEMPLATE: &str = include_str!("../templates/graph.html");
28
29pub const DEFAULT_DIFF_TEMPLATE: &str = include_str!("../templates/diff.html");
33
34pub const DEFAULT_PREVIEW_TEMPLATE: &str = include_str!("../templates/preview.html");
39
40#[derive(Serialize)]
43pub struct LineView {
44 pub kind: String,
45 pub text: String,
46 pub old_line: Option<u32>,
47 pub new_line: Option<u32>,
48}
49
50#[derive(Serialize)]
52pub struct HunkView {
53 pub lines: Vec<LineView>,
54}
55
56#[derive(Serialize)]
58pub struct FileView {
59 pub path: String,
60 pub status: String,
61 pub hunks: Vec<HunkView>,
62}
63
64#[derive(Serialize)]
66pub struct TimelinePointView {
67 pub short_hash: String,
68 pub subject: String,
69 pub author: Option<String>,
70 pub date: Option<String>,
71 pub added_lines: u32,
72 pub removed_lines: u32,
73 pub files: Vec<FileView>,
74}
75
76#[derive(Serialize)]
78pub struct TimelineBucketView {
79 pub label: String,
80 pub points: Vec<TimelinePointView>,
81}
82
83#[derive(Serialize)]
85pub struct HistoryContext<'a> {
86 pub title: &'a str,
87 pub slug: &'a str,
88 pub tree: &'a [TreeNode],
89 pub buckets: &'a [TimelineBucketView],
90 pub base: &'a str,
92 pub site_title: &'a str,
94 pub search_enabled: bool,
96}
97
98#[derive(Serialize)]
101pub struct GraphContext<'a> {
102 pub tree: &'a [TreeNode],
103 pub graph_json: &'a str,
104 pub node_count: usize,
105 pub edge_count: usize,
106 pub base: &'a str,
108 pub site_title: &'a str,
110 pub search_enabled: bool,
112 pub has_diff: bool,
114}
115
116#[derive(Serialize)]
120pub struct DiffContext<'a> {
121 pub tree: &'a [TreeNode],
122 pub base: &'a str,
124 pub site_title: &'a str,
126 pub search_enabled: bool,
128}
129
130#[derive(Serialize)]
134pub struct PreviewContext<'a> {
135 pub title: &'a str,
136 pub body_html: &'a str,
137 pub base: &'a str,
139 pub has_mermaid: bool,
141 pub has_math: bool,
143 pub has_components_css: bool,
145 pub has_component_island: bool,
147}
148
149#[derive(Serialize)]
153pub struct HomeSection<'a> {
154 pub label: &'a str,
155 pub slug: &'a str,
158 pub count: usize,
159}
160
161#[derive(Serialize)]
163pub struct HomeRecent<'a> {
164 pub title: &'a str,
165 pub slug: &'a str,
166 pub section: &'a str,
168}
169
170#[derive(Serialize)]
173pub struct HomeData<'a> {
174 pub description: &'a str,
176 pub pages: usize,
178 pub links: usize,
180 pub sections: &'a [HomeSection<'a>],
182 pub recent: &'a [HomeRecent<'a>],
184}
185
186#[derive(Serialize)]
188pub struct PageContext<'a> {
189 pub title: &'a str,
190 pub description: &'a str,
193 pub slug: &'a str,
194 pub body_html: &'a str,
195 pub tree: &'a [TreeNode],
196 pub backlinks: &'a [Backlink],
199 pub headings: &'a [Heading],
201 pub commit: &'a str,
204 pub built: &'a str,
206 pub has_history: bool,
208 pub has_mermaid: bool,
210 pub has_base_island: bool,
214 pub has_math: bool,
216 pub base: &'a str,
218 pub site_title: &'a str,
220 pub search_enabled: bool,
222 pub has_diff: bool,
224 pub has_components_css: bool,
228 pub has_component_island: bool,
231 pub is_home: bool,
234 pub graph_json: &'a str,
238 pub graph_node_count: usize,
240 pub graph_edge_count: usize,
241 pub home: Option<HomeData<'a>>,
244}
245
246pub struct Renderer {
248 env: Environment<'static>,
249}
250
251fn escape_json_for_script(json: &str) -> String {
264 json.replace('<', "\\u003c")
265}
266
267impl Renderer {
268 pub fn new(page_template: &str) -> Result<Self, minijinja::Error> {
270 let mut env = Environment::new();
271 env.add_template_owned("page.html", page_template.to_string())?;
275 env.add_template_owned("history.html", DEFAULT_HISTORY_TEMPLATE.to_string())?;
276 env.add_template_owned("graph.html", DEFAULT_GRAPH_TEMPLATE.to_string())?;
277 env.add_template_owned("diff.html", DEFAULT_DIFF_TEMPLATE.to_string())?;
278 env.add_template_owned("preview.html", DEFAULT_PREVIEW_TEMPLATE.to_string())?;
279 Ok(Self { env })
280 }
281
282 pub fn render_page(&self, ctx: &PageContext) -> Result<String, minijinja::Error> {
284 let tmpl = self.env.get_template("page.html")?;
285 let safe_graph_json = escape_json_for_script(ctx.graph_json);
289 tmpl.render(context! {
290 title => ctx.title,
291 description => ctx.description,
292 body => ctx.body_html,
293 slug => ctx.slug,
294 tree => ctx.tree,
295 backlinks => ctx.backlinks,
296 headings => ctx.headings,
297 commit => ctx.commit,
298 built => ctx.built,
299 has_history => ctx.has_history,
300 has_mermaid => ctx.has_mermaid,
301 has_base_island => ctx.has_base_island,
302 has_math => ctx.has_math,
303 base => ctx.base,
304 site_title => ctx.site_title,
305 search_enabled => ctx.search_enabled,
306 has_components_css => ctx.has_components_css,
307 has_component_island => ctx.has_component_island,
308 is_home => ctx.is_home,
309 has_diff => ctx.has_diff,
310 graph_json => safe_graph_json,
311 graph_node_count => ctx.graph_node_count,
312 graph_edge_count => ctx.graph_edge_count,
313 home => ctx.home,
314 })
315 }
316
317 pub fn render_graph(&self, ctx: &GraphContext) -> Result<String, minijinja::Error> {
323 let tmpl = self.env.get_template("graph.html")?;
324 let safe_json = escape_json_for_script(ctx.graph_json);
325 tmpl.render(context! {
326 tree => ctx.tree,
327 slug => "",
328 graph_json => safe_json,
329 node_count => ctx.node_count,
330 edge_count => ctx.edge_count,
331 base => ctx.base,
332 site_title => ctx.site_title,
333 search_enabled => ctx.search_enabled,
334 has_diff => ctx.has_diff,
335 })
336 }
337
338 pub fn render_history(&self, ctx: &HistoryContext) -> Result<String, minijinja::Error> {
340 let tmpl = self.env.get_template("history.html")?;
341 tmpl.render(context! {
342 title => ctx.title,
343 slug => ctx.slug,
344 tree => ctx.tree,
345 buckets => ctx.buckets,
346 base => ctx.base,
347 site_title => ctx.site_title,
348 search_enabled => ctx.search_enabled,
349 })
350 }
351
352 pub fn render_preview(&self, ctx: &PreviewContext) -> Result<String, minijinja::Error> {
354 let tmpl = self.env.get_template("preview.html")?;
355 tmpl.render(context! {
356 title => ctx.title,
357 body => ctx.body_html,
358 base => ctx.base,
359 has_mermaid => ctx.has_mermaid,
360 has_math => ctx.has_math,
361 has_components_css => ctx.has_components_css,
362 has_component_island => ctx.has_component_island,
363 })
364 }
365
366 pub fn render_diff(&self, ctx: &DiffContext) -> Result<String, minijinja::Error> {
368 let tmpl = self.env.get_template("diff.html")?;
369 tmpl.render(context! {
370 tree => ctx.tree,
371 slug => "",
372 base => ctx.base,
373 site_title => ctx.site_title,
374 search_enabled => ctx.search_enabled,
375 })
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use docgen_core::model::TreeNode;
383
384 fn renderer() -> Renderer {
385 Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap()
386 }
387
388 #[test]
389 fn renders_title_and_body() {
390 let html = renderer()
391 .render_page(&PageContext {
392 title: "My Page",
393 slug: "my-page",
394 body_html: "<p>hello</p>",
395 tree: &[],
396 backlinks: &[],
397 headings: &[],
398 commit: "",
399 built: "",
400 has_history: false,
401 has_mermaid: false,
402 has_base_island: false,
403 has_math: false,
404 base: "",
405 site_title: "",
406 search_enabled: true,
407 has_components_css: false,
408 has_component_island: false,
409 is_home: false,
410 has_diff: false,
411 graph_json: "",
412 graph_node_count: 0,
413 graph_edge_count: 0,
414 description: "",
415 home: None,
416 })
417 .unwrap();
418 assert!(html.contains("<title>My Page</title>"));
419 assert!(html.contains("<p>hello</p>"));
420 }
421
422 #[test]
423 fn page_has_accessibility_landmarks() {
424 let html = renderer()
425 .render_page(&PageContext {
426 title: "P",
427 slug: "p",
428 body_html: "",
429 tree: &[],
430 backlinks: &[],
431 headings: &[],
432 commit: "",
433 built: "",
434 has_history: false,
435 has_mermaid: false,
436 has_base_island: false,
437 has_math: false,
438 base: "",
439 site_title: "",
440 search_enabled: true,
441 has_components_css: false,
442 has_component_island: false,
443 is_home: false,
444 has_diff: false,
445 graph_json: "",
446 graph_node_count: 0,
447 graph_edge_count: 0,
448 description: "",
449 home: None,
450 })
451 .unwrap();
452 assert!(html.contains(r##"class="docgen-skip-link" href="#docgen-main""##));
454 assert!(html.contains(r#"id="docgen-main""#));
455 assert!(html.contains(r#"tabindex="-1""#));
456 assert!(html.contains(r#"aria-controls="docgen-sidebar""#));
459 assert!(html.contains(r#"aria-controls="docgen-rail""#));
460 assert!(html
461 .contains("@keydown.escape.window=\"navOpen=false; railOpen=false; menuOpen=false\""));
462 assert!(html.contains(":aria-pressed=\"theme==='light'\""));
464 assert!(html.contains(":aria-pressed=\"theme==='dark'\""));
465 }
466
467 #[test]
468 fn component_asset_links_are_gated() {
469 let off = renderer()
470 .render_page(&PageContext {
471 title: "P",
472 slug: "p",
473 body_html: "",
474 tree: &[],
475 backlinks: &[],
476 headings: &[],
477 commit: "",
478 built: "",
479 has_history: false,
480 has_mermaid: false,
481 has_base_island: false,
482 has_math: false,
483 base: "",
484 site_title: "",
485 search_enabled: true,
486 has_components_css: false,
487 has_component_island: false,
488 is_home: false,
489 has_diff: false,
490 graph_json: "",
491 graph_node_count: 0,
492 graph_edge_count: 0,
493 description: "",
494 home: None,
495 })
496 .unwrap();
497 assert!(!off.contains("/components.css"));
498 assert!(!off.contains("/components.js"));
499
500 let on = renderer()
501 .render_page(&PageContext {
502 title: "P",
503 slug: "p",
504 body_html: "",
505 tree: &[],
506 backlinks: &[],
507 headings: &[],
508 commit: "",
509 built: "",
510 has_history: false,
511 has_mermaid: false,
512 has_base_island: false,
513 has_math: false,
514 base: "",
515 site_title: "",
516 search_enabled: true,
517 has_components_css: true,
518 has_component_island: true,
519 is_home: false,
520 has_diff: false,
521 graph_json: "",
522 graph_node_count: 0,
523 graph_edge_count: 0,
524 description: "",
525 home: None,
526 })
527 .unwrap();
528 assert!(on.contains(r#"<link rel="stylesheet" href="/components.css" />"#));
529 assert!(on.contains(r#"<script src="/components.js"></script>"#));
530 }
531
532 #[test]
533 fn page_title_gets_site_suffix_when_configured() {
534 let html = renderer()
535 .render_page(&PageContext {
536 title: "Intro",
537 site_title: "My Docs",
538 search_enabled: true,
539 has_components_css: false,
540 has_component_island: false,
541 is_home: false,
542 has_diff: false,
543 graph_json: "",
544 graph_node_count: 0,
545 graph_edge_count: 0,
546 description: "",
547 home: None,
548 base: "",
549 slug: "x",
550 body_html: "",
551 tree: &[],
552 backlinks: &[],
553 headings: &[],
554 commit: "",
555 built: "",
556 has_history: false,
557 has_mermaid: false,
558 has_base_island: false,
559 has_math: false,
560 })
561 .unwrap();
562 assert!(html.contains("<title>Intro — My Docs</title>"));
563 }
564
565 #[test]
566 fn no_site_title_leaves_plain_title_and_no_base() {
567 let html = renderer()
568 .render_page(&PageContext {
569 title: "Intro",
570 site_title: "",
571 search_enabled: true,
572 has_components_css: false,
573 has_component_island: false,
574 is_home: false,
575 has_diff: false,
576 graph_json: "",
577 graph_node_count: 0,
578 graph_edge_count: 0,
579 description: "",
580 home: None,
581 base: "",
582 slug: "x",
583 body_html: "",
584 tree: &[],
585 backlinks: &[],
586 headings: &[],
587 commit: "",
588 built: "",
589 has_history: false,
590 has_mermaid: false,
591 has_base_island: false,
592 has_math: false,
593 })
594 .unwrap();
595 assert!(html.contains("<title>Intro</title>"));
596 assert!(!html.contains("<base"));
597 }
598
599 #[test]
600 fn search_disabled_hides_search_ui() {
601 let on = renderer()
602 .render_page(&PageContext {
603 title: "X",
604 site_title: "",
605 search_enabled: true,
606 has_components_css: false,
607 has_component_island: false,
608 is_home: false,
609 has_diff: false,
610 graph_json: "",
611 graph_node_count: 0,
612 graph_edge_count: 0,
613 description: "",
614 home: None,
615 base: "",
616 slug: "x",
617 body_html: "",
618 tree: &[],
619 backlinks: &[],
620 headings: &[],
621 commit: "",
622 built: "",
623 has_history: false,
624 has_mermaid: false,
625 has_base_island: false,
626 has_math: false,
627 })
628 .unwrap();
629 assert!(on.contains("data-docgen-search"));
630
631 let off = renderer()
632 .render_page(&PageContext {
633 title: "X",
634 site_title: "",
635 search_enabled: false,
636 has_components_css: false,
637 has_component_island: false,
638 is_home: false,
639 has_diff: false,
640 graph_json: "",
641 graph_node_count: 0,
642 graph_edge_count: 0,
643 description: "",
644 home: None,
645 base: "",
646 slug: "x",
647 body_html: "",
648 tree: &[],
649 backlinks: &[],
650 headings: &[],
651 commit: "",
652 built: "",
653 has_history: false,
654 has_mermaid: false,
655 has_base_island: false,
656 has_math: false,
657 })
658 .unwrap();
659 assert!(!off.contains("data-docgen-search"));
660 assert!(!off.contains("/search.js"));
661 }
662
663 #[test]
664 fn base_prefixes_every_asset_and_nav_link_and_emits_no_base_tag() {
665 let tree = vec![TreeNode::Doc {
668 name: "guide".into(),
669 slug: "guide".into(),
670 title: "Guide".into(),
671 }];
672 let html = renderer()
673 .render_page(&PageContext {
674 title: "X",
675 site_title: "",
676 search_enabled: true,
677 has_components_css: true,
678 has_component_island: false,
679 is_home: false,
680 has_diff: true,
681 graph_json: "",
682 graph_node_count: 0,
683 graph_edge_count: 0,
684 description: "",
685 home: None,
686 base: "/docs",
687 slug: "x",
688 body_html: "",
689 tree: &tree,
690 backlinks: &[],
691 headings: &[],
692 commit: "",
693 built: "",
694 has_history: false,
695 has_mermaid: false,
696 has_base_island: false,
697 has_math: false,
698 })
699 .unwrap();
700 assert!(!html.contains("<base"));
702 assert!(html.contains(r#"href="/docs/docgen.css""#));
704 assert!(html.contains(r#"href="/docs/components.css""#));
705 assert!(html.contains(r#"src="/docs/bootstrap.js""#));
706 assert!(html.contains(r#"src="/docs/search.js""#));
707 assert!(html.contains(r#"href="/docs/guide""#));
710 assert!(html.contains(r#"href="/docs/diff""#));
711 assert!(!html.contains(r#"href="/docgen.css""#));
713 assert!(!html.contains(r#"src="/bootstrap.js""#));
714 }
715
716 #[test]
717 fn renders_sidebar_links() {
718 let tree = vec![TreeNode::Doc {
719 name: "intro".into(),
720 slug: "guide/intro".into(),
721 title: "Intro".into(),
722 }];
723 let html = renderer()
724 .render_page(&PageContext {
725 title: "X",
726 slug: "x",
727 body_html: "",
728 tree: &tree,
729 backlinks: &[],
730 headings: &[],
731 commit: "",
732 built: "",
733 has_history: false,
734 has_mermaid: false,
735 has_base_island: false,
736 has_math: false,
737 base: "",
738 site_title: "",
739 search_enabled: true,
740 has_components_css: false,
741 has_component_island: false,
742 is_home: false,
743 has_diff: false,
744 graph_json: "",
745 graph_node_count: 0,
746 graph_edge_count: 0,
747 description: "",
748 home: None,
749 })
750 .unwrap();
751 assert!(html.contains(r#"href="/guide/intro""#));
752 assert!(html.contains(">Intro</a>"));
753 }
754
755 #[test]
756 fn escapes_title_and_sidebar_text_but_not_body() {
757 let tree = vec![TreeNode::Doc {
758 name: "intro".into(),
759 slug: "guide/intro".into(),
760 title: "A & B <x>".into(),
761 }];
762 let html = renderer()
763 .render_page(&PageContext {
764 title: "Tom & Jerry <script>",
765 slug: "tj",
766 body_html: "<p>raw & ok</p>",
767 tree: &tree,
768 backlinks: &[],
769 headings: &[],
770 commit: "",
771 built: "",
772 has_history: false,
773 has_mermaid: false,
774 has_base_island: false,
775 has_math: false,
776 base: "",
777 site_title: "",
778 search_enabled: true,
779 has_components_css: false,
780 has_component_island: false,
781 is_home: false,
782 has_diff: false,
783 graph_json: "",
784 graph_node_count: 0,
785 graph_edge_count: 0,
786 description: "",
787 home: None,
788 })
789 .unwrap();
790 assert!(html.contains("<title>Tom & Jerry <script></title>"));
792 assert!(!html.contains("<title>Tom & Jerry <script>"));
793 assert!(html.contains("A & B <x>"));
795 assert!(html.contains("<p>raw & ok</p>"));
797 }
798
799 #[test]
800 fn renders_backlinks_section() {
801 use docgen_core::model::Backlink;
802 let backlinks = vec![Backlink {
803 slug: "a".into(),
804 title: "Page A".into(),
805 description: Some("All about A".into()),
806 }];
807 let html = renderer()
808 .render_page(&PageContext {
809 title: "X",
810 slug: "x",
811 body_html: "",
812 tree: &[],
813 backlinks: &backlinks,
814 headings: &[],
815 commit: "",
816 built: "",
817 has_history: false,
818 has_mermaid: false,
819 has_base_island: false,
820 has_math: false,
821 base: "",
822 site_title: "",
823 search_enabled: true,
824 has_components_css: false,
825 has_component_island: false,
826 is_home: false,
827 has_diff: false,
828 graph_json: "",
829 graph_node_count: 0,
830 graph_edge_count: 0,
831 description: "",
832 home: None,
833 })
834 .unwrap();
835 assert!(html.contains("Referenced by"));
837 assert!(html.contains(r#"class="docgen-rail__backlink" href="/a""#));
838 assert!(html.contains("<span>Page A</span>"));
839 assert!(html.contains("<small>All about A</small>"));
840 assert!(!html.contains("docgen-backlinks"));
842 }
843
844 #[test]
845 fn omits_backlinks_section_when_empty() {
846 let html = renderer()
847 .render_page(&PageContext {
848 title: "X",
849 slug: "x",
850 body_html: "",
851 tree: &[],
852 backlinks: &[],
853 headings: &[],
854 commit: "",
855 built: "",
856 has_history: false,
857 has_mermaid: false,
858 has_base_island: false,
859 has_math: false,
860 base: "",
861 site_title: "",
862 search_enabled: true,
863 has_components_css: false,
864 has_component_island: false,
865 is_home: false,
866 has_diff: false,
867 graph_json: "",
868 graph_node_count: 0,
869 graph_edge_count: 0,
870 description: "",
871 home: None,
872 })
873 .unwrap();
874 assert!(!html.contains("Referenced by"));
876 assert!(!html.contains("docgen-rail__backlink"));
877 }
878
879 #[test]
880 fn renders_diff_link_only_when_has_diff() {
881 let with = renderer()
882 .render_page(&PageContext {
883 title: "X",
884 slug: "guide/intro",
885 body_html: "",
886 tree: &[],
887 backlinks: &[],
888 headings: &[],
889 commit: "",
890 built: "",
891 has_history: false,
892 has_mermaid: false,
893 has_base_island: false,
894 has_math: false,
895 base: "",
896 site_title: "",
897 search_enabled: true,
898 has_components_css: false,
899 has_component_island: false,
900 is_home: false,
901 has_diff: true,
902 graph_json: "",
903 graph_node_count: 0,
904 graph_edge_count: 0,
905 description: "",
906 home: None,
907 })
908 .unwrap();
909 assert!(with.contains(r#"href="/diff""#));
910
911 let without = renderer()
912 .render_page(&PageContext {
913 title: "X",
914 slug: "guide/intro",
915 body_html: "",
916 tree: &[],
917 backlinks: &[],
918 headings: &[],
919 commit: "",
920 built: "",
921 has_history: false,
922 has_mermaid: false,
923 has_base_island: false,
924 has_math: false,
925 base: "",
926 site_title: "",
927 search_enabled: true,
928 has_components_css: false,
929 has_component_island: false,
930 is_home: false,
931 has_diff: false,
932 graph_json: "",
933 graph_node_count: 0,
934 graph_edge_count: 0,
935 description: "",
936 home: None,
937 })
938 .unwrap();
939 assert!(!without.contains(r#"href="/diff""#));
940 }
941
942 #[test]
943 fn page_loads_bootstrap_and_alpine_and_gates_mermaid_island() {
944 let html = renderer()
945 .render_page(&PageContext {
946 title: "X",
947 slug: "x",
948 body_html: "",
949 tree: &[],
950 backlinks: &[],
951 headings: &[],
952 commit: "",
953 built: "",
954 has_history: false,
955 has_mermaid: false,
956 has_base_island: false,
957 has_math: false,
958 base: "",
959 site_title: "",
960 search_enabled: true,
961 has_components_css: false,
962 has_component_island: false,
963 is_home: false,
964 has_diff: false,
965 graph_json: "",
966 graph_node_count: 0,
967 graph_edge_count: 0,
968 description: "",
969 home: None,
970 })
971 .unwrap();
972 assert!(html.contains(r#"src="/bootstrap.js""#));
973 assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
974 assert!(!html.contains("islands/mermaid.js")); let withm = renderer()
977 .render_page(&PageContext {
978 title: "X",
979 slug: "x",
980 body_html: "",
981 tree: &[],
982 backlinks: &[],
983 headings: &[],
984 commit: "",
985 built: "",
986 has_history: false,
987 has_mermaid: true,
988 has_base_island: false,
989 has_math: false,
990 base: "",
991 site_title: "",
992 search_enabled: true,
993 has_components_css: false,
994 has_component_island: false,
995 is_home: false,
996 has_diff: false,
997 graph_json: "",
998 graph_node_count: 0,
999 graph_edge_count: 0,
1000 description: "",
1001 home: None,
1002 })
1003 .unwrap();
1004 assert!(withm.contains(r#"src="/islands/mermaid.js""#));
1005 }
1006
1007 #[test]
1008 fn page_gates_bases_island_on_has_base_island() {
1009 let ctx = |has_base_island: bool| PageContext {
1010 title: "X",
1011 slug: "x",
1012 body_html: "",
1013 tree: &[],
1014 backlinks: &[],
1015 headings: &[],
1016 commit: "",
1017 built: "",
1018 has_history: false,
1019 has_mermaid: false,
1020 has_base_island,
1021 has_math: false,
1022 base: "",
1023 site_title: "",
1024 search_enabled: true,
1025 has_components_css: false,
1026 has_component_island: false,
1027 is_home: false,
1028 has_diff: false,
1029 graph_json: "",
1030 graph_node_count: 0,
1031 graph_edge_count: 0,
1032 description: "",
1033 home: None,
1034 };
1035 let off = renderer().render_page(&ctx(false)).unwrap();
1036 assert!(!off.contains("islands/bases.js")); let on = renderer().render_page(&ctx(true)).unwrap();
1038 assert!(on.contains(r#"src="/islands/bases.js""#));
1039 }
1040
1041 #[test]
1042 fn page_links_katex_css_only_when_has_math() {
1043 let no_math = renderer()
1044 .render_page(&PageContext {
1045 title: "X",
1046 slug: "x",
1047 body_html: "",
1048 tree: &[],
1049 backlinks: &[],
1050 headings: &[],
1051 commit: "",
1052 built: "",
1053 has_history: false,
1054 has_mermaid: false,
1055 has_base_island: false,
1056 has_math: false,
1057 base: "",
1058 site_title: "",
1059 search_enabled: true,
1060 has_components_css: false,
1061 has_component_island: false,
1062 is_home: false,
1063 has_diff: false,
1064 graph_json: "",
1065 graph_node_count: 0,
1066 graph_edge_count: 0,
1067 description: "",
1068 home: None,
1069 })
1070 .unwrap();
1071 assert!(!no_math.contains("katex.min.css"));
1072
1073 let with_math = renderer()
1074 .render_page(&PageContext {
1075 title: "X",
1076 slug: "x",
1077 body_html: "",
1078 tree: &[],
1079 backlinks: &[],
1080 headings: &[],
1081 commit: "",
1082 built: "",
1083 has_history: false,
1084 has_mermaid: false,
1085 has_base_island: false,
1086 has_math: true,
1087 base: "",
1088 site_title: "",
1089 search_enabled: true,
1090 has_components_css: false,
1091 has_component_island: false,
1092 is_home: false,
1093 has_diff: false,
1094 graph_json: "",
1095 graph_node_count: 0,
1096 graph_edge_count: 0,
1097 description: "",
1098 home: None,
1099 })
1100 .unwrap();
1101 assert!(with_math.contains(r#"href="/vendor/katex/katex.min.css""#));
1102 }
1103
1104 #[test]
1105 #[allow(deprecated)] fn ships_self_contained_search_assets() {
1107 assert!(SEARCH_JS.contains("search-index.json"));
1108 assert!(SEARCH_JS.contains("metaKey"));
1109 assert!(!SEARCH_JS.contains("import ")); }
1111
1112 #[test]
1115 fn preview_is_content_only_with_real_asset_stack() {
1116 let r = renderer();
1117 let html = r
1118 .render_preview(&PreviewContext {
1119 title: "Intro",
1120 body_html: r#"<h1>Intro</h1><p>See <a class="docgen-wikilink" href="/guide">g</a></p>"#,
1121 base: "",
1122 has_mermaid: false,
1123 has_math: false,
1124 has_components_css: false,
1125 has_component_island: false,
1126 })
1127 .unwrap();
1128 assert!(html.contains(r#"<article class="docgen-doc-content">"#));
1130 assert!(html.contains(r#"href="/guide""#));
1131 assert!(html.contains(r#"href="/docgen.css""#));
1133 assert!(html.contains(r#"href="/code.css""#));
1134 assert!(html.contains(r#"src="/bootstrap.js""#));
1135 assert!(html.contains(r#"src="/islands/wikilink.js""#));
1136 assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
1137 assert!(!html.contains("docgen-topbar"));
1139 assert!(!html.contains("docgen-sidebar"));
1140 assert!(!html.contains("docgen-rail"));
1141 assert!(!html.contains("islands/mermaid.js"));
1143 assert!(!html.contains("components.css"));
1144 assert!(!html.contains("katex.min.css"));
1145 }
1146
1147 #[test]
1148 fn preview_gates_mermaid_math_and_component_assets() {
1149 let r = renderer();
1150 let html = r
1151 .render_preview(&PreviewContext {
1152 title: "D",
1153 body_html: r#"<div class="docgen-mermaid"></div>"#,
1154 base: "",
1155 has_mermaid: true,
1156 has_math: true,
1157 has_components_css: true,
1158 has_component_island: true,
1159 })
1160 .unwrap();
1161 assert!(html.contains(r#"src="/islands/mermaid.js""#));
1162 assert!(html.contains(r#"href="/vendor/katex/katex.min.css""#));
1163 assert!(html.contains(r#"href="/components.css""#));
1164 assert!(html.contains(r#"src="/components.js""#));
1165 }
1166
1167 #[test]
1168 fn preview_prefixes_base() {
1169 let r = renderer();
1170 let html = r
1171 .render_preview(&PreviewContext {
1172 title: "X",
1173 body_html: "<p>x</p>",
1174 base: "/docs",
1175 has_mermaid: true,
1176 has_math: false,
1177 has_components_css: false,
1178 has_component_island: false,
1179 })
1180 .unwrap();
1181 assert!(html.contains(r#"href="/docs/docgen.css""#));
1182 assert!(html.contains(r#"src="/docs/bootstrap.js""#));
1183 assert!(html.contains(r#"src="/docs/islands/mermaid.js""#));
1184 assert!(!html.contains(r#"href="/docgen.css""#));
1185 }
1186
1187 fn sample_buckets() -> Vec<TimelineBucketView> {
1188 vec![TimelineBucketView {
1189 label: "Today".into(),
1190 points: vec![TimelinePointView {
1191 short_hash: "abc1234".into(),
1192 subject: "edit a".into(),
1193 author: Some("docgen test".into()),
1194 date: Some("2026-05-15".into()),
1195 added_lines: 1,
1196 removed_lines: 1,
1197 files: vec![FileView {
1198 path: "docs/a.md".into(),
1199 status: "modified".into(),
1200 hunks: vec![HunkView {
1201 lines: vec![
1202 LineView {
1203 kind: "context".into(),
1204 text: "# A".into(),
1205 old_line: Some(1),
1206 new_line: Some(1),
1207 },
1208 LineView {
1209 kind: "removed".into(),
1210 text: "first".into(),
1211 old_line: Some(2),
1212 new_line: None,
1213 },
1214 LineView {
1215 kind: "added".into(),
1216 text: "second".into(),
1217 old_line: None,
1218 new_line: Some(2),
1219 },
1220 ],
1221 }],
1222 }],
1223 }],
1224 }]
1225 }
1226
1227 #[test]
1230 fn renders_graph_page_with_embedded_json_and_island() {
1231 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1232 let json = r#"{"nodes":[{"slug":"a","title":"A","x":1.0,"y":2.0,"degree":0}],"edges":[]}"#;
1233 let html = r
1234 .render_graph(&GraphContext {
1235 tree: &[],
1236 graph_json: json,
1237 node_count: 1,
1238 has_diff: false,
1239 edge_count: 0,
1240 base: "",
1241 site_title: "",
1242 search_enabled: true,
1243 })
1244 .unwrap();
1245 assert!(html.contains("<title>Graph</title>"));
1246 assert!(html.contains(r#"id="docgen-graph-data""#));
1247 assert!(html.contains(r#"type="application/json""#));
1248 assert!(html.contains(json)); assert!(html.contains(r#"x-data="docgenGraph""#));
1250 assert!(html.contains(r#"src="/islands/graph.js""#));
1251 assert!(html.contains(r#"src="/bootstrap.js""#));
1252 assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
1253 assert!(html.contains("1 nodes")); }
1255
1256 #[test]
1257 fn graph_page_renders_sidebar_tree() {
1258 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1259 let tree = vec![docgen_core::model::TreeNode::Doc {
1260 name: "intro".into(),
1261 slug: "guide/intro".into(),
1262 title: "Intro".into(),
1263 }];
1264 let html = r
1265 .render_graph(&GraphContext {
1266 tree: &tree,
1267 graph_json: r#"{"nodes":[],"edges":[]}"#,
1268 node_count: 0,
1269 has_diff: false,
1270 edge_count: 0,
1271 base: "",
1272 site_title: "",
1273 search_enabled: true,
1274 })
1275 .unwrap();
1276 assert!(html.contains(r#"href="/guide/intro""#));
1277 }
1278
1279 #[test]
1280 fn embedded_json_neutralizes_script_close() {
1281 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1282 let json = r#"{"nodes":[{"slug":"x","title":"a</script>b","x":0.0,"y":0.0,"degree":0}],"edges":[]}"#;
1283 let html = r
1284 .render_graph(&GraphContext {
1285 tree: &[],
1286 graph_json: json,
1287 node_count: 1,
1288 has_diff: false,
1289 edge_count: 0,
1290 base: "",
1291 site_title: "",
1292 search_enabled: true,
1293 })
1294 .unwrap();
1295 assert!(!html.contains("a</script>b")); assert!(html.contains(r#"a\u003c/script>b"#)); }
1298
1299 #[test]
1307 fn embedded_json_neutralizes_script_double_escape_sequence() {
1308 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1309 let json = r#"{"nodes":[{"slug":"x","title":"<!--<script>","x":0.0,"y":0.0,"degree":0}],"edges":[]}"#;
1312 let ctx = GraphContext {
1313 tree: &[],
1314 graph_json: json,
1315 node_count: 1,
1316 has_diff: false,
1317 edge_count: 0,
1318 base: "",
1319 site_title: "",
1320 search_enabled: true,
1321 };
1322 let html = r.render_graph(&ctx).unwrap();
1323 assert!(
1324 !html.contains("<!--<script>"),
1325 "the double-escape sequence must not survive into the payload"
1326 );
1327 assert!(html.contains(r#"\u003c!--\u003cscript>"#));
1328
1329 let page = r
1331 .render_page(&PageContext {
1332 title: "T",
1333 slug: "s",
1334 body_html: "",
1335 tree: &[],
1336 backlinks: &[],
1337 headings: &[],
1338 commit: "",
1339 built: "",
1340 has_history: false,
1341 has_mermaid: false,
1342 has_math: false,
1343 base: "",
1344 site_title: "",
1345 search_enabled: true,
1346 has_components_css: false,
1347 has_component_island: false,
1348 has_base_island: false,
1349 is_home: true,
1350 has_diff: false,
1351 graph_json: json,
1352 graph_node_count: 1,
1353 graph_edge_count: 0,
1354 description: "",
1355 home: None,
1356 })
1357 .unwrap();
1358 assert!(!page.contains("<!--<script>"));
1359 assert!(page.contains(r#"\u003c!--\u003cscript>"#));
1363 }
1364
1365 #[test]
1366 fn graph_page_renders_graph_canvas_without_sidebar_link() {
1367 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1368 let html = r
1369 .render_graph(&GraphContext {
1370 tree: &[],
1371 graph_json: r#"{"nodes":[],"edges":[]}"#,
1372 node_count: 0,
1373 has_diff: false,
1374 edge_count: 0,
1375 base: "",
1376 site_title: "",
1377 search_enabled: true,
1378 })
1379 .unwrap();
1380 assert!(html.contains(r#"x-data="docgenGraph""#));
1382 assert!(html.contains("docgen-graph__svg"));
1383 assert!(!html.contains("docgen-sidebar__graph"));
1385 }
1386
1387 #[test]
1388 fn home_page_embeds_graph_and_non_home_does_not() {
1389 let r = renderer();
1390 let ctx = |is_home: bool, graph_json: &'static str| PageContext {
1391 title: "X",
1392 slug: if is_home { "index" } else { "x" },
1393 body_html: "",
1394 tree: &[],
1395 backlinks: &[],
1396 headings: &[],
1397 commit: "",
1398 built: "",
1399 has_history: false,
1400 has_mermaid: false,
1401 has_base_island: false,
1402 has_math: false,
1403 base: "",
1404 site_title: "",
1405 search_enabled: true,
1406 has_diff: false,
1407 has_components_css: false,
1408 has_component_island: false,
1409 is_home,
1410 graph_json,
1411 graph_node_count: 2,
1412 graph_edge_count: 1,
1413 description: "",
1414 home: None,
1415 };
1416 let home = r
1418 .render_page(&ctx(true, r#"{"nodes":[],"edges":[]}"#))
1419 .unwrap();
1420 assert!(home.contains("docgen-home-graph"));
1421 assert!(home.contains(r#"id="docgen-graph-data""#));
1422 assert!(home.contains(r#"x-data="docgenGraph""#));
1423 assert!(home.contains("islands/graph.js"));
1424 assert!(!home.contains("docgen-sidebar__graph"));
1426 let other = r.render_page(&ctx(false, "")).unwrap();
1428 assert!(!other.contains("docgen-home-graph"));
1429 assert!(!other.contains("islands/graph.js"));
1430 }
1431
1432 #[test]
1433 fn renders_history_timeline_with_buckets_and_diff_lines() {
1434 let buckets = sample_buckets();
1435 let html = renderer()
1436 .render_history(&HistoryContext {
1437 title: "A",
1438 slug: "a",
1439 tree: &[],
1440 buckets: &buckets,
1441 base: "",
1442 site_title: "",
1443 search_enabled: true,
1444 })
1445 .unwrap();
1446 assert!(html.contains("<title>History: A</title>"));
1447 assert!(html.contains("Today"));
1448 assert!(html.contains("edit a"));
1449 assert!(html.contains("abc1234"));
1450 assert!(html.contains("docgen-diff-line--removed"));
1451 assert!(html.contains("docgen-diff-line--added"));
1452 assert!(html.contains("first"));
1453 assert!(html.contains(r#"href="/a""#));
1454 }
1455
1456 #[test]
1457 fn history_escapes_diff_text() {
1458 let buckets = vec![TimelineBucketView {
1459 label: "Today".into(),
1460 points: vec![TimelinePointView {
1461 short_hash: "abc1234".into(),
1462 subject: "edit".into(),
1463 author: None,
1464 date: None,
1465 added_lines: 1,
1466 removed_lines: 0,
1467 files: vec![FileView {
1468 path: "docs/a.md".into(),
1469 status: "modified".into(),
1470 hunks: vec![HunkView {
1471 lines: vec![LineView {
1472 kind: "added".into(),
1473 text: "<script>alert(1)</script>".into(),
1474 old_line: None,
1475 new_line: Some(1),
1476 }],
1477 }],
1478 }],
1479 }],
1480 }];
1481 let html = renderer()
1482 .render_history(&HistoryContext {
1483 title: "A",
1484 slug: "a",
1485 tree: &[],
1486 buckets: &buckets,
1487 base: "",
1488 site_title: "",
1489 search_enabled: true,
1490 })
1491 .unwrap();
1492 assert!(html.contains("<script>alert(1)</script>"));
1493 assert!(!html.contains("<script>alert(1)</script>"));
1494 }
1495
1496 fn page(slug: &str, tree: &[TreeNode]) -> String {
1499 renderer()
1500 .render_page(&PageContext {
1501 title: "X",
1502 slug,
1503 body_html: "<p>hi</p>",
1504 tree,
1505 backlinks: &[],
1506 headings: &[],
1507 commit: "",
1508 built: "",
1509 has_history: false,
1510 has_mermaid: false,
1511 has_base_island: false,
1512 has_math: false,
1513 base: "",
1514 site_title: "Docs",
1515 search_enabled: true,
1516 has_components_css: false,
1517 has_component_island: false,
1518 is_home: false,
1519 has_diff: false,
1520 graph_json: "",
1521 graph_node_count: 0,
1522 graph_edge_count: 0,
1523 description: "",
1524 home: None,
1525 })
1526 .unwrap()
1527 }
1528
1529 #[test]
1530 fn page_has_app_shell() {
1531 let html = page("x", &[]);
1532 for cls in [
1533 "docgen-app",
1534 "docgen-topbar",
1535 "docgen-layout",
1536 "docgen-sidebar",
1537 "docgen-content",
1538 "docgen-doc-content",
1539 ] {
1540 assert!(html.contains(cls), "app shell missing {cls}");
1541 }
1542 }
1543
1544 #[test]
1545 fn page_has_no_flash_script_in_head() {
1546 let html = page("x", &[]);
1547 let script_at = html
1548 .find("localStorage.getItem('doc-theme')")
1549 .expect("no-flash script present");
1550 let css_at = html.find("/docgen.css").expect("docgen.css link present");
1551 assert!(
1552 script_at < css_at,
1553 "no-flash script must precede docgen.css link"
1554 );
1555 assert!(html.contains("prefers-color-scheme"));
1556 assert!(html.contains("'light':'dark'"));
1558 }
1559
1560 #[test]
1561 fn page_has_theme_toggle_island() {
1562 let html = page("x", &[]);
1563 assert!(html.contains(r#"x-data="docgenThemeToggle""#));
1564 assert!(html.contains("/islands/theme-toggle.js"));
1565 assert!(!html.contains(r#"<html lang="en" data-theme="#));
1568 }
1569
1570 #[test]
1571 fn sidebar_marks_active_doc() {
1572 let tree = vec![TreeNode::Doc {
1573 name: "a".into(),
1574 slug: "a".into(),
1575 title: "A".into(),
1576 }];
1577 let active = page("a", &tree);
1578 assert!(active.contains(r#"docgen-tree__item is-active"#));
1579 assert!(active.contains(r#"aria-current="page""#));
1580
1581 let inactive = page("b", &tree);
1582 assert!(!inactive.contains(r#"docgen-tree__item is-active"#));
1583 assert!(!inactive.contains(r#"aria-current="page""#));
1584 }
1585
1586 #[test]
1587 fn sidebar_renders_nested_dir_as_details() {
1588 let tree = vec![TreeNode::Dir {
1589 name: "guide".into(),
1590 slug: None,
1591 children: vec![TreeNode::Doc {
1592 name: "intro".into(),
1593 slug: "guide/intro".into(),
1594 title: "Intro".into(),
1595 }],
1596 }];
1597 let html = page("x", &tree);
1598 assert!(html.contains("<details"));
1599 assert!(html.contains("<summary"));
1600 assert!(html.contains("docgen-tree"));
1601 assert!(html.contains(r#"data-tree-path="/guide""#));
1603 }
1604
1605 #[test]
1606 fn graph_and_history_share_shell() {
1607 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1608 let graph = r
1609 .render_graph(&GraphContext {
1610 tree: &[],
1611 graph_json: r#"{"nodes":[],"edges":[]}"#,
1612 node_count: 0,
1613 has_diff: false,
1614 edge_count: 0,
1615 base: "",
1616 site_title: "",
1617 search_enabled: true,
1618 })
1619 .unwrap();
1620 let hist = r
1621 .render_history(&HistoryContext {
1622 title: "A",
1623 slug: "a",
1624 tree: &[],
1625 buckets: &[],
1626 base: "",
1627 site_title: "",
1628 search_enabled: true,
1629 })
1630 .unwrap();
1631 for html in [&graph, &hist] {
1632 assert!(html.contains("docgen-topbar"));
1633 assert!(html.contains("data-theme"));
1634 assert!(html.contains("/islands/theme-toggle.js"));
1635 assert!(html.contains("localStorage.getItem('doc-theme')"));
1636 }
1637 }
1638}