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 nav_title: None,
712 nav_order: None,
713 hide_from_nav: false,
714 hide_from_feed: false,
715 id: None,
716 source_markdown: String::new(),
717 }
718 }
719
720 #[test]
721 fn render_page_installs_html_attachment_resize_listener() {
722 let page = make_page("index.html", "Home", true);
723 let rendered = HtmlRenderer::new().render_page(&page, "My Site", false);
724
725 assert!(rendered.contains("diaryx-html-attachment-measure"));
726 assert!(rendered.contains("diaryx-html-attachment-size"));
727 assert!(rendered.contains("iframe.diaryx-island"));
728 }
729
730 #[test]
731 fn default_css_has_no_overrides() {
732 let css = HtmlRenderer::new().css();
733 assert!(css.contains("body {"));
734 assert!(!css.contains("Theme overrides"));
735 }
736
737 #[test]
738 fn theme_css_includes_overrides() {
739 let theme = ThemeAppearance {
740 id: Some("custom".into()),
741 light: ColorPalette {
742 bg: Some("#ff0000".into()),
743 ..Default::default()
744 },
745 dark: Default::default(),
746 ..Default::default()
747 };
748
749 let css = HtmlRenderer::with_theme(theme).css();
750 assert!(css.contains("body {"));
751 assert!(css.contains("Theme overrides"));
752 assert!(css.contains("--bg: #ff0000"));
753 }
754
755 #[test]
756 fn custom_css_replaces_base() {
757 let style = SiteStyle {
758 custom_css: Some("/* mine */ body { color: red }".to_string()),
759 ..SiteStyle::default()
760 };
761 let css = HtmlRenderer::with_style(style).css();
762 assert_eq!(css, "/* mine */ body { color: red }");
763 assert!(!css.contains("Theme overrides"));
764 }
765
766 #[test]
767 fn custom_favicon_overrides_theme() {
768 let style = SiteStyle {
769 custom_favicon: Some(FaviconAsset {
770 filename: "fav.png".into(),
771 mime_type: "image/png".into(),
772 data: vec![1, 2, 3],
773 }),
774 ..SiteStyle::default()
775 };
776 let assets = HtmlRenderer::with_style(style).static_assets();
777 assert!(assets.iter().any(|(n, _)| n == "fav.png"));
778 }
779
780 #[test]
781 fn themed_page_inlines_overrides_in_single_file() {
782 let theme = ThemeAppearance {
783 id: None,
784 light: ColorPalette {
785 bg: Some("oklch(0.98 0 0)".into()),
786 ..Default::default()
787 },
788 dark: Default::default(),
789 ..Default::default()
790 };
791
792 let page = make_page("index.html", "Home", true);
793 let html = HtmlRenderer::with_theme(theme).render_page(&page, "Test Site", true);
794 assert!(html.contains("--bg: oklch(0.98 0 0)"));
795 }
796
797 #[test]
798 fn themed_static_assets_include_overrides() {
799 let theme = ThemeAppearance {
800 id: None,
801 light: ColorPalette {
802 accent: Some("hotpink".into()),
803 ..Default::default()
804 },
805 dark: Default::default(),
806 ..Default::default()
807 };
808
809 let assets = HtmlRenderer::with_theme(theme).static_assets();
810 let read = |name: &str| {
811 assets
812 .iter()
813 .find(|(n, _)| n == name)
814 .map(|(_, b)| String::from_utf8(b.clone()).unwrap())
815 .unwrap_or_else(|| panic!("no {name} in the static assets"))
816 };
817 assert_eq!(assets.len(), 3);
819 assert!(read("style.css").contains("--accent: hotpink"));
820 assert!(read("favicon.svg").contains("hotpink"));
822 }
823
824 #[test]
827 fn static_assets_carry_the_island_child_script() {
828 let assets = HtmlRenderer::new().static_assets();
829 let (_, bytes) = assets
830 .iter()
831 .find(|(n, _)| n == ISLAND_CHILD_SCRIPT_FILENAME)
832 .expect("the island child script is always written");
833 let js = String::from_utf8(bytes.clone()).unwrap();
834 assert!(js.contains("diaryx-html-attachment-measure"), "listens");
835 assert!(js.contains("diaryx-html-attachment-size"), "answers");
836 assert!(js.contains("scrollHeight"), "measures the document");
837 assert!(js.contains("'resize'"), "and answers again when it changes");
838 }
839
840 fn site_ctx<'a>(
843 nav: &'a SiteNavigation,
844 template: Option<&'a ShellTemplate>,
845 ) -> PageContext<'a> {
846 PageContext {
847 site_title: "My Site",
848 nav,
849 seo_meta: "",
850 feed_links: "",
851 lang: "en",
852 template,
853 }
854 }
855
856 fn empty_nav() -> SiteNavigation {
857 SiteNavigation {
858 tree: vec![],
859 breadcrumbs: vec![],
860 }
861 }
862
863 #[test]
868 fn the_built_in_shell_is_unchanged() {
869 let page = make_page("index.html", "Home", true);
870 let nav = empty_nav();
871 let html = credited().render_page_in_site(&page, &site_ctx(&nav, None));
872
873 let expected = format!(
878 r#"<!DOCTYPE html>
879<html lang="en">
880<head>
881 <meta charset="UTF-8">
882 <meta name="viewport" content="width=device-width, initial-scale=1.0">
883 <title>{document_title}</title>
884 {css_link}
885 {favicon_link}
886 {seo_meta}
887 {feed_links}
888</head>
889<body{body_class}>
890 {site_nav}
891 <div class="site-content">
892 <main>
893 <article>
894 {breadcrumb}
895 <div class="content">
896 {content}
897 </div>
898 </article>
899 </main>
900 <footer>
901 <p>Generated by <a href="https://example.com">Example</a></p>
902 </footer>
903 </div>
904 <script>
905 (function() {{
906 // Nav hamburger toggle
907 var toggle = document.querySelector('.nav-toggle');
908 var nav = document.querySelector('.site-nav');
909 if (toggle && nav) {{
910 toggle.addEventListener('click', function(e) {{
911 e.stopPropagation();
912 nav.classList.toggle('is-open');
913 }});
914 document.addEventListener('click', function(e) {{
915 if (!nav.contains(e.target)) nav.classList.remove('is-open');
916 }});
917 }}
918 {interactivity_script}
919 }})();
920 </script>
921</body>
922</html>"#,
923 document_title = "Home - My Site",
924 css_link = r#"<link rel="stylesheet" href="style.css">"#,
925 favicon_link = "",
926 seo_meta = "",
927 feed_links = "",
928 body_class = "",
929 site_nav = "",
930 breadcrumb = "",
931 content = "<p>Hello world</p>",
932 interactivity_script = HtmlRenderer::new().interactivity_script(),
933 );
934 assert_eq!(html, expected);
935 }
936
937 #[test]
938 fn a_template_replaces_the_shell_and_escapes_its_text_slots() {
939 let page = make_page("index.html", "Ben & Co", true);
940 let nav = empty_nav();
941 let template = ShellTemplate::parse(
942 "<html lang=\"{{lang}}\"><head>{{{head}}}</head><body>{{{content}}}{{{scripts}}}</body></html>",
943 )
944 .unwrap();
945 let html = credited().render_page_in_site(&page, &site_ctx(&nav, Some(&template)));
946
947 assert!(html.starts_with(r#"<html lang="en">"#));
948 assert!(html.contains(r#"<link rel="stylesheet" href="style.css">"#));
949 assert!(html.contains("<p>Hello world</p>"));
950 assert!(html.contains("installSpoilers();"), "got {html}");
951 assert!(
952 !html.contains("Generated by"),
953 "a template that omits the footer slot has no footer"
954 );
955 }
956
957 #[test]
958 fn a_template_escapes_the_document_title_once() {
959 let page = make_page("index.html", "Ben & Co", true);
960 let nav = empty_nav();
961 let template = ShellTemplate::parse("<title>{{document_title}}</title>").unwrap();
962 let html = HtmlRenderer::new().render_page_in_site(&page, &site_ctx(&nav, Some(&template)));
963 assert_eq!(html, "<title>Ben & Co - My Site</title>");
964 }
965
966 #[test]
970 fn a_bare_page_takes_neither_shell() {
971 let mut page = make_page("notes/poster.html", "Poster", false);
972 page.layout = PageLayout::Bare;
973 page.styles = vec!["assets/poster.css".to_string()];
974 page.scripts = vec!["assets/poster.js".to_string()];
975 let nav = empty_nav();
976 let template = ShellTemplate::parse("<p>{{{content}}}</p>").unwrap();
977 let html = credited().render_page_in_site(&page, &site_ctx(&nav, Some(&template)));
978
979 assert!(html.starts_with("<!DOCTYPE html>"));
980 assert!(html.contains("<title>Poster - My Site</title>"));
981 assert!(html.contains("<p>Hello world</p>"));
982 assert!(html.contains(r#"<link rel="stylesheet" href="../assets/poster.css">"#));
984 assert!(html.contains(r#"<script defer src="../assets/poster.js"></script>"#));
985 assert!(!html.contains("style.css"), "no site stylesheet");
987 assert!(!html.contains("site-content"), "no site frame");
988 assert!(!html.contains("Generated by"), "no footer");
989 assert!(!html.contains("installSpoilers"), "no built-in script");
990 }
991
992 #[test]
995 fn a_verbatim_page_is_only_its_body() {
996 let mut page = make_page("landing.html", "Landing", false);
997 page.layout = PageLayout::Verbatim;
998 page.styles = vec!["assets/landing.css".to_string()];
999 let nav = empty_nav();
1000 let template = ShellTemplate::parse("<main>{{{content}}}</main>").unwrap();
1001 let html = HtmlRenderer::new().render_page_in_site(&page, &site_ctx(&nav, Some(&template)));
1002
1003 assert_eq!(html, "<p>Hello world</p>");
1004 }
1005
1006 #[test]
1009 fn page_assets_are_emitted_after_the_sites_own() {
1010 let mut page = make_page("notes/entry.html", "Entry", false);
1011 page.styles = vec!["assets/entry.css".to_string()];
1012 page.scripts = vec!["assets/entry.js".to_string()];
1013 let nav = empty_nav();
1014 let html = HtmlRenderer::new().render_page_in_site(&page, &site_ctx(&nav, None));
1015
1016 let site_css = html
1017 .find(r#"href="../style.css""#)
1018 .expect("site stylesheet");
1019 let page_css = html
1020 .find(r#"href="../assets/entry.css""#)
1021 .expect("the page's own");
1022 assert!(site_css < page_css, "the page's stylesheet can override");
1023
1024 let builtin = html.find("installSpoilers();").expect("built-in script");
1025 let page_js = html
1026 .find(r#"<script defer src="../assets/entry.js"></script>"#)
1027 .expect("the page's own");
1028 assert!(builtin < page_js);
1029 }
1030}