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_math: bool,
212 pub base: &'a str,
214 pub site_title: &'a str,
216 pub search_enabled: bool,
218 pub has_diff: bool,
220 pub has_components_css: bool,
224 pub has_component_island: bool,
227 pub is_home: bool,
230 pub graph_json: &'a str,
234 pub graph_node_count: usize,
236 pub graph_edge_count: usize,
237 pub home: Option<HomeData<'a>>,
240}
241
242pub struct Renderer {
244 env: Environment<'static>,
245}
246
247impl Renderer {
248 pub fn new(page_template: &str) -> Result<Self, minijinja::Error> {
250 let mut env = Environment::new();
251 env.add_template_owned("page.html", page_template.to_string())?;
255 env.add_template_owned("history.html", DEFAULT_HISTORY_TEMPLATE.to_string())?;
256 env.add_template_owned("graph.html", DEFAULT_GRAPH_TEMPLATE.to_string())?;
257 env.add_template_owned("diff.html", DEFAULT_DIFF_TEMPLATE.to_string())?;
258 env.add_template_owned("preview.html", DEFAULT_PREVIEW_TEMPLATE.to_string())?;
259 Ok(Self { env })
260 }
261
262 pub fn render_page(&self, ctx: &PageContext) -> Result<String, minijinja::Error> {
264 let tmpl = self.env.get_template("page.html")?;
265 let safe_graph_json = ctx.graph_json.replace("</", "<\\/");
269 tmpl.render(context! {
270 title => ctx.title,
271 description => ctx.description,
272 body => ctx.body_html,
273 slug => ctx.slug,
274 tree => ctx.tree,
275 backlinks => ctx.backlinks,
276 headings => ctx.headings,
277 commit => ctx.commit,
278 built => ctx.built,
279 has_history => ctx.has_history,
280 has_mermaid => ctx.has_mermaid,
281 has_math => ctx.has_math,
282 base => ctx.base,
283 site_title => ctx.site_title,
284 search_enabled => ctx.search_enabled,
285 has_components_css => ctx.has_components_css,
286 has_component_island => ctx.has_component_island,
287 is_home => ctx.is_home,
288 has_diff => ctx.has_diff,
289 graph_json => safe_graph_json,
290 graph_node_count => ctx.graph_node_count,
291 graph_edge_count => ctx.graph_edge_count,
292 home => ctx.home,
293 })
294 }
295
296 pub fn render_graph(&self, ctx: &GraphContext) -> Result<String, minijinja::Error> {
303 let tmpl = self.env.get_template("graph.html")?;
304 let safe_json = ctx.graph_json.replace("</", "<\\/");
305 tmpl.render(context! {
306 tree => ctx.tree,
307 slug => "",
308 graph_json => safe_json,
309 node_count => ctx.node_count,
310 edge_count => ctx.edge_count,
311 base => ctx.base,
312 site_title => ctx.site_title,
313 search_enabled => ctx.search_enabled,
314 has_diff => ctx.has_diff,
315 })
316 }
317
318 pub fn render_history(&self, ctx: &HistoryContext) -> Result<String, minijinja::Error> {
320 let tmpl = self.env.get_template("history.html")?;
321 tmpl.render(context! {
322 title => ctx.title,
323 slug => ctx.slug,
324 tree => ctx.tree,
325 buckets => ctx.buckets,
326 base => ctx.base,
327 site_title => ctx.site_title,
328 search_enabled => ctx.search_enabled,
329 })
330 }
331
332 pub fn render_preview(&self, ctx: &PreviewContext) -> Result<String, minijinja::Error> {
334 let tmpl = self.env.get_template("preview.html")?;
335 tmpl.render(context! {
336 title => ctx.title,
337 body => ctx.body_html,
338 base => ctx.base,
339 has_mermaid => ctx.has_mermaid,
340 has_math => ctx.has_math,
341 has_components_css => ctx.has_components_css,
342 has_component_island => ctx.has_component_island,
343 })
344 }
345
346 pub fn render_diff(&self, ctx: &DiffContext) -> Result<String, minijinja::Error> {
348 let tmpl = self.env.get_template("diff.html")?;
349 tmpl.render(context! {
350 tree => ctx.tree,
351 slug => "",
352 base => ctx.base,
353 site_title => ctx.site_title,
354 search_enabled => ctx.search_enabled,
355 })
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use docgen_core::model::TreeNode;
363
364 fn renderer() -> Renderer {
365 Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap()
366 }
367
368 #[test]
369 fn renders_title_and_body() {
370 let html = renderer()
371 .render_page(&PageContext {
372 title: "My Page",
373 slug: "my-page",
374 body_html: "<p>hello</p>",
375 tree: &[],
376 backlinks: &[],
377 headings: &[],
378 commit: "",
379 built: "",
380 has_history: false,
381 has_mermaid: false,
382 has_math: false,
383 base: "",
384 site_title: "",
385 search_enabled: true,
386 has_components_css: false,
387 has_component_island: false,
388 is_home: false,
389 has_diff: false,
390 graph_json: "",
391 graph_node_count: 0,
392 graph_edge_count: 0,
393 description: "",
394 home: None,
395 })
396 .unwrap();
397 assert!(html.contains("<title>My Page</title>"));
398 assert!(html.contains("<p>hello</p>"));
399 }
400
401 #[test]
402 fn page_has_accessibility_landmarks() {
403 let html = renderer()
404 .render_page(&PageContext {
405 title: "P",
406 slug: "p",
407 body_html: "",
408 tree: &[],
409 backlinks: &[],
410 headings: &[],
411 commit: "",
412 built: "",
413 has_history: false,
414 has_mermaid: false,
415 has_math: false,
416 base: "",
417 site_title: "",
418 search_enabled: true,
419 has_components_css: false,
420 has_component_island: false,
421 is_home: false,
422 has_diff: false,
423 graph_json: "",
424 graph_node_count: 0,
425 graph_edge_count: 0,
426 description: "",
427 home: None,
428 })
429 .unwrap();
430 assert!(html.contains(r##"class="docgen-skip-link" href="#docgen-main""##));
432 assert!(html.contains(r#"id="docgen-main""#));
433 assert!(html.contains(r#"tabindex="-1""#));
434 assert!(html.contains(r#"aria-controls="docgen-sidebar""#));
436 assert!(html.contains("@keydown.escape.window=\"navOpen=false\""));
437 assert!(html.contains(":aria-pressed=\"theme==='light'\""));
439 assert!(html.contains(":aria-pressed=\"theme==='dark'\""));
440 }
441
442 #[test]
443 fn component_asset_links_are_gated() {
444 let off = renderer()
445 .render_page(&PageContext {
446 title: "P",
447 slug: "p",
448 body_html: "",
449 tree: &[],
450 backlinks: &[],
451 headings: &[],
452 commit: "",
453 built: "",
454 has_history: false,
455 has_mermaid: false,
456 has_math: false,
457 base: "",
458 site_title: "",
459 search_enabled: true,
460 has_components_css: false,
461 has_component_island: false,
462 is_home: false,
463 has_diff: false,
464 graph_json: "",
465 graph_node_count: 0,
466 graph_edge_count: 0,
467 description: "",
468 home: None,
469 })
470 .unwrap();
471 assert!(!off.contains("/components.css"));
472 assert!(!off.contains("/components.js"));
473
474 let on = renderer()
475 .render_page(&PageContext {
476 title: "P",
477 slug: "p",
478 body_html: "",
479 tree: &[],
480 backlinks: &[],
481 headings: &[],
482 commit: "",
483 built: "",
484 has_history: false,
485 has_mermaid: false,
486 has_math: false,
487 base: "",
488 site_title: "",
489 search_enabled: true,
490 has_components_css: true,
491 has_component_island: true,
492 is_home: false,
493 has_diff: false,
494 graph_json: "",
495 graph_node_count: 0,
496 graph_edge_count: 0,
497 description: "",
498 home: None,
499 })
500 .unwrap();
501 assert!(on.contains(r#"<link rel="stylesheet" href="/components.css" />"#));
502 assert!(on.contains(r#"<script src="/components.js"></script>"#));
503 }
504
505 #[test]
506 fn page_title_gets_site_suffix_when_configured() {
507 let html = renderer()
508 .render_page(&PageContext {
509 title: "Intro",
510 site_title: "My Docs",
511 search_enabled: true,
512 has_components_css: false,
513 has_component_island: false,
514 is_home: false,
515 has_diff: false,
516 graph_json: "",
517 graph_node_count: 0,
518 graph_edge_count: 0,
519 description: "",
520 home: None,
521 base: "",
522 slug: "x",
523 body_html: "",
524 tree: &[],
525 backlinks: &[],
526 headings: &[],
527 commit: "",
528 built: "",
529 has_history: false,
530 has_mermaid: false,
531 has_math: false,
532 })
533 .unwrap();
534 assert!(html.contains("<title>Intro — My Docs</title>"));
535 }
536
537 #[test]
538 fn no_site_title_leaves_plain_title_and_no_base() {
539 let html = renderer()
540 .render_page(&PageContext {
541 title: "Intro",
542 site_title: "",
543 search_enabled: true,
544 has_components_css: false,
545 has_component_island: false,
546 is_home: false,
547 has_diff: false,
548 graph_json: "",
549 graph_node_count: 0,
550 graph_edge_count: 0,
551 description: "",
552 home: None,
553 base: "",
554 slug: "x",
555 body_html: "",
556 tree: &[],
557 backlinks: &[],
558 headings: &[],
559 commit: "",
560 built: "",
561 has_history: false,
562 has_mermaid: false,
563 has_math: false,
564 })
565 .unwrap();
566 assert!(html.contains("<title>Intro</title>"));
567 assert!(!html.contains("<base"));
568 }
569
570 #[test]
571 fn search_disabled_hides_search_ui() {
572 let on = renderer()
573 .render_page(&PageContext {
574 title: "X",
575 site_title: "",
576 search_enabled: true,
577 has_components_css: false,
578 has_component_island: false,
579 is_home: false,
580 has_diff: false,
581 graph_json: "",
582 graph_node_count: 0,
583 graph_edge_count: 0,
584 description: "",
585 home: None,
586 base: "",
587 slug: "x",
588 body_html: "",
589 tree: &[],
590 backlinks: &[],
591 headings: &[],
592 commit: "",
593 built: "",
594 has_history: false,
595 has_mermaid: false,
596 has_math: false,
597 })
598 .unwrap();
599 assert!(on.contains("data-docgen-search"));
600
601 let off = renderer()
602 .render_page(&PageContext {
603 title: "X",
604 site_title: "",
605 search_enabled: false,
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_math: false,
626 })
627 .unwrap();
628 assert!(!off.contains("data-docgen-search"));
629 assert!(!off.contains("/search.js"));
630 }
631
632 #[test]
633 fn base_prefixes_every_asset_and_nav_link_and_emits_no_base_tag() {
634 let tree = vec![TreeNode::Doc {
637 name: "guide".into(),
638 slug: "guide".into(),
639 title: "Guide".into(),
640 }];
641 let html = renderer()
642 .render_page(&PageContext {
643 title: "X",
644 site_title: "",
645 search_enabled: true,
646 has_components_css: true,
647 has_component_island: false,
648 is_home: false,
649 has_diff: true,
650 graph_json: "",
651 graph_node_count: 0,
652 graph_edge_count: 0,
653 description: "",
654 home: None,
655 base: "/docs",
656 slug: "x",
657 body_html: "",
658 tree: &tree,
659 backlinks: &[],
660 headings: &[],
661 commit: "",
662 built: "",
663 has_history: false,
664 has_mermaid: false,
665 has_math: false,
666 })
667 .unwrap();
668 assert!(!html.contains("<base"));
670 assert!(html.contains(r#"href="/docs/docgen.css""#));
672 assert!(html.contains(r#"href="/docs/components.css""#));
673 assert!(html.contains(r#"src="/docs/bootstrap.js""#));
674 assert!(html.contains(r#"src="/docs/search.js""#));
675 assert!(html.contains(r#"href="/docs/guide""#));
678 assert!(html.contains(r#"href="/docs/diff""#));
679 assert!(!html.contains(r#"href="/docgen.css""#));
681 assert!(!html.contains(r#"src="/bootstrap.js""#));
682 }
683
684 #[test]
685 fn renders_sidebar_links() {
686 let tree = vec![TreeNode::Doc {
687 name: "intro".into(),
688 slug: "guide/intro".into(),
689 title: "Intro".into(),
690 }];
691 let html = renderer()
692 .render_page(&PageContext {
693 title: "X",
694 slug: "x",
695 body_html: "",
696 tree: &tree,
697 backlinks: &[],
698 headings: &[],
699 commit: "",
700 built: "",
701 has_history: false,
702 has_mermaid: false,
703 has_math: false,
704 base: "",
705 site_title: "",
706 search_enabled: true,
707 has_components_css: false,
708 has_component_island: false,
709 is_home: false,
710 has_diff: false,
711 graph_json: "",
712 graph_node_count: 0,
713 graph_edge_count: 0,
714 description: "",
715 home: None,
716 })
717 .unwrap();
718 assert!(html.contains(r#"href="/guide/intro""#));
719 assert!(html.contains(">Intro</a>"));
720 }
721
722 #[test]
723 fn escapes_title_and_sidebar_text_but_not_body() {
724 let tree = vec![TreeNode::Doc {
725 name: "intro".into(),
726 slug: "guide/intro".into(),
727 title: "A & B <x>".into(),
728 }];
729 let html = renderer()
730 .render_page(&PageContext {
731 title: "Tom & Jerry <script>",
732 slug: "tj",
733 body_html: "<p>raw & ok</p>",
734 tree: &tree,
735 backlinks: &[],
736 headings: &[],
737 commit: "",
738 built: "",
739 has_history: false,
740 has_mermaid: false,
741 has_math: false,
742 base: "",
743 site_title: "",
744 search_enabled: true,
745 has_components_css: false,
746 has_component_island: false,
747 is_home: false,
748 has_diff: false,
749 graph_json: "",
750 graph_node_count: 0,
751 graph_edge_count: 0,
752 description: "",
753 home: None,
754 })
755 .unwrap();
756 assert!(html.contains("<title>Tom & Jerry <script></title>"));
758 assert!(!html.contains("<title>Tom & Jerry <script>"));
759 assert!(html.contains("A & B <x>"));
761 assert!(html.contains("<p>raw & ok</p>"));
763 }
764
765 #[test]
766 fn renders_backlinks_section() {
767 use docgen_core::model::Backlink;
768 let backlinks = vec![Backlink {
769 slug: "a".into(),
770 title: "Page A".into(),
771 description: Some("All about A".into()),
772 }];
773 let html = renderer()
774 .render_page(&PageContext {
775 title: "X",
776 slug: "x",
777 body_html: "",
778 tree: &[],
779 backlinks: &backlinks,
780 headings: &[],
781 commit: "",
782 built: "",
783 has_history: false,
784 has_mermaid: false,
785 has_math: false,
786 base: "",
787 site_title: "",
788 search_enabled: true,
789 has_components_css: false,
790 has_component_island: false,
791 is_home: false,
792 has_diff: false,
793 graph_json: "",
794 graph_node_count: 0,
795 graph_edge_count: 0,
796 description: "",
797 home: None,
798 })
799 .unwrap();
800 assert!(html.contains("Referenced by"));
802 assert!(html.contains(r#"class="docgen-rail__backlink" href="/a""#));
803 assert!(html.contains("<span>Page A</span>"));
804 assert!(html.contains("<small>All about A</small>"));
805 assert!(!html.contains("docgen-backlinks"));
807 }
808
809 #[test]
810 fn omits_backlinks_section_when_empty() {
811 let html = renderer()
812 .render_page(&PageContext {
813 title: "X",
814 slug: "x",
815 body_html: "",
816 tree: &[],
817 backlinks: &[],
818 headings: &[],
819 commit: "",
820 built: "",
821 has_history: false,
822 has_mermaid: false,
823 has_math: false,
824 base: "",
825 site_title: "",
826 search_enabled: true,
827 has_components_css: false,
828 has_component_island: false,
829 is_home: false,
830 has_diff: false,
831 graph_json: "",
832 graph_node_count: 0,
833 graph_edge_count: 0,
834 description: "",
835 home: None,
836 })
837 .unwrap();
838 assert!(!html.contains("Referenced by"));
840 assert!(!html.contains("docgen-rail__backlink"));
841 }
842
843 #[test]
844 fn renders_diff_link_only_when_has_diff() {
845 let with = renderer()
846 .render_page(&PageContext {
847 title: "X",
848 slug: "guide/intro",
849 body_html: "",
850 tree: &[],
851 backlinks: &[],
852 headings: &[],
853 commit: "",
854 built: "",
855 has_history: false,
856 has_mermaid: false,
857 has_math: false,
858 base: "",
859 site_title: "",
860 search_enabled: true,
861 has_components_css: false,
862 has_component_island: false,
863 is_home: false,
864 has_diff: true,
865 graph_json: "",
866 graph_node_count: 0,
867 graph_edge_count: 0,
868 description: "",
869 home: None,
870 })
871 .unwrap();
872 assert!(with.contains(r#"href="/diff""#));
873
874 let without = renderer()
875 .render_page(&PageContext {
876 title: "X",
877 slug: "guide/intro",
878 body_html: "",
879 tree: &[],
880 backlinks: &[],
881 headings: &[],
882 commit: "",
883 built: "",
884 has_history: false,
885 has_mermaid: false,
886 has_math: false,
887 base: "",
888 site_title: "",
889 search_enabled: true,
890 has_components_css: false,
891 has_component_island: false,
892 is_home: false,
893 has_diff: false,
894 graph_json: "",
895 graph_node_count: 0,
896 graph_edge_count: 0,
897 description: "",
898 home: None,
899 })
900 .unwrap();
901 assert!(!without.contains(r#"href="/diff""#));
902 }
903
904 #[test]
905 fn page_loads_bootstrap_and_alpine_and_gates_mermaid_island() {
906 let html = renderer()
907 .render_page(&PageContext {
908 title: "X",
909 slug: "x",
910 body_html: "",
911 tree: &[],
912 backlinks: &[],
913 headings: &[],
914 commit: "",
915 built: "",
916 has_history: false,
917 has_mermaid: false,
918 has_math: false,
919 base: "",
920 site_title: "",
921 search_enabled: true,
922 has_components_css: false,
923 has_component_island: false,
924 is_home: false,
925 has_diff: false,
926 graph_json: "",
927 graph_node_count: 0,
928 graph_edge_count: 0,
929 description: "",
930 home: None,
931 })
932 .unwrap();
933 assert!(html.contains(r#"src="/bootstrap.js""#));
934 assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
935 assert!(!html.contains("islands/mermaid.js")); let withm = renderer()
938 .render_page(&PageContext {
939 title: "X",
940 slug: "x",
941 body_html: "",
942 tree: &[],
943 backlinks: &[],
944 headings: &[],
945 commit: "",
946 built: "",
947 has_history: false,
948 has_mermaid: true,
949 has_math: false,
950 base: "",
951 site_title: "",
952 search_enabled: true,
953 has_components_css: false,
954 has_component_island: false,
955 is_home: false,
956 has_diff: false,
957 graph_json: "",
958 graph_node_count: 0,
959 graph_edge_count: 0,
960 description: "",
961 home: None,
962 })
963 .unwrap();
964 assert!(withm.contains(r#"src="/islands/mermaid.js""#));
965 }
966
967 #[test]
968 fn page_links_katex_css_only_when_has_math() {
969 let no_math = renderer()
970 .render_page(&PageContext {
971 title: "X",
972 slug: "x",
973 body_html: "",
974 tree: &[],
975 backlinks: &[],
976 headings: &[],
977 commit: "",
978 built: "",
979 has_history: false,
980 has_mermaid: false,
981 has_math: false,
982 base: "",
983 site_title: "",
984 search_enabled: true,
985 has_components_css: false,
986 has_component_island: false,
987 is_home: false,
988 has_diff: false,
989 graph_json: "",
990 graph_node_count: 0,
991 graph_edge_count: 0,
992 description: "",
993 home: None,
994 })
995 .unwrap();
996 assert!(!no_math.contains("katex.min.css"));
997
998 let with_math = renderer()
999 .render_page(&PageContext {
1000 title: "X",
1001 slug: "x",
1002 body_html: "",
1003 tree: &[],
1004 backlinks: &[],
1005 headings: &[],
1006 commit: "",
1007 built: "",
1008 has_history: false,
1009 has_mermaid: false,
1010 has_math: true,
1011 base: "",
1012 site_title: "",
1013 search_enabled: true,
1014 has_components_css: false,
1015 has_component_island: false,
1016 is_home: false,
1017 has_diff: false,
1018 graph_json: "",
1019 graph_node_count: 0,
1020 graph_edge_count: 0,
1021 description: "",
1022 home: None,
1023 })
1024 .unwrap();
1025 assert!(with_math.contains(r#"href="/vendor/katex/katex.min.css""#));
1026 }
1027
1028 #[test]
1029 #[allow(deprecated)] fn ships_self_contained_search_assets() {
1031 assert!(SEARCH_JS.contains("search-index.json"));
1032 assert!(SEARCH_JS.contains("metaKey"));
1033 assert!(!SEARCH_JS.contains("import ")); }
1035
1036 #[test]
1039 fn preview_is_content_only_with_real_asset_stack() {
1040 let r = renderer();
1041 let html = r
1042 .render_preview(&PreviewContext {
1043 title: "Intro",
1044 body_html: r#"<h1>Intro</h1><p>See <a class="docgen-wikilink" href="/guide">g</a></p>"#,
1045 base: "",
1046 has_mermaid: false,
1047 has_math: false,
1048 has_components_css: false,
1049 has_component_island: false,
1050 })
1051 .unwrap();
1052 assert!(html.contains(r#"<article class="docgen-doc-content">"#));
1054 assert!(html.contains(r#"href="/guide""#));
1055 assert!(html.contains(r#"href="/docgen.css""#));
1057 assert!(html.contains(r#"href="/code.css""#));
1058 assert!(html.contains(r#"src="/bootstrap.js""#));
1059 assert!(html.contains(r#"src="/islands/wikilink.js""#));
1060 assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
1061 assert!(!html.contains("docgen-topbar"));
1063 assert!(!html.contains("docgen-sidebar"));
1064 assert!(!html.contains("docgen-rail"));
1065 assert!(!html.contains("islands/mermaid.js"));
1067 assert!(!html.contains("components.css"));
1068 assert!(!html.contains("katex.min.css"));
1069 }
1070
1071 #[test]
1072 fn preview_gates_mermaid_math_and_component_assets() {
1073 let r = renderer();
1074 let html = r
1075 .render_preview(&PreviewContext {
1076 title: "D",
1077 body_html: r#"<div class="docgen-mermaid"></div>"#,
1078 base: "",
1079 has_mermaid: true,
1080 has_math: true,
1081 has_components_css: true,
1082 has_component_island: true,
1083 })
1084 .unwrap();
1085 assert!(html.contains(r#"src="/islands/mermaid.js""#));
1086 assert!(html.contains(r#"href="/vendor/katex/katex.min.css""#));
1087 assert!(html.contains(r#"href="/components.css""#));
1088 assert!(html.contains(r#"src="/components.js""#));
1089 }
1090
1091 #[test]
1092 fn preview_prefixes_base() {
1093 let r = renderer();
1094 let html = r
1095 .render_preview(&PreviewContext {
1096 title: "X",
1097 body_html: "<p>x</p>",
1098 base: "/docs",
1099 has_mermaid: true,
1100 has_math: false,
1101 has_components_css: false,
1102 has_component_island: false,
1103 })
1104 .unwrap();
1105 assert!(html.contains(r#"href="/docs/docgen.css""#));
1106 assert!(html.contains(r#"src="/docs/bootstrap.js""#));
1107 assert!(html.contains(r#"src="/docs/islands/mermaid.js""#));
1108 assert!(!html.contains(r#"href="/docgen.css""#));
1109 }
1110
1111 fn sample_buckets() -> Vec<TimelineBucketView> {
1112 vec![TimelineBucketView {
1113 label: "Today".into(),
1114 points: vec![TimelinePointView {
1115 short_hash: "abc1234".into(),
1116 subject: "edit a".into(),
1117 author: Some("docgen test".into()),
1118 date: Some("2026-05-15".into()),
1119 added_lines: 1,
1120 removed_lines: 1,
1121 files: vec![FileView {
1122 path: "docs/a.md".into(),
1123 status: "modified".into(),
1124 hunks: vec![HunkView {
1125 lines: vec![
1126 LineView {
1127 kind: "context".into(),
1128 text: "# A".into(),
1129 old_line: Some(1),
1130 new_line: Some(1),
1131 },
1132 LineView {
1133 kind: "removed".into(),
1134 text: "first".into(),
1135 old_line: Some(2),
1136 new_line: None,
1137 },
1138 LineView {
1139 kind: "added".into(),
1140 text: "second".into(),
1141 old_line: None,
1142 new_line: Some(2),
1143 },
1144 ],
1145 }],
1146 }],
1147 }],
1148 }]
1149 }
1150
1151 #[test]
1154 fn renders_graph_page_with_embedded_json_and_island() {
1155 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1156 let json = r#"{"nodes":[{"slug":"a","title":"A","x":1.0,"y":2.0,"degree":0}],"edges":[]}"#;
1157 let html = r
1158 .render_graph(&GraphContext {
1159 tree: &[],
1160 graph_json: json,
1161 node_count: 1,
1162 has_diff: false,
1163 edge_count: 0,
1164 base: "",
1165 site_title: "",
1166 search_enabled: true,
1167 })
1168 .unwrap();
1169 assert!(html.contains("<title>Graph</title>"));
1170 assert!(html.contains(r#"id="docgen-graph-data""#));
1171 assert!(html.contains(r#"type="application/json""#));
1172 assert!(html.contains(json)); assert!(html.contains(r#"x-data="docgenGraph""#));
1174 assert!(html.contains(r#"src="/islands/graph.js""#));
1175 assert!(html.contains(r#"src="/bootstrap.js""#));
1176 assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
1177 assert!(html.contains("1 nodes")); }
1179
1180 #[test]
1181 fn graph_page_renders_sidebar_tree() {
1182 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1183 let tree = vec![docgen_core::model::TreeNode::Doc {
1184 name: "intro".into(),
1185 slug: "guide/intro".into(),
1186 title: "Intro".into(),
1187 }];
1188 let html = r
1189 .render_graph(&GraphContext {
1190 tree: &tree,
1191 graph_json: r#"{"nodes":[],"edges":[]}"#,
1192 node_count: 0,
1193 has_diff: false,
1194 edge_count: 0,
1195 base: "",
1196 site_title: "",
1197 search_enabled: true,
1198 })
1199 .unwrap();
1200 assert!(html.contains(r#"href="/guide/intro""#));
1201 }
1202
1203 #[test]
1204 fn embedded_json_neutralizes_script_close() {
1205 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1206 let json = r#"{"nodes":[{"slug":"x","title":"a</script>b","x":0.0,"y":0.0,"degree":0}],"edges":[]}"#;
1207 let html = r
1208 .render_graph(&GraphContext {
1209 tree: &[],
1210 graph_json: json,
1211 node_count: 1,
1212 has_diff: false,
1213 edge_count: 0,
1214 base: "",
1215 site_title: "",
1216 search_enabled: true,
1217 })
1218 .unwrap();
1219 assert!(!html.contains("a</script>b")); assert!(html.contains(r#"a<\/script>b"#)); }
1222
1223 #[test]
1224 fn graph_page_renders_graph_canvas_without_sidebar_link() {
1225 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1226 let html = r
1227 .render_graph(&GraphContext {
1228 tree: &[],
1229 graph_json: r#"{"nodes":[],"edges":[]}"#,
1230 node_count: 0,
1231 has_diff: false,
1232 edge_count: 0,
1233 base: "",
1234 site_title: "",
1235 search_enabled: true,
1236 })
1237 .unwrap();
1238 assert!(html.contains(r#"x-data="docgenGraph""#));
1240 assert!(html.contains("docgen-graph__svg"));
1241 assert!(!html.contains("docgen-sidebar__graph"));
1243 }
1244
1245 #[test]
1246 fn home_page_embeds_graph_and_non_home_does_not() {
1247 let r = renderer();
1248 let ctx = |is_home: bool, graph_json: &'static str| PageContext {
1249 title: "X",
1250 slug: if is_home { "index" } else { "x" },
1251 body_html: "",
1252 tree: &[],
1253 backlinks: &[],
1254 headings: &[],
1255 commit: "",
1256 built: "",
1257 has_history: false,
1258 has_mermaid: false,
1259 has_math: false,
1260 base: "",
1261 site_title: "",
1262 search_enabled: true,
1263 has_diff: false,
1264 has_components_css: false,
1265 has_component_island: false,
1266 is_home,
1267 graph_json,
1268 graph_node_count: 2,
1269 graph_edge_count: 1,
1270 description: "",
1271 home: None,
1272 };
1273 let home = r
1275 .render_page(&ctx(true, r#"{"nodes":[],"edges":[]}"#))
1276 .unwrap();
1277 assert!(home.contains("docgen-home-graph"));
1278 assert!(home.contains(r#"id="docgen-graph-data""#));
1279 assert!(home.contains(r#"x-data="docgenGraph""#));
1280 assert!(home.contains("islands/graph.js"));
1281 assert!(!home.contains("docgen-sidebar__graph"));
1283 let other = r.render_page(&ctx(false, "")).unwrap();
1285 assert!(!other.contains("docgen-home-graph"));
1286 assert!(!other.contains("islands/graph.js"));
1287 }
1288
1289 #[test]
1290 fn renders_history_timeline_with_buckets_and_diff_lines() {
1291 let buckets = sample_buckets();
1292 let html = renderer()
1293 .render_history(&HistoryContext {
1294 title: "A",
1295 slug: "a",
1296 tree: &[],
1297 buckets: &buckets,
1298 base: "",
1299 site_title: "",
1300 search_enabled: true,
1301 })
1302 .unwrap();
1303 assert!(html.contains("<title>History: A</title>"));
1304 assert!(html.contains("Today"));
1305 assert!(html.contains("edit a"));
1306 assert!(html.contains("abc1234"));
1307 assert!(html.contains("docgen-diff-line--removed"));
1308 assert!(html.contains("docgen-diff-line--added"));
1309 assert!(html.contains("first"));
1310 assert!(html.contains(r#"href="/a""#));
1311 }
1312
1313 #[test]
1314 fn history_escapes_diff_text() {
1315 let buckets = vec![TimelineBucketView {
1316 label: "Today".into(),
1317 points: vec![TimelinePointView {
1318 short_hash: "abc1234".into(),
1319 subject: "edit".into(),
1320 author: None,
1321 date: None,
1322 added_lines: 1,
1323 removed_lines: 0,
1324 files: vec![FileView {
1325 path: "docs/a.md".into(),
1326 status: "modified".into(),
1327 hunks: vec![HunkView {
1328 lines: vec![LineView {
1329 kind: "added".into(),
1330 text: "<script>alert(1)</script>".into(),
1331 old_line: None,
1332 new_line: Some(1),
1333 }],
1334 }],
1335 }],
1336 }],
1337 }];
1338 let html = renderer()
1339 .render_history(&HistoryContext {
1340 title: "A",
1341 slug: "a",
1342 tree: &[],
1343 buckets: &buckets,
1344 base: "",
1345 site_title: "",
1346 search_enabled: true,
1347 })
1348 .unwrap();
1349 assert!(html.contains("<script>alert(1)</script>"));
1350 assert!(!html.contains("<script>alert(1)</script>"));
1351 }
1352
1353 fn page(slug: &str, tree: &[TreeNode]) -> String {
1356 renderer()
1357 .render_page(&PageContext {
1358 title: "X",
1359 slug,
1360 body_html: "<p>hi</p>",
1361 tree,
1362 backlinks: &[],
1363 headings: &[],
1364 commit: "",
1365 built: "",
1366 has_history: false,
1367 has_mermaid: false,
1368 has_math: false,
1369 base: "",
1370 site_title: "Docs",
1371 search_enabled: true,
1372 has_components_css: false,
1373 has_component_island: false,
1374 is_home: false,
1375 has_diff: false,
1376 graph_json: "",
1377 graph_node_count: 0,
1378 graph_edge_count: 0,
1379 description: "",
1380 home: None,
1381 })
1382 .unwrap()
1383 }
1384
1385 #[test]
1386 fn page_has_app_shell() {
1387 let html = page("x", &[]);
1388 for cls in [
1389 "docgen-app",
1390 "docgen-topbar",
1391 "docgen-layout",
1392 "docgen-sidebar",
1393 "docgen-content",
1394 "docgen-doc-content",
1395 ] {
1396 assert!(html.contains(cls), "app shell missing {cls}");
1397 }
1398 }
1399
1400 #[test]
1401 fn page_has_no_flash_script_in_head() {
1402 let html = page("x", &[]);
1403 let script_at = html
1404 .find("localStorage.getItem('doc-theme')")
1405 .expect("no-flash script present");
1406 let css_at = html.find("/docgen.css").expect("docgen.css link present");
1407 assert!(
1408 script_at < css_at,
1409 "no-flash script must precede docgen.css link"
1410 );
1411 assert!(html.contains("prefers-color-scheme"));
1412 assert!(html.contains("'light':'dark'"));
1414 }
1415
1416 #[test]
1417 fn page_has_theme_toggle_island() {
1418 let html = page("x", &[]);
1419 assert!(html.contains(r#"x-data="docgenThemeToggle""#));
1420 assert!(html.contains("/islands/theme-toggle.js"));
1421 assert!(!html.contains(r#"<html lang="en" data-theme="#));
1424 }
1425
1426 #[test]
1427 fn sidebar_marks_active_doc() {
1428 let tree = vec![TreeNode::Doc {
1429 name: "a".into(),
1430 slug: "a".into(),
1431 title: "A".into(),
1432 }];
1433 let active = page("a", &tree);
1434 assert!(active.contains(r#"docgen-tree__item is-active"#));
1435 assert!(active.contains(r#"aria-current="page""#));
1436
1437 let inactive = page("b", &tree);
1438 assert!(!inactive.contains(r#"docgen-tree__item is-active"#));
1439 assert!(!inactive.contains(r#"aria-current="page""#));
1440 }
1441
1442 #[test]
1443 fn sidebar_renders_nested_dir_as_details() {
1444 let tree = vec![TreeNode::Dir {
1445 name: "guide".into(),
1446 slug: None,
1447 children: vec![TreeNode::Doc {
1448 name: "intro".into(),
1449 slug: "guide/intro".into(),
1450 title: "Intro".into(),
1451 }],
1452 }];
1453 let html = page("x", &tree);
1454 assert!(html.contains("<details"));
1455 assert!(html.contains("<summary"));
1456 assert!(html.contains("docgen-tree"));
1457 assert!(html.contains(r#"data-tree-path="/guide""#));
1459 }
1460
1461 #[test]
1462 fn graph_and_history_share_shell() {
1463 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1464 let graph = r
1465 .render_graph(&GraphContext {
1466 tree: &[],
1467 graph_json: r#"{"nodes":[],"edges":[]}"#,
1468 node_count: 0,
1469 has_diff: false,
1470 edge_count: 0,
1471 base: "",
1472 site_title: "",
1473 search_enabled: true,
1474 })
1475 .unwrap();
1476 let hist = r
1477 .render_history(&HistoryContext {
1478 title: "A",
1479 slug: "a",
1480 tree: &[],
1481 buckets: &[],
1482 base: "",
1483 site_title: "",
1484 search_enabled: true,
1485 })
1486 .unwrap();
1487 for html in [&graph, &hist] {
1488 assert!(html.contains("docgen-topbar"));
1489 assert!(html.contains("data-theme"));
1490 assert!(html.contains("/islands/theme-toggle.js"));
1491 assert!(html.contains("localStorage.getItem('doc-theme')"));
1492 }
1493 }
1494}