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""#));
437 assert!(html.contains(r#"aria-controls="docgen-rail""#));
438 assert!(html
439 .contains("@keydown.escape.window=\"navOpen=false; railOpen=false; menuOpen=false\""));
440 assert!(html.contains(":aria-pressed=\"theme==='light'\""));
442 assert!(html.contains(":aria-pressed=\"theme==='dark'\""));
443 }
444
445 #[test]
446 fn component_asset_links_are_gated() {
447 let off = renderer()
448 .render_page(&PageContext {
449 title: "P",
450 slug: "p",
451 body_html: "",
452 tree: &[],
453 backlinks: &[],
454 headings: &[],
455 commit: "",
456 built: "",
457 has_history: false,
458 has_mermaid: false,
459 has_math: false,
460 base: "",
461 site_title: "",
462 search_enabled: true,
463 has_components_css: false,
464 has_component_island: false,
465 is_home: false,
466 has_diff: false,
467 graph_json: "",
468 graph_node_count: 0,
469 graph_edge_count: 0,
470 description: "",
471 home: None,
472 })
473 .unwrap();
474 assert!(!off.contains("/components.css"));
475 assert!(!off.contains("/components.js"));
476
477 let on = renderer()
478 .render_page(&PageContext {
479 title: "P",
480 slug: "p",
481 body_html: "",
482 tree: &[],
483 backlinks: &[],
484 headings: &[],
485 commit: "",
486 built: "",
487 has_history: false,
488 has_mermaid: false,
489 has_math: false,
490 base: "",
491 site_title: "",
492 search_enabled: true,
493 has_components_css: true,
494 has_component_island: true,
495 is_home: false,
496 has_diff: false,
497 graph_json: "",
498 graph_node_count: 0,
499 graph_edge_count: 0,
500 description: "",
501 home: None,
502 })
503 .unwrap();
504 assert!(on.contains(r#"<link rel="stylesheet" href="/components.css" />"#));
505 assert!(on.contains(r#"<script src="/components.js"></script>"#));
506 }
507
508 #[test]
509 fn page_title_gets_site_suffix_when_configured() {
510 let html = renderer()
511 .render_page(&PageContext {
512 title: "Intro",
513 site_title: "My Docs",
514 search_enabled: true,
515 has_components_css: false,
516 has_component_island: false,
517 is_home: false,
518 has_diff: false,
519 graph_json: "",
520 graph_node_count: 0,
521 graph_edge_count: 0,
522 description: "",
523 home: None,
524 base: "",
525 slug: "x",
526 body_html: "",
527 tree: &[],
528 backlinks: &[],
529 headings: &[],
530 commit: "",
531 built: "",
532 has_history: false,
533 has_mermaid: false,
534 has_math: false,
535 })
536 .unwrap();
537 assert!(html.contains("<title>Intro — My Docs</title>"));
538 }
539
540 #[test]
541 fn no_site_title_leaves_plain_title_and_no_base() {
542 let html = renderer()
543 .render_page(&PageContext {
544 title: "Intro",
545 site_title: "",
546 search_enabled: true,
547 has_components_css: false,
548 has_component_island: false,
549 is_home: false,
550 has_diff: false,
551 graph_json: "",
552 graph_node_count: 0,
553 graph_edge_count: 0,
554 description: "",
555 home: None,
556 base: "",
557 slug: "x",
558 body_html: "",
559 tree: &[],
560 backlinks: &[],
561 headings: &[],
562 commit: "",
563 built: "",
564 has_history: false,
565 has_mermaid: false,
566 has_math: false,
567 })
568 .unwrap();
569 assert!(html.contains("<title>Intro</title>"));
570 assert!(!html.contains("<base"));
571 }
572
573 #[test]
574 fn search_disabled_hides_search_ui() {
575 let on = renderer()
576 .render_page(&PageContext {
577 title: "X",
578 site_title: "",
579 search_enabled: true,
580 has_components_css: false,
581 has_component_island: false,
582 is_home: false,
583 has_diff: false,
584 graph_json: "",
585 graph_node_count: 0,
586 graph_edge_count: 0,
587 description: "",
588 home: None,
589 base: "",
590 slug: "x",
591 body_html: "",
592 tree: &[],
593 backlinks: &[],
594 headings: &[],
595 commit: "",
596 built: "",
597 has_history: false,
598 has_mermaid: false,
599 has_math: false,
600 })
601 .unwrap();
602 assert!(on.contains("data-docgen-search"));
603
604 let off = renderer()
605 .render_page(&PageContext {
606 title: "X",
607 site_title: "",
608 search_enabled: false,
609 has_components_css: false,
610 has_component_island: false,
611 is_home: false,
612 has_diff: false,
613 graph_json: "",
614 graph_node_count: 0,
615 graph_edge_count: 0,
616 description: "",
617 home: None,
618 base: "",
619 slug: "x",
620 body_html: "",
621 tree: &[],
622 backlinks: &[],
623 headings: &[],
624 commit: "",
625 built: "",
626 has_history: false,
627 has_mermaid: false,
628 has_math: false,
629 })
630 .unwrap();
631 assert!(!off.contains("data-docgen-search"));
632 assert!(!off.contains("/search.js"));
633 }
634
635 #[test]
636 fn base_prefixes_every_asset_and_nav_link_and_emits_no_base_tag() {
637 let tree = vec![TreeNode::Doc {
640 name: "guide".into(),
641 slug: "guide".into(),
642 title: "Guide".into(),
643 }];
644 let html = renderer()
645 .render_page(&PageContext {
646 title: "X",
647 site_title: "",
648 search_enabled: true,
649 has_components_css: true,
650 has_component_island: false,
651 is_home: false,
652 has_diff: true,
653 graph_json: "",
654 graph_node_count: 0,
655 graph_edge_count: 0,
656 description: "",
657 home: None,
658 base: "/docs",
659 slug: "x",
660 body_html: "",
661 tree: &tree,
662 backlinks: &[],
663 headings: &[],
664 commit: "",
665 built: "",
666 has_history: false,
667 has_mermaid: false,
668 has_math: false,
669 })
670 .unwrap();
671 assert!(!html.contains("<base"));
673 assert!(html.contains(r#"href="/docs/docgen.css""#));
675 assert!(html.contains(r#"href="/docs/components.css""#));
676 assert!(html.contains(r#"src="/docs/bootstrap.js""#));
677 assert!(html.contains(r#"src="/docs/search.js""#));
678 assert!(html.contains(r#"href="/docs/guide""#));
681 assert!(html.contains(r#"href="/docs/diff""#));
682 assert!(!html.contains(r#"href="/docgen.css""#));
684 assert!(!html.contains(r#"src="/bootstrap.js""#));
685 }
686
687 #[test]
688 fn renders_sidebar_links() {
689 let tree = vec![TreeNode::Doc {
690 name: "intro".into(),
691 slug: "guide/intro".into(),
692 title: "Intro".into(),
693 }];
694 let html = renderer()
695 .render_page(&PageContext {
696 title: "X",
697 slug: "x",
698 body_html: "",
699 tree: &tree,
700 backlinks: &[],
701 headings: &[],
702 commit: "",
703 built: "",
704 has_history: false,
705 has_mermaid: false,
706 has_math: false,
707 base: "",
708 site_title: "",
709 search_enabled: true,
710 has_components_css: false,
711 has_component_island: false,
712 is_home: false,
713 has_diff: false,
714 graph_json: "",
715 graph_node_count: 0,
716 graph_edge_count: 0,
717 description: "",
718 home: None,
719 })
720 .unwrap();
721 assert!(html.contains(r#"href="/guide/intro""#));
722 assert!(html.contains(">Intro</a>"));
723 }
724
725 #[test]
726 fn escapes_title_and_sidebar_text_but_not_body() {
727 let tree = vec![TreeNode::Doc {
728 name: "intro".into(),
729 slug: "guide/intro".into(),
730 title: "A & B <x>".into(),
731 }];
732 let html = renderer()
733 .render_page(&PageContext {
734 title: "Tom & Jerry <script>",
735 slug: "tj",
736 body_html: "<p>raw & ok</p>",
737 tree: &tree,
738 backlinks: &[],
739 headings: &[],
740 commit: "",
741 built: "",
742 has_history: false,
743 has_mermaid: false,
744 has_math: false,
745 base: "",
746 site_title: "",
747 search_enabled: true,
748 has_components_css: false,
749 has_component_island: false,
750 is_home: false,
751 has_diff: false,
752 graph_json: "",
753 graph_node_count: 0,
754 graph_edge_count: 0,
755 description: "",
756 home: None,
757 })
758 .unwrap();
759 assert!(html.contains("<title>Tom & Jerry <script></title>"));
761 assert!(!html.contains("<title>Tom & Jerry <script>"));
762 assert!(html.contains("A & B <x>"));
764 assert!(html.contains("<p>raw & ok</p>"));
766 }
767
768 #[test]
769 fn renders_backlinks_section() {
770 use docgen_core::model::Backlink;
771 let backlinks = vec![Backlink {
772 slug: "a".into(),
773 title: "Page A".into(),
774 description: Some("All about A".into()),
775 }];
776 let html = renderer()
777 .render_page(&PageContext {
778 title: "X",
779 slug: "x",
780 body_html: "",
781 tree: &[],
782 backlinks: &backlinks,
783 headings: &[],
784 commit: "",
785 built: "",
786 has_history: false,
787 has_mermaid: false,
788 has_math: false,
789 base: "",
790 site_title: "",
791 search_enabled: true,
792 has_components_css: false,
793 has_component_island: false,
794 is_home: false,
795 has_diff: false,
796 graph_json: "",
797 graph_node_count: 0,
798 graph_edge_count: 0,
799 description: "",
800 home: None,
801 })
802 .unwrap();
803 assert!(html.contains("Referenced by"));
805 assert!(html.contains(r#"class="docgen-rail__backlink" href="/a""#));
806 assert!(html.contains("<span>Page A</span>"));
807 assert!(html.contains("<small>All about A</small>"));
808 assert!(!html.contains("docgen-backlinks"));
810 }
811
812 #[test]
813 fn omits_backlinks_section_when_empty() {
814 let html = renderer()
815 .render_page(&PageContext {
816 title: "X",
817 slug: "x",
818 body_html: "",
819 tree: &[],
820 backlinks: &[],
821 headings: &[],
822 commit: "",
823 built: "",
824 has_history: false,
825 has_mermaid: false,
826 has_math: false,
827 base: "",
828 site_title: "",
829 search_enabled: true,
830 has_components_css: false,
831 has_component_island: false,
832 is_home: false,
833 has_diff: false,
834 graph_json: "",
835 graph_node_count: 0,
836 graph_edge_count: 0,
837 description: "",
838 home: None,
839 })
840 .unwrap();
841 assert!(!html.contains("Referenced by"));
843 assert!(!html.contains("docgen-rail__backlink"));
844 }
845
846 #[test]
847 fn renders_diff_link_only_when_has_diff() {
848 let with = renderer()
849 .render_page(&PageContext {
850 title: "X",
851 slug: "guide/intro",
852 body_html: "",
853 tree: &[],
854 backlinks: &[],
855 headings: &[],
856 commit: "",
857 built: "",
858 has_history: false,
859 has_mermaid: false,
860 has_math: false,
861 base: "",
862 site_title: "",
863 search_enabled: true,
864 has_components_css: false,
865 has_component_island: false,
866 is_home: false,
867 has_diff: true,
868 graph_json: "",
869 graph_node_count: 0,
870 graph_edge_count: 0,
871 description: "",
872 home: None,
873 })
874 .unwrap();
875 assert!(with.contains(r#"href="/diff""#));
876
877 let without = renderer()
878 .render_page(&PageContext {
879 title: "X",
880 slug: "guide/intro",
881 body_html: "",
882 tree: &[],
883 backlinks: &[],
884 headings: &[],
885 commit: "",
886 built: "",
887 has_history: false,
888 has_mermaid: false,
889 has_math: false,
890 base: "",
891 site_title: "",
892 search_enabled: true,
893 has_components_css: false,
894 has_component_island: false,
895 is_home: false,
896 has_diff: false,
897 graph_json: "",
898 graph_node_count: 0,
899 graph_edge_count: 0,
900 description: "",
901 home: None,
902 })
903 .unwrap();
904 assert!(!without.contains(r#"href="/diff""#));
905 }
906
907 #[test]
908 fn page_loads_bootstrap_and_alpine_and_gates_mermaid_island() {
909 let html = renderer()
910 .render_page(&PageContext {
911 title: "X",
912 slug: "x",
913 body_html: "",
914 tree: &[],
915 backlinks: &[],
916 headings: &[],
917 commit: "",
918 built: "",
919 has_history: false,
920 has_mermaid: false,
921 has_math: false,
922 base: "",
923 site_title: "",
924 search_enabled: true,
925 has_components_css: false,
926 has_component_island: false,
927 is_home: false,
928 has_diff: false,
929 graph_json: "",
930 graph_node_count: 0,
931 graph_edge_count: 0,
932 description: "",
933 home: None,
934 })
935 .unwrap();
936 assert!(html.contains(r#"src="/bootstrap.js""#));
937 assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
938 assert!(!html.contains("islands/mermaid.js")); let withm = renderer()
941 .render_page(&PageContext {
942 title: "X",
943 slug: "x",
944 body_html: "",
945 tree: &[],
946 backlinks: &[],
947 headings: &[],
948 commit: "",
949 built: "",
950 has_history: false,
951 has_mermaid: true,
952 has_math: false,
953 base: "",
954 site_title: "",
955 search_enabled: true,
956 has_components_css: false,
957 has_component_island: false,
958 is_home: false,
959 has_diff: false,
960 graph_json: "",
961 graph_node_count: 0,
962 graph_edge_count: 0,
963 description: "",
964 home: None,
965 })
966 .unwrap();
967 assert!(withm.contains(r#"src="/islands/mermaid.js""#));
968 }
969
970 #[test]
971 fn page_links_katex_css_only_when_has_math() {
972 let no_math = renderer()
973 .render_page(&PageContext {
974 title: "X",
975 slug: "x",
976 body_html: "",
977 tree: &[],
978 backlinks: &[],
979 headings: &[],
980 commit: "",
981 built: "",
982 has_history: false,
983 has_mermaid: false,
984 has_math: false,
985 base: "",
986 site_title: "",
987 search_enabled: true,
988 has_components_css: false,
989 has_component_island: false,
990 is_home: false,
991 has_diff: false,
992 graph_json: "",
993 graph_node_count: 0,
994 graph_edge_count: 0,
995 description: "",
996 home: None,
997 })
998 .unwrap();
999 assert!(!no_math.contains("katex.min.css"));
1000
1001 let with_math = renderer()
1002 .render_page(&PageContext {
1003 title: "X",
1004 slug: "x",
1005 body_html: "",
1006 tree: &[],
1007 backlinks: &[],
1008 headings: &[],
1009 commit: "",
1010 built: "",
1011 has_history: false,
1012 has_mermaid: false,
1013 has_math: true,
1014 base: "",
1015 site_title: "",
1016 search_enabled: true,
1017 has_components_css: false,
1018 has_component_island: false,
1019 is_home: false,
1020 has_diff: false,
1021 graph_json: "",
1022 graph_node_count: 0,
1023 graph_edge_count: 0,
1024 description: "",
1025 home: None,
1026 })
1027 .unwrap();
1028 assert!(with_math.contains(r#"href="/vendor/katex/katex.min.css""#));
1029 }
1030
1031 #[test]
1032 #[allow(deprecated)] fn ships_self_contained_search_assets() {
1034 assert!(SEARCH_JS.contains("search-index.json"));
1035 assert!(SEARCH_JS.contains("metaKey"));
1036 assert!(!SEARCH_JS.contains("import ")); }
1038
1039 #[test]
1042 fn preview_is_content_only_with_real_asset_stack() {
1043 let r = renderer();
1044 let html = r
1045 .render_preview(&PreviewContext {
1046 title: "Intro",
1047 body_html: r#"<h1>Intro</h1><p>See <a class="docgen-wikilink" href="/guide">g</a></p>"#,
1048 base: "",
1049 has_mermaid: false,
1050 has_math: false,
1051 has_components_css: false,
1052 has_component_island: false,
1053 })
1054 .unwrap();
1055 assert!(html.contains(r#"<article class="docgen-doc-content">"#));
1057 assert!(html.contains(r#"href="/guide""#));
1058 assert!(html.contains(r#"href="/docgen.css""#));
1060 assert!(html.contains(r#"href="/code.css""#));
1061 assert!(html.contains(r#"src="/bootstrap.js""#));
1062 assert!(html.contains(r#"src="/islands/wikilink.js""#));
1063 assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
1064 assert!(!html.contains("docgen-topbar"));
1066 assert!(!html.contains("docgen-sidebar"));
1067 assert!(!html.contains("docgen-rail"));
1068 assert!(!html.contains("islands/mermaid.js"));
1070 assert!(!html.contains("components.css"));
1071 assert!(!html.contains("katex.min.css"));
1072 }
1073
1074 #[test]
1075 fn preview_gates_mermaid_math_and_component_assets() {
1076 let r = renderer();
1077 let html = r
1078 .render_preview(&PreviewContext {
1079 title: "D",
1080 body_html: r#"<div class="docgen-mermaid"></div>"#,
1081 base: "",
1082 has_mermaid: true,
1083 has_math: true,
1084 has_components_css: true,
1085 has_component_island: true,
1086 })
1087 .unwrap();
1088 assert!(html.contains(r#"src="/islands/mermaid.js""#));
1089 assert!(html.contains(r#"href="/vendor/katex/katex.min.css""#));
1090 assert!(html.contains(r#"href="/components.css""#));
1091 assert!(html.contains(r#"src="/components.js""#));
1092 }
1093
1094 #[test]
1095 fn preview_prefixes_base() {
1096 let r = renderer();
1097 let html = r
1098 .render_preview(&PreviewContext {
1099 title: "X",
1100 body_html: "<p>x</p>",
1101 base: "/docs",
1102 has_mermaid: true,
1103 has_math: false,
1104 has_components_css: false,
1105 has_component_island: false,
1106 })
1107 .unwrap();
1108 assert!(html.contains(r#"href="/docs/docgen.css""#));
1109 assert!(html.contains(r#"src="/docs/bootstrap.js""#));
1110 assert!(html.contains(r#"src="/docs/islands/mermaid.js""#));
1111 assert!(!html.contains(r#"href="/docgen.css""#));
1112 }
1113
1114 fn sample_buckets() -> Vec<TimelineBucketView> {
1115 vec![TimelineBucketView {
1116 label: "Today".into(),
1117 points: vec![TimelinePointView {
1118 short_hash: "abc1234".into(),
1119 subject: "edit a".into(),
1120 author: Some("docgen test".into()),
1121 date: Some("2026-05-15".into()),
1122 added_lines: 1,
1123 removed_lines: 1,
1124 files: vec![FileView {
1125 path: "docs/a.md".into(),
1126 status: "modified".into(),
1127 hunks: vec![HunkView {
1128 lines: vec![
1129 LineView {
1130 kind: "context".into(),
1131 text: "# A".into(),
1132 old_line: Some(1),
1133 new_line: Some(1),
1134 },
1135 LineView {
1136 kind: "removed".into(),
1137 text: "first".into(),
1138 old_line: Some(2),
1139 new_line: None,
1140 },
1141 LineView {
1142 kind: "added".into(),
1143 text: "second".into(),
1144 old_line: None,
1145 new_line: Some(2),
1146 },
1147 ],
1148 }],
1149 }],
1150 }],
1151 }]
1152 }
1153
1154 #[test]
1157 fn renders_graph_page_with_embedded_json_and_island() {
1158 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1159 let json = r#"{"nodes":[{"slug":"a","title":"A","x":1.0,"y":2.0,"degree":0}],"edges":[]}"#;
1160 let html = r
1161 .render_graph(&GraphContext {
1162 tree: &[],
1163 graph_json: json,
1164 node_count: 1,
1165 has_diff: false,
1166 edge_count: 0,
1167 base: "",
1168 site_title: "",
1169 search_enabled: true,
1170 })
1171 .unwrap();
1172 assert!(html.contains("<title>Graph</title>"));
1173 assert!(html.contains(r#"id="docgen-graph-data""#));
1174 assert!(html.contains(r#"type="application/json""#));
1175 assert!(html.contains(json)); assert!(html.contains(r#"x-data="docgenGraph""#));
1177 assert!(html.contains(r#"src="/islands/graph.js""#));
1178 assert!(html.contains(r#"src="/bootstrap.js""#));
1179 assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
1180 assert!(html.contains("1 nodes")); }
1182
1183 #[test]
1184 fn graph_page_renders_sidebar_tree() {
1185 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1186 let tree = vec![docgen_core::model::TreeNode::Doc {
1187 name: "intro".into(),
1188 slug: "guide/intro".into(),
1189 title: "Intro".into(),
1190 }];
1191 let html = r
1192 .render_graph(&GraphContext {
1193 tree: &tree,
1194 graph_json: r#"{"nodes":[],"edges":[]}"#,
1195 node_count: 0,
1196 has_diff: false,
1197 edge_count: 0,
1198 base: "",
1199 site_title: "",
1200 search_enabled: true,
1201 })
1202 .unwrap();
1203 assert!(html.contains(r#"href="/guide/intro""#));
1204 }
1205
1206 #[test]
1207 fn embedded_json_neutralizes_script_close() {
1208 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1209 let json = r#"{"nodes":[{"slug":"x","title":"a</script>b","x":0.0,"y":0.0,"degree":0}],"edges":[]}"#;
1210 let html = r
1211 .render_graph(&GraphContext {
1212 tree: &[],
1213 graph_json: json,
1214 node_count: 1,
1215 has_diff: false,
1216 edge_count: 0,
1217 base: "",
1218 site_title: "",
1219 search_enabled: true,
1220 })
1221 .unwrap();
1222 assert!(!html.contains("a</script>b")); assert!(html.contains(r#"a<\/script>b"#)); }
1225
1226 #[test]
1227 fn graph_page_renders_graph_canvas_without_sidebar_link() {
1228 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1229 let html = r
1230 .render_graph(&GraphContext {
1231 tree: &[],
1232 graph_json: r#"{"nodes":[],"edges":[]}"#,
1233 node_count: 0,
1234 has_diff: false,
1235 edge_count: 0,
1236 base: "",
1237 site_title: "",
1238 search_enabled: true,
1239 })
1240 .unwrap();
1241 assert!(html.contains(r#"x-data="docgenGraph""#));
1243 assert!(html.contains("docgen-graph__svg"));
1244 assert!(!html.contains("docgen-sidebar__graph"));
1246 }
1247
1248 #[test]
1249 fn home_page_embeds_graph_and_non_home_does_not() {
1250 let r = renderer();
1251 let ctx = |is_home: bool, graph_json: &'static str| PageContext {
1252 title: "X",
1253 slug: if is_home { "index" } else { "x" },
1254 body_html: "",
1255 tree: &[],
1256 backlinks: &[],
1257 headings: &[],
1258 commit: "",
1259 built: "",
1260 has_history: false,
1261 has_mermaid: false,
1262 has_math: false,
1263 base: "",
1264 site_title: "",
1265 search_enabled: true,
1266 has_diff: false,
1267 has_components_css: false,
1268 has_component_island: false,
1269 is_home,
1270 graph_json,
1271 graph_node_count: 2,
1272 graph_edge_count: 1,
1273 description: "",
1274 home: None,
1275 };
1276 let home = r
1278 .render_page(&ctx(true, r#"{"nodes":[],"edges":[]}"#))
1279 .unwrap();
1280 assert!(home.contains("docgen-home-graph"));
1281 assert!(home.contains(r#"id="docgen-graph-data""#));
1282 assert!(home.contains(r#"x-data="docgenGraph""#));
1283 assert!(home.contains("islands/graph.js"));
1284 assert!(!home.contains("docgen-sidebar__graph"));
1286 let other = r.render_page(&ctx(false, "")).unwrap();
1288 assert!(!other.contains("docgen-home-graph"));
1289 assert!(!other.contains("islands/graph.js"));
1290 }
1291
1292 #[test]
1293 fn renders_history_timeline_with_buckets_and_diff_lines() {
1294 let buckets = sample_buckets();
1295 let html = renderer()
1296 .render_history(&HistoryContext {
1297 title: "A",
1298 slug: "a",
1299 tree: &[],
1300 buckets: &buckets,
1301 base: "",
1302 site_title: "",
1303 search_enabled: true,
1304 })
1305 .unwrap();
1306 assert!(html.contains("<title>History: A</title>"));
1307 assert!(html.contains("Today"));
1308 assert!(html.contains("edit a"));
1309 assert!(html.contains("abc1234"));
1310 assert!(html.contains("docgen-diff-line--removed"));
1311 assert!(html.contains("docgen-diff-line--added"));
1312 assert!(html.contains("first"));
1313 assert!(html.contains(r#"href="/a""#));
1314 }
1315
1316 #[test]
1317 fn history_escapes_diff_text() {
1318 let buckets = vec![TimelineBucketView {
1319 label: "Today".into(),
1320 points: vec![TimelinePointView {
1321 short_hash: "abc1234".into(),
1322 subject: "edit".into(),
1323 author: None,
1324 date: None,
1325 added_lines: 1,
1326 removed_lines: 0,
1327 files: vec![FileView {
1328 path: "docs/a.md".into(),
1329 status: "modified".into(),
1330 hunks: vec![HunkView {
1331 lines: vec![LineView {
1332 kind: "added".into(),
1333 text: "<script>alert(1)</script>".into(),
1334 old_line: None,
1335 new_line: Some(1),
1336 }],
1337 }],
1338 }],
1339 }],
1340 }];
1341 let html = renderer()
1342 .render_history(&HistoryContext {
1343 title: "A",
1344 slug: "a",
1345 tree: &[],
1346 buckets: &buckets,
1347 base: "",
1348 site_title: "",
1349 search_enabled: true,
1350 })
1351 .unwrap();
1352 assert!(html.contains("<script>alert(1)</script>"));
1353 assert!(!html.contains("<script>alert(1)</script>"));
1354 }
1355
1356 fn page(slug: &str, tree: &[TreeNode]) -> String {
1359 renderer()
1360 .render_page(&PageContext {
1361 title: "X",
1362 slug,
1363 body_html: "<p>hi</p>",
1364 tree,
1365 backlinks: &[],
1366 headings: &[],
1367 commit: "",
1368 built: "",
1369 has_history: false,
1370 has_mermaid: false,
1371 has_math: false,
1372 base: "",
1373 site_title: "Docs",
1374 search_enabled: true,
1375 has_components_css: false,
1376 has_component_island: false,
1377 is_home: false,
1378 has_diff: false,
1379 graph_json: "",
1380 graph_node_count: 0,
1381 graph_edge_count: 0,
1382 description: "",
1383 home: None,
1384 })
1385 .unwrap()
1386 }
1387
1388 #[test]
1389 fn page_has_app_shell() {
1390 let html = page("x", &[]);
1391 for cls in [
1392 "docgen-app",
1393 "docgen-topbar",
1394 "docgen-layout",
1395 "docgen-sidebar",
1396 "docgen-content",
1397 "docgen-doc-content",
1398 ] {
1399 assert!(html.contains(cls), "app shell missing {cls}");
1400 }
1401 }
1402
1403 #[test]
1404 fn page_has_no_flash_script_in_head() {
1405 let html = page("x", &[]);
1406 let script_at = html
1407 .find("localStorage.getItem('doc-theme')")
1408 .expect("no-flash script present");
1409 let css_at = html.find("/docgen.css").expect("docgen.css link present");
1410 assert!(
1411 script_at < css_at,
1412 "no-flash script must precede docgen.css link"
1413 );
1414 assert!(html.contains("prefers-color-scheme"));
1415 assert!(html.contains("'light':'dark'"));
1417 }
1418
1419 #[test]
1420 fn page_has_theme_toggle_island() {
1421 let html = page("x", &[]);
1422 assert!(html.contains(r#"x-data="docgenThemeToggle""#));
1423 assert!(html.contains("/islands/theme-toggle.js"));
1424 assert!(!html.contains(r#"<html lang="en" data-theme="#));
1427 }
1428
1429 #[test]
1430 fn sidebar_marks_active_doc() {
1431 let tree = vec![TreeNode::Doc {
1432 name: "a".into(),
1433 slug: "a".into(),
1434 title: "A".into(),
1435 }];
1436 let active = page("a", &tree);
1437 assert!(active.contains(r#"docgen-tree__item is-active"#));
1438 assert!(active.contains(r#"aria-current="page""#));
1439
1440 let inactive = page("b", &tree);
1441 assert!(!inactive.contains(r#"docgen-tree__item is-active"#));
1442 assert!(!inactive.contains(r#"aria-current="page""#));
1443 }
1444
1445 #[test]
1446 fn sidebar_renders_nested_dir_as_details() {
1447 let tree = vec![TreeNode::Dir {
1448 name: "guide".into(),
1449 slug: None,
1450 children: vec![TreeNode::Doc {
1451 name: "intro".into(),
1452 slug: "guide/intro".into(),
1453 title: "Intro".into(),
1454 }],
1455 }];
1456 let html = page("x", &tree);
1457 assert!(html.contains("<details"));
1458 assert!(html.contains("<summary"));
1459 assert!(html.contains("docgen-tree"));
1460 assert!(html.contains(r#"data-tree-path="/guide""#));
1462 }
1463
1464 #[test]
1465 fn graph_and_history_share_shell() {
1466 let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1467 let graph = r
1468 .render_graph(&GraphContext {
1469 tree: &[],
1470 graph_json: r#"{"nodes":[],"edges":[]}"#,
1471 node_count: 0,
1472 has_diff: false,
1473 edge_count: 0,
1474 base: "",
1475 site_title: "",
1476 search_enabled: true,
1477 })
1478 .unwrap();
1479 let hist = r
1480 .render_history(&HistoryContext {
1481 title: "A",
1482 slug: "a",
1483 tree: &[],
1484 buckets: &[],
1485 base: "",
1486 site_title: "",
1487 search_enabled: true,
1488 })
1489 .unwrap();
1490 for html in [&graph, &hist] {
1491 assert!(html.contains("docgen-topbar"));
1492 assert!(html.contains("data-theme"));
1493 assert!(html.contains("/islands/theme-toggle.js"));
1494 assert!(html.contains("localStorage.getItem('doc-theme')"));
1495 }
1496 }
1497}