use crate::appearance::{FaviconAsset, ThemeAppearance};
use crate::headings::render_toc;
use crate::links::root_prefix;
use crate::nav::reading_order;
use crate::page::{
html_escape, render_breadcrumb, render_full_breadcrumbs, render_pager, render_site_nav,
title_to_anchor,
};
use crate::shell::{ShellSlots, ShellTemplate};
use crate::types::{PageLayout, PublishedPage, SiteNavigation};
#[derive(Debug, Clone, Default)]
pub struct SiteStyle {
pub theme: Option<ThemeAppearance>,
pub custom_css: Option<String>,
pub custom_favicon: Option<FaviconAsset>,
pub generator: Option<Generator>,
}
#[derive(Debug, Clone)]
pub struct Generator {
pub name: String,
pub url: Option<String>,
}
impl Generator {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
url: None,
}
}
pub fn linked(name: impl Into<String>, url: impl Into<String>) -> Self {
Self {
name: name.into(),
url: Some(url.into()),
}
}
}
pub const ISLAND_CHILD_SCRIPT_FILENAME: &str = "diaryx-island.js";
pub const ISLAND_CHILD_SCRIPT: &str = r#"(function () {
function measure() {
var doc = document.documentElement;
var body = document.body;
var height = Math.max(
doc ? doc.scrollHeight : 0,
doc ? doc.offsetHeight : 0,
body ? body.scrollHeight : 0,
body ? body.offsetHeight : 0
);
if (!height) return;
try {
parent.postMessage({ type: 'diaryx-html-attachment-size', height: height }, '*');
} catch (_error) {}
}
window.addEventListener('message', function (event) {
var data = event.data;
if (data && data.type === 'diaryx-html-attachment-measure') measure();
});
window.addEventListener('resize', measure);
window.addEventListener('load', measure);
if (document.readyState === 'complete') measure();
})();
"#;
pub struct PageContext<'a> {
pub site_title: &'a str,
pub nav: &'a SiteNavigation,
pub seo_meta: &'a str,
pub feed_links: &'a str,
pub lang: &'a str,
pub template: Option<&'a ShellTemplate>,
pub site_header: &'a str,
pub site_footer: &'a str,
}
pub struct HtmlRenderer {
style: SiteStyle,
}
fn document_title(page_title: &str, site_title: &str) -> String {
if page_title == site_title {
site_title.to_string()
} else {
format!("{page_title} - {site_title}")
}
}
fn style_link_tags(styles: &[String], prefix: &str) -> Vec<String> {
styles
.iter()
.map(|path| {
format!(
r#"<link rel="stylesheet" href="{}{}">"#,
prefix,
html_escape(path)
)
})
.collect()
}
fn script_tags(scripts: &[String], prefix: &str) -> Vec<String> {
scripts
.iter()
.map(|path| {
format!(
r#"<script defer src="{}{}"></script>"#,
prefix,
html_escape(path)
)
})
.collect()
}
fn join_tags(tags: Vec<String>) -> String {
tags.join("\n ")
}
const NAV_DRAWER_SCRIPT: &str = r#" var toggle = document.querySelector('.nav-toggle-state');
var nav = document.querySelector('.site-nav');
if (toggle && nav) {
document.addEventListener('click', function(e) {
var t = e.target;
var onToggle = t === toggle || (t.closest && t.closest('.nav-toggle'));
if (toggle.checked && !onToggle && !nav.contains(t)) toggle.checked = false;
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && toggle.checked) {
toggle.checked = false;
toggle.focus();
}
});
var current = nav.querySelector('[aria-current]');
if (current && current.scrollIntoView) current.scrollIntoView({ block: 'center' });
}"#;
impl HtmlRenderer {
pub fn new() -> Self {
Self {
style: SiteStyle::default(),
}
}
pub fn with_theme(theme: ThemeAppearance) -> Self {
Self {
style: SiteStyle {
theme: Some(theme),
..SiteStyle::default()
},
}
}
pub fn with_style(style: SiteStyle) -> Self {
Self { style }
}
fn css(&self) -> String {
if let Some(custom) = &self.style.custom_css {
return custom.clone();
}
let base = get_base_css();
match &self.style.theme {
Some(theme) => {
let overrides = theme.to_css_overrides();
if overrides.is_empty() {
base.to_string()
} else {
format!("{}\n/* ── Theme overrides ── */\n{}", base, overrides)
}
}
None => base.to_string(),
}
}
fn favicon(&self) -> Option<FaviconAsset> {
if let Some(fav) = &self.style.custom_favicon {
return Some(fav.clone());
}
self.style.theme.as_ref().map(|t| t.favicon_or_default())
}
fn favicon_link_tag(&self, prefix: &str) -> String {
match self.favicon() {
Some(fav) => format!(
r#"<link rel="icon" type="{}" href="{}{}">"#,
fav.mime_type, prefix, fav.filename
),
None => String::new(),
}
}
pub fn interactivity_script(&self) -> &'static str {
r#"function clampIslandHeight(value) {
if (!Number.isFinite(value) || value <= 0) return null;
return Math.max(200, Math.min(Math.round(value), 4000));
}
function requestIslandMeasurement(frame) {
if (!frame || !frame.contentWindow) return;
try {
frame.contentWindow.postMessage({ type: 'diaryx-html-attachment-measure' }, '*');
} catch (_error) {}
}
function installSpoilers() {
document.querySelectorAll('.spoiler-mark').forEach(function(el) {
el.addEventListener('click', function() {
el.classList.toggle('spoiler-hidden');
el.classList.toggle('spoiler-revealed');
});
});
}
function installIslandResizeBridge() {
document.querySelectorAll('iframe.diaryx-island').forEach(function(frame) {
frame.addEventListener('load', function() {
requestIslandMeasurement(frame);
setTimeout(function() { requestIslandMeasurement(frame); }, 80);
});
});
window.addEventListener('message', function(event) {
var data = event.data;
if (!data || data.type !== 'diaryx-html-attachment-size') return;
var nextHeight = clampIslandHeight(Number(data.height));
if (nextHeight === null) return;
var frames = document.querySelectorAll('iframe.diaryx-island');
for (var i = 0; i < frames.length; i += 1) {
var frame = frames[i];
if (frame.contentWindow === event.source) {
frame.style.height = String(nextHeight) + 'px';
break;
}
}
});
}
installSpoilers();
installIslandResizeBridge();"#
}
pub fn render_page(&self, page: &PublishedPage, site_title: &str, single_file: bool) -> String {
let prefix = root_prefix(&page.dest_filename);
let css_link = if single_file {
format!("<style>{}</style>", self.css())
} else {
format!(r#"<link rel="stylesheet" href="{}style.css">"#, prefix)
};
let favicon_link = self.favicon_link_tag(&prefix);
let interactivity_script = self.interactivity_script();
let breadcrumb_html = render_breadcrumb(page, single_file);
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{document_title}</title>
{css_link}
{favicon_link}
</head>
<body>
<main>
<article>
{breadcrumb}
<div class="content">
{content}
</div>
</article>
</main>
{footer}
<script>{interactivity_script}</script>
</body>
</html>"#,
document_title = html_escape(&document_title(&page.title, site_title)),
footer = footer_element(self.style.generator.as_ref()),
css_link = css_link,
favicon_link = favicon_link,
breadcrumb = breadcrumb_html,
content = page.rendered_body,
interactivity_script = interactivity_script,
)
}
pub fn render_single_document(&self, pages: &[PublishedPage], site_title: &str) -> String {
let mut sections = Vec::new();
for page in pages {
let anchor = title_to_anchor(&page.title);
let breadcrumb = render_breadcrumb(page, true);
sections.push(format!(
r#"<section id="{anchor}">
{breadcrumb}
<div class="content">
{content}
</div>
</section>"#,
anchor = html_escape(&anchor),
breadcrumb = breadcrumb,
content = page.rendered_body,
));
}
let mut toc = String::from(r#"<nav class="toc"><h2>Table of Contents</h2><ul>"#);
for page in pages {
let anchor = title_to_anchor(&page.title);
toc.push_str(&format!(
r##"<li><a href="#{}">{}</a></li>"##,
html_escape(&anchor),
html_escape(&page.title)
));
}
toc.push_str("</ul></nav>");
let favicon_link = match self.favicon() {
Some(fav) => {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&fav.data);
format!(
r#"<link rel="icon" type="{}" href="data:{};base64,{}">"#,
fav.mime_type, fav.mime_type, b64
)
}
None => String::new(),
};
let interactivity_script = self.interactivity_script();
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{site_title}</title>
<style>{css}</style>
{favicon_link}
</head>
<body>
<main>
{toc}
{sections}
</main>
{footer}
<script>{interactivity_script}</script>
</body>
</html>"#,
site_title = html_escape(site_title),
footer = footer_element(self.style.generator.as_ref()),
css = self.css(),
favicon_link = favicon_link,
toc = toc,
sections = sections.join("\n<hr>\n"),
interactivity_script = interactivity_script,
)
}
pub fn render_page_in_site(&self, page: &PublishedPage, ctx: &PageContext<'_>) -> String {
match page.layout {
PageLayout::Verbatim => page.rendered_body.clone(),
PageLayout::Bare => self.render_bare_page(page, ctx),
PageLayout::Site => {
let slots = self.site_slots(page, ctx);
match ctx.template {
Some(template) => template.render(&slots),
None => builtin_shell(&slots),
}
}
}
}
fn site_slots(&self, page: &PublishedPage, ctx: &PageContext<'_>) -> ShellSlots {
let prefix = root_prefix(&page.dest_filename);
let mut head = vec![
format!(r#"<link rel="stylesheet" href="{}style.css">"#, prefix),
self.favicon_link_tag(&prefix),
ctx.seo_meta.to_string(),
ctx.feed_links.to_string(),
];
head.extend(style_link_tags(&page.styles, &prefix));
let mut scripts = vec![format!(
r#"<script>
(function() {{
{NAV_DRAWER_SCRIPT}
// The outline is written open, for a reader with scripting off; on a
// narrow screen it would push the content down, so it starts closed.
var toc = document.querySelector('.toc details');
if (toc && !window.matchMedia('(min-width: 88rem)').matches) toc.open = false;
{interactivity_script}
}})();
</script>"#,
interactivity_script = self.interactivity_script(),
)];
scripts.extend(script_tags(&page.scripts, &prefix));
let order = reading_order(&ctx.nav.tree);
ShellSlots {
lang: ctx.lang.to_string(),
document_title: document_title(&page.title, ctx.site_title),
site_title: ctx.site_title.to_string(),
body_class: if ctx.nav.tree.is_empty() {
String::new()
} else {
"has-site-nav".to_string()
},
head: join_tags(head),
site_nav: render_site_nav(ctx.nav, ctx.site_title, &prefix),
breadcrumbs: render_full_breadcrumbs(&ctx.nav.breadcrumbs, &prefix),
toc: if page.toc {
render_toc(&page.headings)
} else {
String::new()
},
site_header: ctx.site_header.to_string(),
content: page.rendered_body.clone(),
pager: render_pager(&order, &page.dest_filename, &prefix),
site_footer: ctx.site_footer.to_string(),
footer: footer_html(self.style.generator.as_ref()),
scripts: join_tags(scripts),
root_prefix: prefix,
}
}
fn render_bare_page(&self, page: &PublishedPage, ctx: &PageContext<'_>) -> String {
let prefix = root_prefix(&page.dest_filename);
let mut head = vec![
self.favicon_link_tag(&prefix),
ctx.seo_meta.to_string(),
ctx.feed_links.to_string(),
];
head.extend(style_link_tags(&page.styles, &prefix));
format!(
r#"<!DOCTYPE html>
<html lang="{lang}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{document_title}</title>
{head}
</head>
<body>
{content}
{scripts}
</body>
</html>"#,
lang = html_escape(ctx.lang),
document_title = html_escape(&document_title(&page.title, ctx.site_title)),
head = join_tags(head),
content = page.rendered_body,
scripts = join_tags(script_tags(&page.scripts, &prefix)),
)
}
pub fn static_assets(&self) -> Vec<(String, Vec<u8>)> {
let mut assets = vec![("style.css".to_string(), self.css().into_bytes())];
if let Some(fav) = self.favicon() {
assets.push((fav.filename, fav.data));
}
assets.push((
ISLAND_CHILD_SCRIPT_FILENAME.to_string(),
ISLAND_CHILD_SCRIPT.as_bytes().to_vec(),
));
assets
}
}
fn footer_html(generator: Option<&Generator>) -> String {
let Some(generator) = generator else {
return String::new();
};
let name = html_escape(&generator.name);
let credit = match &generator.url {
Some(url) => format!(r#"<a href="{}">{}</a>"#, html_escape(url), name),
None => name,
};
format!(r#"<p class="generator">Generated by {credit}</p>"#)
}
fn footer_element(generator: Option<&Generator>) -> String {
let credit = footer_html(generator);
if credit.is_empty() {
return credit;
}
format!("<footer>\n {credit}\n </footer>")
}
fn builtin_shell(slots: &ShellSlots) -> String {
let body_class = if slots.body_class.is_empty() {
String::new()
} else {
format!(r#" class="{}""#, html_escape(&slots.body_class))
};
format!(
r##"<!DOCTYPE html>
<html lang="{lang}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{document_title}</title>
{head}
</head>
<body{body_class}>
<a class="skip-link" href="#content">Skip to content</a>
{site_nav}
<div class="site-content">
<header class="site-header">{site_header}</header>
<main id="content">
<article>
{breadcrumbs}
{toc}
<div class="content">
{content}
</div>
</article>
{pager}
</main>
<footer class="site-footer">{site_footer}{footer}</footer>
</div>
{scripts}
</body>
</html>"##,
lang = html_escape(&slots.lang),
document_title = html_escape(&slots.document_title),
head = slots.head,
body_class = body_class,
site_nav = slots.site_nav,
site_header = slots.site_header,
breadcrumbs = slots.breadcrumbs,
toc = slots.toc,
content = slots.content,
pager = slots.pager,
site_footer = slots.site_footer,
footer = slots.footer,
scripts = slots.scripts,
)
}
impl Default for HtmlRenderer {
fn default() -> Self {
Self::new()
}
}
fn get_base_css() -> &'static str {
include_str!("html_format_css.css")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::appearance::{ColorPalette, ThemeAppearance};
use std::path::PathBuf;
fn credited() -> HtmlRenderer {
HtmlRenderer::with_style(SiteStyle {
generator: Some(Generator::linked("Example", "https://example.com")),
..SiteStyle::default()
})
}
fn make_page(dest: &str, title: &str, is_root: bool) -> PublishedPage {
PublishedPage {
source_path: PathBuf::from(format!("/workspace/{}", dest.replace(".html", ".md"))),
dest_filename: dest.to_string(),
title: title.to_string(),
rendered_body: "<p>Hello world</p>".to_string(),
markdown_body: "Hello world".to_string(),
contents_links: vec![],
parent_link: None,
is_root,
description: None,
author: None,
created: None,
updated: None,
date_of_document: None,
group_keys: vec![],
attachments: vec![],
styles: vec![],
scripts: vec![],
layout: PageLayout::default(),
shell: None,
lang: None,
nav_title: None,
nav_order: None,
hide_from_nav: false,
hide_from_feed: false,
id: None,
source_markdown: String::new(),
headings: vec![],
toc: true,
}
}
#[test]
fn render_page_installs_html_attachment_resize_listener() {
let page = make_page("index.html", "Home", true);
let rendered = HtmlRenderer::new().render_page(&page, "My Site", false);
assert!(rendered.contains("diaryx-html-attachment-measure"));
assert!(rendered.contains("diaryx-html-attachment-size"));
assert!(rendered.contains("iframe.diaryx-island"));
}
#[test]
fn default_css_has_no_overrides() {
let css = HtmlRenderer::new().css();
assert!(css.contains("body {"));
assert!(!css.contains("Theme overrides"));
}
#[test]
fn theme_css_includes_overrides() {
let theme = ThemeAppearance {
id: Some("custom".into()),
light: ColorPalette {
bg: Some("#ff0000".into()),
..Default::default()
},
dark: Default::default(),
..Default::default()
};
let css = HtmlRenderer::with_theme(theme).css();
assert!(css.contains("body {"));
assert!(css.contains("Theme overrides"));
assert!(css.contains("--bg: #ff0000"));
}
#[test]
fn custom_css_replaces_base() {
let style = SiteStyle {
custom_css: Some("/* mine */ body { color: red }".to_string()),
..SiteStyle::default()
};
let css = HtmlRenderer::with_style(style).css();
assert_eq!(css, "/* mine */ body { color: red }");
assert!(!css.contains("Theme overrides"));
}
#[test]
fn custom_favicon_overrides_theme() {
let style = SiteStyle {
custom_favicon: Some(FaviconAsset {
filename: "fav.png".into(),
mime_type: "image/png".into(),
data: vec![1, 2, 3],
}),
..SiteStyle::default()
};
let assets = HtmlRenderer::with_style(style).static_assets();
assert!(assets.iter().any(|(n, _)| n == "fav.png"));
}
#[test]
fn themed_page_inlines_overrides_in_single_file() {
let theme = ThemeAppearance {
id: None,
light: ColorPalette {
bg: Some("oklch(0.98 0 0)".into()),
..Default::default()
},
dark: Default::default(),
..Default::default()
};
let page = make_page("index.html", "Home", true);
let html = HtmlRenderer::with_theme(theme).render_page(&page, "Test Site", true);
assert!(html.contains("--bg: oklch(0.98 0 0)"));
}
#[test]
fn themed_static_assets_include_overrides() {
let theme = ThemeAppearance {
id: None,
light: ColorPalette {
accent: Some("hotpink".into()),
..Default::default()
},
dark: Default::default(),
..Default::default()
};
let assets = HtmlRenderer::with_theme(theme).static_assets();
let read = |name: &str| {
assets
.iter()
.find(|(n, _)| n == name)
.map(|(_, b)| String::from_utf8(b.clone()).unwrap())
.unwrap_or_else(|| panic!("no {name} in the static assets"))
};
assert_eq!(assets.len(), 3);
assert!(read("style.css").contains("--accent: hotpink"));
assert!(read("favicon.svg").contains("hotpink"));
}
#[test]
fn static_assets_carry_the_island_child_script() {
let assets = HtmlRenderer::new().static_assets();
let (_, bytes) = assets
.iter()
.find(|(n, _)| n == ISLAND_CHILD_SCRIPT_FILENAME)
.expect("the island child script is always written");
let js = String::from_utf8(bytes.clone()).unwrap();
assert!(js.contains("diaryx-html-attachment-measure"), "listens");
assert!(js.contains("diaryx-html-attachment-size"), "answers");
assert!(js.contains("scrollHeight"), "measures the document");
assert!(js.contains("'resize'"), "and answers again when it changes");
}
fn site_ctx<'a>(
nav: &'a SiteNavigation,
template: Option<&'a ShellTemplate>,
) -> PageContext<'a> {
PageContext {
site_title: "My Site",
nav,
seo_meta: "",
feed_links: "",
lang: "en",
template,
site_header: "",
site_footer: "",
}
}
fn empty_nav() -> SiteNavigation {
SiteNavigation {
tree: vec![],
breadcrumbs: vec![],
}
}
#[test]
fn the_built_in_shell_is_unchanged() {
let page = make_page("index.html", "Home", true);
let nav = empty_nav();
let html = credited().render_page_in_site(&page, &site_ctx(&nav, None));
let expected = format!(
r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{document_title}</title>
{css_link}
{favicon_link}
{seo_meta}
{feed_links}
</head>
<body{body_class}>
<a class="skip-link" href="#content">Skip to content</a>
{site_nav}
<div class="site-content">
<header class="site-header">{site_header}</header>
<main id="content">
<article>
{breadcrumb}
{toc}
<div class="content">
{content}
</div>
</article>
{pager}
</main>
<footer class="site-footer">{site_footer}<p class="generator">Generated by <a href="https://example.com">Example</a></p></footer>
</div>
<script>
(function() {{
{nav_drawer}
// The outline is written open, for a reader with scripting off; on a
// narrow screen it would push the content down, so it starts closed.
var toc = document.querySelector('.toc details');
if (toc && !window.matchMedia('(min-width: 88rem)').matches) toc.open = false;
{interactivity_script}
}})();
</script>
</body>
</html>"##,
nav_drawer = NAV_DRAWER_SCRIPT,
document_title = "Home - My Site",
css_link = r#"<link rel="stylesheet" href="style.css">"#,
favicon_link = "",
seo_meta = "",
feed_links = "",
body_class = "",
site_nav = "",
site_header = "",
breadcrumb = "",
toc = "",
content = "<p>Hello world</p>",
pager = "",
site_footer = "",
interactivity_script = HtmlRenderer::new().interactivity_script(),
);
assert_eq!(html, expected);
}
#[test]
fn a_template_replaces_the_shell_and_escapes_its_text_slots() {
let page = make_page("index.html", "Ben & Co", true);
let nav = empty_nav();
let template = ShellTemplate::parse(
"<html lang=\"{{lang}}\"><head>{{{head}}}</head><body>{{{content}}}{{{scripts}}}</body></html>",
)
.unwrap();
let html = credited().render_page_in_site(&page, &site_ctx(&nav, Some(&template)));
assert!(html.starts_with(r#"<html lang="en">"#));
assert!(html.contains(r#"<link rel="stylesheet" href="style.css">"#));
assert!(html.contains("<p>Hello world</p>"));
assert!(html.contains("installSpoilers();"), "got {html}");
assert!(
!html.contains("Generated by"),
"a template that omits the footer slot has no footer"
);
}
#[test]
fn a_template_escapes_the_document_title_once() {
let page = make_page("index.html", "Ben & Co", true);
let nav = empty_nav();
let template = ShellTemplate::parse("<title>{{document_title}}</title>").unwrap();
let html = HtmlRenderer::new().render_page_in_site(&page, &site_ctx(&nav, Some(&template)));
assert_eq!(html, "<title>Ben & Co - My Site</title>");
}
#[test]
fn a_bare_page_takes_neither_shell() {
let mut page = make_page("notes/poster.html", "Poster", false);
page.layout = PageLayout::Bare;
page.styles = vec!["assets/poster.css".to_string()];
page.scripts = vec!["assets/poster.js".to_string()];
let nav = empty_nav();
let template = ShellTemplate::parse("<p>{{{content}}}</p>").unwrap();
let html = credited().render_page_in_site(&page, &site_ctx(&nav, Some(&template)));
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.contains("<title>Poster - My Site</title>"));
assert!(html.contains("<p>Hello world</p>"));
assert!(html.contains(r#"<link rel="stylesheet" href="../assets/poster.css">"#));
assert!(html.contains(r#"<script defer src="../assets/poster.js"></script>"#));
assert!(!html.contains("style.css"), "no site stylesheet");
assert!(!html.contains("site-content"), "no site frame");
assert!(!html.contains("Generated by"), "no footer");
assert!(!html.contains("installSpoilers"), "no built-in script");
}
#[test]
fn a_verbatim_page_is_only_its_body() {
let mut page = make_page("landing.html", "Landing", false);
page.layout = PageLayout::Verbatim;
page.styles = vec!["assets/landing.css".to_string()];
let nav = empty_nav();
let template = ShellTemplate::parse("<main>{{{content}}}</main>").unwrap();
let html = HtmlRenderer::new().render_page_in_site(&page, &site_ctx(&nav, Some(&template)));
assert_eq!(html, "<p>Hello world</p>");
}
#[test]
fn page_assets_are_emitted_after_the_sites_own() {
let mut page = make_page("notes/entry.html", "Entry", false);
page.styles = vec!["assets/entry.css".to_string()];
page.scripts = vec!["assets/entry.js".to_string()];
let nav = empty_nav();
let html = HtmlRenderer::new().render_page_in_site(&page, &site_ctx(&nav, None));
let site_css = html
.find(r#"href="../style.css""#)
.expect("site stylesheet");
let page_css = html
.find(r#"href="../assets/entry.css""#)
.expect("the page's own");
assert!(site_css < page_css, "the page's stylesheet can override");
let builtin = html.find("installSpoilers();").expect("built-in script");
let page_js = html
.find(r#"<script defer src="../assets/entry.js"></script>"#)
.expect("the page's own");
assert!(builtin < page_js);
}
}