use std::collections::HashMap;
use std::path::PathBuf;
use serde::Serialize;
use crate::domain::model::decision_record::DecisionRecord;
use crate::domain::model::issue::Issue;
use crate::domain::model::page::Page;
use crate::domain::model::site::{SiteAsset, SitePage, SitePath};
use crate::domain::usecases::site::markdown::{LinkResolver, MarkdownContext};
use crate::domain::usecases::site::templates::{TemplateContext, TemplateEngine};
pub struct SiteInput {
pub site_title: Option<String>,
pub site_nav: Vec<NavLink>,
pub dr_sections: Vec<DrSection>,
pub issues: Vec<SiteIssue>,
pub pages: Vec<Page>,
}
pub struct NavLink {
pub label: String,
pub url: String,
}
pub struct DrSection {
pub kind: String,
pub id_prefix: String,
pub records: Vec<SiteRecord>,
}
pub struct SiteRecord {
pub record: DecisionRecord,
pub slug: String,
}
pub struct SiteIssue {
pub issue: Issue,
pub slug: String,
pub companions: Vec<Companion>,
pub attachments: Vec<Attachment>,
}
pub struct Companion {
pub name: String,
pub markdown: String,
}
pub struct Attachment {
pub filename: String,
pub bytes: Vec<u8>,
}
#[derive(Debug, Default)]
pub struct SiteBundle {
pub pages: Vec<SitePage>,
pub assets: Vec<SiteAsset>,
}
impl SiteBundle {
pub fn push_asset(&mut self, asset: SiteAsset) {
self.assets.push(asset);
}
}
#[derive(Debug)]
pub enum BuildError {
BrokenReference {
from: String, to: String, },
Shortcode {
from: String,
position: usize,
message: String,
},
Template(anyhow::Error),
UrlCollision { url: String },
Other(anyhow::Error),
}
impl std::fmt::Display for BuildError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BrokenReference { from, to } => {
write!(f, "broken reference to {to} from {from}")
}
Self::Shortcode {
from,
position,
message,
} => {
write!(
f,
"{from}: shortcode parse error at byte {position}: {message}"
)
}
Self::Template(e) => write!(f, "template error: {e:#}"),
Self::UrlCollision { url } => {
write!(f, "URL {url} is produced by more than one page or record")
}
Self::Other(e) => write!(f, "{e:#}"),
}
}
}
impl std::error::Error for BuildError {}
#[derive(Debug, Default)]
struct SectionFiles {
pages: Vec<SitePage>,
assets: Vec<SiteAsset>,
urls: Vec<String>,
}
impl SectionFiles {
fn push_page(&mut self, path: SitePath, body: String, url: String) {
self.pages.push(SitePage::new(path, body));
self.urls.push(url);
}
fn push_asset(&mut self, path: SitePath, bytes: Vec<u8>) {
self.assets.push(SiteAsset::new(path, bytes));
}
fn extend(&mut self, other: SectionFiles) {
self.pages.extend(other.pages);
self.assets.extend(other.assets);
self.urls.extend(other.urls);
}
fn into_bundle(self) -> SiteBundle {
SiteBundle {
pages: self.pages,
assets: self.assets,
}
}
}
pub fn build_site(input: &SiteInput, theme: &dyn TemplateEngine) -> Result<SiteBundle, BuildError> {
let site_title = input
.site_title
.as_deref()
.unwrap_or("Documentation")
.to_string();
let mut all = SectionFiles::default();
all.extend(render_homepage_section(input, theme, &site_title)?);
for section in &input.dr_sections {
all.extend(render_dr_section(section, input, theme, &site_title)?);
}
all.extend(render_issues_section(input, theme, &site_title)?);
all.extend(render_pages_section(input, theme, &site_title)?);
detect_url_collisions(&all.urls)?;
let urls_so_far = all.urls.clone();
all.extend(render_infrastructure(
input,
theme,
&site_title,
&urls_so_far,
)?);
Ok(all.into_bundle())
}
fn render_homepage_section(
input: &SiteInput,
theme: &dyn TemplateEngine,
site_title: &str,
) -> Result<SectionFiles, BuildError> {
let mut out = SectionFiles::default();
let html = render_homepage(input, theme, site_title)?;
out.push_page(
SitePath::new("index.html").expect("static path"),
html,
"/".to_string(),
);
Ok(out)
}
fn render_dr_section(
section: &DrSection,
input: &SiteInput,
theme: &dyn TemplateEngine,
site_title: &str,
) -> Result<SectionFiles, BuildError> {
let mut out = SectionFiles::default();
let list_html = render_dr_list(section, input, theme, site_title)?;
out.push_page(
dr_section_index_path(§ion.kind),
list_html,
dr_section_url(§ion.kind),
);
for entry in §ion.records {
let single_html = render_dr_single(section, entry, input, theme, site_title)?;
out.push_page(
dr_record_index_path(§ion.kind, &entry.slug),
single_html,
dr_record_url(§ion.kind, &entry.slug),
);
}
Ok(out)
}
fn render_issues_section(
input: &SiteInput,
theme: &dyn TemplateEngine,
site_title: &str,
) -> Result<SectionFiles, BuildError> {
let mut out = SectionFiles::default();
let list_html = render_issues_list(input, theme, site_title)?;
out.push_page(issues_section_index_path(), list_html, issues_section_url());
for issue in &input.issues {
let single_html = render_issue_single(issue, input, theme, site_title)?;
out.push_page(
issue_index_path(&issue.slug),
single_html,
issue_url(&issue.slug),
);
for companion in &issue.companions {
let html = render_companion(issue, companion, input, theme, site_title)?;
out.push_page(
companion_index_path(&issue.slug, &companion.name),
html,
companion_url(&issue.slug, &companion.name),
);
}
for attachment in &issue.attachments {
out.push_asset(
attachment_path(&issue.slug, &attachment.filename),
attachment.bytes.clone(),
);
}
}
Ok(out)
}
fn render_pages_section(
input: &SiteInput,
theme: &dyn TemplateEngine,
site_title: &str,
) -> Result<SectionFiles, BuildError> {
let mut out = SectionFiles::default();
let by_source: std::collections::BTreeMap<PathBuf, String> = input
.pages
.iter()
.map(|p| (PathBuf::from(p.source.as_str()), p.url.clone()))
.collect();
for page in &input.pages {
let resolver = LinkResolver {
current_source_dir: PathBuf::from(page.source.as_str())
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_default(),
by_source: by_source.clone(),
};
let html = render_free_page(page, input, theme, site_title, resolver)?;
out.push_page(page_output_path(&page.url), html, page.url.clone());
}
Ok(out)
}
fn render_free_page(
page: &Page,
input: &SiteInput,
theme: &dyn TemplateEngine,
site_title: &str,
link_resolver: LinkResolver,
) -> Result<String, BuildError> {
let id = page.source.to_string();
let (body_html, has_mermaid) =
render_page_body(page.body.as_str(), &id, theme, input, Some(link_resolver))?;
let description = resolve_description(None, page.body.as_str());
let ctx = SingleCtx {
site: SiteCtx::new(site_title, &input.site_nav),
section: SectionRef {
title: page.title.clone(),
url: page.url.clone(),
},
page: PageData {
id,
title: page.title.clone(),
url: page.url.clone(),
kind: None,
description,
status: String::new(),
date: String::new(),
body_html,
has_mermaid,
companions: Vec::new(),
},
};
render(theme, "layouts/_default/single.html", &ctx)
}
fn page_output_path(url: &str) -> SitePath {
let trimmed = url.trim_start_matches('/').trim_end_matches('/');
let raw = if trimmed.is_empty() {
"index.html".to_string()
} else {
format!("{trimmed}/index.html")
};
SitePath::new(raw).expect("derived from a URL the build trusts")
}
fn detect_url_collisions(urls: &[String]) -> Result<(), BuildError> {
let mut seen: HashMap<&str, usize> = HashMap::new();
for url in urls {
*seen.entry(url.as_str()).or_insert(0) += 1;
}
for (url, count) in seen {
if count > 1 {
return Err(BuildError::UrlCollision {
url: url.to_string(),
});
}
}
Ok(())
}
fn render_infrastructure(
input: &SiteInput,
theme: &dyn TemplateEngine,
site_title: &str,
urls: &[String],
) -> Result<SectionFiles, BuildError> {
let mut out = SectionFiles::default();
out.push_asset(
SitePath::new("sitemap.xml").expect("static path"),
build_sitemap(urls).into_bytes(),
);
out.push_asset(
SitePath::new("robots.txt").expect("static path"),
build_robots().into_bytes(),
);
out.push_asset(
SitePath::new("404.html").expect("static path"),
render_404(input, theme, site_title)?.into_bytes(),
);
Ok(out)
}
fn dr_section_url(kind: &str) -> String {
format!("/decisions/{kind}/")
}
fn dr_section_index_path(kind: &str) -> SitePath {
SitePath::new(format!("decisions/{kind}/index.html")).expect("kind is a validated slug")
}
fn dr_record_url(kind: &str, slug: &str) -> String {
format!("/decisions/{kind}/{slug}/")
}
fn dr_record_index_path(kind: &str, slug: &str) -> SitePath {
SitePath::new(format!("decisions/{kind}/{slug}/index.html"))
.expect("kind and slug are validated")
}
fn issues_section_url() -> String {
"/issues/".to_string()
}
fn issues_section_index_path() -> SitePath {
SitePath::new("issues/index.html").expect("static path is valid")
}
fn issue_url(slug: &str) -> String {
format!("/issues/{slug}/")
}
fn issue_index_path(slug: &str) -> SitePath {
SitePath::new(format!("issues/{slug}/index.html")).expect("slug is validated")
}
fn companion_url(issue_slug: &str, name: &str) -> String {
format!("/issues/{issue_slug}/{name}/")
}
fn companion_index_path(issue_slug: &str, name: &str) -> SitePath {
SitePath::new(format!("issues/{issue_slug}/{name}/index.html"))
.expect("slug and companion name are validated")
}
fn attachment_path(issue_slug: &str, filename: &str) -> SitePath {
SitePath::new(format!("issues/{issue_slug}/{filename}"))
.expect("slug and filename are validated")
}
fn dr_section_title(kind: &str) -> String {
format!("{}s", kind.to_uppercase())
}
#[derive(Serialize)]
struct SiteCtx<'a> {
title: &'a str,
nav: Vec<NavCtx<'a>>,
}
#[derive(Serialize)]
struct NavCtx<'a> {
label: &'a str,
url: &'a str,
}
impl<'a> SiteCtx<'a> {
fn new(title: &'a str, nav: &'a [NavLink]) -> Self {
let nav = nav
.iter()
.map(|n| NavCtx {
label: n.label.as_str(),
url: n.url.as_str(),
})
.collect();
Self { title, nav }
}
}
#[derive(Serialize)]
struct HomepageCtx<'a> {
site: SiteCtx<'a>,
sections: Vec<HomepageSection>,
}
#[derive(Serialize)]
struct HomepageSection {
title: String,
url: String,
count: usize,
}
fn render_homepage(
input: &SiteInput,
theme: &dyn TemplateEngine,
site_title: &str,
) -> Result<String, BuildError> {
let mut sections = Vec::new();
for s in &input.dr_sections {
sections.push(HomepageSection {
title: dr_section_title(&s.kind),
url: dr_section_url(&s.kind),
count: s.records.len(),
});
}
sections.push(HomepageSection {
title: "Issues".to_string(),
url: issues_section_url(),
count: input.issues.len(),
});
let ctx = HomepageCtx {
site: SiteCtx::new(site_title, &input.site_nav),
sections,
};
render(theme, "layouts/index.html", &ctx)
}
#[derive(Serialize)]
struct ListCtx<'a> {
site: SiteCtx<'a>,
section: ListSection,
}
#[derive(Serialize)]
struct ListSection {
title: String,
url: String,
records: Vec<ListRecord>,
}
#[derive(Serialize)]
struct ListRecord {
id: String,
title: String,
url: String,
status: String,
date: String,
}
fn render_dr_list(
section: &DrSection,
input: &SiteInput,
theme: &dyn TemplateEngine,
site_title: &str,
) -> Result<String, BuildError> {
let mut records: Vec<_> = section
.records
.iter()
.map(|e| ListRecord {
id: e.record.id.to_string(),
title: e.record.title.as_str().to_string(),
url: dr_record_url(§ion.kind, &e.slug),
status: e.record.status.as_str().to_string(),
date: e.record.date.to_string(),
})
.collect();
records.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| b.id.cmp(&a.id)));
let ctx = ListCtx {
site: SiteCtx::new(site_title, &input.site_nav),
section: ListSection {
title: dr_section_title(§ion.kind),
url: dr_section_url(§ion.kind),
records,
},
};
render(theme, "layouts/_default/list.html", &ctx)
}
fn render_issues_list(
input: &SiteInput,
theme: &dyn TemplateEngine,
site_title: &str,
) -> Result<String, BuildError> {
let mut records: Vec<_> = input
.issues
.iter()
.map(|i| ListRecord {
id: i.issue.id.to_string(),
title: i.issue.title.as_str().to_string(),
url: issue_url(&i.slug),
status: i.issue.status.as_str().to_string(),
date: i.issue.date.to_string(),
})
.collect();
records.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| b.id.cmp(&a.id)));
let ctx = ListCtx {
site: SiteCtx::new(site_title, &input.site_nav),
section: ListSection {
title: "Issues".to_string(),
url: issues_section_url(),
records,
},
};
render(theme, "layouts/_default/list.html", &ctx)
}
#[derive(Serialize)]
struct SingleCtx<'a> {
site: SiteCtx<'a>,
section: SectionRef,
page: PageData,
}
#[derive(Serialize)]
struct SectionRef {
title: String,
url: String,
}
#[derive(Serialize)]
struct PageData {
id: String,
title: String,
url: String,
kind: Option<String>,
description: Option<String>,
status: String,
date: String,
body_html: String,
has_mermaid: bool,
companions: Vec<CompanionRef>,
}
#[derive(Serialize)]
struct CompanionRef {
name: String,
url: String,
}
fn render_dr_single(
section: &DrSection,
entry: &SiteRecord,
input: &SiteInput,
theme: &dyn TemplateEngine,
site_title: &str,
) -> Result<String, BuildError> {
let id = entry.record.id.to_string();
let body_text = entry.record.content.as_str();
let page_ref = format!("decisions/{}/{} ({id})", section.kind, entry.slug);
let (body_html, has_mermaid) = render_page_body(body_text, &page_ref, theme, input, None)?;
let description = resolve_description(
entry.record.description.as_ref().map(|d| d.as_str()),
body_text,
);
let ctx = SingleCtx {
site: SiteCtx::new(site_title, &input.site_nav),
section: SectionRef {
title: dr_section_title(§ion.kind),
url: dr_section_url(§ion.kind),
},
page: PageData {
id,
title: entry.record.title.as_str().to_string(),
url: dr_record_url(§ion.kind, &entry.slug),
kind: Some(section.kind.clone()),
description,
status: entry.record.status.as_str().to_string(),
date: entry.record.date.to_string(),
body_html,
has_mermaid,
companions: Vec::new(),
},
};
render(theme, "layouts/_default/single.html", &ctx)
}
fn render_issue_single(
issue: &SiteIssue,
input: &SiteInput,
theme: &dyn TemplateEngine,
site_title: &str,
) -> Result<String, BuildError> {
let id = issue.issue.id.to_string();
let body_text = issue.issue.content.as_str();
let page_ref = format!("issues/{} ({id})", issue.slug);
let (body_html, has_mermaid) = render_page_body(body_text, &page_ref, theme, input, None)?;
let description = resolve_description(
issue.issue.description.as_ref().map(|d| d.as_str()),
body_text,
);
let companions = issue
.companions
.iter()
.map(|c| CompanionRef {
name: format!("{}.md", c.name),
url: companion_url(&issue.slug, &c.name),
})
.collect();
let ctx = SingleCtx {
site: SiteCtx::new(site_title, &input.site_nav),
section: SectionRef {
title: "Issues".to_string(),
url: issues_section_url(),
},
page: PageData {
id,
title: issue.issue.title.as_str().to_string(),
url: issue_url(&issue.slug),
kind: None,
description,
status: issue.issue.status.as_str().to_string(),
date: issue.issue.date.to_string(),
body_html,
has_mermaid,
companions,
},
};
render(theme, "layouts/_default/single.html", &ctx)
}
fn render_companion(
issue: &SiteIssue,
companion: &Companion,
input: &SiteInput,
theme: &dyn TemplateEngine,
site_title: &str,
) -> Result<String, BuildError> {
let id = format!("{} / {}.md", issue.issue.id, companion.name);
let (body_html, has_mermaid) = render_page_body(&companion.markdown, &id, theme, input, None)?;
let description = resolve_description(None, &companion.markdown);
let ctx = SingleCtx {
site: SiteCtx::new(site_title, &input.site_nav),
section: SectionRef {
title: issue.issue.title.as_str().to_string(),
url: issue_url(&issue.slug),
},
page: PageData {
id,
title: format!("{} — {}", issue.issue.title.as_str(), companion.name),
url: companion_url(&issue.slug, &companion.name),
kind: None,
description,
status: issue.issue.status.as_str().to_string(),
date: issue.issue.date.to_string(),
body_html,
has_mermaid,
companions: Vec::new(),
},
};
render(theme, "layouts/_default/single.html", &ctx)
}
fn render_404(
input: &SiteInput,
theme: &dyn TemplateEngine,
site_title: &str,
) -> Result<String, BuildError> {
#[derive(Serialize)]
struct NotFoundCtx<'a> {
site: SiteCtx<'a>,
}
let ctx = NotFoundCtx {
site: SiteCtx::new(site_title, &input.site_nav),
};
render(theme, "layouts/404.html", &ctx)
}
fn render<T: Serialize>(
theme: &dyn TemplateEngine,
name: &str,
ctx: &T,
) -> Result<String, BuildError> {
let tctx = TemplateContext::from(ctx).map_err(BuildError::Other)?;
theme.render(name, &tctx).map_err(BuildError::Template)
}
fn render_page_body(
markdown_source: &str,
page_id: &str,
theme: &dyn TemplateEngine,
input: &SiteInput,
link_resolver: Option<LinkResolver>,
) -> Result<(String, bool), BuildError> {
use crate::domain::usecases::site::markdown::render_body;
use crate::domain::usecases::site::shortcodes;
let tokens = shortcodes::parse(markdown_source).map_err(|e| BuildError::Shortcode {
from: page_id.to_string(),
position: e.position,
message: e.message,
})?;
let mut expanded = String::new();
for token in tokens {
match token {
shortcodes::Token::Text(s) => expanded.push_str(&s),
shortcodes::Token::Shortcode { name, args, body } => {
let ctx = build_shortcode_context(&name, &args, body.as_deref(), input, page_id)?;
let rendered = theme
.render(&format!("shortcodes/{name}.html"), &ctx)
.map_err(BuildError::Template)?;
expanded.push_str(&rendered);
}
}
}
let mut md_ctx = MarkdownContext {
has_mermaid: false,
link_resolver,
};
let html = render_body(&expanded, &mut md_ctx);
Ok((html, md_ctx.has_mermaid))
}
fn build_shortcode_context(
name: &str,
args: &[(String, String)],
body: Option<&str>,
input: &SiteInput,
page_id: &str,
) -> Result<TemplateContext, BuildError> {
let mut map: HashMap<String, serde_json::Value> = HashMap::new();
for (k, v) in args {
map.insert(k.clone(), serde_json::Value::String(v.clone()));
}
if let Some(body) = body {
map.insert(
"body".to_string(),
serde_json::Value::String(body.to_string()),
);
}
let id_attr = args
.iter()
.find(|(k, _)| k == "id")
.map(|(_, v)| v.as_str());
match name {
"adr-ref" | "ddr-ref" => {
let kind = name.trim_end_matches("-ref");
let id = id_attr.ok_or_else(|| BuildError::Shortcode {
from: page_id.to_string(),
position: 0,
message: format!("{name} requires id=\"…\""),
})?;
let section = input.dr_sections.iter().find(|s| s.kind == kind);
let entry = section.and_then(|s| find_dr_in(s, id)).ok_or_else(|| {
BuildError::BrokenReference {
from: page_id.to_string(),
to: section
.map(|s| format!("{}-{id}", s.id_prefix))
.unwrap_or_else(|| id.to_string()),
}
})?;
map.insert(
"url".to_string(),
serde_json::Value::String(dr_record_url(kind, &entry.slug)),
);
map.insert(
"title".to_string(),
serde_json::Value::String(entry.record.title.as_str().to_string()),
);
map.insert(
"status".to_string(),
serde_json::Value::String(entry.record.status.as_str().to_string()),
);
}
"issue-ref" => {
let id = id_attr.ok_or_else(|| BuildError::Shortcode {
from: page_id.to_string(),
position: 0,
message: "issue-ref requires id=\"…\"".to_string(),
})?;
let entry = find_issue(input, id).ok_or_else(|| BuildError::BrokenReference {
from: page_id.to_string(),
to: id.to_string(),
})?;
map.insert(
"url".to_string(),
serde_json::Value::String(issue_url(&entry.slug)),
);
map.insert(
"title".to_string(),
serde_json::Value::String(entry.issue.title.as_str().to_string()),
);
map.insert(
"status".to_string(),
serde_json::Value::String(entry.issue.status.as_str().to_string()),
);
}
_ => {} }
let value = serde_json::Value::Object(map.into_iter().collect());
Ok(TemplateContext(value))
}
fn find_dr_in<'b>(section: &'b DrSection, id: &str) -> Option<&'b SiteRecord> {
let id_num = parse_id_suffix(id);
section.records.iter().find(|r| {
let rid = r.record.id.to_string();
rid.ends_with(&format!("-{id_num:04}")) || rid == format!("{}-{id}", section.id_prefix)
})
}
fn find_issue<'b>(input: &'b SiteInput, id: &str) -> Option<&'b SiteIssue> {
let id_num = parse_id_suffix(id);
input.issues.iter().find(|i| {
let iid = i.issue.id.to_string();
iid == id || iid.ends_with(&format!("-{id_num:04}"))
})
}
fn parse_id_suffix(s: &str) -> u32 {
s.trim_start_matches('0').parse().unwrap_or_else(|_| {
if s.chars().all(|c| c == '0') {
0
} else {
u32::MAX }
})
}
fn resolve_description(explicit: Option<&str>, body: &str) -> Option<String> {
if let Some(s) = explicit {
return Some(s.to_string());
}
crate::domain::usecases::site::markdown::extract_first_paragraph(body)
}
fn build_sitemap(urls: &[String]) -> String {
let mut s = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
);
for url in urls {
s.push_str(&format!(" <url><loc>{url}</loc></url>\n"));
}
s.push_str("</urlset>\n");
s
}
fn build_robots() -> String {
"User-agent: *\nAllow: /\n".to_string()
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use super::*;
use crate::domain::model::body::Body;
use crate::domain::model::decision_record::{DecisionRecord, EventLog, RecordLinks};
use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::model::entry_origin::EntryOrigin;
use crate::domain::model::issue::{
EventLog as IssueEventLog, Issue, IssueLinks, Tracker as IssueTracker,
};
use crate::domain::model::record_kind::RecordKind;
use crate::domain::model::record_ref::{DecisionRecordRef, IssueRef};
use crate::domain::model::status::Status;
use crate::domain::model::tag_list::TagList;
use crate::domain::model::temporal::iso_date::IsoDate;
use crate::domain::model::title::Title;
struct StubTheme {
rendered: RefCell<Vec<(String, String)>>,
}
impl StubTheme {
fn new() -> Self {
Self {
rendered: RefCell::new(Vec::new()),
}
}
}
impl TemplateEngine for StubTheme {
fn render(&self, name: &str, ctx: &TemplateContext) -> anyhow::Result<String> {
let json = ctx.0.to_string();
self.rendered
.borrow_mut()
.push((name.to_string(), json.clone()));
if let Some(short) = name
.strip_prefix("shortcodes/")
.and_then(|s| s.strip_suffix(".html"))
{
let v = ctx.0.as_object().unwrap();
return Ok(format!(
"[SHORT:{short}|url={}|title={}]",
v.get("url").and_then(|x| x.as_str()).unwrap_or(""),
v.get("title").and_then(|x| x.as_str()).unwrap_or(""),
));
}
Ok(format!("<!--{name}-->{json}"))
}
}
fn adr(id: u32, title: &str, body: &str) -> DecisionRecord {
DecisionRecord {
id: DecisionRecordRef::new(format!("ADR-{id:04}")).unwrap(),
kind: RecordKind::new("adr").unwrap(),
title: Title::new(title).unwrap(),
description: None,
status: crate::domain::model::decision_record::DrStatus::Accepted,
date: IsoDate::new("2026-03-11").unwrap(),
tags: TagList::new(),
aliases: Vec::new(),
content: Body::new(body),
events: EventLog::new(),
links: RecordLinks::new(),
relates: crate::domain::model::relates::Relates::default(),
origin: EntryOrigin::Local,
location: EntryLocator::default(),
}
}
fn issue(id: u32, title: &str, body: &str) -> Issue {
Issue {
id: IssueRef::new(format!("ISSUE-{id:04}")).unwrap(),
title: Title::new(title).unwrap(),
description: None,
status: Status::new("open").unwrap(),
date: IsoDate::new("2026-03-11").unwrap(),
tags: TagList::new(),
aliases: Vec::new(),
content: Body::new(body),
events: IssueEventLog::new(),
links: IssueLinks::new(),
relates: crate::domain::model::relates::Relates::default(),
assignee: None,
due_date: None,
tracker: IssueTracker::local("ISSUE-0000"),
origin: EntryOrigin::Local,
location: EntryLocator::default(),
}
}
fn empty_input() -> SiteInput {
SiteInput {
site_title: None,
dr_sections: Vec::new(),
issues: Vec::new(),
pages: Vec::new(),
site_nav: Vec::new(),
}
}
fn paths(out: &SiteBundle) -> Vec<String> {
out.pages
.iter()
.map(|p| p.path.to_string())
.chain(out.assets.iter().map(|a| a.path.to_string()))
.collect()
}
#[test]
fn empty_input_emits_homepage_sitemap_robots_and_404() {
let theme = StubTheme::new();
let out = build_site(&empty_input(), &theme).unwrap();
let p = paths(&out);
assert!(p.contains(&"index.html".to_string()), "got: {p:?}");
assert!(p.contains(&"issues/index.html".to_string()), "got: {p:?}");
assert!(p.contains(&"sitemap.xml".to_string()), "got: {p:?}");
assert!(p.contains(&"robots.txt".to_string()), "got: {p:?}");
assert!(p.contains(&"404.html".to_string()), "got: {p:?}");
}
#[test]
fn render_homepage_section_yields_one_page_at_root_url() {
let theme = StubTheme::new();
let section = render_homepage_section(&empty_input(), &theme, "Title").unwrap();
assert_eq!(section.urls, vec!["/".to_string()]);
assert_eq!(section.pages.len(), 1);
assert_eq!(section.pages[0].path.as_str(), "index.html");
}
#[test]
fn render_infrastructure_emits_sitemap_robots_and_404_with_no_extra_urls() {
let theme = StubTheme::new();
let section =
render_infrastructure(&empty_input(), &theme, "Title", &["/".to_string()]).unwrap();
assert!(section.urls.is_empty());
let names: Vec<String> = section.assets.iter().map(|a| a.path.to_string()).collect();
assert_eq!(names, vec!["sitemap.xml", "robots.txt", "404.html"]);
}
#[test]
fn one_record_per_kind_emits_list_and_single_pages() {
let theme = StubTheme::new();
let dr_sections = vec![DrSection {
kind: "adr".to_string(),
id_prefix: "ADR".to_string(),
records: vec![SiteRecord {
record: adr(1, "Use Rust", "Body."),
slug: "0001-use-rust".to_string(),
}],
}];
let issues = vec![SiteIssue {
issue: issue(1, "Add auth", "Body."),
slug: "0001-add-auth".to_string(),
companions: Vec::new(),
attachments: Vec::new(),
}];
let input = SiteInput {
site_title: Some("Cartulary".to_string()),
dr_sections,
issues,
pages: Vec::new(),
site_nav: Vec::new(),
};
let out = build_site(&input, &theme).unwrap();
let p = paths(&out);
assert!(
p.contains(&"decisions/adr/index.html".to_string()),
"got: {p:?}"
);
assert!(
p.contains(&"decisions/adr/0001-use-rust/index.html".to_string()),
"got: {p:?}"
);
assert!(p.contains(&"issues/index.html".to_string()), "got: {p:?}");
assert!(
p.contains(&"issues/0001-add-auth/index.html".to_string()),
"got: {p:?}"
);
}
#[test]
fn missing_adr_ref_target_returns_broken_reference() {
let theme = StubTheme::new();
let input = SiteInput {
site_title: None,
dr_sections: Vec::new(),
issues: vec![SiteIssue {
issue: issue(1, "Bad ref", r#"See {{< adr-ref id="9999" >}}."#),
slug: "0001-bad-ref".to_string(),
companions: Vec::new(),
attachments: Vec::new(),
}],
pages: Vec::new(),
site_nav: Vec::new(),
};
let err = build_site(&input, &theme).unwrap_err();
match err {
BuildError::BrokenReference { from, to } => {
assert!(from.contains("ISSUE-0001"), "got from: {from}");
assert!(
to.contains("ADR-9999") || to.contains("9999"),
"got to: {to}"
);
}
other => panic!("expected BrokenReference, got: {other}"),
}
}
#[test]
fn page_body_with_mermaid_block_propagates_has_mermaid_to_template() {
let theme = StubTheme::new();
let input = SiteInput {
site_title: None,
dr_sections: Vec::new(),
issues: vec![SiteIssue {
issue: issue(1, "Diagram", "```mermaid\nA-->B\n```"),
slug: "0001-diagram".to_string(),
companions: Vec::new(),
attachments: Vec::new(),
}],
pages: Vec::new(),
site_nav: Vec::new(),
};
let _ = build_site(&input, &theme).unwrap();
let renders = theme.rendered.borrow();
let single = renders
.iter()
.find(|(name, ctx)| {
name == "layouts/_default/single.html" && ctx.contains("0001-diagram")
})
.expect("single page render not captured");
assert!(
single.1.contains("\"has_mermaid\":true"),
"expected has_mermaid=true in: {}",
single.1
);
assert!(
single.1.contains("pre class=\\\"mermaid\\\""),
"expected mermaid <pre> in body_html: {}",
single.1
);
}
#[test]
fn description_falls_back_to_first_paragraph_when_frontmatter_field_is_absent() {
let theme = StubTheme::new();
let input = SiteInput {
site_title: None,
dr_sections: vec![DrSection {
kind: "adr".to_string(),
id_prefix: "ADR".to_string(),
records: vec![SiteRecord {
record: adr(1, "T", "First paragraph as fallback.\n\nSecond para."),
slug: "0001-t".to_string(),
}],
}],
issues: Vec::new(),
pages: Vec::new(),
site_nav: Vec::new(),
};
let _ = build_site(&input, &theme).unwrap();
let renders = theme.rendered.borrow();
let single = renders
.iter()
.find(|(name, _)| name == "layouts/_default/single.html")
.expect("single page render not captured");
assert!(
single
.1
.contains("\"description\":\"First paragraph as fallback.\""),
"expected first-paragraph fallback in description field, got: {}",
single.1
);
}
#[test]
fn explicit_description_wins_over_first_paragraph() {
let mut adr_record = adr(1, "T", "First paragraph.");
adr_record.description =
Some(crate::domain::model::description::Description::new("Explicit summary.").unwrap());
let theme = StubTheme::new();
let input = SiteInput {
site_title: None,
dr_sections: vec![DrSection {
kind: "adr".to_string(),
id_prefix: "ADR".to_string(),
records: vec![SiteRecord {
record: adr_record,
slug: "0001-t".to_string(),
}],
}],
issues: Vec::new(),
pages: Vec::new(),
site_nav: Vec::new(),
};
let _ = build_site(&input, &theme).unwrap();
let renders = theme.rendered.borrow();
let single = renders
.iter()
.find(|(name, _)| name == "layouts/_default/single.html")
.expect("single page render not captured");
assert!(
single.1.contains("\"description\":\"Explicit summary.\""),
"expected explicit description in description field, got: {}",
single.1
);
assert!(
!single.1.contains("\"description\":\"First paragraph.\""),
"did not expect first-paragraph fallback as description field, got: {}",
single.1
);
}
}