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 carries an interactive base — an embedded ```base block or
211    /// a `.base` page that emitted an interactive payload (gates the bases island
212    /// script `islands/bases.js`).
213    pub has_base_island: bool,
214    /// Whether this page contains math (gates the KaTeX stylesheet `<head>` link).
215    pub has_math: bool,
216    /// Deployed base path (e.g. `/docs`); `""` → no `<base>` tag (default).
217    pub base: &'a str,
218    /// Site title; `""` → no `"page — site"` suffix (default).
219    pub site_title: &'a str,
220    /// Whether the search UI ships (gates the trigger + `search.js`).
221    pub search_enabled: bool,
222    /// Whether the `/diff/` workspace page exists (drives the topbar diff icon).
223    pub has_diff: bool,
224    /// Whether any component shipped a `style.css` (links `/components.css`). The
225    /// component stylesheet is small + cacheable, so it links on every page when
226    /// present rather than per-page.
227    pub has_components_css: bool,
228    /// Whether this page used ≥1 component with an `island.js` (links
229    /// `/components.js`, gated per-page like the mermaid island).
230    pub has_component_island: bool,
231    /// Whether this page is the site home. Drives the home-only graph embed
232    /// (the original surfaces the doc graph on the home page, not the sidebar).
233    pub is_home: bool,
234    /// Force-layout graph JSON for the home embed (raw — `render_page` applies
235    /// the `</` → `<\/` escaping for the inline `<script>`). `""` → no graph
236    /// block (not home, or the graph feature is off).
237    pub graph_json: &'a str,
238    /// Node/edge counts for the home graph caption (ignored when `graph_json` is empty).
239    pub graph_node_count: usize,
240    pub graph_edge_count: usize,
241    /// Home dashboard payload (hero/stats/sections/recent). `Some` only for the
242    /// index doc; `None` everywhere else.
243    pub home: Option<HomeData<'a>>,
244}
245
246/// Owns a configured minijinja environment with the `page` template registered.
247pub struct Renderer {
248    env: Environment<'static>,
249}
250
251/// Make a JSON payload safe to inline inside `<script type="application/json">`.
252///
253/// Escapes EVERY `<`, not just `</`. A lone `</script>` guard is not enough: per
254/// the HTML tokenizer, a `<!--` followed by `<script` puts the element into
255/// "script data double escaped" state, in which the next `</script>` does NOT
256/// close it — the rest of the document is swallowed as script text and the
257/// payload stops being parseable JSON. A doc title is enough to carry that
258/// sequence. `\u003c` is valid JSON and parses back to `<`, so the island sees
259/// identical data.
260///
261/// Any other JSON payload inlined into a script element needs this same guard
262/// (the interactive-bases payload builder carries its own copy).
263fn escape_json_for_script(json: &str) -> String {
264    json.replace('<', "\\u003c")
265}
266
267impl Renderer {
268    /// Build a renderer from a page-template source string.
269    pub fn new(page_template: &str) -> Result<Self, minijinja::Error> {
270        let mut env = Environment::new();
271        // Register under a `.html` name so minijinja's default auto-escape callback
272        // enables HTML escaping for `{{ title }}`, `{{ node.name }}`, `{{ node.title }}`.
273        // `{{ body | safe }}` remains raw, as intended for already-rendered markdown.
274        env.add_template_owned("page.html", page_template.to_string())?;
275        env.add_template_owned("history.html", DEFAULT_HISTORY_TEMPLATE.to_string())?;
276        env.add_template_owned("graph.html", DEFAULT_GRAPH_TEMPLATE.to_string())?;
277        env.add_template_owned("diff.html", DEFAULT_DIFF_TEMPLATE.to_string())?;
278        env.add_template_owned("preview.html", DEFAULT_PREVIEW_TEMPLATE.to_string())?;
279        Ok(Self { env })
280    }
281
282    /// Render one page to a full HTML document.
283    pub fn render_page(&self, ctx: &PageContext) -> Result<String, minijinja::Error> {
284        let tmpl = self.env.get_template("page.html")?;
285        // Neutralize the inline `<script type="application/json">` graph payload
286        // (same guard as `render_graph`). Empty → still empty, so the block is
287        // skipped.
288        let safe_graph_json = escape_json_for_script(ctx.graph_json);
289        tmpl.render(context! {
290            title => ctx.title,
291            description => ctx.description,
292            body => ctx.body_html,
293            slug => ctx.slug,
294            tree => ctx.tree,
295            backlinks => ctx.backlinks,
296            headings => ctx.headings,
297            commit => ctx.commit,
298            built => ctx.built,
299            has_history => ctx.has_history,
300            has_mermaid => ctx.has_mermaid,
301            has_base_island => ctx.has_base_island,
302            has_math => ctx.has_math,
303            base => ctx.base,
304            site_title => ctx.site_title,
305            search_enabled => ctx.search_enabled,
306            has_components_css => ctx.has_components_css,
307            has_component_island => ctx.has_component_island,
308            is_home => ctx.is_home,
309            has_diff => ctx.has_diff,
310            graph_json => safe_graph_json,
311            graph_node_count => ctx.graph_node_count,
312            graph_edge_count => ctx.graph_edge_count,
313            home => ctx.home,
314        })
315    }
316
317    /// Render the `/graph/` doc-link-graph page to a full HTML document.
318    ///
319    /// `graph_json` is injected raw (the island's `JSON.parse` needs valid JSON,
320    /// not HTML-escaped text), so it is neutralized by
321    /// [`escape_json_for_script`] first.
322    pub fn render_graph(&self, ctx: &GraphContext) -> Result<String, minijinja::Error> {
323        let tmpl = self.env.get_template("graph.html")?;
324        let safe_json = escape_json_for_script(ctx.graph_json);
325        tmpl.render(context! {
326            tree => ctx.tree,
327            slug => "",
328            graph_json => safe_json,
329            node_count => ctx.node_count,
330            edge_count => ctx.edge_count,
331            base => ctx.base,
332            site_title => ctx.site_title,
333            search_enabled => ctx.search_enabled,
334            has_diff => ctx.has_diff,
335        })
336    }
337
338    /// Render one doc's history timeline to a full HTML document.
339    pub fn render_history(&self, ctx: &HistoryContext) -> Result<String, minijinja::Error> {
340        let tmpl = self.env.get_template("history.html")?;
341        tmpl.render(context! {
342            title => ctx.title,
343            slug => ctx.slug,
344            tree => ctx.tree,
345            buckets => ctx.buckets,
346            base => ctx.base,
347            site_title => ctx.site_title,
348            search_enabled => ctx.search_enabled,
349        })
350    }
351
352    /// Render the editor's live-preview document (content-only, real asset stack).
353    pub fn render_preview(&self, ctx: &PreviewContext) -> Result<String, minijinja::Error> {
354        let tmpl = self.env.get_template("preview.html")?;
355        tmpl.render(context! {
356            title => ctx.title,
357            body => ctx.body_html,
358            base => ctx.base,
359            has_mermaid => ctx.has_mermaid,
360            has_math => ctx.has_math,
361            has_components_css => ctx.has_components_css,
362            has_component_island => ctx.has_component_island,
363        })
364    }
365
366    /// Render the `/diff/` workspace shell to a full HTML document.
367    pub fn render_diff(&self, ctx: &DiffContext) -> Result<String, minijinja::Error> {
368        let tmpl = self.env.get_template("diff.html")?;
369        tmpl.render(context! {
370            tree => ctx.tree,
371            slug => "",
372            base => ctx.base,
373            site_title => ctx.site_title,
374            search_enabled => ctx.search_enabled,
375        })
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use docgen_core::model::TreeNode;
383
384    fn renderer() -> Renderer {
385        Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap()
386    }
387
388    #[test]
389    fn renders_title_and_body() {
390        let html = renderer()
391            .render_page(&PageContext {
392                title: "My Page",
393                slug: "my-page",
394                body_html: "<p>hello</p>",
395                tree: &[],
396                backlinks: &[],
397                headings: &[],
398                commit: "",
399                built: "",
400                has_history: false,
401                has_mermaid: false,
402                has_base_island: false,
403                has_math: false,
404                base: "",
405                site_title: "",
406                search_enabled: true,
407                has_components_css: false,
408                has_component_island: false,
409                is_home: false,
410                has_diff: false,
411                graph_json: "",
412                graph_node_count: 0,
413                graph_edge_count: 0,
414                description: "",
415                home: None,
416            })
417            .unwrap();
418        assert!(html.contains("<title>My Page</title>"));
419        assert!(html.contains("<p>hello</p>"));
420    }
421
422    #[test]
423    fn page_has_accessibility_landmarks() {
424        let html = renderer()
425            .render_page(&PageContext {
426                title: "P",
427                slug: "p",
428                body_html: "",
429                tree: &[],
430                backlinks: &[],
431                headings: &[],
432                commit: "",
433                built: "",
434                has_history: false,
435                has_mermaid: false,
436                has_base_island: false,
437                has_math: false,
438                base: "",
439                site_title: "",
440                search_enabled: true,
441                has_components_css: false,
442                has_component_island: false,
443                is_home: false,
444                has_diff: false,
445                graph_json: "",
446                graph_node_count: 0,
447                graph_edge_count: 0,
448                description: "",
449                home: None,
450            })
451            .unwrap();
452        // Skip link targets a labelled, focusable <main>.
453        assert!(html.contains(r##"class="docgen-skip-link" href="#docgen-main""##));
454        assert!(html.contains(r#"id="docgen-main""#));
455        assert!(html.contains(r#"tabindex="-1""#));
456        // Hamburger links to the left drawer, the page-contents button to the
457        // right rail drawer; Escape closes both drawers and the overflow menu.
458        assert!(html.contains(r#"aria-controls="docgen-sidebar""#));
459        assert!(html.contains(r#"aria-controls="docgen-rail""#));
460        assert!(html
461            .contains("@keydown.escape.window=\"navOpen=false; railOpen=false; menuOpen=false\""));
462        // Theme toggle exposes pressed state to AT.
463        assert!(html.contains(":aria-pressed=\"theme==='light'\""));
464        assert!(html.contains(":aria-pressed=\"theme==='dark'\""));
465    }
466
467    #[test]
468    fn component_asset_links_are_gated() {
469        let off = renderer()
470            .render_page(&PageContext {
471                title: "P",
472                slug: "p",
473                body_html: "",
474                tree: &[],
475                backlinks: &[],
476                headings: &[],
477                commit: "",
478                built: "",
479                has_history: false,
480                has_mermaid: false,
481                has_base_island: false,
482                has_math: false,
483                base: "",
484                site_title: "",
485                search_enabled: true,
486                has_components_css: false,
487                has_component_island: false,
488                is_home: false,
489                has_diff: false,
490                graph_json: "",
491                graph_node_count: 0,
492                graph_edge_count: 0,
493                description: "",
494                home: None,
495            })
496            .unwrap();
497        assert!(!off.contains("/components.css"));
498        assert!(!off.contains("/components.js"));
499
500        let on = renderer()
501            .render_page(&PageContext {
502                title: "P",
503                slug: "p",
504                body_html: "",
505                tree: &[],
506                backlinks: &[],
507                headings: &[],
508                commit: "",
509                built: "",
510                has_history: false,
511                has_mermaid: false,
512                has_base_island: false,
513                has_math: false,
514                base: "",
515                site_title: "",
516                search_enabled: true,
517                has_components_css: true,
518                has_component_island: true,
519                is_home: false,
520                has_diff: false,
521                graph_json: "",
522                graph_node_count: 0,
523                graph_edge_count: 0,
524                description: "",
525                home: None,
526            })
527            .unwrap();
528        assert!(on.contains(r#"<link rel="stylesheet" href="/components.css" />"#));
529        assert!(on.contains(r#"<script src="/components.js"></script>"#));
530    }
531
532    #[test]
533    fn page_title_gets_site_suffix_when_configured() {
534        let html = renderer()
535            .render_page(&PageContext {
536                title: "Intro",
537                site_title: "My Docs",
538                search_enabled: true,
539                has_components_css: false,
540                has_component_island: false,
541                is_home: false,
542                has_diff: false,
543                graph_json: "",
544                graph_node_count: 0,
545                graph_edge_count: 0,
546                description: "",
547                home: None,
548                base: "",
549                slug: "x",
550                body_html: "",
551                tree: &[],
552                backlinks: &[],
553                headings: &[],
554                commit: "",
555                built: "",
556                has_history: false,
557                has_mermaid: false,
558                has_base_island: false,
559                has_math: false,
560            })
561            .unwrap();
562        assert!(html.contains("<title>Intro — My Docs</title>"));
563    }
564
565    #[test]
566    fn no_site_title_leaves_plain_title_and_no_base() {
567        let html = renderer()
568            .render_page(&PageContext {
569                title: "Intro",
570                site_title: "",
571                search_enabled: true,
572                has_components_css: false,
573                has_component_island: false,
574                is_home: false,
575                has_diff: false,
576                graph_json: "",
577                graph_node_count: 0,
578                graph_edge_count: 0,
579                description: "",
580                home: None,
581                base: "",
582                slug: "x",
583                body_html: "",
584                tree: &[],
585                backlinks: &[],
586                headings: &[],
587                commit: "",
588                built: "",
589                has_history: false,
590                has_mermaid: false,
591                has_base_island: false,
592                has_math: false,
593            })
594            .unwrap();
595        assert!(html.contains("<title>Intro</title>"));
596        assert!(!html.contains("<base"));
597    }
598
599    #[test]
600    fn search_disabled_hides_search_ui() {
601        let on = renderer()
602            .render_page(&PageContext {
603                title: "X",
604                site_title: "",
605                search_enabled: true,
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_base_island: false,
626                has_math: false,
627            })
628            .unwrap();
629        assert!(on.contains("data-docgen-search"));
630
631        let off = renderer()
632            .render_page(&PageContext {
633                title: "X",
634                site_title: "",
635                search_enabled: false,
636                has_components_css: false,
637                has_component_island: false,
638                is_home: false,
639                has_diff: false,
640                graph_json: "",
641                graph_node_count: 0,
642                graph_edge_count: 0,
643                description: "",
644                home: None,
645                base: "",
646                slug: "x",
647                body_html: "",
648                tree: &[],
649                backlinks: &[],
650                headings: &[],
651                commit: "",
652                built: "",
653                has_history: false,
654                has_mermaid: false,
655                has_base_island: false,
656                has_math: false,
657            })
658            .unwrap();
659        assert!(!off.contains("data-docgen-search"));
660        assert!(!off.contains("/search.js"));
661    }
662
663    #[test]
664    fn base_prefixes_every_asset_and_nav_link_and_emits_no_base_tag() {
665        // A sub-path deployment must rewrite every root-absolute URL to live under
666        // `base`; <base> alone cannot do this (it only affects relative URLs).
667        let tree = vec![TreeNode::Doc {
668            name: "guide".into(),
669            slug: "guide".into(),
670            title: "Guide".into(),
671        }];
672        let html = renderer()
673            .render_page(&PageContext {
674                title: "X",
675                site_title: "",
676                search_enabled: true,
677                has_components_css: true,
678                has_component_island: false,
679                is_home: false,
680                has_diff: true,
681                graph_json: "",
682                graph_node_count: 0,
683                graph_edge_count: 0,
684                description: "",
685                home: None,
686                base: "/docs",
687                slug: "x",
688                body_html: "",
689                tree: &tree,
690                backlinks: &[],
691                headings: &[],
692                commit: "",
693                built: "",
694                has_history: false,
695                has_mermaid: false,
696                has_base_island: false,
697                has_math: false,
698            })
699            .unwrap();
700        // No <base> tag — links are prefixed directly so they actually resolve.
701        assert!(!html.contains("<base"));
702        // Assets under base.
703        assert!(html.contains(r#"href="/docs/docgen.css""#));
704        assert!(html.contains(r#"href="/docs/components.css""#));
705        assert!(html.contains(r#"src="/docs/bootstrap.js""#));
706        assert!(html.contains(r#"src="/docs/search.js""#));
707        // Nav + diff links under base. (The sidebar graph link was removed;
708        // the graph now lives on the home page, covered by its own test.)
709        assert!(html.contains(r#"href="/docs/guide""#));
710        assert!(html.contains(r#"href="/docs/diff""#));
711        // Nothing left at the bare root.
712        assert!(!html.contains(r#"href="/docgen.css""#));
713        assert!(!html.contains(r#"src="/bootstrap.js""#));
714    }
715
716    #[test]
717    fn renders_sidebar_links() {
718        let tree = vec![TreeNode::Doc {
719            name: "intro".into(),
720            slug: "guide/intro".into(),
721            title: "Intro".into(),
722        }];
723        let html = renderer()
724            .render_page(&PageContext {
725                title: "X",
726                slug: "x",
727                body_html: "",
728                tree: &tree,
729                backlinks: &[],
730                headings: &[],
731                commit: "",
732                built: "",
733                has_history: false,
734                has_mermaid: false,
735                has_base_island: false,
736                has_math: false,
737                base: "",
738                site_title: "",
739                search_enabled: true,
740                has_components_css: false,
741                has_component_island: false,
742                is_home: false,
743                has_diff: false,
744                graph_json: "",
745                graph_node_count: 0,
746                graph_edge_count: 0,
747                description: "",
748                home: None,
749            })
750            .unwrap();
751        assert!(html.contains(r#"href="/guide/intro""#));
752        assert!(html.contains(">Intro</a>"));
753    }
754
755    #[test]
756    fn escapes_title_and_sidebar_text_but_not_body() {
757        let tree = vec![TreeNode::Doc {
758            name: "intro".into(),
759            slug: "guide/intro".into(),
760            title: "A & B <x>".into(),
761        }];
762        let html = renderer()
763            .render_page(&PageContext {
764                title: "Tom & Jerry <script>",
765                slug: "tj",
766                body_html: "<p>raw & ok</p>",
767                tree: &tree,
768                backlinks: &[],
769                headings: &[],
770                commit: "",
771                built: "",
772                has_history: false,
773                has_mermaid: false,
774                has_base_island: false,
775                has_math: false,
776                base: "",
777                site_title: "",
778                search_enabled: true,
779                has_components_css: false,
780                has_component_island: false,
781                is_home: false,
782                has_diff: false,
783                graph_json: "",
784                graph_node_count: 0,
785                graph_edge_count: 0,
786                description: "",
787                home: None,
788            })
789            .unwrap();
790        // Title is HTML-escaped.
791        assert!(html.contains("<title>Tom &amp; Jerry &lt;script&gt;</title>"));
792        assert!(!html.contains("<title>Tom & Jerry <script>"));
793        // Sidebar link text is escaped.
794        assert!(html.contains("A &amp; B &lt;x&gt;"));
795        // Body marked `| safe` is emitted raw.
796        assert!(html.contains("<p>raw & ok</p>"));
797    }
798
799    #[test]
800    fn renders_backlinks_section() {
801        use docgen_core::model::Backlink;
802        let backlinks = vec![Backlink {
803            slug: "a".into(),
804            title: "Page A".into(),
805            description: Some("All about A".into()),
806        }];
807        let html = renderer()
808            .render_page(&PageContext {
809                title: "X",
810                slug: "x",
811                body_html: "",
812                tree: &[],
813                backlinks: &backlinks,
814                headings: &[],
815                commit: "",
816                built: "",
817                has_history: false,
818                has_mermaid: false,
819                has_base_island: false,
820                has_math: false,
821                base: "",
822                site_title: "",
823                search_enabled: true,
824                has_components_css: false,
825                has_component_island: false,
826                is_home: false,
827                has_diff: false,
828                graph_json: "",
829                graph_node_count: 0,
830                graph_edge_count: 0,
831                description: "",
832                home: None,
833            })
834            .unwrap();
835        // Backlinks now live in the right rail's "Referenced by" section as cards.
836        assert!(html.contains("Referenced by"));
837        assert!(html.contains(r#"class="docgen-rail__backlink" href="/a""#));
838        assert!(html.contains("<span>Page A</span>"));
839        assert!(html.contains("<small>All about A</small>"));
840        // The old in-content backlinks block is gone.
841        assert!(!html.contains("docgen-backlinks"));
842    }
843
844    #[test]
845    fn omits_backlinks_section_when_empty() {
846        let html = renderer()
847            .render_page(&PageContext {
848                title: "X",
849                slug: "x",
850                body_html: "",
851                tree: &[],
852                backlinks: &[],
853                headings: &[],
854                commit: "",
855                built: "",
856                has_history: false,
857                has_mermaid: false,
858                has_base_island: false,
859                has_math: false,
860                base: "",
861                site_title: "",
862                search_enabled: true,
863                has_components_css: false,
864                has_component_island: false,
865                is_home: false,
866                has_diff: false,
867                graph_json: "",
868                graph_node_count: 0,
869                graph_edge_count: 0,
870                description: "",
871                home: None,
872            })
873            .unwrap();
874        // No backlinks → the "Referenced by" rail section is omitted entirely.
875        assert!(!html.contains("Referenced by"));
876        assert!(!html.contains("docgen-rail__backlink"));
877    }
878
879    #[test]
880    fn renders_diff_link_only_when_has_diff() {
881        let with = renderer()
882            .render_page(&PageContext {
883                title: "X",
884                slug: "guide/intro",
885                body_html: "",
886                tree: &[],
887                backlinks: &[],
888                headings: &[],
889                commit: "",
890                built: "",
891                has_history: false,
892                has_mermaid: false,
893                has_base_island: false,
894                has_math: false,
895                base: "",
896                site_title: "",
897                search_enabled: true,
898                has_components_css: false,
899                has_component_island: false,
900                is_home: false,
901                has_diff: true,
902                graph_json: "",
903                graph_node_count: 0,
904                graph_edge_count: 0,
905                description: "",
906                home: None,
907            })
908            .unwrap();
909        assert!(with.contains(r#"href="/diff""#));
910
911        let without = renderer()
912            .render_page(&PageContext {
913                title: "X",
914                slug: "guide/intro",
915                body_html: "",
916                tree: &[],
917                backlinks: &[],
918                headings: &[],
919                commit: "",
920                built: "",
921                has_history: false,
922                has_mermaid: false,
923                has_base_island: false,
924                has_math: false,
925                base: "",
926                site_title: "",
927                search_enabled: true,
928                has_components_css: false,
929                has_component_island: false,
930                is_home: false,
931                has_diff: false,
932                graph_json: "",
933                graph_node_count: 0,
934                graph_edge_count: 0,
935                description: "",
936                home: None,
937            })
938            .unwrap();
939        assert!(!without.contains(r#"href="/diff""#));
940    }
941
942    #[test]
943    fn page_loads_bootstrap_and_alpine_and_gates_mermaid_island() {
944        let html = renderer()
945            .render_page(&PageContext {
946                title: "X",
947                slug: "x",
948                body_html: "",
949                tree: &[],
950                backlinks: &[],
951                headings: &[],
952                commit: "",
953                built: "",
954                has_history: false,
955                has_mermaid: false,
956                has_base_island: false,
957                has_math: false,
958                base: "",
959                site_title: "",
960                search_enabled: true,
961                has_components_css: false,
962                has_component_island: false,
963                is_home: false,
964                has_diff: false,
965                graph_json: "",
966                graph_node_count: 0,
967                graph_edge_count: 0,
968                description: "",
969                home: None,
970            })
971            .unwrap();
972        assert!(html.contains(r#"src="/bootstrap.js""#));
973        assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
974        assert!(!html.contains("islands/mermaid.js")); // gated off
975
976        let withm = renderer()
977            .render_page(&PageContext {
978                title: "X",
979                slug: "x",
980                body_html: "",
981                tree: &[],
982                backlinks: &[],
983                headings: &[],
984                commit: "",
985                built: "",
986                has_history: false,
987                has_mermaid: true,
988                has_base_island: false,
989                has_math: false,
990                base: "",
991                site_title: "",
992                search_enabled: true,
993                has_components_css: false,
994                has_component_island: false,
995                is_home: false,
996                has_diff: false,
997                graph_json: "",
998                graph_node_count: 0,
999                graph_edge_count: 0,
1000                description: "",
1001                home: None,
1002            })
1003            .unwrap();
1004        assert!(withm.contains(r#"src="/islands/mermaid.js""#));
1005    }
1006
1007    #[test]
1008    fn page_gates_bases_island_on_has_base_island() {
1009        let ctx = |has_base_island: bool| PageContext {
1010            title: "X",
1011            slug: "x",
1012            body_html: "",
1013            tree: &[],
1014            backlinks: &[],
1015            headings: &[],
1016            commit: "",
1017            built: "",
1018            has_history: false,
1019            has_mermaid: false,
1020            has_base_island,
1021            has_math: false,
1022            base: "",
1023            site_title: "",
1024            search_enabled: true,
1025            has_components_css: false,
1026            has_component_island: false,
1027            is_home: false,
1028            has_diff: false,
1029            graph_json: "",
1030            graph_node_count: 0,
1031            graph_edge_count: 0,
1032            description: "",
1033            home: None,
1034        };
1035        let off = renderer().render_page(&ctx(false)).unwrap();
1036        assert!(!off.contains("islands/bases.js")); // gated off
1037        let on = renderer().render_page(&ctx(true)).unwrap();
1038        assert!(on.contains(r#"src="/islands/bases.js""#));
1039    }
1040
1041    #[test]
1042    fn page_links_katex_css_only_when_has_math() {
1043        let no_math = renderer()
1044            .render_page(&PageContext {
1045                title: "X",
1046                slug: "x",
1047                body_html: "",
1048                tree: &[],
1049                backlinks: &[],
1050                headings: &[],
1051                commit: "",
1052                built: "",
1053                has_history: false,
1054                has_mermaid: false,
1055                has_base_island: false,
1056                has_math: false,
1057                base: "",
1058                site_title: "",
1059                search_enabled: true,
1060                has_components_css: false,
1061                has_component_island: false,
1062                is_home: false,
1063                has_diff: false,
1064                graph_json: "",
1065                graph_node_count: 0,
1066                graph_edge_count: 0,
1067                description: "",
1068                home: None,
1069            })
1070            .unwrap();
1071        assert!(!no_math.contains("katex.min.css"));
1072
1073        let with_math = renderer()
1074            .render_page(&PageContext {
1075                title: "X",
1076                slug: "x",
1077                body_html: "",
1078                tree: &[],
1079                backlinks: &[],
1080                headings: &[],
1081                commit: "",
1082                built: "",
1083                has_history: false,
1084                has_mermaid: false,
1085                has_base_island: false,
1086                has_math: true,
1087                base: "",
1088                site_title: "",
1089                search_enabled: true,
1090                has_components_css: false,
1091                has_component_island: false,
1092                is_home: false,
1093                has_diff: false,
1094                graph_json: "",
1095                graph_node_count: 0,
1096                graph_edge_count: 0,
1097                description: "",
1098                home: None,
1099            })
1100            .unwrap();
1101        assert!(with_math.contains(r#"href="/vendor/katex/katex.min.css""#));
1102    }
1103
1104    #[test]
1105    #[allow(deprecated)] // SEARCH_JS kept one phase as a byte-identical re-export
1106    fn ships_self_contained_search_assets() {
1107        assert!(SEARCH_JS.contains("search-index.json"));
1108        assert!(SEARCH_JS.contains("metaKey"));
1109        assert!(!SEARCH_JS.contains("import ")); // no module imports / npm
1110    }
1111
1112    // ---- editor live-preview document ----
1113
1114    #[test]
1115    fn preview_is_content_only_with_real_asset_stack() {
1116        let r = renderer();
1117        let html = r
1118            .render_preview(&PreviewContext {
1119                title: "Intro",
1120                body_html: r#"<h1>Intro</h1><p>See <a class="docgen-wikilink" href="/guide">g</a></p>"#,
1121                base: "",
1122                has_mermaid: false,
1123                has_math: false,
1124                has_components_css: false,
1125                has_component_island: false,
1126            })
1127            .unwrap();
1128        // The article content is emitted raw inside the published prose wrapper.
1129        assert!(html.contains(r#"<article class="docgen-doc-content">"#));
1130        assert!(html.contains(r#"href="/guide""#));
1131        // Same content + island stack a built page uses — so islands hydrate.
1132        assert!(html.contains(r#"href="/docgen.css""#));
1133        assert!(html.contains(r#"href="/code.css""#));
1134        assert!(html.contains(r#"src="/bootstrap.js""#));
1135        assert!(html.contains(r#"src="/islands/wikilink.js""#));
1136        assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
1137        // No app chrome: this is a preview pane, not a full page.
1138        assert!(!html.contains("docgen-topbar"));
1139        assert!(!html.contains("docgen-sidebar"));
1140        assert!(!html.contains("docgen-rail"));
1141        // Conditional assets gated off.
1142        assert!(!html.contains("islands/mermaid.js"));
1143        assert!(!html.contains("components.css"));
1144        assert!(!html.contains("katex.min.css"));
1145    }
1146
1147    #[test]
1148    fn preview_gates_mermaid_math_and_component_assets() {
1149        let r = renderer();
1150        let html = r
1151            .render_preview(&PreviewContext {
1152                title: "D",
1153                body_html: r#"<div class="docgen-mermaid"></div>"#,
1154                base: "",
1155                has_mermaid: true,
1156                has_math: true,
1157                has_components_css: true,
1158                has_component_island: true,
1159            })
1160            .unwrap();
1161        assert!(html.contains(r#"src="/islands/mermaid.js""#));
1162        assert!(html.contains(r#"href="/vendor/katex/katex.min.css""#));
1163        assert!(html.contains(r#"href="/components.css""#));
1164        assert!(html.contains(r#"src="/components.js""#));
1165    }
1166
1167    #[test]
1168    fn preview_prefixes_base() {
1169        let r = renderer();
1170        let html = r
1171            .render_preview(&PreviewContext {
1172                title: "X",
1173                body_html: "<p>x</p>",
1174                base: "/docs",
1175                has_mermaid: true,
1176                has_math: false,
1177                has_components_css: false,
1178                has_component_island: false,
1179            })
1180            .unwrap();
1181        assert!(html.contains(r#"href="/docs/docgen.css""#));
1182        assert!(html.contains(r#"src="/docs/bootstrap.js""#));
1183        assert!(html.contains(r#"src="/docs/islands/mermaid.js""#));
1184        assert!(!html.contains(r#"href="/docgen.css""#));
1185    }
1186
1187    fn sample_buckets() -> Vec<TimelineBucketView> {
1188        vec![TimelineBucketView {
1189            label: "Today".into(),
1190            points: vec![TimelinePointView {
1191                short_hash: "abc1234".into(),
1192                subject: "edit a".into(),
1193                author: Some("docgen test".into()),
1194                date: Some("2026-05-15".into()),
1195                added_lines: 1,
1196                removed_lines: 1,
1197                files: vec![FileView {
1198                    path: "docs/a.md".into(),
1199                    status: "modified".into(),
1200                    hunks: vec![HunkView {
1201                        lines: vec![
1202                            LineView {
1203                                kind: "context".into(),
1204                                text: "# A".into(),
1205                                old_line: Some(1),
1206                                new_line: Some(1),
1207                            },
1208                            LineView {
1209                                kind: "removed".into(),
1210                                text: "first".into(),
1211                                old_line: Some(2),
1212                                new_line: None,
1213                            },
1214                            LineView {
1215                                kind: "added".into(),
1216                                text: "second".into(),
1217                                old_line: None,
1218                                new_line: Some(2),
1219                            },
1220                        ],
1221                    }],
1222                }],
1223            }],
1224        }]
1225    }
1226
1227    // ---- P4 B-4: /graph/ page ----
1228
1229    #[test]
1230    fn renders_graph_page_with_embedded_json_and_island() {
1231        let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1232        let json = r#"{"nodes":[{"slug":"a","title":"A","x":1.0,"y":2.0,"degree":0}],"edges":[]}"#;
1233        let html = r
1234            .render_graph(&GraphContext {
1235                tree: &[],
1236                graph_json: json,
1237                node_count: 1,
1238                has_diff: false,
1239                edge_count: 0,
1240                base: "",
1241                site_title: "",
1242                search_enabled: true,
1243            })
1244            .unwrap();
1245        assert!(html.contains("<title>Graph</title>"));
1246        assert!(html.contains(r#"id="docgen-graph-data""#));
1247        assert!(html.contains(r#"type="application/json""#));
1248        assert!(html.contains(json)); // JSON embedded verbatim, NOT escaped
1249        assert!(html.contains(r#"x-data="docgenGraph""#));
1250        assert!(html.contains(r#"src="/islands/graph.js""#));
1251        assert!(html.contains(r#"src="/bootstrap.js""#));
1252        assert!(html.contains(r#"src="/vendor/alpine/alpine.min.js""#));
1253        assert!(html.contains("1 nodes")); // meta caption
1254    }
1255
1256    #[test]
1257    fn graph_page_renders_sidebar_tree() {
1258        let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1259        let tree = vec![docgen_core::model::TreeNode::Doc {
1260            name: "intro".into(),
1261            slug: "guide/intro".into(),
1262            title: "Intro".into(),
1263        }];
1264        let html = r
1265            .render_graph(&GraphContext {
1266                tree: &tree,
1267                graph_json: r#"{"nodes":[],"edges":[]}"#,
1268                node_count: 0,
1269                has_diff: false,
1270                edge_count: 0,
1271                base: "",
1272                site_title: "",
1273                search_enabled: true,
1274            })
1275            .unwrap();
1276        assert!(html.contains(r#"href="/guide/intro""#));
1277    }
1278
1279    #[test]
1280    fn embedded_json_neutralizes_script_close() {
1281        let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1282        let json = r#"{"nodes":[{"slug":"x","title":"a</script>b","x":0.0,"y":0.0,"degree":0}],"edges":[]}"#;
1283        let html = r
1284            .render_graph(&GraphContext {
1285                tree: &[],
1286                graph_json: json,
1287                node_count: 1,
1288                has_diff: false,
1289                edge_count: 0,
1290                base: "",
1291                site_title: "",
1292                search_enabled: true,
1293            })
1294            .unwrap();
1295        assert!(!html.contains("a</script>b")); // raw close-tag must not survive
1296        assert!(html.contains(r#"a\u003c/script>b"#)); // escaped form present
1297    }
1298
1299    /// `</script>` is not the only way out of a `<script>` element. Per the HTML
1300    /// tokenizer, `<!--` followed by `<script` puts it in "script data double
1301    /// escaped" state, where the NEXT `</script>` does not close the element — it
1302    /// swallows the following markup instead, so the payload stops being valid
1303    /// JSON and the island dies. Escaping only `</` does nothing about this,
1304    /// which is why every `<` has to go (`<` is valid JSON and parses back
1305    /// to `<`, so the island is unaffected).
1306    #[test]
1307    fn embedded_json_neutralizes_script_double_escape_sequence() {
1308        let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1309        // A doc TITLE is enough to carry this: it is author-controlled text that
1310        // reaches graph_json verbatim.
1311        let json = r#"{"nodes":[{"slug":"x","title":"<!--<script>","x":0.0,"y":0.0,"degree":0}],"edges":[]}"#;
1312        let ctx = GraphContext {
1313            tree: &[],
1314            graph_json: json,
1315            node_count: 1,
1316            has_diff: false,
1317            edge_count: 0,
1318            base: "",
1319            site_title: "",
1320            search_enabled: true,
1321        };
1322        let html = r.render_graph(&ctx).unwrap();
1323        assert!(
1324            !html.contains("<!--<script>"),
1325            "the double-escape sequence must not survive into the payload"
1326        );
1327        assert!(html.contains(r#"\u003c!--\u003cscript>"#));
1328
1329        // render_page embeds the same payload and needs the same guard.
1330        let page = r
1331            .render_page(&PageContext {
1332                title: "T",
1333                slug: "s",
1334                body_html: "",
1335                tree: &[],
1336                backlinks: &[],
1337                headings: &[],
1338                commit: "",
1339                built: "",
1340                has_history: false,
1341                has_mermaid: false,
1342                has_math: false,
1343                base: "",
1344                site_title: "",
1345                search_enabled: true,
1346                has_components_css: false,
1347                has_component_island: false,
1348                has_base_island: false,
1349                is_home: true,
1350                has_diff: false,
1351                graph_json: json,
1352                graph_node_count: 1,
1353                graph_edge_count: 0,
1354                description: "",
1355                home: None,
1356            })
1357            .unwrap();
1358        assert!(!page.contains("<!--<script>"));
1359        // Positive counterpart: proves the payload block was actually emitted
1360        // with the escaped bytes, so this half cannot pass vacuously if the
1361        // `is_home`-gated embed ever regresses to not rendering at all.
1362        assert!(page.contains(r#"\u003c!--\u003cscript>"#));
1363    }
1364
1365    #[test]
1366    fn graph_page_renders_graph_canvas_without_sidebar_link() {
1367        let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1368        let html = r
1369            .render_graph(&GraphContext {
1370                tree: &[],
1371                graph_json: r#"{"nodes":[],"edges":[]}"#,
1372                node_count: 0,
1373                has_diff: false,
1374                edge_count: 0,
1375                base: "",
1376                site_title: "",
1377                search_enabled: true,
1378            })
1379            .unwrap();
1380        // The standalone /graph page still renders its graph canvas + island.
1381        assert!(html.contains(r#"x-data="docgenGraph""#));
1382        assert!(html.contains("docgen-graph__svg"));
1383        // The sidebar graph link was removed (the graph lives on the home page).
1384        assert!(!html.contains("docgen-sidebar__graph"));
1385    }
1386
1387    #[test]
1388    fn home_page_embeds_graph_and_non_home_does_not() {
1389        let r = renderer();
1390        let ctx = |is_home: bool, graph_json: &'static str| PageContext {
1391            title: "X",
1392            slug: if is_home { "index" } else { "x" },
1393            body_html: "",
1394            tree: &[],
1395            backlinks: &[],
1396            headings: &[],
1397            commit: "",
1398            built: "",
1399            has_history: false,
1400            has_mermaid: false,
1401            has_base_island: false,
1402            has_math: false,
1403            base: "",
1404            site_title: "",
1405            search_enabled: true,
1406            has_diff: false,
1407            has_components_css: false,
1408            has_component_island: false,
1409            is_home,
1410            graph_json,
1411            graph_node_count: 2,
1412            graph_edge_count: 1,
1413            description: "",
1414            home: None,
1415        };
1416        // Home page with graph data: embeds the graph block + data + island script.
1417        let home = r
1418            .render_page(&ctx(true, r#"{"nodes":[],"edges":[]}"#))
1419            .unwrap();
1420        assert!(home.contains("docgen-home-graph"));
1421        assert!(home.contains(r#"id="docgen-graph-data""#));
1422        assert!(home.contains(r#"x-data="docgenGraph""#));
1423        assert!(home.contains("islands/graph.js"));
1424        // The sidebar graph link is gone.
1425        assert!(!home.contains("docgen-sidebar__graph"));
1426        // A non-home page (even if a graph_json were passed) embeds nothing.
1427        let other = r.render_page(&ctx(false, "")).unwrap();
1428        assert!(!other.contains("docgen-home-graph"));
1429        assert!(!other.contains("islands/graph.js"));
1430    }
1431
1432    #[test]
1433    fn renders_history_timeline_with_buckets_and_diff_lines() {
1434        let buckets = sample_buckets();
1435        let html = renderer()
1436            .render_history(&HistoryContext {
1437                title: "A",
1438                slug: "a",
1439                tree: &[],
1440                buckets: &buckets,
1441                base: "",
1442                site_title: "",
1443                search_enabled: true,
1444            })
1445            .unwrap();
1446        assert!(html.contains("<title>History: A</title>"));
1447        assert!(html.contains("Today"));
1448        assert!(html.contains("edit a"));
1449        assert!(html.contains("abc1234"));
1450        assert!(html.contains("docgen-diff-line--removed"));
1451        assert!(html.contains("docgen-diff-line--added"));
1452        assert!(html.contains("first"));
1453        assert!(html.contains(r#"href="/a""#));
1454    }
1455
1456    #[test]
1457    fn history_escapes_diff_text() {
1458        let buckets = vec![TimelineBucketView {
1459            label: "Today".into(),
1460            points: vec![TimelinePointView {
1461                short_hash: "abc1234".into(),
1462                subject: "edit".into(),
1463                author: None,
1464                date: None,
1465                added_lines: 1,
1466                removed_lines: 0,
1467                files: vec![FileView {
1468                    path: "docs/a.md".into(),
1469                    status: "modified".into(),
1470                    hunks: vec![HunkView {
1471                        lines: vec![LineView {
1472                            kind: "added".into(),
1473                            text: "<script>alert(1)</script>".into(),
1474                            old_line: None,
1475                            new_line: Some(1),
1476                        }],
1477                    }],
1478                }],
1479            }],
1480        }];
1481        let html = renderer()
1482            .render_history(&HistoryContext {
1483                title: "A",
1484                slug: "a",
1485                tree: &[],
1486                buckets: &buckets,
1487                base: "",
1488                site_title: "",
1489                search_enabled: true,
1490            })
1491            .unwrap();
1492        assert!(html.contains("&lt;script&gt;alert(1)&lt;&#x2f;script&gt;"));
1493        assert!(!html.contains("<script>alert(1)</script>"));
1494    }
1495
1496    // ---- P7 Cluster A: app shell, themes, theme-toggle, sidebar tree ----
1497
1498    fn page(slug: &str, tree: &[TreeNode]) -> String {
1499        renderer()
1500            .render_page(&PageContext {
1501                title: "X",
1502                slug,
1503                body_html: "<p>hi</p>",
1504                tree,
1505                backlinks: &[],
1506                headings: &[],
1507                commit: "",
1508                built: "",
1509                has_history: false,
1510                has_mermaid: false,
1511                has_base_island: false,
1512                has_math: false,
1513                base: "",
1514                site_title: "Docs",
1515                search_enabled: true,
1516                has_components_css: false,
1517                has_component_island: false,
1518                is_home: false,
1519                has_diff: false,
1520                graph_json: "",
1521                graph_node_count: 0,
1522                graph_edge_count: 0,
1523                description: "",
1524                home: None,
1525            })
1526            .unwrap()
1527    }
1528
1529    #[test]
1530    fn page_has_app_shell() {
1531        let html = page("x", &[]);
1532        for cls in [
1533            "docgen-app",
1534            "docgen-topbar",
1535            "docgen-layout",
1536            "docgen-sidebar",
1537            "docgen-content",
1538            "docgen-doc-content",
1539        ] {
1540            assert!(html.contains(cls), "app shell missing {cls}");
1541        }
1542    }
1543
1544    #[test]
1545    fn page_has_no_flash_script_in_head() {
1546        let html = page("x", &[]);
1547        let script_at = html
1548            .find("localStorage.getItem('doc-theme')")
1549            .expect("no-flash script present");
1550        let css_at = html.find("/docgen.css").expect("docgen.css link present");
1551        assert!(
1552            script_at < css_at,
1553            "no-flash script must precede docgen.css link"
1554        );
1555        assert!(html.contains("prefers-color-scheme"));
1556        // Dark is the bare default: pre-paint falls back to dark, not light.
1557        assert!(html.contains("'light':'dark'"));
1558    }
1559
1560    #[test]
1561    fn page_has_theme_toggle_island() {
1562        let html = page("x", &[]);
1563        assert!(html.contains(r#"x-data="docgenThemeToggle""#));
1564        assert!(html.contains("/islands/theme-toggle.js"));
1565        // Dark is the bare default: <html> carries NO data-theme attr server-side;
1566        // the pre-paint script sets it (dark when nothing stored / no light pref).
1567        assert!(!html.contains(r#"<html lang="en" data-theme="#));
1568    }
1569
1570    #[test]
1571    fn sidebar_marks_active_doc() {
1572        let tree = vec![TreeNode::Doc {
1573            name: "a".into(),
1574            slug: "a".into(),
1575            title: "A".into(),
1576        }];
1577        let active = page("a", &tree);
1578        assert!(active.contains(r#"docgen-tree__item is-active"#));
1579        assert!(active.contains(r#"aria-current="page""#));
1580
1581        let inactive = page("b", &tree);
1582        assert!(!inactive.contains(r#"docgen-tree__item is-active"#));
1583        assert!(!inactive.contains(r#"aria-current="page""#));
1584    }
1585
1586    #[test]
1587    fn sidebar_renders_nested_dir_as_details() {
1588        let tree = vec![TreeNode::Dir {
1589            name: "guide".into(),
1590            slug: None,
1591            children: vec![TreeNode::Doc {
1592                name: "intro".into(),
1593                slug: "guide/intro".into(),
1594                title: "Intro".into(),
1595            }],
1596        }];
1597        let html = page("x", &tree);
1598        assert!(html.contains("<details"));
1599        assert!(html.contains("<summary"));
1600        assert!(html.contains("docgen-tree"));
1601        // Each folder carries a stable path key so its collapse state persists.
1602        assert!(html.contains(r#"data-tree-path="/guide""#));
1603    }
1604
1605    #[test]
1606    fn graph_and_history_share_shell() {
1607        let r = Renderer::new(DEFAULT_PAGE_TEMPLATE).unwrap();
1608        let graph = r
1609            .render_graph(&GraphContext {
1610                tree: &[],
1611                graph_json: r#"{"nodes":[],"edges":[]}"#,
1612                node_count: 0,
1613                has_diff: false,
1614                edge_count: 0,
1615                base: "",
1616                site_title: "",
1617                search_enabled: true,
1618            })
1619            .unwrap();
1620        let hist = r
1621            .render_history(&HistoryContext {
1622                title: "A",
1623                slug: "a",
1624                tree: &[],
1625                buckets: &[],
1626                base: "",
1627                site_title: "",
1628                search_enabled: true,
1629            })
1630            .unwrap();
1631        for html in [&graph, &hist] {
1632            assert!(html.contains("docgen-topbar"));
1633            assert!(html.contains("data-theme"));
1634            assert!(html.contains("/islands/theme-toggle.js"));
1635            assert!(html.contains("localStorage.getItem('doc-theme')"));
1636        }
1637    }
1638}