Skip to main content

docgen_render/
lib.rs

1use docgen_core::headings::Heading;
2use docgen_core::model::{Backlink, TreeNode};
3use minijinja::{context, Environment};
4use serde::Serialize;
5
6/// The built-in page template, embedded at compile time.
7pub const DEFAULT_PAGE_TEMPLATE: &str = include_str!("../templates/page.html");
8
9/// The vendored search client script, emitted to `dist/search.js`.
10///
11/// Deprecated: assets now flow through the `docgen-assets` crate. Kept for one
12/// phase so dependents migrate without breakage. The bytes are byte-identical to
13/// `docgen-assets`' embedded copy.
14#[deprecated(note = "use docgen-assets::core_assets() / emit()")]
15pub const SEARCH_JS: &str = include_str!("../assets/search.js");
16
17// The canonical, fully-themed stylesheet now lives in the `docgen-assets` crate
18// (`assets/docgen/docgen.css`, embedded via include_dir and emitted as
19// `dist/docgen.css`). The previous stale 37-line copy under
20// `crates/docgen-render/assets/docgen.css` was deleted to avoid divergence; use
21// `docgen-assets::core_assets()` / `emit()` for the shipped theme.
22
23/// The built-in per-doc history-timeline template, embedded at compile time.
24pub const DEFAULT_HISTORY_TEMPLATE: &str = include_str!("../templates/history.html");
25
26/// The built-in `/graph/` doc-link-graph template, embedded at compile time.
27pub const DEFAULT_GRAPH_TEMPLATE: &str = include_str!("../templates/graph.html");
28
29/// The built-in `/diff/` workspace shell template, embedded at compile time.
30/// The page is a mount point (`#docgen-diff-root`) hydrated by `islands/diff.js`
31/// from the build-time `timeline.json` + `revisions/<id>.json` payloads.
32pub const DEFAULT_DIFF_TEMPLATE: &str = include_str!("../templates/diff.html");
33
34/// The dev editor's live-preview document template, embedded at compile time.
35/// Content-only (no app chrome): the rendered article wrapped in the SAME asset
36/// and island stack a published page uses, so a doc previewed in the editor
37/// renders identically to its built page (mermaid, components, tooltips, math).
38pub const DEFAULT_PREVIEW_TEMPLATE: &str = include_str!("../templates/preview.html");
39
40/// One diff line, render-friendly. `kind`/line numbers are pre-stringified by
41/// the caller so `docgen-render` stays free of the `docgen-diff` domain types.
42#[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/// A contiguous diff hunk (run of lines).
51#[derive(Serialize)]
52pub struct HunkView {
53    pub lines: Vec<LineView>,
54}
55
56/// One changed file within a timeline point.
57#[derive(Serialize)]
58pub struct FileView {
59    pub path: String,
60    pub status: String,
61    pub hunks: Vec<HunkView>,
62}
63
64/// One commit in the timeline (render-friendly projection of a `DocDiffTimelinePoint`).
65#[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/// A labelled bucket of timeline points (e.g. "Today").
77#[derive(Serialize)]
78pub struct TimelineBucketView {
79    pub label: String,
80    pub points: Vec<TimelinePointView>,
81}
82
83/// Everything the history page render needs.
84#[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    /// Deployed base path (e.g. `/docs`); `""` → no `<base>` tag (default).
91    pub base: &'a str,
92    /// Site title; `""` → no `"page — site"` suffix (default).
93    pub site_title: &'a str,
94    /// Whether the search UI ships (gates the trigger + `search.js`).
95    pub search_enabled: bool,
96}
97
98/// Everything the `/graph/` page render needs. `graph_json` is the serialized
99/// `GraphData` embedded verbatim into a `<script type="application/json">` tag.
100#[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    /// Deployed base path (e.g. `/docs`); `""` → no `<base>` tag (default).
107    pub base: &'a str,
108    /// Site title; `""` → no `"page — site"` suffix (default).
109    pub site_title: &'a str,
110    /// Whether the search UI ships (gates the trigger + `search.js`).
111    pub search_enabled: bool,
112    /// Whether the `/diff/` workspace page exists (drives the topbar diff icon).
113    pub has_diff: bool,
114}
115
116/// Everything the `/diff/` workspace shell render needs. The diff data itself
117/// is not templated — it ships as `timeline.json` + `revisions/<id>.json` and
118/// is hydrated client-side by `islands/diff.js` into `#docgen-diff-root`.
119#[derive(Serialize)]
120pub struct DiffContext<'a> {
121    pub tree: &'a [TreeNode],
122    /// Deployed base path (e.g. `/docs`); `""` → no `<base>` tag (default).
123    pub base: &'a str,
124    /// Site title; `""` → no `"page — site"` suffix (default).
125    pub site_title: &'a str,
126    /// Whether the search UI ships (gates the trigger + `search.js`).
127    pub search_enabled: bool,
128}
129
130/// Everything the editor's live-preview document render needs. The body is the
131/// already-rendered inner HTML (run through the same per-doc pipeline as a build);
132/// the flags gate the same conditional asset links a published page uses.
133#[derive(Serialize)]
134pub struct PreviewContext<'a> {
135    pub title: &'a str,
136    pub body_html: &'a str,
137    /// Deployed base path (e.g. `/docs`); `""` for the dev server root (default).
138    pub base: &'a str,
139    /// Whether this doc contains a mermaid diagram (gates the mermaid island).
140    pub has_mermaid: bool,
141    /// Whether this doc contains math (gates the KaTeX stylesheet link).
142    pub has_math: bool,
143    /// Whether any component shipped a `style.css` (links `/components.css`).
144    pub has_components_css: bool,
145    /// Whether this doc used a component with an `island.js` (links `/components.js`).
146    pub has_component_island: bool,
147}
148
149/// One section card on the home dashboard: a top-level folder ("section"), the
150/// number of docs in it, and a link to its first page. Mirrors the original
151/// home's `sectionCards`.
152#[derive(Serialize)]
153pub struct HomeSection<'a> {
154    pub label: &'a str,
155    /// Slug of the section's first doc (template prefixes `base`). Folders have no
156    /// index doc, so this points at a real page.
157    pub slug: &'a str,
158    pub count: usize,
159}
160
161/// One row in the home dashboard's "Recent" list.
162#[derive(Serialize)]
163pub struct HomeRecent<'a> {
164    pub title: &'a str,
165    pub slug: &'a str,
166    /// The doc's top-level section label (or `""` for a root-level doc).
167    pub section: &'a str,
168}
169
170/// The home dashboard payload. `PageContext.home` is `Some` only for the index
171/// doc; every other page passes `None` (and the template skips the dashboard).
172#[derive(Serialize)]
173pub struct HomeData<'a> {
174    /// Hero subtitle (the index doc's frontmatter `description`). `""` → omitted.
175    pub description: &'a str,
176    /// Total published doc count — the "pages" stat tile.
177    pub pages: usize,
178    /// Total resolved wikilink count — the "links" stat tile.
179    pub links: usize,
180    /// Section cards (top-level folders). Empty → the Sections column is omitted.
181    pub sections: &'a [HomeSection<'a>],
182    /// The most-recent docs (build order, home excluded), capped for the panel.
183    pub recent: &'a [HomeRecent<'a>],
184}
185
186/// Everything a single page render needs.
187#[derive(Serialize)]
188pub struct PageContext<'a> {
189    pub title: &'a str,
190    /// Optional frontmatter `description:`, rendered as the page header "lede"
191    /// under the title on doc pages. `""` → no lede paragraph.
192    pub description: &'a str,
193    pub slug: &'a str,
194    pub body_html: &'a str,
195    pub tree: &'a [TreeNode],
196    /// Inbound references, rendered as cards in the right rail's "Referenced by"
197    /// section (this supersedes the old in-content backlinks block).
198    pub backlinks: &'a [Backlink],
199    /// The `h2`/`h3` outline of this page, for the right-rail "On this page" TOC.
200    pub headings: &'a [Heading],
201    /// Short commit hash for the rail's "Additional info" → Commit row. `""` →
202    /// the Commit row is omitted (no git repo / detached build).
203    pub commit: &'a str,
204    /// Build timestamp (`YYYY-MM-DD HH:MM`) for the "Built" row. `""` → omitted.
205    pub built: &'a str,
206    /// Whether this doc has an emitted `/<slug>/history/` page (drives the nav link).
207    pub has_history: bool,
208    /// Whether this page contains a mermaid diagram (gates the mermaid island script).
209    pub has_mermaid: bool,
210    /// Whether this page contains math (gates the KaTeX stylesheet `<head>` link).
211    pub has_math: bool,
212    /// Deployed base path (e.g. `/docs`); `""` → no `<base>` tag (default).
213    pub base: &'a str,
214    /// Site title; `""` → no `"page — site"` suffix (default).
215    pub site_title: &'a str,
216    /// Whether the search UI ships (gates the trigger + `search.js`).
217    pub search_enabled: bool,
218    /// Whether the `/diff/` workspace page exists (drives the topbar diff icon).
219    pub has_diff: bool,
220    /// Whether any component shipped a `style.css` (links `/components.css`). The
221    /// component stylesheet is small + cacheable, so it links on every page when
222    /// present rather than per-page.
223    pub has_components_css: bool,
224    /// Whether this page used ≥1 component with an `island.js` (links
225    /// `/components.js`, gated per-page like the mermaid island).
226    pub has_component_island: bool,
227    /// Whether this page is the site home. Drives the home-only graph embed
228    /// (the original surfaces the doc graph on the home page, not the sidebar).
229    pub is_home: bool,
230    /// Force-layout graph JSON for the home embed (raw — `render_page` applies
231    /// the `</` → `<\/` escaping for the inline `<script>`). `""` → no graph
232    /// block (not home, or the graph feature is off).
233    pub graph_json: &'a str,
234    /// Node/edge counts for the home graph caption (ignored when `graph_json` is empty).
235    pub graph_node_count: usize,
236    pub graph_edge_count: usize,
237    /// Home dashboard payload (hero/stats/sections/recent). `Some` only for the
238    /// index doc; `None` everywhere else.
239    pub home: Option<HomeData<'a>>,
240}
241
242/// Owns a configured minijinja environment with the `page` template registered.
243pub struct Renderer {
244    env: Environment<'static>,
245}
246
247impl Renderer {
248    /// Build a renderer from a page-template source string.
249    pub fn new(page_template: &str) -> Result<Self, minijinja::Error> {
250        let mut env = Environment::new();
251        // Register under a `.html` name so minijinja's default auto-escape callback
252        // enables HTML escaping for `{{ title }}`, `{{ node.name }}`, `{{ node.title }}`.
253        // `{{ body | safe }}` remains raw, as intended for already-rendered markdown.
254        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    /// Render one page to a full HTML document.
263    pub fn render_page(&self, ctx: &PageContext) -> Result<String, minijinja::Error> {
264        let tmpl = self.env.get_template("page.html")?;
265        // Escape `</` so a literal `</script>` inside a doc title can't break out
266        // of the inline `<script type="application/json">` graph payload (same
267        // guard as `render_graph`). Empty → still empty, so the block is skipped.
268        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    /// Render the `/graph/` doc-link-graph page to a full HTML document.
297    ///
298    /// `graph_json` is injected raw (the island's `JSON.parse` needs valid JSON,
299    /// not HTML-escaped text). To stop a literal `</script>` inside a doc title
300    /// from breaking out of the embedding `<script type="application/json">` tag,
301    /// `</` is rewritten to `<\/` first — still valid JSON, inert as markup.
302    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    /// Render one doc's history timeline to a full HTML document.
319    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    /// Render the editor's live-preview document (content-only, real asset stack).
333    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    /// Render the `/diff/` workspace shell to a full HTML document.
347    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        // Skip link targets a labelled, focusable <main>.
431        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        // Hamburger links to the left drawer, the page-contents button to the
435        // right rail drawer; Escape closes both drawers and the overflow menu.
436        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        // Theme toggle exposes pressed state to AT.
441        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        // A sub-path deployment must rewrite every root-absolute URL to live under
638        // `base`; <base> alone cannot do this (it only affects relative URLs).
639        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        // No <base> tag — links are prefixed directly so they actually resolve.
672        assert!(!html.contains("<base"));
673        // Assets under base.
674        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        // Nav + diff links under base. (The sidebar graph link was removed;
679        // the graph now lives on the home page, covered by its own test.)
680        assert!(html.contains(r#"href="/docs/guide""#));
681        assert!(html.contains(r#"href="/docs/diff""#));
682        // Nothing left at the bare root.
683        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        // Title is HTML-escaped.
760        assert!(html.contains("<title>Tom &amp; Jerry &lt;script&gt;</title>"));
761        assert!(!html.contains("<title>Tom & Jerry <script>"));
762        // Sidebar link text is escaped.
763        assert!(html.contains("A &amp; B &lt;x&gt;"));
764        // Body marked `| safe` is emitted raw.
765        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        // Backlinks now live in the right rail's "Referenced by" section as cards.
804        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        // The old in-content backlinks block is gone.
809        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        // No backlinks → the "Referenced by" rail section is omitted entirely.
842        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")); // gated off
939
940        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)] // SEARCH_JS kept one phase as a byte-identical re-export
1033    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 ")); // no module imports / npm
1037    }
1038
1039    // ---- editor live-preview document ----
1040
1041    #[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        // The article content is emitted raw inside the published prose wrapper.
1056        assert!(html.contains(r#"<article class="docgen-doc-content">"#));
1057        assert!(html.contains(r#"href="/guide""#));
1058        // Same content + island stack a built page uses — so islands hydrate.
1059        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        // No app chrome: this is a preview pane, not a full page.
1065        assert!(!html.contains("docgen-topbar"));
1066        assert!(!html.contains("docgen-sidebar"));
1067        assert!(!html.contains("docgen-rail"));
1068        // Conditional assets gated off.
1069        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    // ---- P4 B-4: /graph/ page ----
1155
1156    #[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)); // JSON embedded verbatim, NOT escaped
1176        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")); // meta caption
1181    }
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")); // raw close-tag must not survive
1223        assert!(html.contains(r#"a<\/script>b"#)); // escaped form present
1224    }
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        // The standalone /graph page still renders its graph canvas + island.
1242        assert!(html.contains(r#"x-data="docgenGraph""#));
1243        assert!(html.contains("docgen-graph__svg"));
1244        // The sidebar graph link was removed (the graph lives on the home page).
1245        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        // Home page with graph data: embeds the graph block + data + island script.
1277        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        // The sidebar graph link is gone.
1285        assert!(!home.contains("docgen-sidebar__graph"));
1286        // A non-home page (even if a graph_json were passed) embeds nothing.
1287        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("&lt;script&gt;alert(1)&lt;&#x2f;script&gt;"));
1353        assert!(!html.contains("<script>alert(1)</script>"));
1354    }
1355
1356    // ---- P7 Cluster A: app shell, themes, theme-toggle, sidebar tree ----
1357
1358    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        // Dark is the bare default: pre-paint falls back to dark, not light.
1416        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        // Dark is the bare default: <html> carries NO data-theme attr server-side;
1425        // the pre-paint script sets it (dark when nothing stored / no light pref).
1426        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        // Each folder carries a stable path key so its collapse state persists.
1461        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}