use crate::build::markup;
use crate::config::{self, Config};
use crate::doc::{Doc, DocMeta};
use crate::doc_index::DocIndex;
use crate::permalink;
use crate::site_data::SiteData;
use crate::tera_env::{MarkupEnv, build_markup_env};
use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, Utc};
use rayon::prelude::*;
use serde::Serialize;
use serde_yaml_ng::{Mapping, Value};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct Archive {
pub id_path: PathBuf,
pub kind: ArchiveKind,
pub per_page: Option<usize>,
pub limit: Option<usize>,
pub permalink: String,
pub template: Option<String>,
pub body: String,
pub data: Mapping,
}
pub enum ArchiveKind {
Collection { collection: String },
Taxonomy { taxonomy: String },
}
#[derive(Serialize)]
pub struct Pagination {
pub current: usize,
pub total: usize,
pub prev_url: Option<String>,
pub next_url: Option<String>,
pub items: Vec<Doc>,
}
#[derive(Serialize)]
pub struct Term {
pub slug: String,
pub text: String,
}
impl Archive {
pub fn parse(id_path: PathBuf, source: &str) -> Result<Archive> {
let (data, body) = crate::frontmatter::parse(source)?;
let permalink = data
.get("permalink")
.and_then(Value::as_str)
.ok_or_else(|| {
anyhow!(
"archive `{}` missing required `permalink` field",
id_path.display()
)
})?
.to_string();
let per_page = data
.get("per_page")
.and_then(Value::as_u64)
.map(|n| n as usize);
let limit = data
.get("limit")
.and_then(Value::as_u64)
.map(|n| n as usize);
let template = data
.get("template")
.and_then(Value::as_str)
.map(str::to_string);
let kind_str = data.get("kind").and_then(Value::as_str).ok_or_else(|| {
anyhow!(
"archive `{}` missing required `kind` field (collection|taxonomy)",
id_path.display()
)
})?;
let kind = match kind_str {
"collection" => {
let collection = required_name(&data, "collection", &id_path)?;
ArchiveKind::Collection { collection }
}
"taxonomy" => {
let taxonomy = required_name(&data, "taxonomy", &id_path)?;
ArchiveKind::Taxonomy { taxonomy }
}
other => {
return Err(anyhow!(
"archive `{}` has unknown kind `{}` (expected collection|taxonomy)",
id_path.display(),
other
));
}
};
Ok(Archive {
id_path,
kind,
per_page,
limit,
permalink,
template,
body,
data,
})
}
}
fn required_name(data: &Mapping, key: &str, id_path: &std::path::Path) -> Result<String> {
data.get(key)
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| {
anyhow!(
"archive `{}` of this kind requires a `{}` field naming the {}",
id_path.display(),
key,
key
)
})
}
fn is_dotfile(path: &Path) -> bool {
path.file_name()
.map(|n| n.to_string_lossy().starts_with('.'))
.unwrap_or(false)
}
pub fn run(
config: &Config,
site_data: &SiteData,
classification: &Arc<DocIndex>,
) -> Result<Vec<Doc>> {
let mut archives: Vec<Archive> = Vec::new();
for (id_path, path) in config::overlay_files(&config.archive_roots(), |p| !is_dotfile(p))? {
let source =
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
let a = Archive::parse(id_path, &source)
.with_context(|| format!("parsing archive {}", path.display()))?;
archives.push(a);
}
if archives.is_empty() {
return Ok(Vec::new());
}
let snapshot: Arc<Vec<DocMeta>> = Arc::new(classification.to_doc_metas());
let markup_env = build_markup_env(config, snapshot)?;
let emitted: Vec<Doc> = archives
.par_iter()
.map_init(
|| markup_env.clone(),
|env, archive| produce(env, site_data, classification, archive),
)
.collect::<Result<Vec<Vec<Doc>>>>()?
.into_iter()
.flatten()
.collect();
Ok(emitted)
}
fn produce(
env: &mut MarkupEnv,
site_data: &SiteData,
classification: &DocIndex,
archive: &Archive,
) -> Result<Vec<Doc>> {
match &archive.kind {
ArchiveKind::Collection { collection } => {
let items: Vec<Doc> = classification.get_collection(collection).cloned().collect();
paginate(env, site_data, archive, &items, None)
}
ArchiveKind::Taxonomy { taxonomy } => {
let mut out = Vec::new();
let Some(terms) = classification.get_taxonomy(taxonomy) else {
return Ok(out);
};
for (slug, ids) in terms {
let items: Vec<Doc> = ids
.iter()
.filter_map(|id| classification.doc(id).cloned())
.collect();
let text = items
.iter()
.find_map(|d| d.terms.get(taxonomy).and_then(|b| b.get(slug)).cloned())
.unwrap_or_else(|| slug.clone());
let term = Term {
slug: slug.clone(),
text,
};
out.extend(paginate(env, site_data, archive, &items, Some(term))?);
}
Ok(out)
}
}
}
fn paginate(
env: &mut MarkupEnv,
site_data: &SiteData,
archive: &Archive,
items: &[Doc],
term: Option<Term>,
) -> Result<Vec<Doc>> {
let term_slug = term.as_ref().map(|t| t.slug.clone());
let term_value = term
.map(|t| serde_yaml_ng::to_value(&t))
.transpose()
.context("serializing term context")?;
let items = match archive.limit.filter(|n| *n > 0) {
Some(n) => &items[..items.len().min(n)],
None => items,
};
let per_page = archive
.per_page
.filter(|n| *n > 0)
.unwrap_or(items.len().max(1));
let total_pages = if items.is_empty() {
1
} else {
items.len().div_ceil(per_page)
};
let url_for = |page: usize| -> String {
let pattern = permalink::paginate_pattern(&archive.permalink, page);
permalink::to_url(&permalink::expand(
&pattern,
&archive.id_path,
&epoch(),
term_slug.as_deref(),
))
};
let mut pages = Vec::with_capacity(total_pages);
for page_idx in 0..total_pages {
let page = page_idx + 1;
let start = page_idx * per_page;
let end = ((page_idx + 1) * per_page).min(items.len());
let page_items: Vec<Doc> = items[start..end].to_vec();
let pattern = permalink::paginate_pattern(&archive.permalink, page);
let output_path =
permalink::expand(&pattern, &archive.id_path, &epoch(), term_slug.as_deref());
let prev_url = (page > 1).then(|| url_for(page - 1));
let next_url = (page < total_pages).then(|| url_for(page + 1));
let pagination = Pagination {
current: page,
total: total_pages,
prev_url,
next_url,
items: page_items,
};
let mut data = archive.data.clone();
data.insert(
Value::String("pagination".into()),
serde_yaml_ng::to_value(&pagination).context("serializing pagination context")?,
);
if let Some(term_value) = &term_value {
data.insert(Value::String("term".into()), term_value.clone());
}
let mut doc = Doc {
id_path: output_path.clone(),
output_path,
template: archive.template.clone(),
title: String::new(),
summary: String::new(),
draft: false,
content: archive.body.clone(),
terms: std::collections::BTreeMap::new(),
date: epoch(),
updated: epoch(),
data,
links: Vec::new(),
};
markup::render(env, site_data, &mut doc)?;
pages.push(doc);
}
Ok(pages)
}
fn epoch() -> DateTime<Utc> {
DateTime::<Utc>::UNIX_EPOCH
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::tempdir;
#[test]
fn parse_collection_archive() {
let source = "---\nkind: collection\ncollection: posts\npermalink: /blog/\nper_page: 2\nlimit: 6\ntemplate: blog.html\n---\nBODY";
let a = Archive::parse(PathBuf::from("blog.html"), source).unwrap();
assert_eq!(a.permalink, "/blog/");
assert_eq!(a.per_page, Some(2));
assert_eq!(a.limit, Some(6));
assert_eq!(a.template.as_deref(), Some("blog.html"));
assert_eq!(a.body, "BODY");
match a.kind {
ArchiveKind::Collection { collection } => assert_eq!(collection, "posts"),
_ => panic!("expected collection kind"),
}
}
#[test]
fn parse_taxonomy_archive() {
let source = "---\nkind: taxonomy\ntaxonomy: tags\npermalink: /tags/:term/\n---\nBODY";
let a = Archive::parse(PathBuf::from("tags.html"), source).unwrap();
assert_eq!(a.permalink, "/tags/:term/");
assert_eq!(a.per_page, None);
assert_eq!(a.limit, None);
match a.kind {
ArchiveKind::Taxonomy { taxonomy } => assert_eq!(taxonomy, "tags"),
_ => panic!("expected taxonomy kind"),
}
}
#[test]
fn parse_missing_kind_errors() {
let source = "---\npermalink: /blog/\n---\nBODY";
assert!(Archive::parse(PathBuf::from("x.html"), source).is_err());
}
#[test]
fn parse_missing_permalink_errors() {
let source = "---\nkind: collection\ncollection: posts\n---\nBODY";
assert!(Archive::parse(PathBuf::from("x.html"), source).is_err());
}
#[test]
fn parse_unknown_kind_errors() {
let source = "---\nkind: all\npermalink: /sitemap.xml\n---\nBODY";
assert!(Archive::parse(PathBuf::from("x.html"), source).is_err());
}
#[test]
fn parse_collection_kind_requires_collection_name() {
let source = "---\nkind: collection\npermalink: /blog/\n---\nBODY";
assert!(Archive::parse(PathBuf::from("x.html"), source).is_err());
}
#[test]
fn parse_taxonomy_kind_requires_taxonomy_name() {
let source = "---\nkind: taxonomy\npermalink: /tags/:term/\n---\nBODY";
assert!(Archive::parse(PathBuf::from("x.html"), source).is_err());
}
fn write_archive(base: &std::path::Path, layer: &str, rel: &str, body: &str) {
let path = base.join(layer).join("archives").join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, body).unwrap();
}
#[test]
fn site_archive_overrides_theme_archive_of_same_name() {
let base = tempdir("overlay");
write_archive(
&base,
"theme",
"blog.html",
"---\nkind: collection\ncollection: posts\npermalink: /theme-blog/\n---\nBODY",
);
write_archive(
&base,
"site",
"blog.html",
"---\nkind: collection\ncollection: posts\npermalink: /site-blog/\n---\nBODY",
);
let config = Config {
archives_dir: base.join("site").join("archives"),
theme: Some(base.join("theme")),
templates_dir: base.join("none"),
..Config::default()
};
let site_data = SiteData {
site: Mapping::new(),
data: Mapping::new(),
};
let classification = Arc::new(DocIndex::new());
let pages = run(&config, &site_data, &classification).unwrap();
assert_eq!(pages.len(), 1);
assert_eq!(pages[0].output_path, PathBuf::from("site-blog/index.html"));
let _ = fs::remove_dir_all(&base);
}
#[test]
fn theme_only_archive_is_produced() {
let base = tempdir("theme-only");
write_archive(
&base,
"theme",
"blog.html",
"---\nkind: collection\ncollection: posts\npermalink: /blog/\n---\nBODY",
);
let config = Config {
archives_dir: base.join("site").join("archives"), theme: Some(base.join("theme")),
templates_dir: base.join("none"),
..Config::default()
};
let site_data = SiteData {
site: Mapping::new(),
data: Mapping::new(),
};
let classification = Arc::new(DocIndex::new());
let pages = run(&config, &site_data, &classification).unwrap();
assert_eq!(pages.len(), 1);
assert_eq!(pages[0].output_path, PathBuf::from("blog/index.html"));
let _ = fs::remove_dir_all(&base);
}
}