use std::path::Path;
use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::model::issue::companion::{CompanionContent, CANONICAL_FILENAMES};
use crate::domain::model::page::Page;
use crate::domain::model::site::MarkdownEntry;
use crate::domain::usecases::decision_record::DecisionRecordRepository;
use crate::domain::usecases::issue::IssueRepository;
fn locator_to_path(loc: &EntryLocator) -> std::path::PathBuf {
let s = loc.as_str();
let bare = s.strip_prefix("file://").unwrap_or(s);
std::path::PathBuf::from(bare)
}
use crate::domain::usecases::site::build::{
build_site, Attachment, BuildError, Companion, DrSection, NavLink, SiteInput, SiteIssue,
SiteRecord,
};
use crate::domain::usecases::site::pages_walker::{build_pages, PagesError};
use crate::domain::usecases::site::readers::{AssetsReader, PagesReader};
use crate::domain::usecases::site::templates::TemplateEngine;
use crate::domain::usecases::site::writers::SiteWriter;
pub struct GenerateSiteRequest<'a> {
pub dr_sources: Vec<DrSource>,
pub issue_repo: &'a dyn IssueRepository,
pub page_sources: Vec<PageSource>,
pub assets: &'a dyn AssetsReader,
pub theme: &'a dyn TemplateEngine,
pub writer: &'a dyn SiteWriter,
pub site_title: Option<String>,
pub site_nav: Vec<SiteNavLink>,
}
pub struct DrSource {
pub kind: String,
pub repo: Box<dyn DecisionRecordRepository>,
}
pub struct PageSource {
pub label: String,
pub publish: String,
pub reader: Box<dyn PagesReader>,
}
pub struct SiteNavLink {
pub label: String,
pub url: String,
}
#[derive(Debug)]
pub enum GenerateError {
Pages {
docs_label: String,
error: PagesError,
},
Build(BuildError),
Other(anyhow::Error),
}
impl From<anyhow::Error> for GenerateError {
fn from(e: anyhow::Error) -> Self {
Self::Other(e)
}
}
pub fn generate_site(req: GenerateSiteRequest<'_>) -> Result<usize, GenerateError> {
let input = SiteInput {
site_title: req.site_title,
site_nav: req
.site_nav
.into_iter()
.map(|n| NavLink {
label: n.label,
url: n.url,
})
.collect(),
dr_sections: load_decision_records(&req.dr_sources),
issues: load_issues(req.issue_repo)?,
pages: load_pages(&req.page_sources)?,
};
let mut bundle = build_site(&input, req.theme).map_err(GenerateError::Build)?;
for theme_asset in req.assets.assets() {
let theme_asset = theme_asset.map_err(GenerateError::from)?;
let path = crate::domain::model::site::SitePath::new(theme_asset.source.as_str()).map_err(
|e| {
GenerateError::Build(crate::domain::usecases::site::build::BuildError::Other(
anyhow::anyhow!("invalid theme asset path: {e}"),
))
},
)?;
bundle.push_asset(crate::domain::model::site::SiteAsset::new(
path,
theme_asset.bytes,
));
}
let total = bundle.pages.len() + bundle.assets.len();
req.writer.publish(bundle)?;
Ok(total)
}
fn load_decision_records(sources: &[DrSource]) -> Vec<DrSection> {
sources
.iter()
.map(|src| {
let id_prefix = src
.repo
.configured_id_prefix()
.map(str::to_string)
.unwrap_or_else(|| src.kind.to_uppercase());
let records = src
.repo
.list()
.unwrap_or_default()
.into_iter()
.map(|record| {
let path = locator_to_path(&record.location);
SiteRecord {
slug: slug_from_index_path(&path),
record,
}
})
.collect();
DrSection {
kind: src.kind.to_string(),
id_prefix,
records,
}
})
.collect()
}
fn load_issues(repo: &dyn IssueRepository) -> Result<Vec<SiteIssue>, GenerateError> {
let mut out = Vec::new();
for issue in repo.list().unwrap_or_default() {
let path = locator_to_path(&issue.location);
let slug = slug_from_index_path(&path);
let mut companions: Vec<Companion> = Vec::new();
let mut attachments: Vec<Attachment> = Vec::new();
for companion in repo.issue_companions(&issue.id)?.iter() {
let Some(content) = repo.read_companion(&issue.id, &companion.identifier)? else {
continue;
};
let name = companion.identifier.as_str().to_string();
match content {
CompanionContent::Text(body) => {
let stem = name.strip_suffix(".md").unwrap_or(&name).to_string();
companions.push(Companion {
name: stem,
markdown: body.as_str().to_string(),
});
}
CompanionContent::Binary(bytes) => {
attachments.push(Attachment {
filename: name,
bytes,
});
}
}
}
companions.sort_by_key(|c| {
CANONICAL_FILENAMES
.iter()
.position(|f| f.strip_suffix(".md") == Some(c.name.as_str()))
.unwrap_or(usize::MAX)
});
attachments.sort_by(|a, b| a.filename.cmp(&b.filename));
out.push(SiteIssue {
issue,
slug,
companions,
attachments,
});
}
Ok(out)
}
fn load_pages(sources: &[PageSource]) -> Result<Vec<Page>, GenerateError> {
let mut all = Vec::new();
for src in sources {
let entries: Vec<MarkdownEntry> = src
.reader
.entries()
.collect::<anyhow::Result<_>>()
.map_err(GenerateError::from)?;
if entries.is_empty() {
continue;
}
let pages = build_pages(entries, &src.publish).map_err(|error| GenerateError::Pages {
docs_label: src.label.clone(),
error,
})?;
all.extend(pages);
}
Ok(all)
}
fn slug_from_index_path(index_path: &Path) -> String {
index_path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string()
}