use std::collections::{BTreeMap, HashMap};
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};
use crate::shell::ShellTemplate;
use crate::types::{LinkEdge, NavLink, OutlineNode, PageLayout, PublishedPage};
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 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>,
}
impl Default for SiteOptions {
fn default() -> Self {
Self {
audience: None,
site_title: 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(),
}
}
}
#[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);
pages_from(
sources,
opts,
#[cfg(feature = "syntax-highlighting")]
syntaxes.get(),
&mut Vec::new(),
)
}
fn pages_from(
sources: &[SourceDoc],
opts: &SiteOptions,
#[cfg(feature = "syntax-highlighting")] syntaxes: &crate::syntax::Syntaxes,
reports: &mut Vec<String>,
) -> Vec<PublishedPage> {
let mut path_to_filename: HashMap<PathBuf, String> = HashMap::new();
let mut title_map: HashMap<PathBuf, String> = HashMap::new();
for s in sources {
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();
if let Some(t) = frontmatter::get_string(&fm, "title") {
title_map.insert(key.clone(), t.to_string());
}
path_to_filename.insert(key, dest_for(&s.path, s.is_root, &fm));
}
let collected = collect_context(sources, opts, &path_to_filename);
sources
.iter()
.map(|s| {
build_page(
s,
opts,
&path_to_filename,
&title_map,
&collected,
#[cfg(feature = "syntax-highlighting")]
syntaxes,
reports,
)
})
.collect()
}
struct Collected {
context: template::SiteContext,
by_path: HashMap<PathBuf, JsonValue>,
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>,
) -> 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 link = prov::Link::parse_path_only(parent.trim());
let canonical = prov::link::resolve(Path::new(&s.path), &link.target);
parent_of.insert(
key.clone(),
PathBuf::from(links::sanitize_rel_path(&canonical.to_string_lossy())),
);
}
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(),
});
Collected {
context: template::SiteContext::new(
site,
entries.clone(),
groups_of(&order, &meta_of, &by_path, &opts.arrangement),
),
by_path,
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"),
"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))
.map(link_value)
.collect(),
collected
.parent_of
.get(&key)
.and_then(|container| collected.by_path.get(container))
.map(link_value)
.unwrap_or(JsonValue::Null),
),
None => (
contents_links
.iter()
.map(nav_link_value)
.collect::<Vec<_>>(),
parent_link.map(nav_link_value).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
}
fn nav_link_value(link: &NavLink) -> JsonValue {
serde_json::json!({ "title": link.title, "href": link.href })
}
fn link_value(entry: &JsonValue) -> JsonValue {
serde_json::json!({ "title": entry.get("title"), "href": entry.get("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 = pages_from(
sources,
opts,
#[cfg(feature = "syntax-highlighting")]
syntaxes.get(),
&mut body_template_errors,
);
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());
if !pages.iter().any(|p| p.is_root) && !pages.is_empty() && !opts.front_page_supplied {
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);
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 p in &pages {
let nav = nav_for_page(&nav_tree, &p.dest_filename, &pages);
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: &opts.lang,
template: shell,
},
);
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 build_page(
s: &SourceDoc,
opts: &SiteOptions,
path_to_filename: &HashMap<PathBuf, String>,
title_map: &HashMap<PathBuf, String>,
collected: &Collected,
#[cfg(feature = "syntax-highlighting")] syntaxes: &crate::syntax::Syntaxes,
reports: &mut Vec<String>,
) -> PublishedPage {
let audience = opts.audience.as_deref();
let parsed = frontmatter::parse_or_empty(&s.markdown).unwrap_or(frontmatter::ParsedFile {
frontmatter: IndexMap::new(),
body: s.markdown.clone(),
});
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))
.collect();
let parent_link = frontmatter::get_string(fm, "part_of")
.and_then(|p| resolve_link(p, ¤t_path, path_to_filename, title_map));
let layout = PageLayout::parse(frontmatter::get_string(fm, "layout"));
let file_path = Path::new(&s.path);
let format = ContentFormat::from_extension(file_path).unwrap_or(ContentFormat::Markdown);
let rendered_body = if layout.is_verbatim() {
parsed.body.clone()
} else {
let values = page_context_values(
s,
fm,
collected,
&contents_links,
parent_link.as_ref(),
audience,
);
let context = template::Context::new(&collected.context, &values);
let mut warnings = Vec::new();
let rendered = match audience {
Some(a) => {
template::render_for_audiences(&parsed.body, format, context, &[a], &mut warnings)
}
None => template::render(&parsed.body, format, context, &mut warnings),
};
reports.extend(
warnings
.into_iter()
.map(|w| format!("{}: {w}", current_path.display())),
);
match rendered {
Ok(body) => body,
Err(err) => {
reports.push(format!(
"{}: {err} — the page is published as its own source",
current_path.display()
));
parsed.body.clone()
}
}
};
let final_html = if layout.is_verbatim() {
rendered_body.clone()
} else {
#[cfg(feature = "syntax-highlighting")]
let converted = body::render_body_with(&rendered_body, format, syntaxes);
#[cfg(not(feature = "syntax-highlighting"))]
let converted = body::render_body(&rendered_body, format);
links::transform_links(
&converted,
file_path,
path_to_filename,
Path::new(""),
&dest_filename,
)
};
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: final_html,
markdown_body: rendered_body,
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,
},
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(),
}
}
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))
}
};
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,
nav_title: None,
nav_order: None,
hide_from_nav: false,
hide_from_feed: true,
id: None,
source_markdown: String::new(),
}
}
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>,
) -> Option<NavLink> {
let link = prov::Link::parse_path_only(link_str.trim());
let canonical = prov::link::resolve(current_relative, &link.target)
.to_string_lossy()
.into_owned();
let key = PathBuf::from(links::sanitize_rel_path(&canonical));
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(&canonical));
Some(NavLink { href, title })
}
pub fn output_filename(canonical_md: &str) -> String {
let with_ext = Path::new(canonical_md).with_extension("html");
let sanitized: PathBuf = with_ext
.components()
.map(|c| match c {
std::path::Component::Normal(s) => {
std::ffi::OsString::from(links::sanitize_path_component(&s.to_string_lossy()))
}
other => other.as_os_str().to_owned(),
})
.collect();
sanitized.to_string_lossy().into_owned()
}
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 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 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 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("<h2>Grandpa</h2>"));
assert!(index.rendered_body.contains("<h2>Nan</h2>"));
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 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();
assert!(home.html.contains(r#"href="a.html""#), "got {}", home.html);
assert!(home.html.contains("Alpha"), "got {}", home.html);
assert!(home.html.contains("Beta"), "got {}", home.html);
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)
);
}
}