Skip to main content

plates_render/
html.rs

1//! Full HTML document assembly: wraps rendered page bodies in the site shell
2//! (head, nav, breadcrumbs, footer, interactivity script) and produces the
3//! static CSS/favicon assets.
4//!
5//! Appearance is a *caller-supplied* input ([`SiteStyle`]) with built-in
6//! defaults. A publishing client can pass a color theme, fully-custom CSS, or a
7//! custom favicon; when it passes nothing, the server-side default styling
8//! (the bundled stylesheet, no favicon) is used. The same renderer runs
9//! client-side (publish plugin) and server-side (ARK Layer 3 render-on-write).
10//!
11//! The *document* around the body is caller-supplied on the same terms: a
12//! [`ShellTemplate`] replaces the built-in shell below, filling the same
13//! [`ShellSlots`] it does. Both shells are assembled from one set of slots
14//! precisely so a template cannot see a different page than the default does.
15
16use crate::appearance::{FaviconAsset, ThemeAppearance};
17
18use crate::links::root_prefix;
19use crate::page::{
20    html_escape, render_breadcrumb, render_full_breadcrumbs, render_site_nav, title_to_anchor,
21};
22use crate::shell::{ShellSlots, ShellTemplate};
23use crate::types::{PageLayout, PublishedPage, SiteNavigation};
24
25/// Caller-supplied appearance for the rendered site.
26///
27/// All fields are optional; an empty `SiteStyle` yields the built-in default
28/// styling. Precedence:
29/// - CSS: [`custom_css`](Self::custom_css) replaces the stylesheet entirely;
30///   otherwise the bundled base CSS is used, with [`theme`](Self::theme) color
31///   overrides appended when present.
32/// - Favicon: [`custom_favicon`](Self::custom_favicon) wins; otherwise the
33///   theme's favicon (or its accent-derived default) is used; otherwise none.
34/// - Footer: [`generator`](Self::generator) names the tool that built the site,
35///   or `None` for no attribution line at all.
36#[derive(Debug, Clone, Default)]
37pub struct SiteStyle {
38    /// Color theme (palette + optional favicon). `None` → default palette.
39    pub theme: Option<ThemeAppearance>,
40    /// Fully custom stylesheet, replacing the built-in CSS entirely.
41    pub custom_css: Option<String>,
42    /// Custom favicon, overriding the theme/default favicon.
43    pub custom_favicon: Option<FaviconAsset>,
44    /// Who to credit in the site footer. `None` → no footer.
45    pub generator: Option<Generator>,
46}
47
48/// The tool that built the site, credited in the footer of every shell that
49/// carries one.
50///
51/// The engine does not name itself. A renderer with no generator writes an
52/// empty `footer` slot and no `<footer>` element, so a caller that wants a
53/// "Generated by …" line is the one that asks for it — and a caller embedding
54/// this crate in something else credits *that*, rather than shipping a footer
55/// pointing at a program its readers never ran.
56#[derive(Debug, Clone)]
57pub struct Generator {
58    /// Display name, e.g. `"Diaryx"`. HTML-escaped when rendered.
59    pub name: String,
60    /// Where the name links, if anywhere. HTML-escaped when rendered.
61    pub url: Option<String>,
62}
63
64impl Generator {
65    /// A generator credited by name alone, with no link.
66    pub fn new(name: impl Into<String>) -> Self {
67        Self {
68            name: name.into(),
69            url: None,
70        }
71    }
72
73    /// A generator whose name links to `url`.
74    pub fn linked(name: impl Into<String>, url: impl Into<String>) -> Self {
75        Self {
76            name: name.into(),
77            url: Some(url.into()),
78        }
79    }
80}
81
82/// The well-known filename [`HtmlRenderer::static_assets`] writes
83/// [`ISLAND_CHILD_SCRIPT`] to.
84pub const ISLAND_CHILD_SCRIPT_FILENAME: &str = "diaryx-island.js";
85
86/// The child half of the island resize protocol, for an embedded HTML document
87/// to load with `<script src="…/diaryx-island.js"></script>`.
88///
89/// An island (`![alt](page.html)`) is an `<iframe>` whose height the parent page
90/// cannot read: the frame is sandboxed without `allow-same-origin`, so its
91/// document is cross-origin by construction. The two sides therefore agree by
92/// `postMessage`, and this is the half that lives inside the frame — see
93/// [`HtmlRenderer::interactivity_script`] for the parent half and the protocol.
94///
95/// Written to the site root by [`HtmlRenderer::static_assets`] so an island
96/// document can reference it without the site having to ship its own copy.
97pub const ISLAND_CHILD_SCRIPT: &str = r#"(function () {
98    function measure() {
99        var doc = document.documentElement;
100        var body = document.body;
101        var height = Math.max(
102            doc ? doc.scrollHeight : 0,
103            doc ? doc.offsetHeight : 0,
104            body ? body.scrollHeight : 0,
105            body ? body.offsetHeight : 0
106        );
107        if (!height) return;
108        try {
109            parent.postMessage({ type: 'diaryx-html-attachment-size', height: height }, '*');
110        } catch (_error) {}
111    }
112
113    window.addEventListener('message', function (event) {
114        var data = event.data;
115        if (data && data.type === 'diaryx-html-attachment-measure') measure();
116    });
117    window.addEventListener('resize', measure);
118    window.addEventListener('load', measure);
119    if (document.readyState === 'complete') measure();
120})();
121"#;
122
123/// Everything about the *site* that wrapping one page in its shell needs.
124///
125/// A struct rather than seven positional arguments because the shell grew slots
126/// — a template, a language, per-page assets — and a call site that reads
127/// `(page, title, false, &nav, &seo, &feeds)` is one whose next argument goes in
128/// the wrong place.
129pub struct PageContext<'a> {
130    /// The site's name, for `<title>` and the `{{site_title}}` slot.
131    pub site_title: &'a str,
132    /// This page's nav tree and breadcrumb trail.
133    pub nav: &'a SiteNavigation,
134    /// Pre-rendered SEO `<meta>` tags, or empty.
135    pub seo_meta: &'a str,
136    /// Pre-rendered feed `<link>` tags, or empty.
137    pub feed_links: &'a str,
138    /// BCP 47 language tag for `<html lang="…">`.
139    pub lang: &'a str,
140    /// The caller's shell, or `None` for the built-in one. Ignored by a page
141    /// whose layout is [`PageLayout::Bare`].
142    pub template: Option<&'a ShellTemplate>,
143}
144
145/// Assembles complete HTML documents from rendered page bodies.
146pub struct HtmlRenderer {
147    style: SiteStyle,
148}
149
150/// The `<title>` for a page: `"Entry - Site"`, or just the site's name on the
151/// page that *is* the site.
152///
153/// A front page named after its site — which a synthesized index is, and an
154/// authored root usually is too — would otherwise be published as
155/// `"Blog - Blog"`.
156///
157/// Returns the text unescaped: it is a text slot, and escaping it here as well
158/// as where it is filled is how a site called `Ben & Co` comes out `&amp;amp;`.
159fn document_title(page_title: &str, site_title: &str) -> String {
160    if page_title == site_title {
161        site_title.to_string()
162    } else {
163        format!("{page_title} - {site_title}")
164    }
165}
166
167/// `<link rel="stylesheet">` tags for a page's own `styles:`, rebased to the
168/// page's depth exactly as the attachments in its body are.
169fn style_link_tags(styles: &[String], prefix: &str) -> Vec<String> {
170    styles
171        .iter()
172        .map(|path| {
173            format!(
174                r#"<link rel="stylesheet" href="{}{}">"#,
175                prefix,
176                html_escape(path)
177            )
178        })
179        .collect()
180}
181
182/// `<script defer src>` tags for a page's own `scripts:`.
183///
184/// `defer` rather than bare or `async`: a page script is written against the
185/// rendered body, and the built-in interactivity script it follows has already
186/// installed its listeners by then.
187fn script_tags(scripts: &[String], prefix: &str) -> Vec<String> {
188    scripts
189        .iter()
190        .map(|path| {
191            format!(
192                r#"<script defer src="{}{}"></script>"#,
193                prefix,
194                html_escape(path)
195            )
196        })
197        .collect()
198}
199
200/// Join a run of head/script tags the way both shells indent them.
201fn join_tags(tags: Vec<String>) -> String {
202    tags.join("\n    ")
203}
204
205impl HtmlRenderer {
206    /// Renderer with built-in default styling (no theme, bundled CSS).
207    pub fn new() -> Self {
208        Self {
209            style: SiteStyle::default(),
210        }
211    }
212
213    /// Renderer with a color theme overriding the default palette.
214    pub fn with_theme(theme: ThemeAppearance) -> Self {
215        Self {
216            style: SiteStyle {
217                theme: Some(theme),
218                ..SiteStyle::default()
219            },
220        }
221    }
222
223    /// Renderer with a fully caller-specified [`SiteStyle`].
224    pub fn with_style(style: SiteStyle) -> Self {
225        Self { style }
226    }
227
228    /// Get the CSS stylesheet: custom CSS if provided, otherwise the bundled
229    /// base stylesheet with theme color overrides appended.
230    fn css(&self) -> String {
231        if let Some(custom) = &self.style.custom_css {
232            return custom.clone();
233        }
234        let base = get_base_css();
235        match &self.style.theme {
236            Some(theme) => {
237                let overrides = theme.to_css_overrides();
238                if overrides.is_empty() {
239                    base.to_string()
240                } else {
241                    format!("{}\n/* ── Theme overrides ── */\n{}", base, overrides)
242                }
243            }
244            None => base.to_string(),
245        }
246    }
247
248    /// Resolve the favicon: custom favicon if provided, else the theme's
249    /// favicon (or its accent-derived default). `None` when no styling at all.
250    fn favicon(&self) -> Option<FaviconAsset> {
251        if let Some(fav) = &self.style.custom_favicon {
252            return Some(fav.clone());
253        }
254        self.style.theme.as_ref().map(|t| t.favicon_or_default())
255    }
256
257    /// Generate the `<link rel="icon">` tag for the favicon, if available.
258    fn favicon_link_tag(&self, prefix: &str) -> String {
259        match self.favicon() {
260            Some(fav) => format!(
261                r#"<link rel="icon" type="{}" href="{}{}">"#,
262                fav.mime_type, prefix, fav.filename
263            ),
264            None => String::new(),
265        }
266    }
267
268    /// The built-in interactivity: spoiler toggles, and the parent half of the
269    /// island resize bridge.
270    ///
271    /// ## The island resize protocol
272    ///
273    /// An island is a sandboxed `<iframe>` with no `allow-same-origin`, so the
274    /// page holding it cannot read the embedded document's height. Instead:
275    ///
276    /// 1. On each frame's `load`, the parent posts
277    ///    `{type: 'diaryx-html-attachment-measure'}` into it (twice, 80ms apart,
278    ///    to catch a document whose own layout settles after load).
279    /// 2. The child answers with
280    ///    `{type: 'diaryx-html-attachment-size', height}` — see
281    ///    [`ISLAND_CHILD_SCRIPT`], which is written to the site as
282    ///    [`ISLAND_CHILD_SCRIPT_FILENAME`] for island documents to load.
283    /// 3. The parent matches the reply to a frame by `event.source` and sets
284    ///    that frame's height, clamped to 200–4000px — the same range
285    ///    `![alt](x.html){height=…}` is clamped to, so an island cannot make
286    ///    itself a pixel tall or taller than any screen.
287    ///
288    /// An island whose document loads no child script simply keeps the
289    /// `min-height` its embed asked for; the protocol is an improvement on that
290    /// default, not a requirement of it.
291    pub fn interactivity_script(&self) -> &'static str {
292        r#"function clampIslandHeight(value) {
293        if (!Number.isFinite(value) || value <= 0) return null;
294        return Math.max(200, Math.min(Math.round(value), 4000));
295    }
296
297    function requestIslandMeasurement(frame) {
298        if (!frame || !frame.contentWindow) return;
299        try {
300            frame.contentWindow.postMessage({ type: 'diaryx-html-attachment-measure' }, '*');
301        } catch (_error) {}
302    }
303
304    function installSpoilers() {
305        document.querySelectorAll('.spoiler-mark').forEach(function(el) {
306            el.addEventListener('click', function() {
307                el.classList.toggle('spoiler-hidden');
308                el.classList.toggle('spoiler-revealed');
309            });
310        });
311    }
312
313    function installIslandResizeBridge() {
314        document.querySelectorAll('iframe.diaryx-island').forEach(function(frame) {
315            frame.addEventListener('load', function() {
316                requestIslandMeasurement(frame);
317                setTimeout(function() { requestIslandMeasurement(frame); }, 80);
318            });
319        });
320
321        window.addEventListener('message', function(event) {
322            var data = event.data;
323            if (!data || data.type !== 'diaryx-html-attachment-size') return;
324
325            var nextHeight = clampIslandHeight(Number(data.height));
326            if (nextHeight === null) return;
327
328            var frames = document.querySelectorAll('iframe.diaryx-island');
329            for (var i = 0; i < frames.length; i += 1) {
330                var frame = frames[i];
331                if (frame.contentWindow === event.source) {
332                    frame.style.height = String(nextHeight) + 'px';
333                    break;
334                }
335            }
336        });
337    }
338
339    installSpoilers();
340    installIslandResizeBridge();"#
341    }
342
343    /// Wrap a rendered page into a complete HTML document.
344    pub fn render_page(&self, page: &PublishedPage, site_title: &str, single_file: bool) -> String {
345        let prefix = root_prefix(&page.dest_filename);
346        let css_link = if single_file {
347            format!("<style>{}</style>", self.css())
348        } else {
349            format!(r#"<link rel="stylesheet" href="{}style.css">"#, prefix)
350        };
351        let favicon_link = self.favicon_link_tag(&prefix);
352        let interactivity_script = self.interactivity_script();
353
354        let breadcrumb_html = render_breadcrumb(page, single_file);
355
356        format!(
357            r#"<!DOCTYPE html>
358<html lang="en">
359<head>
360    <meta charset="UTF-8">
361    <meta name="viewport" content="width=device-width, initial-scale=1.0">
362    <title>{document_title}</title>
363    {css_link}
364    {favicon_link}
365</head>
366<body>
367    <main>
368        <article>
369            {breadcrumb}
370            <div class="content">
371                {content}
372            </div>
373        </article>
374    </main>
375    {footer}
376    <script>{interactivity_script}</script>
377</body>
378</html>"#,
379            document_title = html_escape(&document_title(&page.title, site_title)),
380            footer = footer_html(self.style.generator.as_ref()),
381            css_link = css_link,
382            favicon_link = favicon_link,
383            breadcrumb = breadcrumb_html,
384            content = page.rendered_body,
385            interactivity_script = interactivity_script,
386        )
387    }
388
389    /// Render all pages into a single combined document.
390    pub fn render_single_document(&self, pages: &[PublishedPage], site_title: &str) -> String {
391        let mut sections = Vec::new();
392
393        for page in pages {
394            let anchor = title_to_anchor(&page.title);
395            let breadcrumb = render_breadcrumb(page, true);
396
397            sections.push(format!(
398                r#"<section id="{anchor}">
399    {breadcrumb}
400    <div class="content">
401        {content}
402    </div>
403</section>"#,
404                anchor = html_escape(&anchor),
405                breadcrumb = breadcrumb,
406                content = page.rendered_body,
407            ));
408        }
409
410        // Build table of contents
411        let mut toc = String::from(r#"<nav class="toc"><h2>Table of Contents</h2><ul>"#);
412        for page in pages {
413            let anchor = title_to_anchor(&page.title);
414            toc.push_str(&format!(
415                r##"<li><a href="#{}">{}</a></li>"##,
416                html_escape(&anchor),
417                html_escape(&page.title)
418            ));
419        }
420        toc.push_str("</ul></nav>");
421
422        // For single-file output, inline the favicon as a data URI
423        let favicon_link = match self.favicon() {
424            Some(fav) => {
425                use base64::Engine;
426                let b64 = base64::engine::general_purpose::STANDARD.encode(&fav.data);
427                format!(
428                    r#"<link rel="icon" type="{}" href="data:{};base64,{}">"#,
429                    fav.mime_type, fav.mime_type, b64
430                )
431            }
432            None => String::new(),
433        };
434
435        let interactivity_script = self.interactivity_script();
436
437        format!(
438            r#"<!DOCTYPE html>
439<html lang="en">
440<head>
441    <meta charset="UTF-8">
442    <meta name="viewport" content="width=device-width, initial-scale=1.0">
443    <title>{site_title}</title>
444    <style>{css}</style>
445    {favicon_link}
446</head>
447<body>
448    <main>
449        {toc}
450        {sections}
451    </main>
452    {footer}
453    <script>{interactivity_script}</script>
454</body>
455</html>"#,
456            site_title = html_escape(site_title),
457            footer = footer_html(self.style.generator.as_ref()),
458            css = self.css(),
459            favicon_link = favicon_link,
460            toc = toc,
461            sections = sections.join("\n<hr>\n"),
462            interactivity_script = interactivity_script,
463        )
464    }
465
466    /// Render a page with full site context (nav, breadcrumbs, SEO, feeds),
467    /// into the caller's shell template or the built-in one.
468    ///
469    /// A page whose layout is [`PageLayout::Bare`] takes neither: it carries its
470    /// own frame, and gets only the head this crate must write for it. A
471    /// [`PageLayout::Verbatim`] page takes not even that — its body is already
472    /// the file, and the rendered document is those bytes and nothing else.
473    pub fn render_page_in_site(&self, page: &PublishedPage, ctx: &PageContext<'_>) -> String {
474        match page.layout {
475            PageLayout::Verbatim => page.rendered_body.clone(),
476            PageLayout::Bare => self.render_bare_page(page, ctx),
477            PageLayout::Site => {
478                let slots = self.site_slots(page, ctx);
479                match ctx.template {
480                    Some(template) => template.render(&slots),
481                    None => builtin_shell(&slots),
482                }
483            }
484        }
485    }
486
487    /// The slot values both site shells — built-in and caller-supplied — are
488    /// filled from.
489    fn site_slots(&self, page: &PublishedPage, ctx: &PageContext<'_>) -> ShellSlots {
490        let prefix = root_prefix(&page.dest_filename);
491
492        let mut head = vec![
493            format!(r#"<link rel="stylesheet" href="{}style.css">"#, prefix),
494            self.favicon_link_tag(&prefix),
495            ctx.seo_meta.to_string(),
496            ctx.feed_links.to_string(),
497        ];
498        head.extend(style_link_tags(&page.styles, &prefix));
499
500        let mut scripts = vec![format!(
501            r#"<script>
502    (function() {{
503        // Nav hamburger toggle
504        var toggle = document.querySelector('.nav-toggle');
505        var nav = document.querySelector('.site-nav');
506        if (toggle && nav) {{
507            toggle.addEventListener('click', function(e) {{
508                e.stopPropagation();
509                nav.classList.toggle('is-open');
510            }});
511            document.addEventListener('click', function(e) {{
512                if (!nav.contains(e.target)) nav.classList.remove('is-open');
513            }});
514        }}
515        {interactivity_script}
516    }})();
517    </script>"#,
518            interactivity_script = self.interactivity_script(),
519        )];
520        scripts.extend(script_tags(&page.scripts, &prefix));
521
522        ShellSlots {
523            lang: ctx.lang.to_string(),
524            document_title: document_title(&page.title, ctx.site_title),
525            site_title: ctx.site_title.to_string(),
526            body_class: if ctx.nav.tree.is_empty() {
527                String::new()
528            } else {
529                "has-site-nav".to_string()
530            },
531            head: join_tags(head),
532            site_nav: render_site_nav(ctx.nav, &prefix),
533            breadcrumbs: render_full_breadcrumbs(&ctx.nav.breadcrumbs, &prefix),
534            content: page.rendered_body.clone(),
535            footer: footer_html(self.style.generator.as_ref()),
536            scripts: join_tags(scripts),
537        }
538    }
539
540    /// A `layout: bare` page: the document this crate is obliged to write —
541    /// doctype, charset, viewport, title, favicon, SEO, feeds — plus the page's
542    /// own styles, its body, and its own scripts. Nothing else.
543    fn render_bare_page(&self, page: &PublishedPage, ctx: &PageContext<'_>) -> String {
544        let prefix = root_prefix(&page.dest_filename);
545
546        let mut head = vec![
547            self.favicon_link_tag(&prefix),
548            ctx.seo_meta.to_string(),
549            ctx.feed_links.to_string(),
550        ];
551        head.extend(style_link_tags(&page.styles, &prefix));
552
553        format!(
554            r#"<!DOCTYPE html>
555<html lang="{lang}">
556<head>
557    <meta charset="UTF-8">
558    <meta name="viewport" content="width=device-width, initial-scale=1.0">
559    <title>{document_title}</title>
560    {head}
561</head>
562<body>
563{content}
564    {scripts}
565</body>
566</html>"#,
567            lang = html_escape(ctx.lang),
568            document_title = html_escape(&document_title(&page.title, ctx.site_title)),
569            head = join_tags(head),
570            content = page.rendered_body,
571            scripts = join_tags(script_tags(&page.scripts, &prefix)),
572        )
573    }
574
575    /// Static assets to write alongside output files: the stylesheet, the
576    /// favicon when there is one, and the island child script.
577    ///
578    /// The island script is written unconditionally because an island document
579    /// referencing it is written by hand, and a site that publishes one has no
580    /// way to ask for the file to appear. Returns `(filename, content)` pairs.
581    pub fn static_assets(&self) -> Vec<(String, Vec<u8>)> {
582        let mut assets = vec![("style.css".to_string(), self.css().into_bytes())];
583        if let Some(fav) = self.favicon() {
584            assets.push((fav.filename, fav.data));
585        }
586        assets.push((
587            ISLAND_CHILD_SCRIPT_FILENAME.to_string(),
588            ISLAND_CHILD_SCRIPT.as_bytes().to_vec(),
589        ));
590        assets
591    }
592}
593
594/// The attribution footer both site shells carry, indented for its place inside
595/// `<div class="site-content">`.
596///
597/// Empty — no `<footer>` element at all — when no [`Generator`] is named, which
598/// is what an unbranded render is.
599fn footer_html(generator: Option<&Generator>) -> String {
600    let Some(generator) = generator else {
601        return String::new();
602    };
603    let name = html_escape(&generator.name);
604    let credit = match &generator.url {
605        Some(url) => format!(r#"<a href="{}">{}</a>"#, html_escape(url), name),
606        None => name,
607    };
608    format!("<footer>\n        <p>Generated by {credit}</p>\n    </footer>")
609}
610
611/// The built-in site shell.
612///
613/// Kept as a `format!` rather than expressed as a [`ShellTemplate`] because its
614/// inline script is full of braces that a slot syntax would have to be taught to
615/// ignore, and because the output of *this* function is what "byte-identical to
616/// what we published yesterday" means. It reads the same slots a template does,
617/// so the two shells cannot come to disagree about what a page contains.
618fn builtin_shell(slots: &ShellSlots) -> String {
619    let body_class = if slots.body_class.is_empty() {
620        String::new()
621    } else {
622        format!(r#" class="{}""#, html_escape(&slots.body_class))
623    };
624
625    format!(
626        r#"<!DOCTYPE html>
627<html lang="{lang}">
628<head>
629    <meta charset="UTF-8">
630    <meta name="viewport" content="width=device-width, initial-scale=1.0">
631    <title>{document_title}</title>
632    {head}
633</head>
634<body{body_class}>
635    {site_nav}
636    <div class="site-content">
637    <main>
638        <article>
639            {breadcrumbs}
640            <div class="content">
641                {content}
642            </div>
643        </article>
644    </main>
645    {footer}
646    </div>
647    {scripts}
648</body>
649</html>"#,
650        lang = html_escape(&slots.lang),
651        document_title = html_escape(&slots.document_title),
652        head = slots.head,
653        body_class = body_class,
654        site_nav = slots.site_nav,
655        breadcrumbs = slots.breadcrumbs,
656        content = slots.content,
657        footer = slots.footer,
658        scripts = slots.scripts,
659    )
660}
661
662impl Default for HtmlRenderer {
663    fn default() -> Self {
664        Self::new()
665    }
666}
667
668/// Get the built-in base CSS stylesheet (without theme overrides).
669fn get_base_css() -> &'static str {
670    include_str!("html_format_css.css")
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use crate::appearance::{ColorPalette, ThemeAppearance};
677    use std::path::PathBuf;
678
679    /// A renderer that credits a generator, so a test asserting a shell has
680    /// *no* footer is asserting something. Deliberately not this project's own
681    /// name: the engine ships no attribution, and a fixture that hardcoded one
682    /// would be the branding coming back in through the tests.
683    fn credited() -> HtmlRenderer {
684        HtmlRenderer::with_style(SiteStyle {
685            generator: Some(Generator::linked("Example", "https://example.com")),
686            ..SiteStyle::default()
687        })
688    }
689
690    fn make_page(dest: &str, title: &str, is_root: bool) -> PublishedPage {
691        PublishedPage {
692            source_path: PathBuf::from(format!("/workspace/{}", dest.replace(".html", ".md"))),
693            dest_filename: dest.to_string(),
694            title: title.to_string(),
695            rendered_body: "<p>Hello world</p>".to_string(),
696            markdown_body: "Hello world".to_string(),
697            contents_links: vec![],
698            parent_link: None,
699            is_root,
700            description: None,
701            author: None,
702            created: None,
703            updated: None,
704            date_of_document: None,
705            group_keys: vec![],
706            attachments: vec![],
707            styles: vec![],
708            scripts: vec![],
709            layout: PageLayout::default(),
710            shell: None,
711            lang: None,
712            nav_title: None,
713            nav_order: None,
714            hide_from_nav: false,
715            hide_from_feed: false,
716            id: None,
717            source_markdown: String::new(),
718        }
719    }
720
721    #[test]
722    fn render_page_installs_html_attachment_resize_listener() {
723        let page = make_page("index.html", "Home", true);
724        let rendered = HtmlRenderer::new().render_page(&page, "My Site", false);
725
726        assert!(rendered.contains("diaryx-html-attachment-measure"));
727        assert!(rendered.contains("diaryx-html-attachment-size"));
728        assert!(rendered.contains("iframe.diaryx-island"));
729    }
730
731    #[test]
732    fn default_css_has_no_overrides() {
733        let css = HtmlRenderer::new().css();
734        assert!(css.contains("body {"));
735        assert!(!css.contains("Theme overrides"));
736    }
737
738    #[test]
739    fn theme_css_includes_overrides() {
740        let theme = ThemeAppearance {
741            id: Some("custom".into()),
742            light: ColorPalette {
743                bg: Some("#ff0000".into()),
744                ..Default::default()
745            },
746            dark: Default::default(),
747            ..Default::default()
748        };
749
750        let css = HtmlRenderer::with_theme(theme).css();
751        assert!(css.contains("body {"));
752        assert!(css.contains("Theme overrides"));
753        assert!(css.contains("--bg: #ff0000"));
754    }
755
756    #[test]
757    fn custom_css_replaces_base() {
758        let style = SiteStyle {
759            custom_css: Some("/* mine */ body { color: red }".to_string()),
760            ..SiteStyle::default()
761        };
762        let css = HtmlRenderer::with_style(style).css();
763        assert_eq!(css, "/* mine */ body { color: red }");
764        assert!(!css.contains("Theme overrides"));
765    }
766
767    #[test]
768    fn custom_favicon_overrides_theme() {
769        let style = SiteStyle {
770            custom_favicon: Some(FaviconAsset {
771                filename: "fav.png".into(),
772                mime_type: "image/png".into(),
773                data: vec![1, 2, 3],
774            }),
775            ..SiteStyle::default()
776        };
777        let assets = HtmlRenderer::with_style(style).static_assets();
778        assert!(assets.iter().any(|(n, _)| n == "fav.png"));
779    }
780
781    #[test]
782    fn themed_page_inlines_overrides_in_single_file() {
783        let theme = ThemeAppearance {
784            id: None,
785            light: ColorPalette {
786                bg: Some("oklch(0.98 0 0)".into()),
787                ..Default::default()
788            },
789            dark: Default::default(),
790            ..Default::default()
791        };
792
793        let page = make_page("index.html", "Home", true);
794        let html = HtmlRenderer::with_theme(theme).render_page(&page, "Test Site", true);
795        assert!(html.contains("--bg: oklch(0.98 0 0)"));
796    }
797
798    #[test]
799    fn themed_static_assets_include_overrides() {
800        let theme = ThemeAppearance {
801            id: None,
802            light: ColorPalette {
803                accent: Some("hotpink".into()),
804                ..Default::default()
805            },
806            dark: Default::default(),
807            ..Default::default()
808        };
809
810        let assets = HtmlRenderer::with_theme(theme).static_assets();
811        let read = |name: &str| {
812            assets
813                .iter()
814                .find(|(n, _)| n == name)
815                .map(|(_, b)| String::from_utf8(b.clone()).unwrap())
816                .unwrap_or_else(|| panic!("no {name} in the static assets"))
817        };
818        // CSS + auto-generated favicon + the island child script
819        assert_eq!(assets.len(), 3);
820        assert!(read("style.css").contains("--accent: hotpink"));
821        // Favicon is auto-generated from accent color
822        assert!(read("favicon.svg").contains("hotpink"));
823    }
824
825    /// An island document has to be able to answer the parent's measurement
826    /// request, and nothing in a vault can ask for the file that lets it.
827    #[test]
828    fn static_assets_carry_the_island_child_script() {
829        let assets = HtmlRenderer::new().static_assets();
830        let (_, bytes) = assets
831            .iter()
832            .find(|(n, _)| n == ISLAND_CHILD_SCRIPT_FILENAME)
833            .expect("the island child script is always written");
834        let js = String::from_utf8(bytes.clone()).unwrap();
835        assert!(js.contains("diaryx-html-attachment-measure"), "listens");
836        assert!(js.contains("diaryx-html-attachment-size"), "answers");
837        assert!(js.contains("scrollHeight"), "measures the document");
838        assert!(js.contains("'resize'"), "and answers again when it changes");
839    }
840
841    // ── The shell ───────────────────────────────────────────────────────────
842
843    fn site_ctx<'a>(
844        nav: &'a SiteNavigation,
845        template: Option<&'a ShellTemplate>,
846    ) -> PageContext<'a> {
847        PageContext {
848            site_title: "My Site",
849            nav,
850            seo_meta: "",
851            feed_links: "",
852            lang: "en",
853            template,
854        }
855    }
856
857    fn empty_nav() -> SiteNavigation {
858        SiteNavigation {
859            tree: vec![],
860            breadcrumbs: vec![],
861        }
862    }
863
864    /// The exact document the built-in shell has always produced. Pinned byte
865    /// for byte, because "the default is unchanged" is the promise every site
866    /// published before templates existed was published under, and a promise
867    /// about bytes cannot be kept by an assertion about substrings.
868    #[test]
869    fn the_built_in_shell_is_unchanged() {
870        let page = make_page("index.html", "Home", true);
871        let nav = empty_nav();
872        let html = credited().render_page_in_site(&page, &site_ctx(&nav, None));
873
874        // The shell's *old* format string, reproduced verbatim with its slots
875        // filled by hand. Written this way rather than as the finished document
876        // because the empty slots leave lines of trailing whitespace, which a
877        // literal in this file would be one editor away from losing.
878        let expected = format!(
879            r#"<!DOCTYPE html>
880<html lang="en">
881<head>
882    <meta charset="UTF-8">
883    <meta name="viewport" content="width=device-width, initial-scale=1.0">
884    <title>{document_title}</title>
885    {css_link}
886    {favicon_link}
887    {seo_meta}
888    {feed_links}
889</head>
890<body{body_class}>
891    {site_nav}
892    <div class="site-content">
893    <main>
894        <article>
895            {breadcrumb}
896            <div class="content">
897                {content}
898            </div>
899        </article>
900    </main>
901    <footer>
902        <p>Generated by <a href="https://example.com">Example</a></p>
903    </footer>
904    </div>
905    <script>
906    (function() {{
907        // Nav hamburger toggle
908        var toggle = document.querySelector('.nav-toggle');
909        var nav = document.querySelector('.site-nav');
910        if (toggle && nav) {{
911            toggle.addEventListener('click', function(e) {{
912                e.stopPropagation();
913                nav.classList.toggle('is-open');
914            }});
915            document.addEventListener('click', function(e) {{
916                if (!nav.contains(e.target)) nav.classList.remove('is-open');
917            }});
918        }}
919        {interactivity_script}
920    }})();
921    </script>
922</body>
923</html>"#,
924            document_title = "Home - My Site",
925            css_link = r#"<link rel="stylesheet" href="style.css">"#,
926            favicon_link = "",
927            seo_meta = "",
928            feed_links = "",
929            body_class = "",
930            site_nav = "",
931            breadcrumb = "",
932            content = "<p>Hello world</p>",
933            interactivity_script = HtmlRenderer::new().interactivity_script(),
934        );
935        assert_eq!(html, expected);
936    }
937
938    #[test]
939    fn a_template_replaces_the_shell_and_escapes_its_text_slots() {
940        let page = make_page("index.html", "Ben & Co", true);
941        let nav = empty_nav();
942        let template = ShellTemplate::parse(
943            "<html lang=\"{{lang}}\"><head>{{{head}}}</head><body>{{{content}}}{{{scripts}}}</body></html>",
944        )
945        .unwrap();
946        let html = credited().render_page_in_site(&page, &site_ctx(&nav, Some(&template)));
947
948        assert!(html.starts_with(r#"<html lang="en">"#));
949        assert!(html.contains(r#"<link rel="stylesheet" href="style.css">"#));
950        assert!(html.contains("<p>Hello world</p>"));
951        assert!(html.contains("installSpoilers();"), "got {html}");
952        assert!(
953            !html.contains("Generated by"),
954            "a template that omits the footer slot has no footer"
955        );
956    }
957
958    #[test]
959    fn a_template_escapes_the_document_title_once() {
960        let page = make_page("index.html", "Ben & Co", true);
961        let nav = empty_nav();
962        let template = ShellTemplate::parse("<title>{{document_title}}</title>").unwrap();
963        let html = HtmlRenderer::new().render_page_in_site(&page, &site_ctx(&nav, Some(&template)));
964        assert_eq!(html, "<title>Ben &amp; Co - My Site</title>");
965    }
966
967    /// `bare` is a page that carries its own frame: no nav, no breadcrumbs, no
968    /// footer, no site stylesheet and no built-in script — and a supplied
969    /// template does not get to put one back.
970    #[test]
971    fn a_bare_page_takes_neither_shell() {
972        let mut page = make_page("notes/poster.html", "Poster", false);
973        page.layout = PageLayout::Bare;
974        page.styles = vec!["assets/poster.css".to_string()];
975        page.scripts = vec!["assets/poster.js".to_string()];
976        let nav = empty_nav();
977        let template = ShellTemplate::parse("<p>{{{content}}}</p>").unwrap();
978        let html = credited().render_page_in_site(&page, &site_ctx(&nav, Some(&template)));
979
980        assert!(html.starts_with("<!DOCTYPE html>"));
981        assert!(html.contains("<title>Poster - My Site</title>"));
982        assert!(html.contains("<p>Hello world</p>"));
983        // Its own assets, rebased to its own depth.
984        assert!(html.contains(r#"<link rel="stylesheet" href="../assets/poster.css">"#));
985        assert!(html.contains(r#"<script defer src="../assets/poster.js"></script>"#));
986        // And nothing of the site's.
987        assert!(!html.contains("style.css"), "no site stylesheet");
988        assert!(!html.contains("site-content"), "no site frame");
989        assert!(!html.contains("Generated by"), "no footer");
990        assert!(!html.contains("installSpoilers"), "no built-in script");
991    }
992
993    /// A verbatim page is its body and nothing else — not even the head a bare
994    /// page gets, and not a supplied template either.
995    #[test]
996    fn a_verbatim_page_is_only_its_body() {
997        let mut page = make_page("landing.html", "Landing", false);
998        page.layout = PageLayout::Verbatim;
999        page.styles = vec!["assets/landing.css".to_string()];
1000        let nav = empty_nav();
1001        let template = ShellTemplate::parse("<main>{{{content}}}</main>").unwrap();
1002        let html = HtmlRenderer::new().render_page_in_site(&page, &site_ctx(&nav, Some(&template)));
1003
1004        assert_eq!(html, "<p>Hello world</p>");
1005    }
1006
1007    /// A page's own styles follow the site stylesheet, so they can override it,
1008    /// and its scripts follow the built-in one, so it has already run.
1009    #[test]
1010    fn page_assets_are_emitted_after_the_sites_own() {
1011        let mut page = make_page("notes/entry.html", "Entry", false);
1012        page.styles = vec!["assets/entry.css".to_string()];
1013        page.scripts = vec!["assets/entry.js".to_string()];
1014        let nav = empty_nav();
1015        let html = HtmlRenderer::new().render_page_in_site(&page, &site_ctx(&nav, None));
1016
1017        let site_css = html
1018            .find(r#"href="../style.css""#)
1019            .expect("site stylesheet");
1020        let page_css = html
1021            .find(r#"href="../assets/entry.css""#)
1022            .expect("the page's own");
1023        assert!(site_css < page_css, "the page's stylesheet can override");
1024
1025        let builtin = html.find("installSpoilers();").expect("built-in script");
1026        let page_js = html
1027            .find(r#"<script defer src="../assets/entry.js"></script>"#)
1028            .expect("the page's own");
1029        assert!(builtin < page_js);
1030    }
1031}