use docgen_core::headings::Heading;
use docgen_core::model::{Backlink, TreeNode};
use minijinja::{context, Environment};
use serde::Serialize;
pub const DEFAULT_PAGE_TEMPLATE: &str = include_str!("../templates/page.html");
#[deprecated(note = "use docgen-assets::core_assets() / emit()")]
pub const SEARCH_JS: &str = include_str!("../assets/search.js");
pub const DEFAULT_HISTORY_TEMPLATE: &str = include_str!("../templates/history.html");
pub const DEFAULT_GRAPH_TEMPLATE: &str = include_str!("../templates/graph.html");
pub const DEFAULT_DIFF_TEMPLATE: &str = include_str!("../templates/diff.html");
pub const DEFAULT_PREVIEW_TEMPLATE: &str = include_str!("../templates/preview.html");
#[derive(Serialize)]
pub struct LineView {
pub kind: String,
pub text: String,
pub old_line: Option<u32>,
pub new_line: Option<u32>,
}
#[derive(Serialize)]
pub struct HunkView {
pub lines: Vec<LineView>,
}
#[derive(Serialize)]
pub struct FileView {
pub path: String,
pub status: String,
pub hunks: Vec<HunkView>,
}
#[derive(Serialize)]
pub struct TimelinePointView {
pub short_hash: String,
pub subject: String,
pub author: Option<String>,
pub date: Option<String>,
pub added_lines: u32,
pub removed_lines: u32,
pub files: Vec<FileView>,
}
#[derive(Serialize)]
pub struct TimelineBucketView {
pub label: String,
pub points: Vec<TimelinePointView>,
}
#[derive(Serialize)]
pub struct HistoryContext<'a> {
pub title: &'a str,
pub slug: &'a str,
pub tree: &'a [TreeNode],
pub buckets: &'a [TimelineBucketView],
pub base: &'a str,
pub site_title: &'a str,
pub search_enabled: bool,
}
#[derive(Serialize)]
pub struct GraphContext<'a> {
pub tree: &'a [TreeNode],
pub graph_json: &'a str,
pub node_count: usize,
pub edge_count: usize,
pub base: &'a str,
pub site_title: &'a str,
pub search_enabled: bool,
pub has_diff: bool,
}
#[derive(Serialize)]
pub struct DiffContext<'a> {
pub tree: &'a [TreeNode],
pub base: &'a str,
pub site_title: &'a str,
pub search_enabled: bool,
}
#[derive(Serialize)]
pub struct PreviewContext<'a> {
pub title: &'a str,
pub body_html: &'a str,
pub base: &'a str,
pub has_mermaid: bool,
pub has_math: bool,
pub has_components_css: bool,
pub has_component_island: bool,
}
#[derive(Serialize)]
pub struct HomeSection<'a> {
pub label: &'a str,
pub slug: &'a str,
pub count: usize,
}
#[derive(Serialize)]
pub struct HomeRecent<'a> {
pub title: &'a str,
pub slug: &'a str,
pub section: &'a str,
}
#[derive(Serialize)]
pub struct HomeData<'a> {
pub description: &'a str,
pub pages: usize,
pub links: usize,
pub sections: &'a [HomeSection<'a>],
pub recent: &'a [HomeRecent<'a>],
}
#[derive(Serialize)]
pub struct PageContext<'a> {
pub title: &'a str,
pub description: &'a str,
pub slug: &'a str,
pub body_html: &'a str,
pub tree: &'a [TreeNode],
pub backlinks: &'a [Backlink],
pub headings: &'a [Heading],
pub commit: &'a str,
pub built: &'a str,
pub has_history: bool,
pub has_mermaid: bool,
pub has_math: bool,
pub base: &'a str,
pub site_title: &'a str,
pub search_enabled: bool,
pub has_diff: bool,
pub has_components_css: bool,
pub has_component_island: bool,
pub is_home: bool,
pub graph_json: &'a str,
pub graph_node_count: usize,
pub graph_edge_count: usize,
pub home: Option<HomeData<'a>>,
}
pub struct Renderer {
env: Environment<'static>,
}
impl Renderer {
pub fn new(page_template: &str) -> Result<Self, minijinja::Error> {
let mut env = Environment::new();
env.add_template_owned("page.html", page_template.to_string())?;
env.add_template_owned("history.html", DEFAULT_HISTORY_TEMPLATE.to_string())?;
env.add_template_owned("graph.html", DEFAULT_GRAPH_TEMPLATE.to_string())?;
env.add_template_owned("diff.html", DEFAULT_DIFF_TEMPLATE.to_string())?;
env.add_template_owned("preview.html", DEFAULT_PREVIEW_TEMPLATE.to_string())?;
Ok(Self { env })
}
pub fn render_page(&self, ctx: &PageContext) -> Result<String, minijinja::Error> {
let tmpl = self.env.get_template("page.html")?;
let safe_graph_json = ctx.graph_json.replace("</", "<\\/");
tmpl.render(context! {
title => ctx.title,
description => ctx.description,
body => ctx.body_html,
slug => ctx.slug,
tree => ctx.tree,
backlinks => ctx.backlinks,
headings => ctx.headings,
commit => ctx.commit,
built => ctx.built,
has_history => ctx.has_history,
has_mermaid => ctx.has_mermaid,
has_math => ctx.has_math,
base => ctx.base,
site_title => ctx.site_title,
search_enabled => ctx.search_enabled,
has_components_css => ctx.has_components_css,
has_component_island => ctx.has_component_island,
is_home => ctx.is_home,
has_diff => ctx.has_diff,
graph_json => safe_graph_json,
graph_node_count => ctx.graph_node_count,
graph_edge_count => ctx.graph_edge_count,
home => ctx.home,
})
}
pub fn render_graph(&self, ctx: &GraphContext) -> Result<String, minijinja::Error> {
let tmpl = self.env.get_template("graph.html")?;
let safe_json = ctx.graph_json.replace("</", "<\\/");
tmpl.render(context! {
tree => ctx.tree,
slug => "",
graph_json => safe_json,
node_count => ctx.node_count,
edge_count => ctx.edge_count,
base => ctx.base,
site_title => ctx.site_title,
search_enabled => ctx.search_enabled,
has_diff => ctx.has_diff,
})
}
pub fn render_history(&self, ctx: &HistoryContext) -> Result<String, minijinja::Error> {
let tmpl = self.env.get_template("history.html")?;
tmpl.render(context! {
title => ctx.title,
slug => ctx.slug,
tree => ctx.tree,
buckets => ctx.buckets,
base => ctx.base,
site_title => ctx.site_title,
search_enabled => ctx.search_enabled,
})
}
pub fn render_preview(&self, ctx: &PreviewContext) -> Result<String, minijinja::Error> {
let tmpl = self.env.get_template("preview.html")?;
tmpl.render(context! {
title => ctx.title,
body => ctx.body_html,
base => ctx.base,
has_mermaid => ctx.has_mermaid,
has_math => ctx.has_math,
has_components_css => ctx.has_components_css,
has_component_island => ctx.has_component_island,
})
}
pub fn render_diff(&self, ctx: &DiffContext) -> Result<String, minijinja::Error> {
let tmpl = self.env.get_template("diff.html")?;
tmpl.render(context! {
tree => ctx.tree,
slug => "",
base => ctx.base,
site_title => ctx.site_title,
search_enabled => ctx.search_enabled,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use docgen_core::model::TreeNode;
fn renderer() -> Renderer {
Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap()
}
#[test]
fn renders_title_and_body() {
let html = renderer()
.render_page(&PageContext {
title: "My Page",
slug: "my-page",
body_html: "<p>hello</p>",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
base: "",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap();
assert!(html.contains("<title>My Page</title>"));
assert!(html.contains("<p>hello</p>"));
}
#[test]
fn page_has_accessibility_landmarks() {
let html = renderer()
.render_page(&PageContext {
title: "P",
slug: "p",
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
base: "",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap();
assert!(html.contains(r##"class="docgen-skip-link" href="#docgen-main""##));
assert!(html.contains(r#"id="docgen-main""#));
assert!(html.contains(r#"tabindex="-1""#));
assert!(html.contains(r#"aria-controls="docgen-sidebar""#));
assert!(html.contains("@keydown.escape.window=\"navOpen=false\""));
assert!(html.contains(":aria-pressed=\"theme==='light'\""));
assert!(html.contains(":aria-pressed=\"theme==='dark'\""));
}
#[test]
fn component_asset_links_are_gated() {
let off = renderer()
.render_page(&PageContext {
title: "P",
slug: "p",
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
base: "",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap();
assert!(!off.contains("/components.css"));
assert!(!off.contains("/components.js"));
let on = renderer()
.render_page(&PageContext {
title: "P",
slug: "p",
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
base: "",
site_title: "",
search_enabled: true,
has_components_css: true,
has_component_island: true,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap();
assert!(on.contains(r#"<link rel="stylesheet" href="/components.css" />"#));
assert!(on.contains(r#"<script src="/components.js"></script>"#));
}
#[test]
fn page_title_gets_site_suffix_when_configured() {
let html = renderer()
.render_page(&PageContext {
title: "Intro",
site_title: "My Docs",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
base: "",
slug: "x",
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
})
.unwrap();
assert!(html.contains("<title>Intro — My Docs</title>"));
}
#[test]
fn no_site_title_leaves_plain_title_and_no_base() {
let html = renderer()
.render_page(&PageContext {
title: "Intro",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
base: "",
slug: "x",
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
})
.unwrap();
assert!(html.contains("<title>Intro</title>"));
assert!(!html.contains("<base"));
}
#[test]
fn search_disabled_hides_search_ui() {
let on = renderer()
.render_page(&PageContext {
title: "X",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
base: "",
slug: "x",
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
})
.unwrap();
assert!(on.contains("data-docgen-search"));
let off = renderer()
.render_page(&PageContext {
title: "X",
site_title: "",
search_enabled: false,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
base: "",
slug: "x",
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
})
.unwrap();
assert!(!off.contains("data-docgen-search"));
assert!(!off.contains("/search.js"));
}
#[test]
fn base_prefixes_every_asset_and_nav_link_and_emits_no_base_tag() {
let tree = vec![TreeNode::Doc {
name: "guide".into(),
slug: "guide".into(),
title: "Guide".into(),
}];
let html = renderer()
.render_page(&PageContext {
title: "X",
site_title: "",
search_enabled: true,
has_components_css: true,
has_component_island: false,
is_home: false,
has_diff: true,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
base: "/docs",
slug: "x",
body_html: "",
tree: &tree,
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
})
.unwrap();
assert!(!html.contains("<base"));
assert!(html.contains(r#"href="/docs/docgen.css""#));
assert!(html.contains(r#"href="/docs/components.css""#));
assert!(html.contains(r#"src="/docs/bootstrap.js""#));
assert!(html.contains(r#"src="/docs/search.js""#));
assert!(html.contains(r#"href="/docs/guide""#));
assert!(html.contains(r#"href="/docs/diff""#));
assert!(!html.contains(r#"href="/docgen.css""#));
assert!(!html.contains(r#"src="/bootstrap.js""#));
}
#[test]
fn renders_sidebar_links() {
let tree = vec![TreeNode::Doc {
name: "intro".into(),
slug: "guide/intro".into(),
title: "Intro".into(),
}];
let html = renderer()
.render_page(&PageContext {
title: "X",
slug: "x",
body_html: "",
tree: &tree,
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
base: "",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap();
assert!(html.contains(r#"href="/guide/intro""#));
assert!(html.contains(">Intro</a>"));
}
#[test]
fn escapes_title_and_sidebar_text_but_not_body() {
let tree = vec![TreeNode::Doc {
name: "intro".into(),
slug: "guide/intro".into(),
title: "A & B <x>".into(),
}];
let html = renderer()
.render_page(&PageContext {
title: "Tom & Jerry <script>",
slug: "tj",
body_html: "<p>raw & ok</p>",
tree: &tree,
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
base: "",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap();
assert!(html.contains("<title>Tom & Jerry <script></title>"));
assert!(!html.contains("<title>Tom & Jerry <script>"));
assert!(html.contains("A & B <x>"));
assert!(html.contains("<p>raw & ok</p>"));
}
#[test]
fn renders_backlinks_section() {
use docgen_core::model::Backlink;
let backlinks = vec![Backlink {
slug: "a".into(),
title: "Page A".into(),
description: Some("All about A".into()),
}];
let html = renderer()
.render_page(&PageContext {
title: "X",
slug: "x",
body_html: "",
tree: &[],
backlinks: &backlinks,
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
base: "",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap();
assert!(html.contains("Referenced by"));
assert!(html.contains(r#"class="docgen-rail__backlink" href="/a""#));
assert!(html.contains("<span>Page A</span>"));
assert!(html.contains("<small>All about A</small>"));
assert!(!html.contains("docgen-backlinks"));
}
#[test]
fn omits_backlinks_section_when_empty() {
let html = renderer()
.render_page(&PageContext {
title: "X",
slug: "x",
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
base: "",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap();
assert!(!html.contains("Referenced by"));
assert!(!html.contains("docgen-rail__backlink"));
}
#[test]
fn renders_diff_link_only_when_has_diff() {
let with = renderer()
.render_page(&PageContext {
title: "X",
slug: "guide/intro",
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
base: "",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: true,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap();
assert!(with.contains(r#"href="/diff""#));
let without = renderer()
.render_page(&PageContext {
title: "X",
slug: "guide/intro",
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
base: "",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap();
assert!(!without.contains(r#"href="/diff""#));
}
#[test]
fn page_loads_bootstrap_and_alpine_and_gates_mermaid_island() {
let html = renderer()
.render_page(&PageContext {
title: "X",
slug: "x",
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
base: "",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap();
assert!(html.contains(r#"src="/bootstrap.js""#));
assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
assert!(!html.contains("islands/mermaid.js"));
let withm = renderer()
.render_page(&PageContext {
title: "X",
slug: "x",
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: true,
has_math: false,
base: "",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap();
assert!(withm.contains(r#"src="/islands/mermaid.js""#));
}
#[test]
fn page_links_katex_css_only_when_has_math() {
let no_math = renderer()
.render_page(&PageContext {
title: "X",
slug: "x",
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
base: "",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap();
assert!(!no_math.contains("katex.min.css"));
let with_math = renderer()
.render_page(&PageContext {
title: "X",
slug: "x",
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: true,
base: "",
site_title: "",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap();
assert!(with_math.contains(r#"href="/vendor/katex/katex.min.css""#));
}
#[test]
#[allow(deprecated)] fn ships_self_contained_search_assets() {
assert!(SEARCH_JS.contains("search-index.json"));
assert!(SEARCH_JS.contains("metaKey"));
assert!(!SEARCH_JS.contains("import ")); }
#[test]
fn preview_is_content_only_with_real_asset_stack() {
let r = renderer();
let html = r
.render_preview(&PreviewContext {
title: "Intro",
body_html: r#"<h1>Intro</h1><p>See <a class="docgen-wikilink" href="/guide">g</a></p>"#,
base: "",
has_mermaid: false,
has_math: false,
has_components_css: false,
has_component_island: false,
})
.unwrap();
assert!(html.contains(r#"<article class="docgen-doc-content">"#));
assert!(html.contains(r#"href="/guide""#));
assert!(html.contains(r#"href="/docgen.css""#));
assert!(html.contains(r#"href="/code.css""#));
assert!(html.contains(r#"src="/bootstrap.js""#));
assert!(html.contains(r#"src="/islands/wikilink.js""#));
assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
assert!(!html.contains("docgen-topbar"));
assert!(!html.contains("docgen-sidebar"));
assert!(!html.contains("docgen-rail"));
assert!(!html.contains("islands/mermaid.js"));
assert!(!html.contains("components.css"));
assert!(!html.contains("katex.min.css"));
}
#[test]
fn preview_gates_mermaid_math_and_component_assets() {
let r = renderer();
let html = r
.render_preview(&PreviewContext {
title: "D",
body_html: r#"<div class="docgen-mermaid"></div>"#,
base: "",
has_mermaid: true,
has_math: true,
has_components_css: true,
has_component_island: true,
})
.unwrap();
assert!(html.contains(r#"src="/islands/mermaid.js""#));
assert!(html.contains(r#"href="/vendor/katex/katex.min.css""#));
assert!(html.contains(r#"href="/components.css""#));
assert!(html.contains(r#"src="/components.js""#));
}
#[test]
fn preview_prefixes_base() {
let r = renderer();
let html = r
.render_preview(&PreviewContext {
title: "X",
body_html: "<p>x</p>",
base: "/docs",
has_mermaid: true,
has_math: false,
has_components_css: false,
has_component_island: false,
})
.unwrap();
assert!(html.contains(r#"href="/docs/docgen.css""#));
assert!(html.contains(r#"src="/docs/bootstrap.js""#));
assert!(html.contains(r#"src="/docs/islands/mermaid.js""#));
assert!(!html.contains(r#"href="/docgen.css""#));
}
fn sample_buckets() -> Vec<TimelineBucketView> {
vec![TimelineBucketView {
label: "Today".into(),
points: vec![TimelinePointView {
short_hash: "abc1234".into(),
subject: "edit a".into(),
author: Some("docgen test".into()),
date: Some("2026-05-15".into()),
added_lines: 1,
removed_lines: 1,
files: vec![FileView {
path: "docs/a.md".into(),
status: "modified".into(),
hunks: vec![HunkView {
lines: vec![
LineView {
kind: "context".into(),
text: "# A".into(),
old_line: Some(1),
new_line: Some(1),
},
LineView {
kind: "removed".into(),
text: "first".into(),
old_line: Some(2),
new_line: None,
},
LineView {
kind: "added".into(),
text: "second".into(),
old_line: None,
new_line: Some(2),
},
],
}],
}],
}],
}]
}
#[test]
fn renders_graph_page_with_embedded_json_and_island() {
let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
let json = r#"{"nodes":[{"slug":"a","title":"A","x":1.0,"y":2.0,"degree":0}],"edges":[]}"#;
let html = r
.render_graph(&GraphContext {
tree: &[],
graph_json: json,
node_count: 1,
has_diff: false,
edge_count: 0,
base: "",
site_title: "",
search_enabled: true,
})
.unwrap();
assert!(html.contains("<title>Graph</title>"));
assert!(html.contains(r#"id="docgen-graph-data""#));
assert!(html.contains(r#"type="application/json""#));
assert!(html.contains(json)); assert!(html.contains(r#"x-data="docgenGraph""#));
assert!(html.contains(r#"src="/islands/graph.js""#));
assert!(html.contains(r#"src="/bootstrap.js""#));
assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
assert!(html.contains("1 nodes")); }
#[test]
fn graph_page_renders_sidebar_tree() {
let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
let tree = vec![docgen_core::model::TreeNode::Doc {
name: "intro".into(),
slug: "guide/intro".into(),
title: "Intro".into(),
}];
let html = r
.render_graph(&GraphContext {
tree: &tree,
graph_json: r#"{"nodes":[],"edges":[]}"#,
node_count: 0,
has_diff: false,
edge_count: 0,
base: "",
site_title: "",
search_enabled: true,
})
.unwrap();
assert!(html.contains(r#"href="/guide/intro""#));
}
#[test]
fn embedded_json_neutralizes_script_close() {
let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
let json = r#"{"nodes":[{"slug":"x","title":"a</script>b","x":0.0,"y":0.0,"degree":0}],"edges":[]}"#;
let html = r
.render_graph(&GraphContext {
tree: &[],
graph_json: json,
node_count: 1,
has_diff: false,
edge_count: 0,
base: "",
site_title: "",
search_enabled: true,
})
.unwrap();
assert!(!html.contains("a</script>b")); assert!(html.contains(r#"a<\/script>b"#)); }
#[test]
fn graph_page_renders_graph_canvas_without_sidebar_link() {
let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
let html = r
.render_graph(&GraphContext {
tree: &[],
graph_json: r#"{"nodes":[],"edges":[]}"#,
node_count: 0,
has_diff: false,
edge_count: 0,
base: "",
site_title: "",
search_enabled: true,
})
.unwrap();
assert!(html.contains(r#"x-data="docgenGraph""#));
assert!(html.contains("docgen-graph__svg"));
assert!(!html.contains("docgen-sidebar__graph"));
}
#[test]
fn home_page_embeds_graph_and_non_home_does_not() {
let r = renderer();
let ctx = |is_home: bool, graph_json: &'static str| PageContext {
title: "X",
slug: if is_home { "index" } else { "x" },
body_html: "",
tree: &[],
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
base: "",
site_title: "",
search_enabled: true,
has_diff: false,
has_components_css: false,
has_component_island: false,
is_home,
graph_json,
graph_node_count: 2,
graph_edge_count: 1,
description: "",
home: None,
};
let home = r
.render_page(&ctx(true, r#"{"nodes":[],"edges":[]}"#))
.unwrap();
assert!(home.contains("docgen-home-graph"));
assert!(home.contains(r#"id="docgen-graph-data""#));
assert!(home.contains(r#"x-data="docgenGraph""#));
assert!(home.contains("islands/graph.js"));
assert!(!home.contains("docgen-sidebar__graph"));
let other = r.render_page(&ctx(false, "")).unwrap();
assert!(!other.contains("docgen-home-graph"));
assert!(!other.contains("islands/graph.js"));
}
#[test]
fn renders_history_timeline_with_buckets_and_diff_lines() {
let buckets = sample_buckets();
let html = renderer()
.render_history(&HistoryContext {
title: "A",
slug: "a",
tree: &[],
buckets: &buckets,
base: "",
site_title: "",
search_enabled: true,
})
.unwrap();
assert!(html.contains("<title>History: A</title>"));
assert!(html.contains("Today"));
assert!(html.contains("edit a"));
assert!(html.contains("abc1234"));
assert!(html.contains("docgen-diff-line--removed"));
assert!(html.contains("docgen-diff-line--added"));
assert!(html.contains("first"));
assert!(html.contains(r#"href="/a""#));
}
#[test]
fn history_escapes_diff_text() {
let buckets = vec![TimelineBucketView {
label: "Today".into(),
points: vec![TimelinePointView {
short_hash: "abc1234".into(),
subject: "edit".into(),
author: None,
date: None,
added_lines: 1,
removed_lines: 0,
files: vec![FileView {
path: "docs/a.md".into(),
status: "modified".into(),
hunks: vec![HunkView {
lines: vec![LineView {
kind: "added".into(),
text: "<script>alert(1)</script>".into(),
old_line: None,
new_line: Some(1),
}],
}],
}],
}],
}];
let html = renderer()
.render_history(&HistoryContext {
title: "A",
slug: "a",
tree: &[],
buckets: &buckets,
base: "",
site_title: "",
search_enabled: true,
})
.unwrap();
assert!(html.contains("<script>alert(1)</script>"));
assert!(!html.contains("<script>alert(1)</script>"));
}
fn page(slug: &str, tree: &[TreeNode]) -> String {
renderer()
.render_page(&PageContext {
title: "X",
slug,
body_html: "<p>hi</p>",
tree,
backlinks: &[],
headings: &[],
commit: "",
built: "",
has_history: false,
has_mermaid: false,
has_math: false,
base: "",
site_title: "Docs",
search_enabled: true,
has_components_css: false,
has_component_island: false,
is_home: false,
has_diff: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
description: "",
home: None,
})
.unwrap()
}
#[test]
fn page_has_app_shell() {
let html = page("x", &[]);
for cls in [
"docgen-app",
"docgen-topbar",
"docgen-layout",
"docgen-sidebar",
"docgen-content",
"docgen-doc-content",
] {
assert!(html.contains(cls), "app shell missing {cls}");
}
}
#[test]
fn page_has_no_flash_script_in_head() {
let html = page("x", &[]);
let script_at = html
.find("localStorage.getItem('doc-theme')")
.expect("no-flash script present");
let css_at = html.find("/docgen.css").expect("docgen.css link present");
assert!(
script_at < css_at,
"no-flash script must precede docgen.css link"
);
assert!(html.contains("prefers-color-scheme"));
assert!(html.contains("'light':'dark'"));
}
#[test]
fn page_has_theme_toggle_island() {
let html = page("x", &[]);
assert!(html.contains(r#"x-data="docgenThemeToggle""#));
assert!(html.contains("/islands/theme-toggle.js"));
assert!(!html.contains(r#"<html lang="en" data-theme="#));
}
#[test]
fn sidebar_marks_active_doc() {
let tree = vec![TreeNode::Doc {
name: "a".into(),
slug: "a".into(),
title: "A".into(),
}];
let active = page("a", &tree);
assert!(active.contains(r#"docgen-tree__item is-active"#));
assert!(active.contains(r#"aria-current="page""#));
let inactive = page("b", &tree);
assert!(!inactive.contains(r#"docgen-tree__item is-active"#));
assert!(!inactive.contains(r#"aria-current="page""#));
}
#[test]
fn sidebar_renders_nested_dir_as_details() {
let tree = vec![TreeNode::Dir {
name: "guide".into(),
slug: None,
children: vec![TreeNode::Doc {
name: "intro".into(),
slug: "guide/intro".into(),
title: "Intro".into(),
}],
}];
let html = page("x", &tree);
assert!(html.contains("<details"));
assert!(html.contains("<summary"));
assert!(html.contains("docgen-tree"));
assert!(html.contains(r#"data-tree-path="/guide""#));
}
#[test]
fn graph_and_history_share_shell() {
let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
let graph = r
.render_graph(&GraphContext {
tree: &[],
graph_json: r#"{"nodes":[],"edges":[]}"#,
node_count: 0,
has_diff: false,
edge_count: 0,
base: "",
site_title: "",
search_enabled: true,
})
.unwrap();
let hist = r
.render_history(&HistoryContext {
title: "A",
slug: "a",
tree: &[],
buckets: &[],
base: "",
site_title: "",
search_enabled: true,
})
.unwrap();
for html in [&graph, &hist] {
assert!(html.contains("docgen-topbar"));
assert!(html.contains("data-theme"));
assert!(html.contains("/islands/theme-toggle.js"));
assert!(html.contains("localStorage.getItem('doc-theme')"));
}
}
}