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