1use 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#[derive(Debug, Clone, Default)]
40pub struct SiteStyle {
41 pub theme: Option<ThemeAppearance>,
43 pub custom_css: Option<String>,
45 pub custom_favicon: Option<FaviconAsset>,
47 pub generator: Option<Generator>,
49}
50
51#[derive(Debug, Clone)]
60pub struct Generator {
61 pub name: String,
63 pub url: Option<String>,
65}
66
67impl Generator {
68 pub fn new(name: impl Into<String>) -> Self {
70 Self {
71 name: name.into(),
72 url: None,
73 }
74 }
75
76 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
85pub const ISLAND_CHILD_SCRIPT_FILENAME: &str = "diaryx-island.js";
88
89pub 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
126pub struct PageContext<'a> {
133 pub site_title: &'a str,
135 pub nav: &'a SiteNavigation,
137 pub seo_meta: &'a str,
139 pub feed_links: &'a str,
141 pub lang: &'a str,
143 pub template: Option<&'a ShellTemplate>,
146 pub site_header: &'a str,
149 pub site_footer: &'a str,
152}
153
154pub struct HtmlRenderer {
156 style: SiteStyle,
157}
158
159fn 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
176fn 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
191fn 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
209fn join_tags(tags: Vec<String>) -> String {
211 tags.join("\n ")
212}
213
214const 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 pub fn new() -> Self {
241 Self {
242 style: SiteStyle::default(),
243 }
244 }
245
246 pub fn with_theme(theme: ThemeAppearance) -> Self {
248 Self {
249 style: SiteStyle {
250 theme: Some(theme),
251 ..SiteStyle::default()
252 },
253 }
254 }
255
256 pub fn with_style(style: SiteStyle) -> Self {
258 Self { style }
259 }
260
261 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 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 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 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 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 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 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 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 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 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 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 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
631fn 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
650fn 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
660fn 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
725fn 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 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 assert_eq!(assets.len(), 3);
879 assert!(read("style.css").contains("--accent: hotpink"));
880 assert!(read("favicon.svg").contains("hotpink"));
882 }
883
884 #[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 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 #[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 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 & Co - My Site</title>");
1028 }
1029
1030 #[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 assert!(html.contains(r#"<link rel="stylesheet" href="../assets/poster.css">"#));
1048 assert!(html.contains(r#"<script defer src="../assets/poster.js"></script>"#));
1049 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 #[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 #[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}