use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use plates::prov::block_on;
use plates::{
CollectOptions, DigestMemo, NoStamp, SiteSpec, SiteTheme, TermConfig, collect_site, plan_site,
read_page_shells, read_term_config, read_theme,
};
use plates_render::SiteStyle;
use plates_render::html::Generator;
use plates_render::site::{SiteOptions, SourceDoc, humanize_name, render_site};
use crate::config::Source;
use crate::session::Session;
const STRIP_KEYS: &[&str] = &[
plates::prov::config::ROOT_CONFIG_KEY,
crate::config::SITES_KEY,
];
pub struct BuiltSite {
pub name: String,
pub audience: String,
pub files: BTreeMap<String, Vec<u8>>,
pub attachments: BTreeMap<String, PathBuf>,
pub pages: usize,
pub warnings: Vec<String>,
}
struct UnreadAttachments;
impl DigestMemo for UnreadAttachments {
fn recall(&self, _rel: &Path, _len: u64, _mtime_ms: Option<i64>) -> Option<String> {
Some(String::new())
}
fn remember(&self, _rel: &Path, _len: u64, _mtime_ms: Option<i64>, _hash: &str) {}
}
fn no_digest(_bytes: &[u8]) -> String {
String::new()
}
fn generator() -> Generator {
Generator::linked("plates", "https://github.com/diaryx-org/plates")
}
pub fn build_sites(
session: &Session,
only: Option<&str>,
base_url: Option<&str>,
) -> Result<Vec<BuiltSite>, String> {
if session.sites.is_empty() {
return Err(session.nothing_to_publish());
}
let ws = session.workspace()?;
let _scope = ws.read_scope();
let id_by_path = session.id_by_path(&ws);
let census = block_on(ws.census(&session.root_doc)).unwrap_or_default();
let backlinks = block_on(ws.backlinks(&session.root_doc))
.map_err(|e| format!("cannot read this archive's links: {e}"))?;
let mut built = Vec::new();
let mut known = Vec::new();
for spec in &session.sites {
known.push(spec.name.clone());
if !selected(only, &spec.name) {
continue;
}
let (spec, term_warnings) = match session.source {
Source::Declared => (spec.clone(), Vec::new()),
_ => with_term_config(
spec,
block_on(read_term_config(
&ws,
&session.root_doc,
&session.config.fields,
spec.gate_field(),
spec.audience.trim(),
)),
),
};
let spec = &spec;
let plan = block_on(plan_site(
&ws,
spec,
&session.config.views,
&session.root_doc,
&census,
))
.map_err(|e| format!("site {:?}: {e}", spec.name))?;
let collected = block_on(collect_site(
&ws,
&plan,
&CollectOptions {
audience: &spec.audience,
strip_keys: STRIP_KEYS,
stamp: &NoStamp,
id_by_path: &id_by_path,
backlinks: &backlinks,
census: &census,
spanning_root: Some(&session.root_doc),
digests: &UnreadAttachments,
digest: no_digest,
},
))
.map_err(|e| format!("site {:?}: {e}", spec.name))?;
let mut theme = block_on(read_theme(&ws, spec, &session.config.views));
block_on(read_page_shells(&ws, &collected.sources, &mut theme));
let mut warnings = term_warnings;
warnings.extend(theme.warnings.iter().cloned());
if !plan.case_drift.is_empty() {
warnings.push(format!(
"{} document(s) declare an audience matching {:?} only in case, so the gate \
held them back (e.g. {})",
plan.case_drift.len(),
spec.audience,
plan.case_drift[0].display(),
));
}
for diagnostic in &plan.link_diagnostics {
warnings.push(format!("site {:?}: {diagnostic}", spec.name));
}
built.push(assemble(
&spec.name,
&theme,
&spec.audience,
collected,
&session.root_dir,
base_url,
warnings,
));
}
if built.is_empty() {
return Err(match only {
Some(name) => format!(
"no site named {name:?} — this archive has {}",
if known.is_empty() {
"none".to_string()
} else {
known.join(", ")
}
),
None => session.nothing_to_publish(),
});
}
Ok(built)
}
fn with_term_config(spec: &SiteSpec, term: TermConfig) -> (SiteSpec, Vec<String>) {
(
SiteSpec {
index: term.index,
shell: term.shell,
stylesheet: term.stylesheet,
lang: term.lang,
syntaxes: term.syntaxes,
..spec.clone()
},
term.warnings,
)
}
fn selected(only: Option<&str>, name: &str) -> bool {
only.is_none_or(|wanted| wanted.trim().eq_ignore_ascii_case(name.trim()))
}
fn assemble(
name: &str,
theme: &SiteTheme,
audience: &str,
collected: plates::CollectedSite,
root: &Path,
base_url: Option<&str>,
mut warnings: Vec<String>,
) -> BuiltSite {
let sources: Vec<SourceDoc> = collected
.sources
.iter()
.map(|source| SourceDoc {
path: source.source_rel_path.clone(),
markdown: source.source_markdown.clone(),
is_root: source.is_index,
inbound: source.inbound.clone(),
outbound: source.outbound.clone(),
})
.collect();
let rendered = render_site(
&sources,
&SiteOptions {
audience: Some(audience.to_string()),
site_title: Some(match theme.title.trim().is_empty() {
true => humanize_name(name),
false => theme.title.clone(),
}),
base_url: base_url.map(str::to_string),
generate_seo: true,
generate_feeds: true,
style: SiteStyle {
custom_css: theme.custom_css.clone(),
generator: Some(generator()),
..SiteStyle::default()
},
arrangement: theme.arrangement.clone(),
outline: collected.outline,
front_page_supplied: collected.verbatim_front_page,
template: theme.template.clone(),
templates: theme
.shells
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
lang: theme.lang.clone(),
syntaxes: theme
.syntaxes
.iter()
.map(|(path, text)| (path.clone(), text.clone()))
.collect(),
},
);
if let Some(error) = &rendered.template_error {
warnings.push(format!(
"site {name:?} has a shell template that will not compile, so it was ignored: {error}"
));
}
for error in &rendered.page_shell_errors {
warnings.push(format!("site {name:?}: {error}"));
}
for error in &rendered.syntax_errors {
warnings.push(format!("site {name:?}: {error}"));
}
for error in &rendered.body_template_errors {
warnings.push(format!("site {name:?}: {error}"));
}
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
for page in rendered.pages {
files.insert(page.dest_filename, page.html.into_bytes());
}
let mut page_keys: HashSet<String> = files.keys().cloned().collect();
for (filename, bytes) in rendered.assets {
files.insert(filename, bytes);
}
let mut attachments = BTreeMap::new();
for a in &collected.attachments {
if files.contains_key(&a.dest_rel) {
if !collected.verbatim_front_page {
continue;
}
files.remove(&a.dest_rel);
page_keys.remove(&a.dest_rel);
}
attachments.insert(a.dest_rel.clone(), root.join(&a.source_path));
}
let pages = page_keys.len();
BuiltSite {
name: name.to_string(),
audience: audience.to_string(),
files,
attachments,
pages,
warnings,
}
}
pub fn asset_count(built: &BuiltSite) -> usize {
built.files.len() - built.pages
}
pub fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}