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