1use 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#[derive(Debug, Clone, Default)]
37pub struct SiteStyle {
38 pub theme: Option<ThemeAppearance>,
40 pub custom_css: Option<String>,
42 pub custom_favicon: Option<FaviconAsset>,
44 pub generator: Option<Generator>,
46}
47
48#[derive(Debug, Clone)]
57pub struct Generator {
58 pub name: String,
60 pub url: Option<String>,
62}
63
64impl Generator {
65 pub fn new(name: impl Into<String>) -> Self {
67 Self {
68 name: name.into(),
69 url: None,
70 }
71 }
72
73 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
82pub const ISLAND_CHILD_SCRIPT_FILENAME: &str = "diaryx-island.js";
85
86pub 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
123pub struct PageContext<'a> {
130 pub site_title: &'a str,
132 pub nav: &'a SiteNavigation,
134 pub seo_meta: &'a str,
136 pub feed_links: &'a str,
138 pub lang: &'a str,
140 pub template: Option<&'a ShellTemplate>,
143}
144
145pub struct HtmlRenderer {
147 style: SiteStyle,
148}
149
150fn 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
167fn 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
182fn 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
200fn join_tags(tags: Vec<String>) -> String {
202 tags.join("\n ")
203}
204
205impl HtmlRenderer {
206 pub fn new() -> Self {
208 Self {
209 style: SiteStyle::default(),
210 }
211 }
212
213 pub fn with_theme(theme: ThemeAppearance) -> Self {
215 Self {
216 style: SiteStyle {
217 theme: Some(theme),
218 ..SiteStyle::default()
219 },
220 }
221 }
222
223 pub fn with_style(style: SiteStyle) -> Self {
225 Self { style }
226 }
227
228 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 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 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 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 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 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 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 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 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 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 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 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
594fn 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
611fn 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
668fn 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 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 assert_eq!(assets.len(), 3);
820 assert!(read("style.css").contains("--accent: hotpink"));
821 assert!(read("favicon.svg").contains("hotpink"));
823 }
824
825 #[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 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 #[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 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 & Co - My Site</title>");
965 }
966
967 #[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 assert!(html.contains(r#"<link rel="stylesheet" href="../assets/poster.css">"#));
985 assert!(html.contains(r#"<script defer src="../assets/poster.js"></script>"#));
986 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 #[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 #[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}