use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::frontmatter;
use indexmap::IndexMap;
use prov::ContentFormat;
use prov::Value as YamlValue;
use prov::views::{Row, Selection};
use serde_json::Value as JsonValue;
use crate::dates;
use crate::html::{HtmlRenderer, PageContext, SiteStyle};
use crate::nav::{build_site_nav_tree, forest_roots, nav_for_page, neighbours, reading_order};
use crate::shell::ShellTemplate;
use crate::types::{
Heading, LinkEdge, NavLink, OutlineNode, PageLayout, PublishedPage, SiteNavNode,
};
pub use crate::types::FrameDoc;
use crate::{body, links, page, template};
pub struct SourceDoc {
pub path: String,
pub markdown: String,
pub is_root: bool,
pub inbound: Vec<LinkEdge>,
pub outbound: Vec<LinkEdge>,
}
pub struct RenderedPage {
pub dest_filename: String,
pub html: String,
pub id: Option<String>,
pub styles: Vec<String>,
pub scripts: Vec<String>,
}
pub use crate::types::{Arrangement, Grain, Grouping, serve_at_dest};
pub struct SiteOptions {
pub audience: Option<String>,
pub site_title: Option<String>,
pub published_files: Option<HashSet<String>>,
pub base_url: Option<String>,
pub generate_seo: bool,
pub generate_feeds: bool,
pub style: SiteStyle,
pub arrangement: Arrangement,
pub outline: Vec<OutlineNode>,
pub template: Option<String>,
pub templates: IndexMap<String, String>,
pub lang: String,
pub front_page_supplied: bool,
pub syntaxes: IndexMap<String, String>,
pub header: Option<FrameDoc>,
pub footer: Option<FrameDoc>,
}
impl Default for SiteOptions {
fn default() -> Self {
Self {
audience: None,
site_title: None,
published_files: None,
base_url: None,
generate_seo: true,
generate_feeds: true,
style: SiteStyle::default(),
arrangement: Arrangement::default(),
outline: Vec::new(),
template: None,
templates: IndexMap::new(),
lang: DEFAULT_LANG.to_string(),
front_page_supplied: false,
syntaxes: IndexMap::new(),
header: None,
footer: None,
}
}
}
#[cfg(feature = "syntax-highlighting")]
enum ResolvedSyntaxes {
Bundled,
Custom(crate::syntax::Syntaxes),
}
#[cfg(feature = "syntax-highlighting")]
impl ResolvedSyntaxes {
fn get(&self) -> &crate::syntax::Syntaxes {
match self {
Self::Bundled => crate::syntax::Syntaxes::bundled(),
Self::Custom(set) => set,
}
}
fn warnings(&self) -> &[String] {
self.get().warnings()
}
}
#[cfg(feature = "syntax-highlighting")]
fn resolve_syntaxes(opts: &SiteOptions) -> ResolvedSyntaxes {
if opts.syntaxes.is_empty() {
return ResolvedSyntaxes::Bundled;
}
ResolvedSyntaxes::Custom(crate::syntax::Syntaxes::with_custom(
opts.syntaxes
.iter()
.map(|(path, text)| (path.as_str(), text.as_str())),
))
}
const DEFAULT_LANG: &str = "en";
pub struct SiteRender {
pub pages: Vec<RenderedPage>,
pub assets: Vec<(String, Vec<u8>)>,
pub template_error: Option<String>,
pub page_shell_errors: Vec<String>,
pub syntax_errors: Vec<String>,
pub body_template_errors: Vec<String>,
}
pub fn build_pages(sources: &[SourceDoc], opts: &SiteOptions) -> Vec<PublishedPage> {
#[cfg(feature = "syntax-highlighting")]
let syntaxes = resolve_syntaxes(opts);
let (mut pages, mut prepared) = prepare(sources, opts);
let tree = build_site_nav_tree(&pages, &opts.outline);
render_bodies(
&mut pages,
0,
sources,
opts,
&mut prepared,
&tree,
#[cfg(feature = "syntax-highlighting")]
syntaxes.get(),
&mut Vec::new(),
);
pages
}
struct Prepared {
path_to_filename: HashMap<PathBuf, String>,
collected: Collected,
parsed: Vec<frontmatter::ParsedFile>,
values: Vec<serde_json::Map<String, JsonValue>>,
}
fn prepare(sources: &[SourceDoc], opts: &SiteOptions) -> (Vec<PublishedPage>, Prepared) {
let mut path_to_filename: HashMap<PathBuf, String> = HashMap::new();
let mut title_map: HashMap<PathBuf, String> = HashMap::new();
let mut resolver = Resolver::new();
let mut parsed = Vec::with_capacity(sources.len());
for s in sources {
let key = PathBuf::from(links::sanitize_rel_path(&s.path));
let file = frontmatter::parse_or_empty(&s.markdown).unwrap_or(frontmatter::ParsedFile {
frontmatter: IndexMap::new(),
body: s.markdown.clone(),
});
if let Some(t) = frontmatter::get_string(&file.frontmatter, "title") {
title_map.insert(key.clone(), t.to_string());
}
resolver.learn(&key, &file.frontmatter);
path_to_filename.insert(key, dest_for(&s.path, s.is_root, &file.frontmatter));
parsed.push(file);
}
let collected = collect_context(sources, opts, &path_to_filename, &resolver);
let pages = sources
.iter()
.zip(&parsed)
.map(|(s, file)| page_skeleton(s, file, opts, &path_to_filename, &title_map, &resolver))
.collect();
(
pages,
Prepared {
path_to_filename,
collected,
parsed,
values: Vec::new(),
},
)
}
#[allow(clippy::too_many_arguments)]
fn render_bodies(
pages: &mut [PublishedPage],
offset: usize,
sources: &[SourceDoc],
opts: &SiteOptions,
prepared: &mut Prepared,
tree: &[SiteNavNode],
#[cfg(feature = "syntax-highlighting")] syntaxes: &crate::syntax::Syntaxes,
reports: &mut Vec<String>,
) {
let order = reading_order(tree);
for (i, s) in sources.iter().enumerate() {
let page = &mut pages[i + offset];
let (prev, next) = neighbours(&order, &page.dest_filename);
let values = render_body(
page,
s,
&prepared.parsed[i],
opts,
&prepared.path_to_filename,
&prepared.collected,
(prev, next),
#[cfg(feature = "syntax-highlighting")]
syntaxes,
reports,
);
prepared.values.push(values);
}
}
struct Collected {
context: template::SiteContext,
by_path: HashMap<PathBuf, JsonValue>,
by_href: HashMap<String, PathBuf>,
parent_of: HashMap<PathBuf, PathBuf>,
spine: Option<HashMap<PathBuf, Vec<PathBuf>>>,
backlinks: HashMap<PathBuf, Vec<JsonValue>>,
inbound: HashMap<PathBuf, JsonValue>,
relations: HashMap<PathBuf, JsonValue>,
order: Vec<PathBuf>,
}
fn collect_context(
sources: &[SourceDoc],
opts: &SiteOptions,
path_to_filename: &HashMap<PathBuf, String>,
resolver: &Resolver,
) -> Collected {
let mut by_path: HashMap<PathBuf, JsonValue> = HashMap::new();
let mut parent_of: HashMap<PathBuf, PathBuf> = HashMap::new();
let mut sortable: Vec<(i32, PathBuf)> = Vec::new();
let mut root_title: Option<String> = None;
let mut meta_of: HashMap<PathBuf, YamlValue> = HashMap::new();
for (idx, s) in sources.iter().enumerate() {
let key = PathBuf::from(links::sanitize_rel_path(&s.path));
let fm = frontmatter::parse_or_empty(&s.markdown)
.map(|parsed| parsed.frontmatter)
.unwrap_or_default();
let title = frontmatter::get_string(&fm, "title")
.map(String::from)
.unwrap_or_else(|| filename_to_title(&s.path));
if s.is_root {
root_title = Some(title.clone());
}
let date = frontmatter::get_string(&fm, "date_of_document")
.or_else(|| frontmatter::get_string(&fm, "created"))
.or_else(|| frontmatter::get_string(&fm, "updated"))
.filter(|d| !d.is_empty())
.map(String::from);
let group_keys = match &opts.arrangement {
Arrangement::Containment => Vec::new(),
Arrangement::Grouped(grouping) => grouping.keys_of(&YamlValue::Mapping(fm.clone())),
};
if opts.outline.is_empty()
&& let Some(parent) = frontmatter::get_string(&fm, "part_of")
&& let Some(parent) = resolver.key(Path::new(&s.path), parent)
{
parent_of.insert(key.clone(), parent);
}
let href = path_to_filename
.get(&key)
.cloned()
.unwrap_or_else(|| dest_for(&s.path, s.is_root, &fm));
by_path.insert(
key.clone(),
entry_value(&s.path, &title, &href, date, &fm, group_keys, s.is_root),
);
meta_of.insert(key.clone(), YamlValue::Mapping(fm.clone()));
let order_key = fm
.get("nav_order")
.and_then(|v| match v {
YamlValue::Int(i) => Some(*i as i32),
YamlValue::Float(f) => Some(*f as i32),
YamlValue::String(st) => st.parse::<i32>().ok(),
_ => None,
})
.unwrap_or(idx as i32);
sortable.push((order_key, key));
}
let spine = (!opts.outline.is_empty()).then(|| {
let mut spine: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
for edge in
crate::nav::pruned_edges(&opts.outline, &|path| by_path.contains_key(Path::new(path)))
{
let (container, contained) =
(PathBuf::from(edge.container), PathBuf::from(edge.contained));
spine
.entry(container.clone())
.or_default()
.push(contained.clone());
parent_of.entry(contained).or_insert(container);
}
spine
});
sortable.sort_by_key(|(k, _)| *k);
let order: Vec<PathBuf> = sortable.into_iter().map(|(_, key)| key).collect();
let entries: Vec<JsonValue> = order
.iter()
.filter_map(|key| by_path.get(key).cloned())
.collect();
let mut backlinks = HashMap::new();
let mut inbound = HashMap::new();
let mut relations = HashMap::new();
for s in sources {
let key = PathBuf::from(links::sanitize_rel_path(&s.path));
backlinks.insert(
key.clone(),
entry_records(s.inbound.iter().map(|e| e.path.as_str()), &by_path),
);
inbound.insert(key.clone(), edges_by_relation(&s.inbound, &by_path));
relations.insert(key, edges_by_relation(&s.outbound, &by_path));
}
let site = serde_json::json!({
"title": opts
.site_title
.clone()
.or(root_title)
.unwrap_or_else(|| DEFAULT_SITE_TITLE.to_string()),
"lang": opts.lang.clone(),
"base_url": opts.base_url.clone().unwrap_or_default(),
});
let by_href = by_path
.iter()
.filter_map(|(path, entry)| Some((entry.get("href")?.as_str()?.to_string(), path.clone())))
.collect();
Collected {
context: template::SiteContext::new(
site,
entries.clone(),
groups_of(&order, &meta_of, &by_path, &opts.arrangement),
),
by_path,
by_href,
parent_of,
spine,
order,
backlinks,
inbound,
relations,
}
}
fn entry_records<'a>(
paths: impl IntoIterator<Item = &'a str>,
by_path: &HashMap<PathBuf, JsonValue>,
) -> Vec<JsonValue> {
let mut keys: Vec<PathBuf> = paths
.into_iter()
.map(|path| PathBuf::from(links::sanitize_rel_path(path)))
.collect();
keys.sort();
keys.dedup();
keys.iter()
.filter_map(|key| by_path.get(key).cloned())
.collect()
}
fn edges_by_relation(edges: &[LinkEdge], by_path: &HashMap<PathBuf, JsonValue>) -> JsonValue {
let mut by_relation: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for edge in edges {
let Some(relation) = edge.relation.as_deref() else {
continue;
};
by_relation
.entry(relation)
.or_default()
.push(edge.path.as_str());
}
let mut out = serde_json::Map::new();
for (relation, paths) in by_relation {
let records = entry_records(paths, by_path);
if !records.is_empty() {
out.insert(relation.to_string(), JsonValue::Array(records));
}
}
JsonValue::Object(out)
}
fn entry_value(
path: &str,
title: &str,
href: &str,
date: Option<String>,
fm: &IndexMap<String, YamlValue>,
group_keys: Vec<String>,
is_root: bool,
) -> JsonValue {
let normalized = date.as_deref().and_then(dates::to_rfc3339);
serde_json::json!({
"path": path,
"title": title,
"href": href,
"date": date,
"date_year": normalized.as_deref().and_then(|d| d.get(0..4)),
"date_month": normalized.as_deref().and_then(|d| d.get(0..7)),
"id": frontmatter::get_string(fm, "id"),
"description": frontmatter::get_string(fm, "description"),
"color": frontmatter::get_string(fm, "color"),
"group_keys": group_keys,
"is_root": is_root,
})
}
fn groups_of(
order: &[PathBuf],
meta_of: &HashMap<PathBuf, YamlValue>,
by_path: &HashMap<PathBuf, JsonValue>,
arrangement: &Arrangement,
) -> Vec<JsonValue> {
let Arrangement::Grouped(grouping) = arrangement else {
return Vec::new();
};
let selection = Selection {
view: String::new(),
rows: order
.iter()
.filter_map(|key| {
Some(Row {
path: key.clone(),
meta: meta_of.get(key)?.clone(),
})
})
.collect(),
};
prov::views::group(&selection, grouping)
.groups
.into_iter()
.map(|group| {
let entries: Vec<JsonValue> = group
.rows
.iter()
.filter_map(|row| by_path.get(&row.path).cloned())
.collect();
serde_json::json!({ "key": group.key, "entries": entries })
})
.collect()
}
fn page_context_values(
s: &SourceDoc,
fm: &IndexMap<String, YamlValue>,
collected: &Collected,
contents_links: &[NavLink],
parent_link: Option<&NavLink>,
audience: Option<&str>,
) -> serde_json::Map<String, JsonValue> {
let viewer: Vec<&str> = audience.into_iter().collect();
let mut values = template::page_values(fm, Path::new(&s.path), None, &viewer);
let key = PathBuf::from(links::sanitize_rel_path(&s.path));
if let Some(entry) = collected.by_path.get(&key) {
values.insert("page".into(), entry.clone());
}
let (children, parent) = match &collected.spine {
Some(spine) => (
spine
.get(&key)
.into_iter()
.flatten()
.filter_map(|child| collected.by_path.get(child))
.cloned()
.collect(),
collected
.parent_of
.get(&key)
.and_then(|container| collected.by_path.get(container))
.cloned()
.unwrap_or(JsonValue::Null),
),
None => (
contents_links
.iter()
.map(|link| collected.entry_of(link))
.collect::<Vec<_>>(),
parent_link
.map(|link| collected.entry_of(link))
.unwrap_or(JsonValue::Null),
),
};
values.insert("children".into(), JsonValue::Array(children));
values.insert("parent".into(), parent);
values.insert(
"breadcrumbs".into(),
JsonValue::Array(breadcrumbs_of(&key, collected)),
);
values.insert(
"backlinks".into(),
JsonValue::Array(collected.backlinks.get(&key).cloned().unwrap_or_default()),
);
let empty = || JsonValue::Object(serde_json::Map::new());
values.insert(
"relations".into(),
collected.relations.get(&key).cloned().unwrap_or_else(empty),
);
values.insert(
"inbound".into(),
collected.inbound.get(&key).cloned().unwrap_or_else(empty),
);
values
}
impl Collected {
fn entry_of(&self, link: &NavLink) -> JsonValue {
self.by_href
.get(&link.href)
.and_then(|path| self.by_path.get(path))
.cloned()
.unwrap_or_else(|| serde_json::json!({ "title": link.title, "href": link.href }))
}
}
fn breadcrumbs_of(from: &Path, collected: &Collected) -> Vec<JsonValue> {
let mut trail: Vec<JsonValue> = Vec::new();
let mut seen: Vec<PathBuf> = Vec::new();
let mut at = from.to_path_buf();
for _ in 0..=collected.order.len() {
if seen.contains(&at) {
break;
}
let Some(entry) = collected.by_path.get(&at) else {
break;
};
trail.push(entry.clone());
seen.push(at.clone());
let Some(parent) = collected.parent_of.get(&at) else {
break;
};
at = parent.clone();
}
trail.reverse();
trail
}
pub fn render_site(sources: &[SourceDoc], opts: &SiteOptions) -> SiteRender {
#[cfg(feature = "syntax-highlighting")]
let syntaxes = resolve_syntaxes(opts);
let mut body_template_errors = Vec::new();
let (mut pages, mut prepared) = prepare(sources, opts);
let site_title = opts
.site_title
.clone()
.or_else(|| pages.iter().find(|p| p.is_root).map(|p| p.title.clone()))
.unwrap_or_else(|| DEFAULT_SITE_TITLE.to_string());
let synthesized =
!pages.iter().any(|p| p.is_root) && !pages.is_empty() && !opts.front_page_supplied;
if synthesized {
let index = synthesize_index(&pages, opts);
pages.insert(0, index);
}
let renderer = HtmlRenderer::with_style(opts.style.clone());
let nav_tree = build_site_nav_tree(&pages, &opts.outline);
render_bodies(
&mut pages,
usize::from(synthesized),
sources,
opts,
&mut prepared,
&nav_tree,
#[cfg(feature = "syntax-highlighting")]
syntaxes.get(),
&mut body_template_errors,
);
let (template, template_error) = match opts.template.as_deref() {
None => (None, None),
Some(source) => match ShellTemplate::parse(source) {
Ok(compiled) => (Some(compiled), None),
Err(err) => (None, Some(err.to_string())),
},
};
let mut page_shell_errors: Vec<String> = Vec::new();
let mut page_templates: IndexMap<&str, Option<ShellTemplate>> = IndexMap::new();
for p in &pages {
let Some(key) = p.shell.as_deref() else {
continue;
};
if page_templates.contains_key(key) {
continue;
}
let compiled = match opts.templates.get(key) {
None => {
page_shell_errors.push(format!(
"{} asks for the shell {key:?}, which this site does not carry — \
it is rendered in the site's own shell",
p.source_path.display()
));
None
}
Some(source) => match ShellTemplate::parse(source) {
Ok(compiled) => Some(compiled),
Err(err) => {
page_shell_errors.push(format!(
"the shell {key:?}, which {} asks for, will not compile ({err}) — \
it is rendered in the site's own shell",
p.source_path.display()
));
None
}
},
};
page_templates.insert(key, compiled);
}
let base_url = opts.base_url.as_deref().unwrap_or("");
let writes_feeds = opts.generate_feeds && !base_url.is_empty();
let mut out_pages = Vec::with_capacity(pages.len());
for (i, p) in pages.iter().enumerate() {
let nav = nav_for_page(&nav_tree, &p.dest_filename, &pages);
let values = i
.checked_sub(usize::from(synthesized))
.and_then(|source_index| prepared.values.get(source_index));
let empty = serde_json::Map::new();
let context = template::Context::new(&prepared.collected.context, values.unwrap_or(&empty));
let mut frame = |doc: Option<&FrameDoc>, what: &str| {
doc.map(|doc| {
render_frame_doc(
doc,
what,
p,
context,
opts,
&prepared.path_to_filename,
#[cfg(feature = "syntax-highlighting")]
syntaxes.get(),
&mut body_template_errors,
)
})
.unwrap_or_default()
};
let site_header = frame(opts.header.as_ref(), "header");
let site_footer = frame(opts.footer.as_ref(), "footer");
let seo = if opts.generate_seo {
page::generate_seo_meta(p, &site_title, base_url)
} else {
String::new()
};
let feeds = if writes_feeds {
page::generate_feed_link_tags(&links::root_prefix(&p.dest_filename))
} else {
String::new()
};
let shell = p
.shell
.as_deref()
.and_then(|key| page_templates.get(key))
.and_then(Option::as_ref)
.or(template.as_ref());
let html = renderer.render_page_in_site(
p,
&PageContext {
site_title: &site_title,
nav: &nav,
seo_meta: &seo,
feed_links: &feeds,
lang: p.lang.as_deref().unwrap_or(&opts.lang),
template: shell,
site_header: &site_header,
site_footer: &site_footer,
},
);
out_pages.push(RenderedPage {
dest_filename: p.dest_filename.clone(),
html,
id: p.id.clone(),
styles: p.styles.clone(),
scripts: p.scripts.clone(),
});
}
let mut assets = renderer.static_assets();
if !base_url.is_empty() {
if opts.generate_seo {
assets.push((
"sitemap.xml".to_string(),
page::generate_sitemap(&pages, base_url).into_bytes(),
));
assets.push((
"robots.txt".to_string(),
page::generate_robots_txt(base_url, true).into_bytes(),
));
}
if writes_feeds {
let root = pages.iter().find(|p| p.is_root);
let desc = root.and_then(|r| r.description.as_deref()).unwrap_or("");
let author = root.and_then(|r| r.author.as_deref()).unwrap_or("");
assets.push((
"feed.xml".to_string(),
page::generate_atom_feed(&pages, &site_title, base_url, desc, author).into_bytes(),
));
assets.push((
"rss.xml".to_string(),
page::generate_rss_feed(&pages, &site_title, base_url, desc, author).into_bytes(),
));
}
}
SiteRender {
pages: out_pages,
assets,
template_error,
page_shell_errors,
#[cfg(feature = "syntax-highlighting")]
syntax_errors: syntaxes.warnings().to_vec(),
#[cfg(not(feature = "syntax-highlighting"))]
syntax_errors: Vec::new(),
body_template_errors,
}
}
fn page_skeleton(
s: &SourceDoc,
parsed: &frontmatter::ParsedFile,
opts: &SiteOptions,
path_to_filename: &HashMap<PathBuf, String>,
title_map: &HashMap<PathBuf, String>,
resolver: &Resolver,
) -> PublishedPage {
let fm = &parsed.frontmatter;
let current_path = PathBuf::from(&s.path);
let dest_filename = path_to_filename
.get(&PathBuf::from(links::sanitize_rel_path(&s.path)))
.cloned()
.unwrap_or_else(|| dest_for(&s.path, s.is_root, fm));
let title = frontmatter::get_string(fm, "title")
.map(String::from)
.unwrap_or_else(|| {
Path::new(&s.path)
.file_stem()
.and_then(|x| x.to_str())
.unwrap_or("Untitled")
.to_string()
});
let contents_links: Vec<NavLink> = frontmatter::get_string_array(fm, "contents")
.into_iter()
.filter_map(|child| {
resolve_link(&child, ¤t_path, path_to_filename, title_map, resolver)
})
.collect();
let parent_link = frontmatter::get_string(fm, "part_of")
.and_then(|p| resolve_link(p, ¤t_path, path_to_filename, title_map, resolver));
let layout = PageLayout::parse(frontmatter::get_string(fm, "layout"));
let nav_order = fm.get("nav_order").and_then(|v| match v {
YamlValue::Int(i) => Some(*i as i32),
YamlValue::Float(f) => Some(*f as i32),
YamlValue::String(st) => st.parse::<i32>().ok(),
_ => None,
});
let created = frontmatter::get_string(fm, "created").map(String::from);
let updated = frontmatter::get_string(fm, "updated").map(String::from);
let date_of_document = frontmatter::get_string(fm, "date_of_document").map(String::from);
let group_keys = match &opts.arrangement {
Arrangement::Containment => Vec::new(),
Arrangement::Grouped(grouping) => grouping.keys_of(&YamlValue::Mapping(fm.clone())),
};
let styles = resolve_asset_paths(fm, "styles", ¤t_path);
let scripts = resolve_asset_paths(fm, "scripts", ¤t_path);
PublishedPage {
source_path: current_path,
dest_filename,
title,
rendered_body: String::new(),
markdown_body: String::new(),
contents_links,
parent_link,
is_root: s.is_root,
description: frontmatter::get_string(fm, "description").map(String::from),
author: frontmatter::get_string(fm, "author").map(String::from),
created,
updated,
date_of_document,
group_keys,
attachments: frontmatter::get_string_array(fm, "attachments"),
styles,
scripts,
layout,
shell: match layout {
PageLayout::Site => frontmatter::get_string(fm, "shell")
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from),
PageLayout::Bare | PageLayout::Verbatim => None,
},
lang: frontmatter::get_string(fm, "lang")
.map(str::trim)
.filter(|l| !l.is_empty())
.map(String::from),
nav_title: frontmatter::get_string(fm, "nav_title").map(String::from),
nav_order,
hide_from_nav: fm
.get("hide_from_nav")
.and_then(|v| v.as_bool())
.unwrap_or(false),
hide_from_feed: fm
.get("hide_from_feed")
.and_then(|v| v.as_bool())
.unwrap_or(false),
id: frontmatter::get_string(fm, "id").map(String::from),
source_markdown: s.markdown.clone(),
headings: Vec::new(),
toc: fm.get("toc").and_then(|v| v.as_bool()).unwrap_or(true),
}
}
#[allow(clippy::too_many_arguments)]
fn render_body(
page: &mut PublishedPage,
s: &SourceDoc,
parsed: &frontmatter::ParsedFile,
opts: &SiteOptions,
path_to_filename: &HashMap<PathBuf, String>,
collected: &Collected,
neighbours: (Option<&NavLink>, Option<&NavLink>),
#[cfg(feature = "syntax-highlighting")] syntaxes: &crate::syntax::Syntaxes,
reports: &mut Vec<String>,
) -> serde_json::Map<String, JsonValue> {
let audience = opts.audience.as_deref();
let fm = &parsed.frontmatter;
let current_path = Path::new(&s.path);
let format = ContentFormat::from_extension(current_path).unwrap_or(ContentFormat::Markdown);
let mut values = page_context_values(
s,
fm,
collected,
&page.contents_links,
page.parent_link.as_ref(),
audience,
);
let (prev, next) = neighbours;
values.insert(
"prev".into(),
prev.map(|link| collected.entry_of(link))
.unwrap_or(JsonValue::Null),
);
values.insert(
"next".into(),
next.map(|link| collected.entry_of(link))
.unwrap_or(JsonValue::Null),
);
if page.layout.is_verbatim() {
page.rendered_body = parsed.body.clone();
page.markdown_body = parsed.body.clone();
values.insert("headings".into(), JsonValue::Array(Vec::new()));
return values;
}
if let Some(payload) = crate::attachment::payload_of(fm) {
let (html, markdown) = crate::attachment::render(&page.title, payload);
page.rendered_body = html;
page.markdown_body = markdown;
values.insert("headings".into(), JsonValue::Array(Vec::new()));
return values;
}
values.insert("headings".into(), JsonValue::Array(Vec::new()));
let (expanded, html, headings) = render_source_body(
&parsed.body,
format,
template::Context::new(&collected.context, &values),
audience,
current_path,
reports,
#[cfg(feature = "syntax-highlighting")]
syntaxes,
);
values.insert("headings".into(), headings_value(&headings));
let (expanded, html, headings) = if !headings.is_empty() && parsed.body.contains("headings") {
render_source_body(
&parsed.body,
format,
template::Context::new(&collected.context, &values),
audience,
current_path,
&mut Vec::new(),
#[cfg(feature = "syntax-highlighting")]
syntaxes,
)
} else {
(expanded, html, headings)
};
page.rendered_body = links::transform_links_with_files(
&html,
current_path,
path_to_filename,
Path::new(""),
&page.dest_filename,
opts.published_files.as_ref(),
);
page.markdown_body = expanded;
page.headings = headings;
values
}
fn render_source_body(
body: &str,
format: ContentFormat,
context: template::Context<'_>,
audience: Option<&str>,
at: &Path,
reports: &mut Vec<String>,
#[cfg(feature = "syntax-highlighting")] syntaxes: &crate::syntax::Syntaxes,
) -> (String, String, Vec<Heading>) {
let mut warnings = Vec::new();
let rendered = match audience {
Some(a) => template::render_for_audiences(body, format, context, &[a], &mut warnings),
None => template::render(body, format, context, &mut warnings),
};
reports.extend(
warnings
.into_iter()
.map(|w| format!("{}: {w}", at.display())),
);
let expanded = match rendered {
Ok(body) => body,
Err(err) => {
reports.push(format!(
"{}: {err} — the page is published as its own source",
at.display()
));
body.to_string()
}
};
#[cfg(feature = "syntax-highlighting")]
let converted = body::render_body_with(&expanded, format, syntaxes);
#[cfg(not(feature = "syntax-highlighting"))]
let converted = body::render_body(&expanded, format);
let (anchored, headings) = crate::headings::anchor_headings(&converted);
(expanded, anchored, headings)
}
fn headings_value(headings: &[Heading]) -> JsonValue {
JsonValue::Array(
headings
.iter()
.map(|h| serde_json::json!({ "level": h.level, "id": h.id, "text": h.text }))
.collect(),
)
}
#[allow(clippy::too_many_arguments)]
fn render_frame_doc(
doc: &FrameDoc,
what: &str,
page: &PublishedPage,
context: template::Context<'_>,
opts: &SiteOptions,
path_to_filename: &HashMap<PathBuf, String>,
#[cfg(feature = "syntax-highlighting")] syntaxes: &crate::syntax::Syntaxes,
reports: &mut Vec<String>,
) -> String {
let at = Path::new(&doc.path);
let format = ContentFormat::from_extension(at).unwrap_or(ContentFormat::Markdown);
let body = frontmatter::parse_or_empty(&doc.source)
.map(|parsed| parsed.body)
.unwrap_or_else(|_| doc.source.clone());
let mut warnings = Vec::new();
let rendered = match opts.audience.as_deref() {
Some(a) => template::render_for_audiences(&body, format, context, &[a], &mut warnings),
None => template::render(&body, format, context, &mut warnings),
};
reports.extend(
warnings
.into_iter()
.map(|w| format!("site {what} {}: {w}", at.display())),
);
let expanded = match rendered {
Ok(body) => body,
Err(err) => {
reports.push(format!(
"site {what} {}: {err} — it is published as its own source",
at.display()
));
body
}
};
#[cfg(feature = "syntax-highlighting")]
let converted = body::render_body_with(&expanded, format, syntaxes);
#[cfg(not(feature = "syntax-highlighting"))]
let converted = body::render_body(&expanded, format);
links::transform_links_with_files(
&converted,
at,
path_to_filename,
Path::new(""),
&page.dest_filename,
opts.published_files.as_ref(),
)
}
fn resolve_asset_paths(fm: &prov::Mapping, key: &str, current_relative: &Path) -> Vec<String> {
frontmatter::get_string_array(fm, key)
.iter()
.filter_map(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let link = prov::Link::parse_path_only(trimmed);
Some(
prov::link::resolve(current_relative, &link.target)
.to_string_lossy()
.into_owned(),
)
})
.collect()
}
const UNGROUPED: &str = "Other";
const DEFAULT_SITE_TITLE: &str = "Site";
pub fn synthesize_index(pages: &[PublishedPage], opts: &SiteOptions) -> PublishedPage {
let title = opts
.site_title
.clone()
.unwrap_or_else(|| DEFAULT_SITE_TITLE.to_string());
let (body, links) = match &opts.arrangement {
Arrangement::Containment => {
let roots = forest_roots(pages, &opts.outline);
(render_entry_list(&roots), nav_links(&roots))
}
Arrangement::Grouped(grouping) => {
let groups = group_entries(pages, grouping);
let ordered: Vec<&PublishedPage> = groups
.iter()
.flat_map(|(_, ps)| ps.iter().copied())
.collect();
(render_groups(&groups), nav_links(&ordered))
}
};
let (body, headings) = crate::headings::anchor_headings(&body);
PublishedPage {
source_path: PathBuf::from("index.md"),
dest_filename: "index.html".to_string(),
title,
rendered_body: body,
markdown_body: String::new(),
contents_links: links,
parent_link: None,
is_root: true,
description: None,
author: None,
created: None,
updated: None,
date_of_document: None,
group_keys: Vec::new(),
attachments: Vec::new(),
styles: Vec::new(),
scripts: Vec::new(),
layout: PageLayout::default(),
shell: None,
lang: None,
nav_title: None,
nav_order: None,
hide_from_nav: false,
hide_from_feed: true,
id: None,
source_markdown: String::new(),
headings,
toc: true,
}
}
fn group_entries<'p>(
pages: &'p [PublishedPage],
grouping: &Grouping,
) -> Vec<(String, Vec<&'p PublishedPage>)> {
let mut groups: BTreeMap<String, Vec<&PublishedPage>> = BTreeMap::new();
let mut ungrouped: Vec<&PublishedPage> = Vec::new();
for page in pages {
if page.hide_from_nav {
continue;
}
if page.group_keys.is_empty() {
ungrouped.push(page);
continue;
}
for key in &page.group_keys {
groups.entry(key.clone()).or_default().push(page);
}
}
let descending = matches!(grouping.by, Some(Grain::Year | Grain::Month | Grain::Day));
let mut out: Vec<(String, Vec<&PublishedPage>)> = groups.into_iter().collect();
if descending {
out.reverse();
}
for (_, entries) in &mut out {
sort_entries(entries, descending);
}
if !ungrouped.is_empty() {
sort_entries(&mut ungrouped, descending);
out.push((UNGROUPED.to_string(), ungrouped));
}
out
}
fn sort_entries(entries: &mut [&PublishedPage], by_date: bool) {
if by_date {
entries.sort_by(|a, b| page::newest_first(a, b));
} else {
entries.sort_by(|a, b| a.title.cmp(&b.title));
}
}
fn render_entry_list(entries: &[&PublishedPage]) -> String {
let mut out = String::from("<ul class=\"entry-list\">\n");
for page in entries {
out.push_str(&format!(
"<li><a href=\"{}\">{}</a>{}</li>\n",
page::html_escape(&page.dest_filename),
page::html_escape(page.nav_title.as_deref().unwrap_or(&page.title)),
match page.description.as_deref() {
Some(d) if !d.is_empty() => format!(
" <span class=\"entry-description\">{}</span>",
page::html_escape(d)
),
_ => String::new(),
}
));
}
out.push_str("</ul>\n");
out
}
fn render_groups(groups: &[(String, Vec<&PublishedPage>)]) -> String {
let mut out = String::new();
for (label, entries) in groups {
out.push_str(&format!(
"<section class=\"entry-group\">\n<h2>{}</h2>\n",
page::html_escape(label)
));
out.push_str(&render_entry_list(entries));
out.push_str("</section>\n");
}
out
}
fn nav_links(entries: &[&PublishedPage]) -> Vec<NavLink> {
entries
.iter()
.map(|p| NavLink {
href: p.dest_filename.clone(),
title: p.nav_title.clone().unwrap_or_else(|| p.title.clone()),
})
.collect()
}
fn resolve_link(
link_str: &str,
current_relative: &Path,
path_to_filename: &HashMap<PathBuf, String>,
title_map: &HashMap<PathBuf, String>,
resolver: &Resolver,
) -> Option<NavLink> {
let link = prov::Link::parse(link_str.trim());
let key = resolver.key(current_relative, link_str)?;
let key = if !path_to_filename.contains_key(&key)
&& prov::document::whole_file_format(&key).is_some()
{
key.with_extension("md")
} else {
key
};
let href = path_to_filename.get(&key)?.clone();
let title = title_map
.get(&key)
.cloned()
.or_else(|| link.label.clone())
.unwrap_or_else(|| filename_to_title(&key.to_string_lossy()));
Some(NavLink { href, title })
}
struct Resolver {
graph: prov::Graph<prov::InMemoryFs, prov::InMemoryIndex>,
titles: prov::TitleIndex,
}
impl Resolver {
fn new() -> Self {
Self {
graph: prov::Graph::new(
prov::InMemoryFs::new(),
PathBuf::new(),
prov::InMemoryIndex::new(),
prov::ReadSettings::default(),
),
titles: prov::TitleIndex::new(),
}
}
fn learn(&mut self, key: &Path, fm: &prov::Mapping) {
use prov::IndexStore as _;
if let Some(id) = frontmatter::get_string(fm, "id") {
self.graph
.index_mut()
.register(&prov::Id(id.to_string()), key);
}
if let Some(stem) = key.file_stem().and_then(|s| s.to_str()) {
self.titles.insert(stem, key);
}
if let Some(title) = frontmatter::get_string(fm, "title") {
self.titles.insert(title, key);
}
}
fn key(&self, doc: &Path, target: &str) -> Option<PathBuf> {
let link = prov::Link::parse(target.trim());
match self.graph.resolve_link_with(doc, &link, Some(&self.titles)) {
prov::Target::Path(path) => Some(PathBuf::from(links::sanitize_rel_path(
&path.to_string_lossy(),
))),
_ => None,
}
}
}
pub use crate::types::output_filename;
pub fn dest_of(source: &SourceDoc) -> String {
let fm = frontmatter::parse_or_empty(&source.markdown)
.map(|parsed| parsed.frontmatter)
.unwrap_or_default();
dest_for(&source.path, source.is_root, &fm)
}
fn dest_for(path: &str, is_root: bool, fm: &prov::Mapping) -> String {
if is_root {
return "index.html".to_string();
}
frontmatter::get_string(fm, "serve_at")
.and_then(serve_at_dest)
.unwrap_or_else(|| output_filename(path))
}
fn filename_to_title(filename: &str) -> String {
let stem = Path::new(filename)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(filename);
humanize_name(stem)
}
pub fn humanize_name(name: &str) -> String {
name.split(['_', '-'])
.filter(|s| !s.is_empty())
.map(|word| {
let mut chars: Vec<char> = word.chars().collect();
if let Some(first) = chars.first_mut() {
*first = first.to_ascii_uppercase();
}
chars.into_iter().collect::<String>()
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
fn src(path: &str, markdown: &str, is_root: bool) -> SourceDoc {
SourceDoc {
path: path.to_string(),
markdown: markdown.to_string(),
is_root,
inbound: Vec::new(),
outbound: Vec::new(),
}
}
fn linked(path: &str, markdown: &str, backlinks: &[&str]) -> SourceDoc {
SourceDoc {
inbound: backlinks
.iter()
.map(|s| LinkEdge {
relation: None,
path: (*s).to_string(),
})
.collect(),
..src(path, markdown, false)
}
}
fn edges(pairs: &[(&str, &str)]) -> Vec<LinkEdge> {
pairs
.iter()
.map(|(relation, path)| LinkEdge {
relation: Some((*relation).to_string()),
path: (*path).to_string(),
})
.collect()
}
fn date_grouping(by: Grain) -> Grouping {
Grouping {
keys: ["date_of_document", "created", "updated"]
.iter()
.map(|k| (*k).to_string())
.collect(),
by: Some(by),
}
}
#[test]
fn output_filename_sanitizes_and_sets_html() {
assert_eq!(output_filename("notes/My Note!.md"), "notes/My Note.html");
assert_eq!(output_filename("a/b/c.md"), "a/b/c.html");
}
#[test]
fn output_filename_covers_every_content_format() {
assert_eq!(output_filename("notes/entry.dj"), "notes/entry.html");
assert_eq!(output_filename("notes/entry.djot"), "notes/entry.html");
assert_eq!(
output_filename("notes/artifact.html"),
"notes/artifact.html"
);
assert_eq!(output_filename("notes/artifact.htm"), "notes/artifact.html");
}
#[test]
fn output_filename_makes_a_folder_note_its_directorys_index() {
assert_eq!(output_filename("page/page.md"), "page/index.html");
assert_eq!(output_filename("page/page.dj"), "page/index.html");
assert_eq!(output_filename("page/page.djot"), "page/index.html");
assert_eq!(output_filename("about/about.html"), "about/index.html");
assert_eq!(
output_filename("a/physics-121/physics-121.md"),
"a/physics-121/index.html"
);
assert_eq!(output_filename("page/index.md"), "page/index.html");
assert_eq!(output_filename("notes/page.md"), "notes/page.html");
assert_eq!(output_filename("page.md"), "page.html");
assert_eq!(
output_filename("page/pages.md"),
"page/pages.html",
"near is not the same"
);
}
#[test]
fn a_link_to_a_folder_note_is_rewritten_to_its_directorys_index() {
let index = "---\ntitle: Home\ncontents:\n - \"[Page](/page/page.md)\"\n---\nSee [the page](/page/page.md).\n";
let page = "---\ntitle: Page\npart_of: \"/index.md\"\n---\nBack [home](/index.md).\n";
let sources = vec![
src("index.md", index, true),
src("page/page.md", page, false),
];
let pages = build_pages(&sources, &SiteOptions::default());
let folder_note = pages.iter().find(|p| p.title == "Page").unwrap();
assert_eq!(
folder_note.dest_filename, "page/index.html",
"the folder note is its directory's index"
);
let home = pages.iter().find(|p| p.is_root).unwrap();
assert!(
home.rendered_body.contains(r#"href="page/index.html""#),
"the body link follows the destination: {}",
home.rendered_body
);
assert_eq!(
home.contents_links[0].href, "page/index.html",
"and so does the contents entry"
);
assert!(
folder_note
.rendered_body
.contains(r#"href="../index.html""#),
"got {}",
folder_note.rendered_body
);
}
#[test]
fn a_site_may_mix_content_formats() {
let index = "---\ntitle: Home\ncontents:\n - \"/note.dj\"\n - \"/artifact.html\"\n---\nSee [the note](/note.dj).\n";
let note = "---\ntitle: Note\npart_of: \"/index.md\"\n---\nA _djot_ note with a ==highlight== and a [link](/artifact.html).\n";
let artifact =
"---\ntitle: Artifact\npart_of: \"/index.md\"\n---\n<p>Already <em>HTML</em>.</p>\n";
let sources = vec![
src("index.md", index, true),
src("note.dj", note, false),
src("artifact.html", artifact, false),
];
let pages = build_pages(&sources, &SiteOptions::default());
let note_page = pages.iter().find(|p| p.title == "Note").unwrap();
assert!(
note_page.rendered_body.contains("<em>djot</em>"),
"djot emphasis is `_x_`, which Markdown would not have italicized: {}",
note_page.rendered_body
);
assert!(
note_page.rendered_body.contains("highlight-mark"),
"Diaryx's custom syntax works in djot too: {}",
note_page.rendered_body
);
assert!(
note_page.rendered_body.contains(r#"href="artifact.html""#),
"a djot link to an html document is rewritten: {}",
note_page.rendered_body
);
let artifact_page = pages.iter().find(|p| p.title == "Artifact").unwrap();
assert!(artifact_page.rendered_body.contains("<em>HTML</em>"));
let home = pages.iter().find(|p| p.is_root).unwrap();
assert!(
home.rendered_body.contains(r#"href="note.html""#),
"got {}",
home.rendered_body
);
assert_eq!(home.contents_links.len(), 2);
}
#[test]
fn filename_to_title_titlecases() {
assert_eq!(filename_to_title("hello-world.md"), "Hello World");
assert_eq!(filename_to_title("my_cool_note.md"), "My Cool Note");
}
#[test]
fn build_pages_derives_graph_and_renders() {
let index = "---\ntitle: Home\ncontents:\n - \"[Child](/child.md)\"\n---\nWelcome to :val[title].\n";
let child = "---\ntitle: Child Page\npart_of: \"/index.md\"\n---\nSee [home](/index.md) and a ==highlight==.\n";
let sources = vec![src("index.md", index, true), src("child.md", child, false)];
let pages = build_pages(&sources, &SiteOptions::default());
let home = pages.iter().find(|p| p.is_root).unwrap();
let kid = pages.iter().find(|p| !p.is_root).unwrap();
assert_eq!(home.dest_filename, "index.html");
assert_eq!(kid.dest_filename, "child.html");
assert!(home.rendered_body.contains("Welcome to Home."));
assert_eq!(home.contents_links.len(), 1);
assert_eq!(home.contents_links[0].href, "child.html");
assert_eq!(home.contents_links[0].title, "Child Page");
let parent = kid.parent_link.as_ref().unwrap();
assert_eq!(parent.href, "index.html");
assert_eq!(parent.title, "Home");
assert!(kid.rendered_body.contains(r#"href="index.html""#));
assert!(kid.rendered_body.contains("highlight-mark"));
}
#[test]
fn root_by_workspace_name_and_special_chars_resolve() {
let root = "---\ntitle: Home\ncontents:\n - \"/My Note!.md\"\n---\nHi.\n";
let note = "---\ntitle: My Note\npart_of: \"/Welcome.md\"\n---\nBody.\n";
let sources = vec![
src("Welcome.md", root, true),
src("My Note.md", note, false), ];
let pages = build_pages(&sources, &SiteOptions::default());
let home = pages.iter().find(|p| p.is_root).unwrap();
let note_page = pages.iter().find(|p| !p.is_root).unwrap();
assert_eq!(home.dest_filename, "index.html");
assert_eq!(home.contents_links.len(), 1);
assert_eq!(home.contents_links[0].href, "My Note.html");
assert_eq!(home.contents_links[0].title, "My Note");
let parent = note_page.parent_link.as_ref().unwrap();
assert_eq!(parent.href, "index.html");
assert_eq!(parent.title, "Home");
}
#[test]
fn a_relation_entry_resolves_in_every_spelling_prov_reads() {
let index = "---\ntitle: Home\ncontents:\n - \"/blog/blog.md\"\n---\nHi.\n";
let blog = concat!(
"---\ntitle: Blog\nid: mn1j30r\npart_of: \"/index.md\"\ncontents:\n",
" - \"[By path](/blog/by-path.md)\"\n",
" - id:9wq31fj\n",
" - colophon:k2h7f0a\n",
" - \"[[By alias]]\"\n",
" - by-stem\n",
" - id:elsewhere/zzzzzzz\n",
" - id:\n",
"---\nPosts.\n",
);
let by_path = "---\ntitle: By path\nid: x8qqhzx\npart_of: \"/blog/blog.md\"\n---\nBody.\n";
let by_id = "---\ntitle: By id\nid: 9wq31fj\npart_of: id:mn1j30r\n---\nBody.\n";
let by_legacy =
"---\ntitle: By legacy id\nid: k2h7f0a\npart_of: colophon:mn1j30r\n---\nBody.\n";
let by_alias = "---\ntitle: By alias\npart_of: \"[[Blog]]\"\n---\nBody.\n";
let by_stem = "---\ntitle: By stem\npart_of: blog\n---\nBody.\n";
let sources = vec![
src("index.md", index, true),
src("blog/blog.md", blog, false),
src("blog/by-path.md", by_path, false),
src("blog/by-id.md", by_id, false),
src("blog/by-legacy.md", by_legacy, false),
src("blog/by-alias.md", by_alias, false),
src("blog/by-stem.md", by_stem, false),
];
let pages = build_pages(&sources, &SiteOptions::default());
let listed = [
"blog/by-path.html",
"blog/by-id.html",
"blog/by-legacy.html",
"blog/by-alias.html",
"blog/by-stem.html",
];
let blog = pages.iter().find(|p| p.title == "Blog").unwrap();
let hrefs: Vec<&str> = blog
.contents_links
.iter()
.map(|l| l.href.as_str())
.collect();
assert_eq!(
hrefs, listed,
"every spelling resolves; a foreign id and a malformed one name nothing here"
);
assert_eq!(
blog.contents_links[1].title, "By id",
"titled from the target, as a path entry is"
);
for page in pages
.iter()
.filter(|p| listed.contains(&p.dest_filename.as_str()))
{
let parent = page
.parent_link
.as_ref()
.unwrap_or_else(|| panic!("{}'s part_of resolves too", page.dest_filename));
assert_eq!(parent.href, "blog/index.html", "{}", page.dest_filename);
}
let tree = crate::nav::build_site_nav_tree(&pages, &[]);
let home = &tree[0];
assert_eq!(
home.children.len(),
1,
"only the section hangs off the root: {:?}",
home.children.iter().map(|n| &n.href).collect::<Vec<_>>()
);
let section = &home.children[0];
assert_eq!(section.href, "blog/index.html");
let nested: Vec<&str> = section.children.iter().map(|n| n.href.as_str()).collect();
assert_eq!(nested, listed);
let out = render_site(&sources, &SiteOptions::default());
let by_id_html = out
.pages
.iter()
.find(|p| p.dest_filename == "blog/by-id.html")
.unwrap();
assert!(
by_id_html.html.contains("blog/index.html"),
"breadcrumbs reach the section"
);
}
#[test]
fn contents_link_to_excluded_page_is_dropped() {
let index = "---\ntitle: Home\ncontents:\n - \"/public-child.md\"\n - \"/private-child.md\"\n---\nHi.\n";
let public_child = "---\ntitle: Public Child\npart_of: \"/index.md\"\n---\nBody.\n";
let sources = vec![
src("index.md", index, true),
src("public-child.md", public_child, false),
];
let pages = build_pages(&sources, &SiteOptions::default());
let home = pages.iter().find(|p| p.is_root).unwrap();
assert_eq!(home.contents_links.len(), 1, "excluded child dropped");
assert_eq!(home.contents_links[0].href, "public-child.html");
let out = render_site(&sources, &SiteOptions::default());
let home_html = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
assert!(home_html.html.contains("public-child.html"));
assert!(!home_html.html.contains("private-child.html"));
}
#[test]
fn parent_link_to_excluded_page_is_dropped() {
let index = "---\ntitle: Home\n---\nHi.\n";
let orphan = "---\ntitle: Orphan\npart_of: \"/excluded.md\"\n---\nBody.\n";
let sources = vec![
src("index.md", index, true),
src("orphan.md", orphan, false),
];
let pages = build_pages(&sources, &SiteOptions::default());
let orphan_page = pages
.iter()
.find(|p| p.dest_filename == "orphan.html")
.unwrap();
assert!(orphan_page.parent_link.is_none());
}
fn entry(title: &str, date: &str) -> String {
format!("---\ntitle: {title}\ndate_of_document: {date}\n---\nBody of {title}.\n")
}
#[cfg(feature = "syntax-highlighting")]
#[test]
fn a_declared_grammar_colours_the_sites_code() {
let note = "---\ntitle: Note\n---\n```wat\n;; a note\n```\n";
let mut opts = SiteOptions::default();
opts.syntaxes.insert(
".config/sites/blog/wat.sublime-syntax".to_string(),
"name: Wat\nfile_extensions: [wat]\nscope: source.wat\ncontexts:\n main:\n \
- match: ';;.*$'\n scope: comment.line.wat\n"
.to_string(),
);
let out = render_site(&[src("index.md", note, true)], &opts);
assert!(out.syntax_errors.is_empty(), "{:?}", out.syntax_errors);
assert!(
out.pages[0].html.contains("plates-comment"),
"the site's own grammar did not reach the page: {}",
out.pages[0].html
);
}
#[cfg(feature = "syntax-highlighting")]
#[test]
fn a_broken_declared_grammar_is_reported_not_fatal() {
let note = "---\ntitle: Note\n---\n```rust\nlet x = 1;\n```\n";
let mut opts = SiteOptions::default();
opts.syntaxes.insert(
".config/sites/blog/broken.sublime-syntax".to_string(),
"this: is: not: a grammar".to_string(),
);
let out = render_site(&[src("index.md", note, true)], &opts);
assert_eq!(out.syntax_errors.len(), 1, "{:?}", out.syntax_errors);
assert!(
out.syntax_errors[0].contains("broken.sublime-syntax"),
"names the file: {:?}",
out.syntax_errors
);
assert!(
out.pages[0].html.contains("plates-storage"),
"rust still highlights: {}",
out.pages[0].html
);
}
#[test]
fn a_rootless_set_gets_a_generated_index() {
let sources = vec![
src("mon.md", &entry("Monday", "2026-07-27"), false),
src("tue.md", &entry("Tuesday", "2026-07-28"), false),
];
let out = render_site(&sources, &SiteOptions::default());
let index = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.expect("a synthesized index");
assert!(index.html.contains("mon.html"));
assert!(index.html.contains("tue.html"));
assert!(index.id.is_none(), "nothing in the vault to identify");
assert_eq!(out.pages.len(), 3, "the index plus both entries");
}
#[test]
fn an_attachment_renders_as_a_page_its_parent_lists() {
let sources = vec![
src(
"index.md",
"---\ntitle: Home\ncontents:\n- archive/archive.md\n---\nHome.\n",
true,
),
src(
"archive/archive.md",
"---\ntitle: Archive\npart_of: /index.md\ncontents:\n- attachments/scan.pdf.yaml\n- notes.md\n---\nPapers.\n",
false,
),
src(
"archive/attachments/scan.pdf.md",
"---\ntitle: The Scan\ncontent: scan.pdf\nattachment: true\npart_of: /archive/archive.md\n---\n",
false,
),
src(
"archive/notes.md",
"---\ntitle: Notes\npart_of: /archive/archive.md\n---\nNotes.\n",
false,
),
];
let out = render_site(&sources, &SiteOptions::default());
let scan = out
.pages
.iter()
.find(|p| p.dest_filename == "archive/attachments/scan.pdf.html")
.expect("the sidecar is a page");
assert!(
scan.html
.contains(r#"<iframe src="scan.pdf" title="The Scan">"#),
"the payload is the body: {}",
scan.html
);
assert!(
scan.html
.contains(r#"<a href="scan.pdf" download>Download scan.pdf</a>"#),
"{}",
scan.html
);
assert!(scan.html.contains("<title>The Scan"), "framed like a page");
let archive = out
.pages
.iter()
.find(|p| p.dest_filename == "archive/index.html")
.unwrap();
assert!(
archive
.html
.contains(r#"<a href="../archive/attachments/scan.pdf.html">The Scan</a>"#),
"listed by the page that holds it: {}",
archive.html
);
assert!(
!archive.html.contains("scan.pdf.yaml"),
"the sidecar's own spelling never reaches the page: {}",
archive.html
);
assert!(
scan.html.contains(r#"rel="next""#) && scan.html.contains("notes.html"),
"in the reading order: {}",
scan.html
);
}
#[test]
fn a_supplied_front_page_is_not_generated_over() {
let sources = vec![
src("mon.md", &entry("Monday", "2026-07-27"), false),
src("tue.md", &entry("Tuesday", "2026-07-28"), false),
];
let out = render_site(
&sources,
&SiteOptions {
site_title: Some("Diaryx".to_string()),
front_page_supplied: true,
..SiteOptions::default()
},
);
assert!(
!out.pages.iter().any(|p| p.dest_filename == "index.html"),
"the render must leave the site's root key alone"
);
assert_eq!(out.pages.len(), 2, "the entries, and nothing invented");
let mon = out
.pages
.iter()
.find(|p| p.dest_filename == "mon.html")
.expect("the entries still render");
assert!(mon.html.contains("<title>Monday - Diaryx<"));
}
#[test]
fn a_site_with_no_base_url_advertises_no_feed() {
let sources = vec![src("mon.md", &entry("Monday", "2026-07-27"), false)];
let out = render_site(&sources, &SiteOptions::default());
assert!(
!out.assets
.iter()
.any(|(n, _)| n == "feed.xml" || n == "rss.xml"),
"no absolute URL to write them against"
);
for page in &out.pages {
assert!(
!page.html.contains("rel=\"alternate\""),
"nor anything to advertise: {}",
page.dest_filename
);
}
}
#[test]
fn a_rootless_site_is_not_named_after_its_generated_index() {
let sources = vec![src("mon.md", &entry("Monday", "2026-07-27"), false)];
let opts = SiteOptions {
site_title: Some("Family Letters".to_string()),
base_url: Some("https://example.test".to_string()),
..SiteOptions::default()
};
let out = render_site(&sources, &opts);
let entry_page = out
.pages
.iter()
.find(|p| p.dest_filename == "mon.html")
.unwrap();
assert!(entry_page.html.contains("<title>Monday - Family Letters<"));
assert!(
entry_page
.html
.contains(r#"og:site_name" content="Family Letters""#)
);
assert!(!entry_page.html.contains("Index"));
let index = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
assert!(index.html.contains("<title>Family Letters</title>"));
let feed = out
.assets
.iter()
.find(|(n, _)| n == "feed.xml")
.map(|(_, b)| String::from_utf8_lossy(b).into_owned())
.unwrap();
assert!(feed.contains("<title>Family Letters</title>"));
}
#[test]
fn an_unnamed_rootless_site_falls_back_to_one_word_everywhere() {
let sources = vec![src("mon.md", &entry("Monday", "2026-07-27"), false)];
let out = render_site(&sources, &SiteOptions::default());
let index = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
assert!(index.html.contains("<title>Site</title>"));
let entry_page = out
.pages
.iter()
.find(|p| p.dest_filename == "mon.html")
.unwrap();
assert!(entry_page.html.contains("<title>Monday - Site<"));
}
#[test]
fn an_authored_root_still_names_the_site() {
let root = "---\ntitle: Home\n---\nHand written.\n";
let sources = vec![
src("index.md", root, true),
src("mon.md", &entry("Monday", "2026-07-27"), false),
];
let out = render_site(&sources, &SiteOptions::default());
let entry_page = out
.pages
.iter()
.find(|p| p.dest_filename == "mon.html")
.unwrap();
assert!(entry_page.html.contains("<title>Monday - Home<"));
}
#[test]
fn humanize_name_title_cases_a_machine_name() {
assert_eq!(humanize_name("family-letters"), "Family Letters");
assert_eq!(humanize_name("blog"), "Blog");
}
#[test]
fn an_authored_index_is_not_replaced() {
let root = "---\ntitle: Home\n---\nHand written.\n";
let sources = vec![
src("index.md", root, true),
src("mon.md", &entry("Monday", "2026-07-27"), false),
];
let out = render_site(&sources, &SiteOptions::default());
let index = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
assert!(index.html.contains("Hand written."));
assert_eq!(out.pages.len(), 2);
}
#[test]
fn a_dated_arrangement_groups_newest_first() {
let sources = vec![
src("old.md", &entry("Old", "2024-01-02"), false),
src("new.md", &entry("New", "2026-07-27"), false),
src("mid.md", &entry("Mid", "2025-05-05"), false),
];
let opts = SiteOptions {
arrangement: Arrangement::Grouped(date_grouping(Grain::Year)),
..SiteOptions::default()
};
let pages = build_pages(&sources, &opts);
let index = synthesize_index(&pages, &opts);
let order: Vec<&str> = index
.contents_links
.iter()
.map(|l| l.href.as_str())
.collect();
assert_eq!(order, ["new.html", "mid.html", "old.html"]);
let y26 = index.rendered_body.find("2026").expect("a 2026 heading");
let y25 = index.rendered_body.find("2025").expect("a 2025 heading");
let y24 = index.rendered_body.find("2024").expect("a 2024 heading");
assert!(y26 < y25 && y25 < y24, "groups run newest to oldest");
}
#[test]
fn a_month_grain_cuts_to_the_month() {
let sources = vec![
src("a.md", &entry("A", "2026-07-27"), false),
src("b.md", &entry("B", "2026-08-01"), false),
];
let opts = SiteOptions {
arrangement: Arrangement::Grouped(date_grouping(Grain::Month)),
..SiteOptions::default()
};
let index = synthesize_index(&build_pages(&sources, &opts), &opts);
assert!(index.rendered_body.contains("2026-08"));
assert!(index.rendered_body.contains("2026-07"));
}
#[test]
fn a_field_arrangement_groups_by_value() {
let scalar = "---\ntitle: Lunch\npeople: Nan\n---\nBody.\n";
let list = "---\ntitle: Trip\npeople:\n - Nan\n - Grandpa\n---\nBody.\n";
let sources = vec![src("lunch.md", scalar, false), src("trip.md", list, false)];
let opts = SiteOptions {
arrangement: Arrangement::Grouped(Grouping::field("people")),
..SiteOptions::default()
};
let pages = build_pages(&sources, &opts);
assert_eq!(
pages
.iter()
.find(|p| p.title == "Lunch")
.unwrap()
.group_keys,
vec!["Nan".to_string()],
"a scalar field value groups like a one-element list"
);
let index = synthesize_index(&pages, &opts);
assert!(index.rendered_body.contains(r#"<h2 id="grandpa">Grandpa "#));
assert!(index.rendered_body.contains(r#"<h2 id="nan">Nan "#));
let trips = index.rendered_body.matches("trip.html").count();
assert_eq!(trips, 2, "one entry per group it belongs to");
}
#[test]
fn an_entry_with_no_grouping_value_is_still_listed() {
let sources = vec![
src("dated.md", &entry("Dated", "2026-07-27"), false),
src("undated.md", "---\ntitle: Undated\n---\nBody.\n", false),
];
let opts = SiteOptions {
arrangement: Arrangement::Grouped(date_grouping(Grain::Year)),
..SiteOptions::default()
};
let index = synthesize_index(&build_pages(&sources, &opts), &opts);
assert!(index.rendered_body.contains("undated.html"));
assert!(index.rendered_body.contains(UNGROUPED));
}
#[test]
fn a_generated_index_stays_out_of_the_feed() {
let sources = vec![src("mon.md", &entry("Monday", "2026-07-27"), false)];
let opts = SiteOptions {
base_url: Some("https://example.test".to_string()),
..SiteOptions::default()
};
let index = synthesize_index(&build_pages(&sources, &opts), &opts);
assert!(index.hide_from_feed);
let out = render_site(&sources, &opts);
let feed = out
.assets
.iter()
.find(|(n, _)| n == "feed.xml")
.map(|(_, b)| String::from_utf8_lossy(b).into_owned())
.expect("a feed");
assert!(feed.contains("mon.html"));
assert!(!feed.contains("index.html"), "the index is not an entry");
}
#[test]
fn a_containment_index_lists_the_forest_roots() {
let parent_doc = "---\ntitle: Daily\ncontents:\n - \"/mon.md\"\n---\nBody.\n";
let child = "---\ntitle: Monday\npart_of: \"/daily.md\"\n---\nBody.\n";
let loose = "---\ntitle: Loose\n---\nBody.\n";
let sources = vec![
src("daily.md", parent_doc, false),
src("mon.md", child, false),
src("loose.md", loose, false),
];
let opts = SiteOptions::default();
let index = synthesize_index(&build_pages(&sources, &opts), &opts);
let listed: Vec<&str> = index
.contents_links
.iter()
.map(|l| l.href.as_str())
.collect();
assert_eq!(
listed,
["daily.html", "loose.html"],
"the nested child is reached through its parent, not listed twice"
);
let out = render_site(&sources, &opts);
let home = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
assert!(home.html.contains("mon.html"), "still reachable in nav");
}
#[test]
fn a_template_replaces_the_built_in_shell() {
let index = "---\ntitle: Home\ncontents:\n - \"/child.md\"\n---\nHi.\n";
let child = "---\ntitle: Child\npart_of: \"/index.md\"\n---\nKid.\n";
let sources = vec![src("index.md", index, true), src("child.md", child, false)];
let out = render_site(
&sources,
&SiteOptions {
template: Some(
"<!DOCTYPE html>\n<html lang=\"{{lang}}\"><head><title>{{document_title}}</title>{{{head}}}</head>\
<body class=\"{{body_class}}\">{{{site_nav}}}<article>{{{content}}}</article>{{{scripts}}}</body></html>"
.to_string(),
),
lang: "cy".to_string(),
..SiteOptions::default()
},
);
assert!(out.template_error.is_none(), "{:?}", out.template_error);
let home = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
assert!(home.html.contains(r#"<html lang="cy">"#));
assert!(
home.html.contains("<title>Home</title>"),
"got {}",
home.html
);
assert!(home.html.contains(r#"<body class="has-site-nav">"#));
assert!(home.html.contains("child.html"), "the nav is still there");
assert!(
!home.html.contains(r#"<div class="site-content">"#),
"and the built-in furniture the template did not ask for is not"
);
}
#[test]
fn a_broken_template_falls_back_and_reports_itself() {
let sources = vec![src("index.md", "---\ntitle: Home\n---\nHi.\n", true)];
let out = render_site(
&sources,
&SiteOptions {
template: Some("<html>{{contnet}}</html>".to_string()),
..SiteOptions::default()
},
);
let error = out.template_error.expect("the reason it was ignored");
assert!(error.contains("unknown shell slot `contnet`"), "{error}");
assert!(
out.pages[0].html.contains(r#"<div class="site-content">"#),
"the built-in shell"
);
}
#[test]
fn a_bare_page_keeps_its_place_in_the_site() {
let index = "---\ntitle: Home\ncontents:\n - \"/poster.md\"\n---\nHi.\n";
let poster = "---\ntitle: Poster\npart_of: \"/index.md\"\nlayout: bare\nstyles:\n - \"/assets/poster.css\"\n---\nArt.\n";
let sources = vec![
src("index.md", index, true),
src("poster.md", poster, false),
];
let out = render_site(&sources, &SiteOptions::default());
let bare = out
.pages
.iter()
.find(|p| p.dest_filename == "poster.html")
.unwrap();
assert!(bare.html.starts_with("<!DOCTYPE html>"));
assert!(bare.html.contains(r#"href="assets/poster.css""#));
assert!(!bare.html.contains("site-nav"), "no frame: {}", bare.html);
assert!(!bare.html.contains("style.css"), "no site stylesheet");
let home = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
assert!(home.html.contains("poster.html"));
}
#[test]
fn a_verbatim_page_is_published_byte_for_byte() {
let mut body = String::from(
"<!doctype html>\n\
<html lang=\"en\" data-theme='dark'>\n\
<head>\n\
<meta charset=utf-8>\n\
<title>Diaryx — {{ not a template }}</title>\n\
<style>.a{color:red}.b{color:blue}</style>\n\
</head>\n\
<body>\n\
<img src=\"/img/hero.png\" alt=\"a ==highlight== and \">\n\
<a href=\"/about.md\">about</a>\n\
<script>if (a<b && c>d) { f({x: 1}); }</script>\n",
);
for i in 0..400 {
body.push_str(&format!(
"<p class='row' data-i={i}>Line {i} & more<br>\n"
));
}
body.push_str("</body>\n</html>\n");
let source = format!("---\ntitle: Front\nlayout: verbatim\n---\n{body}");
let sources = vec![src("index.md", &source, true)];
let out = render_site(&sources, &SiteOptions::default());
let page = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
assert_eq!(page.html, body, "the body is the file");
}
#[test]
fn a_verbatim_page_keeps_its_place_in_the_site() {
let index = "---\ntitle: Home\ncontents:\n - \"/landing.md\"\n---\nHi.\n";
let landing =
"---\ntitle: Landing\npart_of: \"/index.md\"\nlayout: verbatim\n---\n<h1>Hi</h1>\n";
let sources = vec![
src("index.md", index, true),
src("landing.md", landing, false),
];
let opts = SiteOptions {
base_url: Some("https://example.test".to_string()),
..SiteOptions::default()
};
let out = render_site(&sources, &opts);
let landing_page = out
.pages
.iter()
.find(|p| p.dest_filename == "landing.html")
.unwrap();
assert_eq!(landing_page.html, "<h1>Hi</h1>\n");
let home = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
assert!(home.html.contains("landing.html"), "listed in the nav");
let sitemap = out
.assets
.iter()
.find(|(n, _)| n == "sitemap.xml")
.map(|(_, b)| String::from_utf8_lossy(b).into_owned())
.expect("a sitemap");
assert!(sitemap.contains("landing.html"));
}
#[test]
fn page_assets_resolve_and_rebase_like_attachments() {
let front = "---\ntitle: Home\nstyles:\n - \"/assets/site.css\"\nscripts:\n - \"assets/site.js\"\n---\nHi.\n";
let deep = "---\ntitle: Deep\nstyles:\n - \"../assets/site.css\"\n---\nBody.\n";
let sources = vec![
src("index.md", front, true),
src("notes/deep.md", deep, false),
];
let out = render_site(&sources, &SiteOptions::default());
let home = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
assert_eq!(home.styles, vec!["assets/site.css".to_string()]);
assert_eq!(home.scripts, vec!["assets/site.js".to_string()]);
assert!(
home.html
.contains(r#"<link rel="stylesheet" href="assets/site.css">"#)
);
assert!(
home.html
.contains(r#"<script defer src="assets/site.js"></script>"#)
);
let deep_page = out
.pages
.iter()
.find(|p| p.dest_filename == "notes/deep.html")
.unwrap();
assert_eq!(
deep_page.styles,
vec!["assets/site.css".to_string()],
"one file, however the document spelled its way to it"
);
assert!(
deep_page
.html
.contains(r#"<link rel="stylesheet" href="../assets/site.css">"#),
"rebased to the page's own depth: {}",
deep_page.html
);
}
#[test]
fn serve_at_normalizes_a_site_root_claim() {
assert_eq!(serve_at_dest("/privacy"), Some("privacy.html".to_string()));
assert_eq!(
serve_at_dest("/privacy.html"),
Some("privacy.html".to_string()),
"the two spellings are one claim"
);
assert_eq!(
serve_at_dest(" /legal/privacy "),
Some("legal/privacy.html".to_string())
);
assert_eq!(
serve_at_dest("/My Page!"),
Some("My Page.html".to_string()),
"sanitized like every other published path"
);
assert_eq!(
serve_at_dest("/../../etc/passwd"),
Some("etc/passwd.html".to_string()),
"there is nothing above a site's root to reach"
);
assert_eq!(serve_at_dest("privacy.html"), None, "must be site-absolute");
assert_eq!(serve_at_dest("/"), None);
assert_eq!(serve_at_dest(""), None);
}
#[test]
fn a_serve_at_page_publishes_where_it_claims() {
let index = "---\ntitle: Home\ncontents:\n - \"/docs/privacy.md\"\n---\nSee [the policy](/docs/privacy.md).\n";
let privacy =
"---\ntitle: Privacy\npart_of: \"/index.md\"\nserve_at: /privacy\n---\nThe policy.\n";
let sources = vec![
src("index.md", index, true),
src("docs/privacy.md", privacy, false),
];
let opts = SiteOptions {
base_url: Some("https://example.test".to_string()),
..SiteOptions::default()
};
let pages = build_pages(&sources, &opts);
let page = pages.iter().find(|p| p.title == "Privacy").unwrap();
assert_eq!(page.dest_filename, "privacy.html");
let home = pages.iter().find(|p| p.is_root).unwrap();
assert_eq!(home.contents_links[0].href, "privacy.html");
assert!(
home.rendered_body.contains(r#"href="privacy.html""#),
"a body link follows the claim: {}",
home.rendered_body
);
let out = render_site(&sources, &opts);
assert!(
out.pages.iter().any(|p| p.dest_filename == "privacy.html"),
"the rendered page is written at the claimed key"
);
let sitemap = out
.assets
.iter()
.find(|(n, _)| n == "sitemap.xml")
.map(|(_, b)| String::from_utf8_lossy(b).into_owned())
.unwrap();
assert!(sitemap.contains("privacy.html"));
assert!(!sitemap.contains("docs/privacy.html"));
}
#[test]
fn a_link_from_depth_to_a_serve_at_page_is_rebased() {
let index = "---\ntitle: Home\n---\nHi.\n";
let about =
"---\ntitle: About\npart_of: \"/index.md\"\n---\nSee [privacy](../docs/privacy.md).\n";
let privacy = "---\ntitle: Privacy\nserve_at: /privacy.html\n---\nThe policy.\n";
let sources = vec![
src("index.md", index, true),
src("about/index.md", about, false),
src("docs/privacy.md", privacy, false),
];
let pages = build_pages(&sources, &SiteOptions::default());
let about_page = pages.iter().find(|p| p.title == "About").unwrap();
assert_eq!(about_page.dest_filename, "about/index.html");
assert!(
about_page
.rendered_body
.contains(r#"href="../privacy.html""#),
"got {}",
about_page.rendered_body
);
}
#[test]
fn the_site_index_ignores_serve_at() {
let index = "---\ntitle: Home\nserve_at: /home.html\n---\nHi.\n";
let sources = vec![src("index.md", index, true)];
let pages = build_pages(&sources, &SiteOptions::default());
assert_eq!(pages[0].dest_filename, "index.html");
assert_eq!(dest_of(&sources[0]), "index.html");
}
#[test]
fn dest_of_answers_for_a_source_the_way_the_render_will() {
let claimed = src(
"docs/privacy.md",
"---\ntitle: Privacy\nserve_at: /privacy\n---\nBody.\n",
false,
);
let plain = src("docs/note.md", "---\ntitle: Note\n---\nBody.\n", false);
assert_eq!(dest_of(&claimed), "privacy.html");
assert_eq!(dest_of(&plain), "docs/note.html");
let pages = build_pages(&[claimed, plain], &SiteOptions::default());
assert_eq!(pages[0].dest_filename, "privacy.html");
assert_eq!(pages[1].dest_filename, "docs/note.html");
}
fn templates(pairs: &[(&str, &str)]) -> IndexMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
const POSTER: &str = "<!DOCTYPE html><html lang=\"{{lang}}\"><head><title>{{document_title}}</title>\
{{{head}}}</head><body class=\"poster\">{{{content}}}</body></html>";
#[test]
fn a_page_may_name_its_own_shell() {
let index = "---\ntitle: Home\ncontents:\n - \"/poster.md\"\n---\nHi.\n";
let poster =
"---\ntitle: Poster\npart_of: \"/index.md\"\nshell: themes/poster.html\n---\nArt.\n";
let sources = vec![
src("index.md", index, true),
src("poster.md", poster, false),
];
let out = render_site(
&sources,
&SiteOptions {
template: Some(
"<!DOCTYPE html><html><body class=\"site\">{{{content}}}</body></html>"
.to_string(),
),
templates: templates(&[("themes/poster.html", POSTER)]),
..SiteOptions::default()
},
);
assert!(out.template_error.is_none());
assert!(
out.page_shell_errors.is_empty(),
"{:?}",
out.page_shell_errors
);
let page = out
.pages
.iter()
.find(|p| p.dest_filename == "poster.html")
.unwrap();
assert!(
page.html.contains(r#"<body class="poster">"#),
"{}",
page.html
);
assert!(page.html.contains("<title>Poster - Home</title>"));
let home = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
assert!(home.html.contains(r#"<body class="site">"#));
}
#[test]
fn a_missing_page_shell_falls_back_and_reports_itself() {
let poster = "---\ntitle: Poster\nshell: themes/gone.html\n---\nArt.\n";
let sources = vec![src("poster.md", poster, false)];
let out = render_site(
&sources,
&SiteOptions {
template: Some(
"<!DOCTYPE html><html><body class=\"site\">{{{content}}}</body></html>"
.to_string(),
),
..SiteOptions::default()
},
);
assert_eq!(out.page_shell_errors.len(), 1);
let report = &out.page_shell_errors[0];
assert!(report.contains("poster.md"), "{report}");
assert!(report.contains("themes/gone.html"), "{report}");
let page = out
.pages
.iter()
.find(|p| p.dest_filename == "poster.html")
.unwrap();
assert!(page.html.contains(r#"<body class="site">"#));
}
#[test]
fn a_broken_page_shell_is_reported_once_for_the_shell() {
let one = "---\ntitle: One\nshell: themes/poster.html\n---\nA.\n";
let two = "---\ntitle: Two\nshell: themes/poster.html\n---\nB.\n";
let sources = vec![src("one.md", one, false), src("two.md", two, false)];
let out = render_site(
&sources,
&SiteOptions {
templates: templates(&[("themes/poster.html", "<html>{{contnet}}</html>")]),
..SiteOptions::default()
},
);
assert_eq!(
out.page_shell_errors.len(),
1,
"one broken template is one report: {:?}",
out.page_shell_errors
);
assert!(
out.page_shell_errors[0].contains("unknown shell slot `contnet`"),
"{:?}",
out.page_shell_errors
);
assert!(out.template_error.is_none(), "the site's shell is fine");
for page in &out.pages {
assert!(
page.html.contains(r#"<div class="site-content">"#),
"the built-in shell"
);
}
}
#[test]
fn a_page_shell_does_not_disturb_the_site_shells_report() {
let sources = vec![src(
"poster.md",
"---\ntitle: Poster\nshell: themes/gone.html\n---\nArt.\n",
false,
)];
let out = render_site(
&sources,
&SiteOptions {
template: Some("<html>{{contnet}}</html>".to_string()),
..SiteOptions::default()
},
);
assert!(
out.template_error
.as_deref()
.is_some_and(|e| e.contains("unknown shell slot")),
"{:?}",
out.template_error
);
assert_eq!(out.page_shell_errors.len(), 1);
}
#[test]
fn a_bare_or_verbatim_page_takes_no_page_shell() {
let bare = "---\ntitle: Bare\nlayout: bare\nshell: themes/gone.html\n---\nArt.\n";
let verbatim =
"---\ntitle: Verbatim\nlayout: verbatim\nshell: themes/poster.html\n---\n<h1>Hi</h1>\n";
let sources = vec![
src("bare.md", bare, false),
src("verbatim.md", verbatim, false),
];
let out = render_site(
&sources,
&SiteOptions {
templates: templates(&[("themes/poster.html", POSTER)]),
..SiteOptions::default()
},
);
assert!(
out.page_shell_errors.is_empty(),
"nothing was going to wear it: {:?}",
out.page_shell_errors
);
let verbatim_page = out
.pages
.iter()
.find(|p| p.dest_filename == "verbatim.html")
.unwrap();
assert_eq!(verbatim_page.html, "<h1>Hi</h1>\n");
let bare_page = out
.pages
.iter()
.find(|p| p.dest_filename == "bare.html")
.unwrap();
assert!(!bare_page.html.contains("class=\"poster\""));
}
#[test]
fn a_page_may_claim_a_shell_and_a_destination_at_once() {
let poster =
"---\ntitle: Poster\nserve_at: /poster\nshell: themes/poster.html\n---\nArt.\n";
let sources = vec![src("deep/nested/poster.md", poster, false)];
let out = render_site(
&sources,
&SiteOptions {
templates: templates(&[("themes/poster.html", POSTER)]),
..SiteOptions::default()
},
);
let page = out
.pages
.iter()
.find(|p| p.dest_filename == "poster.html")
.expect("the claimed destination");
assert!(page.html.contains(r#"<body class="poster">"#));
}
#[test]
fn a_page_may_declare_its_own_language() {
let index = "---\ntitle: Home\ncontents:\n - \"/letter.md\"\n---\nHi.\n";
let letter = "---\ntitle: Letter\npart_of: \"/index.md\"\nlang: ' cy '\n---\nBore da.\n";
let sources = vec![
src("index.md", index, true),
src("letter.md", letter, false),
];
let out = render_site(
&sources,
&SiteOptions {
lang: "en".to_string(),
..SiteOptions::default()
},
);
let page = |name: &str| {
out.pages
.iter()
.find(|p| p.dest_filename == name)
.unwrap()
.html
.clone()
};
assert!(page("letter.html").contains(r#"<html lang="cy">"#));
assert!(
page("index.html").contains(r#"<html lang="en">"#),
"and the site's own tag is untouched by the page that declared one"
);
}
#[test]
fn a_blank_page_language_reads_as_absent() {
let sources = vec![src(
"index.md",
"---\ntitle: Home\nlang: ' '\n---\nHi.\n",
true,
)];
let out = render_site(
&sources,
&SiteOptions {
lang: "en".to_string(),
..SiteOptions::default()
},
);
assert!(out.pages[0].html.contains(r#"<html lang="en">"#));
}
#[test]
fn a_synthesized_index_keeps_the_sites_language() {
let sources = vec![src(
"letter.md",
"---\ntitle: Letter\nlang: cy\n---\nBore da.\n",
false,
)];
let out = render_site(
&sources,
&SiteOptions {
lang: "en".to_string(),
..SiteOptions::default()
},
);
let home = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.expect("a synthesized front page");
assert!(home.html.contains(r#"<html lang="en">"#), "{}", home.html);
}
#[test]
fn render_site_produces_pages_nav_and_assets() {
let index = "---\ntitle: Home\ncontents:\n - \"[Child](/child.md)\"\n---\nHi.\n";
let child = "---\ntitle: Child\npart_of: \"/index.md\"\n---\nKid.\n";
let sources = vec![src("index.md", index, true), src("child.md", child, false)];
let out = render_site(&sources, &SiteOptions::default());
assert_eq!(out.pages.len(), 2);
let home = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
assert!(home.html.contains("site-nav"));
assert!(home.html.contains("child.html"));
assert!(home.html.contains("<!DOCTYPE html>"));
assert!(out.assets.iter().any(|(n, _)| n == "style.css"));
}
#[test]
fn a_page_can_list_the_sites_entries() {
let index =
"---\ntitle: Home\n---\n:::each{of=entries as=e}\n- [:val[e.title]]({{e.href}})\n:::\n";
let sources = vec![
src("index.md", index, true),
src("a.md", "---\ntitle: Alpha\n---\nA.\n", false),
src("b.md", "---\ntitle: Beta\n---\nB.\n", false),
];
let out = render_site(&sources, &SiteOptions::default());
let home = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
let content = &home.html[home.html.find(r#"<div class="content">"#).unwrap()..];
assert!(
content.contains(r#"<li><a href="a.html">Alpha</a></li>"#),
"the link survives the rewrite: {content}"
);
assert!(home.html.contains("Beta"), "got {}", home.html);
assert!(
out.body_template_errors.is_empty(),
"{:?}",
out.body_template_errors
);
}
#[test]
fn a_page_can_dress_each_child_in_its_color() {
let index = "---\ntitle: Home\ncontents:\n- '[Lake](lake.md)'\n- '[Kitchen](kitchen.md)'\n---\n\
:::each{of=children as=book}\n\
:::article{class=\"cover tone-{{book.color}}\"}\n\
[:val[book.title]]({{book.href}})\n\
:::\n\
:::\n";
let sources = vec![
src("index.md", index, true),
src("lake.md", "---\ntitle: Lake\ncolor: blue\n---\nA.\n", false),
src(
"kitchen.md",
"---\ntitle: Kitchen\ncolor: green\n---\nB.\n",
false,
),
];
let out = render_site(&sources, &SiteOptions::default());
let home = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
let content = &home.html[home.html.find(r#"<div class="content">"#).unwrap()..];
assert!(
content.contains(r#"<article class="cover tone-blue">"#),
"{content}"
);
assert!(
content.contains(r#"<article class="cover tone-green">"#),
"{content}"
);
assert!(
content.contains(r#"<a href="lake.html">Lake</a>"#),
"{content}"
);
assert!(
out.body_template_errors.is_empty(),
"{:?}",
out.body_template_errors
);
}
#[test]
fn a_templates_groups_are_ordered_by_key_not_by_arrival() {
let index = "---\ntitle: Home\n---\n:::each{of=groups as=g}\n- :val[g.key]\n:::\n";
let sources = vec![
src("index.md", index, true),
src("c.md", "---\ntitle: C\npeople: Nan\n---\nC.\n", false),
src("a.md", "---\ntitle: A\npeople: Ada\n---\nA.\n", false),
];
let opts = SiteOptions {
arrangement: Arrangement::Grouped(Grouping::field("people")),
..SiteOptions::default()
};
let out = render_site(&sources, &opts);
let home = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
let ada = home.html.find("Ada").expect("an Ada group");
let nan = home.html.find("Nan").expect("a Nan group");
assert!(
ada < nan,
"ascending by key, not `Nan` first: {}",
home.html
);
}
#[test]
fn a_template_cannot_reach_a_withheld_document() {
let index = "---\ntitle: Home\n---\n:::each{of=entries as=e}\n- :val[e.title]\n:::\n";
let admitted = vec![
src("index.md", index, true),
src("public.md", "---\ntitle: Public\n---\nP.\n", false),
];
let out = render_site(&admitted, &SiteOptions::default());
let home = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
assert!(home.html.contains("Public"), "got {}", home.html);
assert!(!home.html.contains("Private"), "got {}", home.html);
}
#[test]
fn backlinks_are_entries_the_linked_page_can_list() {
let body = "---\ntitle: Beta\n---\n:::each{of=backlinks as=b}\n- [:val[b.title]]({{b.href}})\n:::\n";
let sources = vec.\n", false),
linked("b.md", body, &["a.md"]),
];
let out = render_site(&sources, &SiteOptions::default());
let beta = out
.pages
.iter()
.find(|p| p.dest_filename == "b.html")
.unwrap();
assert!(beta.html.contains("Alpha"), "got {}", beta.html);
assert!(beta.html.contains(r#"href="a.html""#), "got {}", beta.html);
}
#[test]
fn backlinks_name_each_linking_document_once_and_in_path_order() {
let body = "---\ntitle: Beta\n---\n:::each{of=backlinks as=b}\n:val[b.path];\n:::\n";
let sources = vec![
src("index.md", "---\ntitle: Home\n---\nH.\n", true),
src("z.md", "---\ntitle: Zed\n---\nZ.\n", false),
src("a.md", "---\ntitle: Alpha\n---\nA.\n", false),
linked("b.md", body, &["z.md", "a.md", "a.md"]),
];
let out = render_site(&sources, &SiteOptions::default());
let beta = out
.pages
.iter()
.find(|p| p.dest_filename == "b.html")
.unwrap();
let listed: Vec<&str> = beta
.html
.split(';')
.filter_map(|chunk| ["a.md", "z.md"].into_iter().find(|p| chunk.contains(p)))
.collect();
assert_eq!(listed, ["a.md", "z.md"], "got {}", beta.html);
}
#[test]
fn a_backlink_to_a_document_outside_this_render_is_not_published() {
let body = "---\ntitle: Beta\n---\nLinked from:\n:::each{of=backlinks as=b}\n- :val[b.path]\n:::\n";
let sources = vec![
src("index.md", "---\ntitle: Home\n---\nH.\n", true),
linked("b.md", body, &["private.md"]),
];
let out = render_site(&sources, &SiteOptions::default());
let beta = out
.pages
.iter()
.find(|p| p.dest_filename == "b.html")
.unwrap();
assert!(beta.html.contains("Linked from"), "got {}", beta.html);
assert!(!beta.html.contains("private"), "got {}", beta.html);
assert!(
out.body_template_errors.is_empty(),
"{:?}",
out.body_template_errors
);
}
#[test]
fn an_inbound_relation_is_addressed_by_the_name_the_vault_gave_it() {
let body = "---\ntitle: Beta\n---\n:::each{of=inbound.sequel as=s}\n- [:val[s.title]]({{s.href}})\n:::\n";
let sources = vec![
src("index.md", "---\ntitle: Home\n---\nH.\n", true),
src("a.md", "---\ntitle: Alpha\n---\nA.\n", false),
SourceDoc {
inbound: edges(&[("sequel", "a.md")]),
..src("b.md", body, false)
},
];
let out = render_site(&sources, &SiteOptions::default());
let beta = out
.pages
.iter()
.find(|p| p.dest_filename == "b.html")
.unwrap();
assert!(beta.html.contains("Alpha"), "got {}", beta.html);
assert!(beta.html.contains(r#"href="a.html""#), "got {}", beta.html);
assert!(
out.body_template_errors.is_empty(),
"{:?}",
out.body_template_errors
);
}
#[test]
fn an_outbound_relation_is_published_and_a_prose_link_is_not_named_as_one() {
let body = "---\ntitle: Alpha\n---\nSequels:\n:::each{of=relations.sequel as=s}\n- :val[s.path]\n:::\nProse:\n:::each{of=relations.body as=s}\n- :val[s.path]\n:::\n";
let sources = vec![
src("index.md", "---\ntitle: Home\n---\nH.\n", true),
src("b.md", "---\ntitle: Beta\n---\nB.\n", false),
src("c.md", "---\ntitle: Gamma\n---\nC.\n", false),
SourceDoc {
outbound: edges(&[("sequel", "b.md")]),
inbound: vec![LinkEdge {
relation: None,
path: "c.md".into(),
}],
..src("a.md", body, false)
},
];
let out = render_site(&sources, &SiteOptions::default());
let alpha = out
.pages
.iter()
.find(|p| p.dest_filename == "a.html")
.unwrap();
assert!(alpha.html.contains("b.md"), "got {}", alpha.html);
assert!(!alpha.html.contains("c.md"), "got {}", alpha.html);
assert!(
out.body_template_errors.is_empty(),
"{:?}",
out.body_template_errors
);
}
#[test]
fn backlinks_stay_the_union_of_the_typed_and_the_untyped() {
let body = "---\ntitle: Beta\n---\n:::each{of=backlinks as=b}\n:val[b.path];\n:::\n";
let mut beta = src("b.md", body, false);
beta.inbound = edges(&[("sequel", "z.md")]);
beta.inbound.push(LinkEdge {
relation: None,
path: "a.md".into(),
});
beta.inbound.push(LinkEdge {
relation: Some("sequel".into()),
path: "a.md".into(),
});
let sources = vec![
src("index.md", "---\ntitle: Home\n---\nH.\n", true),
src("z.md", "---\ntitle: Zed\n---\nZ.\n", false),
src("a.md", "---\ntitle: Alpha\n---\nA.\n", false),
beta,
];
let out = render_site(&sources, &SiteOptions::default());
let beta = out
.pages
.iter()
.find(|p| p.dest_filename == "b.html")
.unwrap();
let listed: Vec<&str> = beta
.html
.split(';')
.filter_map(|chunk| ["a.md", "z.md"].into_iter().find(|p| chunk.contains(p)))
.collect();
assert_eq!(listed, ["a.md", "z.md"], "got {}", beta.html);
}
#[test]
fn a_relation_pointing_only_outside_this_render_leaves_no_key() {
let body = "---\ntitle: Alpha\n---\n:::if{has=relations.sequel}\nHas a sequel.\n:::\n";
let sources = vec![
src("index.md", "---\ntitle: Home\n---\nH.\n", true),
SourceDoc {
outbound: edges(&[("sequel", "private.md")]),
..src("a.md", body, false)
},
];
let out = render_site(&sources, &SiteOptions::default());
let alpha = out
.pages
.iter()
.find(|p| p.dest_filename == "a.html")
.unwrap();
assert!(!alpha.html.contains("Has a sequel"), "got {}", alpha.html);
assert!(!alpha.html.contains("private"), "got {}", alpha.html);
}
#[test]
fn a_stray_brace_is_reported_against_the_page_that_wrote_it() {
let index = "---\ntitle: Home\n---\nWelcome to {{ title }}.\n";
let sources = vec![src("index.md", index, true)];
let out = render_site(&sources, &SiteOptions::default());
let home = &out.pages[0];
assert!(home.html.contains("{{ title }}"), "got {}", home.html);
assert_eq!(out.body_template_errors.len(), 1);
assert!(
out.body_template_errors[0].starts_with("index.md:"),
"{:?}",
out.body_template_errors
);
}
#[test]
fn a_broken_body_template_is_reported_rather_than_swallowed() {
let index = "---\ntitle: Home\n---\n:::if{equals=title}\nX\n:::\n";
let sources = vec![src("index.md", index, true)];
let out = render_site(&sources, &SiteOptions::default());
assert_eq!(out.body_template_errors.len(), 1);
assert!(
out.body_template_errors[0].contains("equals"),
"{:?}",
out.body_template_errors
);
}
#[test]
fn a_page_can_name_its_parent_children_and_trail() {
let index = "---\ntitle: Home\ncontents:\n - \"[Child](/child.md)\"\n---\n:::each{of=children as=c}\n- :val[c.title]\n:::\n";
let child = "---\ntitle: Child\npart_of: \"/index.md\"\n---\nparent: :val[parent.title]\n\n:::each{of=breadcrumbs as=b}\n- :val[b.title]\n:::\n";
let sources = vec![src("index.md", index, true), src("child.md", child, false)];
let out = render_site(&sources, &SiteOptions::default());
let home = out
.pages
.iter()
.find(|p| p.dest_filename == "index.html")
.unwrap();
let kid = out
.pages
.iter()
.find(|p| p.dest_filename == "child.html")
.unwrap();
assert!(home.html.contains("<li>Child</li>"), "got {}", home.html);
assert!(kid.html.contains("parent: Home"), "got {}", kid.html);
let trail = kid
.html
.find("<li>Home</li>")
.zip(kid.html.find("<li>Child</li>"));
let (root_at, self_at) = trail.unwrap_or_else(|| panic!("got {}", kid.html));
assert!(root_at < self_at, "got {}", kid.html);
}
#[test]
fn the_site_nests_by_the_outline_it_is_given() {
let sources = vec![
src("index.md", "---\ntitle: Home\n---\nHi.\n", true),
src("letters.md", "---\ntitle: Letters\n---\nBody.\n", false),
src(
"letters/first.md",
"---\ntitle: The First Letter\n---\nin: :val[parent.title]\n",
false,
),
];
let outline = vec![OutlineNode {
path: "index.md".into(),
label: None,
children: vec![OutlineNode {
path: "letters.md".into(),
label: None,
children: vec![OutlineNode {
path: "letters/first.md".into(),
label: None,
children: Vec::new(),
}],
}],
}];
let letter = |opts: &SiteOptions| {
render_site(&sources, opts)
.pages
.into_iter()
.find(|p| p.dest_filename == "letters/first.html")
.expect("the letter")
.html
};
let trail = |html: &str| {
let start = html
.find(r#"<nav class="breadcrumbs""#)
.expect("a breadcrumb trail");
let end = html[start..].find("</nav>").expect("a closed one") + start;
html[start..end].to_string()
};
let placed = letter(&SiteOptions {
outline,
..SiteOptions::default()
});
assert!(
trail(&placed).contains(">Letters</a>"),
"the trail the archive's own hierarchy gives it: {}",
trail(&placed)
);
assert!(
placed.contains("in: Letters"),
"and the same answer where a template asks for it: {placed}"
);
let loose = letter(&SiteOptions::default());
assert!(
!trail(&loose).contains(">Letters</a>"),
"nothing here nests them: {}",
trail(&loose)
);
}
fn page_named<'a>(out: &'a SiteRender, dest: &str) -> &'a RenderedPage {
out.pages
.iter()
.find(|p| p.dest_filename == dest)
.unwrap_or_else(|| panic!("no page at {dest}"))
}
#[test]
fn headings_are_anchored_and_outlined() {
let index = "---\ntitle: Home\n---\n# Home\n\n## First\n\ntext\n\n### Inner\n\n## Second\n";
let djot = "---\ntitle: Note\n---\n## Alpha\n\n## Beta\n";
let sources = vec![src("index.md", index, true), src("note.dj", djot, false)];
let out = render_site(&sources, &SiteOptions::default());
let home = &page_named(&out, "index.html").html;
assert!(
home.contains(r##"<h2 id="first">First <a class="heading-anchor" href="#first" aria-label="Link to this section">#</a></h2>"##),
"got {home}"
);
assert!(
home.contains(r##"<nav class="toc" aria-label="On this page"><details open><summary>On this page</summary><ul><li><a href="#first">First</a><ul><li><a href="#inner">Inner</a></li></ul></li><li><a href="#second">Second</a></li></ul></details></nav>"##),
"got {home}"
);
let note = &page_named(&out, "note.html").html;
assert!(
note.contains(r##"<h2 id="alpha">Alpha "##),
"djot too: {note}"
);
assert!(note.contains(r##"<a href="#beta">Beta</a>"##), "got {note}");
}
#[test]
fn the_outline_is_omitted_when_short_or_refused() {
let short = "---\ntitle: Short\n---\n## Only\n";
let refused = "---\ntitle: Refused\ntoc: false\n---\n## One\n\n## Two\n";
let sources = vec![
src("index.md", "---\ntitle: Home\n---\nHi.\n", true),
src("short.md", short, false),
src("refused.md", refused, false),
];
let out = render_site(&sources, &SiteOptions::default());
assert!(
!page_named(&out, "short.html")
.html
.contains(r#"class="toc""#)
);
let refused = &page_named(&out, "refused.html").html;
assert!(!refused.contains(r#"class="toc""#), "got {refused}");
assert!(
refused.contains(r##"<h2 id="one">"##),
"anchors stay: {refused}"
);
}
#[test]
fn a_page_can_list_its_own_headings() {
let body = "---\ntitle: Home\n---\n:::each{of=headings as=h}\n- [:val[h.text]](#{{h.id}})\n:::\n\n## Ben & Co\n\n## Second\n";
let out = render_site(&[src("index.md", body, true)], &SiteOptions::default());
assert!(
out.body_template_errors.is_empty(),
"{:?}",
out.body_template_errors
);
let home = &page_named(&out, "index.html").html;
assert!(
home.contains(r##"<a href="#ben-co">Ben & Co</a>"##),
"got {home}"
);
assert!(
home.contains(r##"<a href="#second">Second</a>"##),
"got {home}"
);
}
#[test]
fn a_verbatim_page_keeps_its_headings_as_written() {
let verbatim = "---\ntitle: Landing\nlayout: verbatim\n---\n<h2>Raw</h2><h2>Rawer</h2>";
let sources = vec![
src("index.md", "---\ntitle: Home\n---\nHi.\n", true),
src("landing.html", verbatim, false),
];
let out = render_site(&sources, &SiteOptions::default());
assert_eq!(
page_named(&out, "landing.html").html,
"<h2>Raw</h2><h2>Rawer</h2>"
);
}
#[test]
fn the_pager_and_the_context_agree_on_the_reading_order() {
let index = "---\ntitle: Home\ncontents:\n - \"/a.md\"\n - \"/b.md\"\n---\nHi.\n";
let a = "---\ntitle: A\npart_of: \"/index.md\"\ncontents:\n - \"/a/kid.md\"\n---\nA. Next: [:val[next.title]]({{next.href}}); prev: :val[prev.title].\n";
let kid = "---\ntitle: Kid\npart_of: \"/a.md\"\n---\nKid.\n";
let b = "---\ntitle: B\npart_of: \"/index.md\"\n---\nB.\n";
let sources = vec![
src("index.md", index, true),
src("a.md", a, false),
src("a/kid.md", kid, false),
src("b.md", b, false),
];
let out = render_site(&sources, &SiteOptions::default());
assert!(
out.body_template_errors.is_empty(),
"{:?}",
out.body_template_errors
);
let a = &page_named(&out, "a.html").html;
assert!(
a.contains(r#"<a class="pager-prev" rel="prev" href="index.html"><span>Previous</span> Home</a>"#),
"got {a}"
);
assert!(
a.contains(
r#"<a class="pager-next" rel="next" href="a/kid.html"><span>Next</span> Kid</a>"#
),
"got {a}"
);
assert!(
a.contains(r#"Next: <a href="a/kid.html">Kid</a>; prev: Home."#),
"the context names the same neighbours: {a}"
);
let kid = &page_named(&out, "a/kid.html").html;
assert!(
kid.contains(r#"rel="next" href="../b.html""#),
"depth-first, rebased: {kid}"
);
let b = &page_named(&out, "b.html").html;
assert!(!b.contains("pager-next"), "the last page has no next: {b}");
}
#[test]
fn a_hidden_page_is_in_no_sequence() {
let sources = vec![
src("index.md", "---\ntitle: Home\n---\nHi.\n", true),
src("a.md", "---\ntitle: A\n---\nA.\n", false),
src(
"h.md",
"---\ntitle: H\nhide_from_nav: true\n---\nH.\n",
false,
),
];
let out = render_site(&sources, &SiteOptions::default());
assert!(!page_named(&out, "h.html").html.contains(r#"class="pager""#));
assert!(!page_named(&out, "a.html").html.contains("h.html"));
}
#[test]
fn a_synthesized_front_page_heads_the_reading_order() {
let sources = vec![
src("a.md", "---\ntitle: A\n---\nA.\n", false),
src("b.md", "---\ntitle: B\n---\nB.\n", false),
];
let out = render_site(&sources, &SiteOptions::default());
let a = &page_named(&out, "a.html").html;
assert!(a.contains(r#"rel="prev" href="index.html""#), "got {a}");
let home = &page_named(&out, "index.html").html;
assert!(home.contains(r#"rel="next" href="a.html""#), "got {home}");
}
fn frame(path: &str, source: &str) -> Option<FrameDoc> {
Some(FrameDoc {
path: path.to_string(),
source: source.to_string(),
})
}
#[test]
fn the_header_and_footer_are_rendered_per_page() {
let index = "---\ntitle: Home\ncontents:\n - \"/notes/entry.md\"\n---\nHi.\n";
let entry = "---\ntitle: Entry\npart_of: \"/index.md\"\n---\nE.\n";
let opts = SiteOptions {
site_title: Some("My Site".into()),
header: frame(
".config/sites/docs/header.md",
"---\ntitle: never published\n---\n- [Home](/index.md)\n- [About](/about.md)\n",
),
footer: frame(
".config/sites/docs/footer.md",
"© :val[site.title] · you are reading :val[page.title]\n",
),
..SiteOptions::default()
};
let out = render_site(
&[
src("index.md", index, true),
src("notes/entry.md", entry, false),
],
&opts,
);
assert!(
out.body_template_errors.is_empty(),
"{:?}",
out.body_template_errors
);
let home = &page_named(&out, "index.html").html;
assert!(
home.contains(r#"<header class="site-header"><ul>"#),
"the header is a rendered document: {home}"
);
assert!(
home.contains(r#"<a href="index.html">Home</a>"#),
"got {home}"
);
assert!(
home.contains(
r#"<span class="unpublished-link" title="This page isn’t published">About</span>"#
),
"a link to a page the site does not publish goes inert: {home}"
);
assert!(
home.contains("© My Site · you are reading Home"),
"the footer names the page: {home}"
);
assert!(
!home.contains("never published"),
"the frame's own metadata is unread"
);
let entry = &page_named(&out, "notes/entry.html").html;
assert!(
entry.contains(r#"<a href="../index.html">Home</a>"#),
"rebased: {entry}"
);
assert!(entry.contains("you are reading Entry"), "got {entry}");
assert!(
entry.contains(r#"</footer>"#)
&& entry.contains(r#"<footer class="site-footer"><p>© "#),
"the footer is inside the shell's footer: {entry}"
);
}
#[test]
fn a_frame_is_filtered_for_the_audience() {
let footer = ":::vis{.family}\nfor family\n:::\n\n:::vis{.public}\nfor everyone\n:::\n";
let sources = vec![src("index.md", "---\ntitle: Home\n---\nHi.\n", true)];
let out = render_site(
&sources,
&SiteOptions {
audience: Some("public".into()),
footer: frame("footer.md", footer),
..SiteOptions::default()
},
);
let home = &page_named(&out, "index.html").html;
assert!(home.contains("for everyone"), "got {home}");
assert!(!home.contains("for family"), "got {home}");
}
#[test]
fn a_broken_frame_is_reported_against_the_frame() {
let sources = vec![src("index.md", "---\ntitle: Home\n---\nHi.\n", true)];
let out = render_site(
&sources,
&SiteOptions {
header: frame(
".config/sites/docs/header.md",
":::if{equals=title}\nX\n:::\n",
),
..SiteOptions::default()
},
);
assert_eq!(
out.body_template_errors.len(),
1,
"{:?}",
out.body_template_errors
);
assert!(
out.body_template_errors[0].starts_with("site header .config/sites/docs/header.md:"),
"{:?}",
out.body_template_errors
);
}
#[test]
fn an_undeclared_frame_is_an_empty_slot() {
let sources = vec![src("index.md", "---\ntitle: Home\n---\nHi.\n", true)];
let out = render_site(&sources, &SiteOptions::default());
let home = &page_named(&out, "index.html").html;
assert!(
home.contains(r#"<header class="site-header"></header>"#),
"got {home}"
);
assert!(
home.contains(r#"<footer class="site-footer"></footer>"#),
"got {home}"
);
}
#[test]
fn a_template_may_place_the_frame_the_outline_and_the_pager() {
let index = "---\ntitle: Home\ncontents:\n - \"/a.md\"\n---\n## One\n\n## Two\n";
let sources = vec![
src("index.md", index, true),
src(
"a.md",
"---\ntitle: A\npart_of: \"/index.md\"\n---\nA.\n",
false,
),
];
let out = render_site(
&sources,
&SiteOptions {
template: Some(
"<a href=\"{{root_prefix}}index.html\">home</a>[{{{site_header}}}][{{{toc}}}][{{{content}}}][{{{pager}}}][{{{site_footer}}}]"
.to_string(),
),
header: frame("h.md", "H\n"),
footer: frame("f.md", "F\n"),
..SiteOptions::default()
},
);
assert!(out.template_error.is_none(), "{:?}", out.template_error);
let home = &page_named(&out, "index.html").html;
assert!(
home.starts_with(r#"<a href="index.html">home</a>[<p>H</p>"#),
"got {home}"
);
assert!(home.contains(r#"[<nav class="toc""#), "got {home}");
assert!(home.contains(r#"[<nav class="pager""#), "got {home}");
assert!(home.ends_with("[<p>F</p>\n]"), "got {home}");
}
}