Packages
rbt_weave
0.2.64
0.2.67
0.2.66
0.2.65
0.2.64
0.2.63
0.2.62
0.2.61
0.2.60
0.2.59
0.2.58
0.2.57
0.2.56
0.2.55
0.2.54
0.2.53
0.2.52
0.2.51
0.2.50
0.2.49
0.2.48
0.2.47
0.2.46
0.2.45
0.2.44
0.2.43
0.2.42
0.2.41
0.2.40
0.2.39
0.2.38
0.2.37
0.2.36
0.2.35
0.2.34
0.2.33
0.2.32
0.2.31
0.2.30
0.2.29
0.2.28
0.2.27
0.2.26
0.2.25
0.2.24
0.2.23
0.2.22
0.2.21
0.2.20
0.2.19
0.2.18
0.2.17
0.2.16
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Graph conflict detection and image processing NIFs for OSINT knowledge graphs, powered by Rust.
Current section
Files
Jump to
Current section
Files
crates/weave-content/src/lib.rs
#![deny(unsafe_code)]
#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::implicit_hasher)]
#![allow(clippy::struct_field_names)]
pub mod build_cache;
pub mod cache;
pub mod commands;
pub mod countries;
pub mod domain;
pub mod entity;
pub mod html;
pub mod nulid_gen;
pub mod output;
pub mod parser;
pub mod registry;
pub mod relationship;
pub mod staleness;
pub mod tags;
pub mod timeline;
pub mod verifier;
pub mod writeback;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use rayon::prelude::*;
use crate::entity::Entity;
use crate::output::{CaseOutput, NodeOutput};
use crate::parser::{ParseError, ParsedCase, SectionKind};
use crate::relationship::Rel;
/// Build a case index: scan case files for front matter `id:` and H1 title.
///
/// Returns a map of `case_path → (nulid, title)` used to resolve cross-case
/// references in `## Related Cases` sections.
pub fn build_case_index(
case_files: &[String],
content_root: &std::path::Path,
) -> Result<std::collections::HashMap<String, (String, String)>, i32> {
let full = build_case_index_with_contents(case_files, content_root)?;
Ok(full
.into_iter()
.map(|(k, (id, title, _content))| (k, (id, title)))
.collect())
}
/// Build a case index and retain file contents for later reuse.
///
/// Returns `case_slug → (nulid, title, file_content)`. The file contents can
/// be passed to [`build_case_output_tracked_from_content`] to avoid re-reading
/// each file from disk.
///
/// File reads are parallelized with rayon.
pub fn build_case_index_with_contents(
case_files: &[String],
content_root: &std::path::Path,
) -> Result<std::collections::HashMap<String, (String, String, String)>, i32> {
let results: Result<Vec<_>, i32> = case_files
.par_iter()
.map(|path| {
let content = std::fs::read_to_string(path).map_err(|e| {
eprintln!("{path}: {e}");
1
})?;
if let Some(case_path) = case_slug_from_path(std::path::Path::new(path), content_root) {
let id = extract_front_matter_id(&content).unwrap_or_else(|| {
nulid::Nulid::new()
.map(|n| n.to_string())
.unwrap_or_default()
});
let title = extract_title(&content).unwrap_or_else(|| case_path.clone());
Ok(Some((case_path, (id, title, content))))
} else {
Ok(None)
}
})
.collect();
let entries = results?;
Ok(entries.into_iter().flatten().collect())
}
/// Extract the `id:` value from YAML front matter without full parsing.
fn extract_front_matter_id(content: &str) -> Option<String> {
let content = content.strip_prefix("---\n")?;
let end = content.find("\n---")?;
let fm = &content[..end];
for line in fm.lines() {
let trimmed = line.trim();
if let Some(id) = trimmed.strip_prefix("id:") {
let id = id.trim().trim_matches('"').trim_matches('\'');
if !id.is_empty() {
return Some(id.to_string());
}
}
}
None
}
/// Extract the first H1 heading (`# Title`) after the front matter closing delimiter.
fn extract_title(content: &str) -> Option<String> {
let content = content.strip_prefix("---\n")?;
let end = content.find("\n---")?;
let after_fm = &content[end + 4..];
for line in after_fm.lines() {
if let Some(title) = line.strip_prefix("# ") {
let title = title.trim();
if !title.is_empty() {
return Some(title.to_string());
}
}
}
None
}
/// Derive a case slug from a file path relative to content root.
///
/// E.g. `content/cases/id/corruption/2002/foo.md` with content root
/// `content/` → `id/corruption/2002/foo`.
pub fn case_slug_from_path(
path: &std::path::Path,
content_root: &std::path::Path,
) -> Option<String> {
let cases_dir = content_root.join("cases");
let rel = path.strip_prefix(&cases_dir).ok()?;
let s = rel.to_str()?;
Some(s.strip_suffix(".md").unwrap_or(s).to_string())
}
/// Parse a case file fully: front matter, entities, relationships, timeline.
/// Returns the parsed case, inline entities, and relationships (including NEXT from timeline).
///
/// When a registry is provided, relationship and timeline names are resolved
/// against both inline events AND the global entity registry.
pub fn parse_full(
content: &str,
reg: Option<®istry::EntityRegistry>,
) -> Result<(ParsedCase, Vec<Entity>, Vec<Rel>), Vec<ParseError>> {
let case = parser::parse(content)?;
let mut errors = Vec::new();
let mut all_entities = Vec::new();
for section in &case.sections {
if matches!(
section.kind,
SectionKind::Events | SectionKind::Documents | SectionKind::Assets
) {
let entities =
entity::parse_entities(§ion.body, section.kind, section.line, &mut errors);
all_entities.extend(entities);
}
}
// Build combined name set: inline events + registry entities
let mut entity_names: HashSet<&str> = all_entities.iter().map(|e| e.name.as_str()).collect();
if let Some(registry) = reg {
for name in registry.names() {
entity_names.insert(name);
}
}
let event_names: HashSet<&str> = all_entities
.iter()
.filter(|e| e.label == entity::Label::Event)
.map(|e| e.name.as_str())
.collect();
let mut all_rels = Vec::new();
for section in &case.sections {
if section.kind == SectionKind::Relationships {
let rels = relationship::parse_relationships(
§ion.body,
section.line,
&entity_names,
&case.sources,
&mut errors,
);
all_rels.extend(rels);
}
}
for section in &case.sections {
if section.kind == SectionKind::Timeline {
let rels =
timeline::parse_timeline(§ion.body, section.line, &event_names, &mut errors);
all_rels.extend(rels);
}
}
if errors.is_empty() {
Ok((case, all_entities, all_rels))
} else {
Err(errors)
}
}
/// Collect registry entities referenced by relationships in this case.
/// Sets the `slug` field on each entity from the registry's file path.
pub fn collect_referenced_registry_entities(
rels: &[Rel],
inline_entities: &[Entity],
reg: ®istry::EntityRegistry,
) -> Vec<Entity> {
let inline_names: HashSet<&str> = inline_entities.iter().map(|e| e.name.as_str()).collect();
let mut referenced = Vec::new();
let mut seen_names: HashSet<String> = HashSet::new();
for rel in rels {
for name in [&rel.source_name, &rel.target_name] {
if !inline_names.contains(name.as_str())
&& seen_names.insert(name.clone())
&& let Some(entry) = reg.get_by_name(name)
{
let mut entity = entry.entity.clone();
entity.slug = reg.slug_for(entry);
referenced.push(entity);
}
}
}
referenced
}
/// Build a `CaseOutput` from a case file path.
/// Handles parsing and ID writeback.
pub fn build_case_output(
path: &str,
reg: ®istry::EntityRegistry,
) -> Result<output::CaseOutput, i32> {
let mut written = HashSet::new();
build_case_output_tracked(
path,
reg,
&mut written,
&std::collections::HashMap::new(),
false,
)
}
/// Build a `CaseOutput` from a case file path, tracking which entity files
/// have already been written back. This avoids re-reading entity files from
/// disk when multiple cases reference the same shared entity.
///
/// When `no_writeback` is true, no files are modified on disk. If any IDs
/// would need to be generated, an error is returned — the content must be
/// pre-built with all IDs already present.
pub fn build_case_output_tracked(
path: &str,
reg: ®istry::EntityRegistry,
written_entities: &mut HashSet<std::path::PathBuf>,
case_nulid_map: &std::collections::HashMap<String, (String, String)>,
no_writeback: bool,
) -> Result<output::CaseOutput, i32> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
eprintln!("{path}: error reading file: {e}");
return Err(2);
}
};
build_case_output_tracked_from_content(
path,
&content,
reg,
written_entities,
case_nulid_map,
no_writeback,
)
}
/// Like [`build_case_output_tracked`] but accepts pre-read file content.
///
/// Use this with [`build_case_index_with_contents`] to avoid reading each
/// case file from disk twice.
#[allow(clippy::too_many_lines)]
pub fn build_case_output_tracked_from_content(
path: &str,
content: &str,
reg: ®istry::EntityRegistry,
written_entities: &mut HashSet<std::path::PathBuf>,
case_nulid_map: &std::collections::HashMap<String, (String, String)>,
no_writeback: bool,
) -> Result<output::CaseOutput, i32> {
let (case, entities, rels) = match parse_full(content, Some(reg)) {
Ok(result) => result,
Err(errors) => {
for err in &errors {
eprintln!("{path}:{err}");
}
return Err(1);
}
};
let referenced_entities = collect_referenced_registry_entities(&rels, &entities, reg);
// Resolve case NULID
let (case_nulid, case_nulid_generated) = match nulid_gen::resolve_id(case.id.as_deref(), 1) {
Ok(result) => result,
Err(err) => {
eprintln!("{path}:{err}");
return Err(1);
}
};
let case_nulid_str = case_nulid.to_string();
// Compute case slug from file path
let case_slug = reg
.content_root()
.and_then(|root| registry::path_to_slug(std::path::Path::new(path), root));
// Derive case_id from slug (filename-based) or fall back to empty string
let case_id = case_slug
.as_deref()
.and_then(|s| s.rsplit('/').next())
.unwrap_or_default();
let build_result = match output::build_output(
case_id,
&case_nulid_str,
&case.title,
&case.summary,
&case.tags,
case_slug.as_deref(),
case.case_type.as_deref(),
case.status.as_deref(),
case.amounts.as_deref(),
case.tagline.as_deref(),
&case.sources,
&case.related_cases,
case_nulid_map,
&entities,
&rels,
&referenced_entities,
&case.involved,
) {
Ok(out) => out,
Err(errors) => {
for err in &errors {
eprintln!("{path}:{err}");
}
return Err(1);
}
};
let case_output = build_result.output;
// Write back generated IDs to source case file
let mut case_pending = build_result.case_pending;
if case_nulid_generated {
case_pending.push(writeback::PendingId {
line: writeback::find_front_matter_end(content).unwrap_or(2),
id: case_nulid_str.clone(),
kind: writeback::WriteBackKind::CaseId,
});
}
let has_pending_case = !case_pending.is_empty();
let has_pending_entity = !build_result.registry_pending.is_empty();
if no_writeback {
if has_pending_case || has_pending_entity {
let mut missing = Vec::new();
if case_nulid_generated {
missing.push(format!("{path}: case ID not generated"));
}
for (entity_path, pending) in &build_result.registry_pending {
missing.push(format!(
"{entity_path}: entity ID not generated ({})",
pending.id
));
}
for msg in &missing {
eprintln!("{msg}");
}
eprintln!(
"{path}: {} missing ID(s) — run `make content.build` locally and push before deploying",
missing.len()
);
return Err(1);
}
} else {
if has_pending_case
&& let Some(modified) = writeback::apply_writebacks(content, &mut case_pending)
{
if let Err(e) = writeback::write_file(std::path::Path::new(path), &modified) {
eprintln!("{e}");
return Err(2);
}
let count = case_pending.len();
eprintln!("{path}: wrote {count} generated ID(s) back to file");
}
// Write back generated IDs to entity files
if let Some(code) =
writeback_registry_entities(&build_result.registry_pending, reg, written_entities)
{
return Err(code);
}
}
eprintln!(
"{path}: built ({} nodes, {} relationships)",
case_output.nodes.len(),
case_output.relationships.len()
);
Ok(case_output)
}
/// Write back generated IDs to registry entity files.
/// Tracks already-written paths in `written` to avoid redundant disk reads.
/// Returns `Some(exit_code)` on error, `None` on success.
fn writeback_registry_entities(
pending: &[(String, writeback::PendingId)],
reg: ®istry::EntityRegistry,
written: &mut HashSet<std::path::PathBuf>,
) -> Option<i32> {
for (entity_name, pending_id) in pending {
let Some(entry) = reg.get_by_name(entity_name) else {
continue;
};
let entity_path = &entry.path;
// Skip if this entity file was already written by a previous case.
if !written.insert(entity_path.clone()) {
continue;
}
// Also skip if the entity already has an ID in the registry
// (loaded from file at startup).
if entry.entity.id.is_some() {
continue;
}
let entity_content = match std::fs::read_to_string(entity_path) {
Ok(c) => c,
Err(e) => {
eprintln!("{}: error reading file: {e}", entity_path.display());
return Some(2);
}
};
let fm_end = writeback::find_front_matter_end(&entity_content);
let mut ids = vec![writeback::PendingId {
line: fm_end.unwrap_or(2),
id: pending_id.id.clone(),
kind: writeback::WriteBackKind::EntityFrontMatter,
}];
if let Some(modified) = writeback::apply_writebacks(&entity_content, &mut ids) {
if let Err(e) = writeback::write_file(entity_path, &modified) {
eprintln!("{e}");
return Some(2);
}
eprintln!("{}: wrote generated ID back to file", entity_path.display());
}
}
None
}
/// Check whether a file's YAML front matter already contains an `id:` field.
#[cfg(test)]
fn front_matter_has_id(content: &str) -> bool {
let mut in_front_matter = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "---" && !in_front_matter {
in_front_matter = true;
} else if trimmed == "---" && in_front_matter {
return false; // end of front matter, no id found
} else if in_front_matter && trimmed.starts_with("id:") {
return true;
}
}
false
}
/// Resolve the content root directory.
///
/// Priority: explicit `--root` flag > parent of given path > current directory.
pub fn resolve_content_root(path: Option<&str>, root: Option<&str>) -> std::path::PathBuf {
if let Some(r) = root {
return std::path::PathBuf::from(r);
}
if let Some(p) = path {
let p = std::path::Path::new(p);
if p.is_file() {
if let Some(parent) = p.parent() {
for ancestor in parent.ancestors() {
if ancestor.join("cases").is_dir()
|| ancestor.join("people").is_dir()
|| ancestor.join("organizations").is_dir()
{
return ancestor.to_path_buf();
}
}
return parent.to_path_buf();
}
} else if p.is_dir() {
return p.to_path_buf();
}
}
std::path::PathBuf::from(".")
}
/// Load entity registry from content root. Returns empty registry if no entity dirs exist.
pub fn load_registry(content_root: &std::path::Path) -> Result<registry::EntityRegistry, i32> {
match registry::EntityRegistry::load(content_root) {
Ok(reg) => Ok(reg),
Err(errors) => {
for err in &errors {
eprintln!("registry: {err}");
}
Err(1)
}
}
}
/// Load tag registry from content root. Returns empty registry if no tags.yaml exists.
pub fn load_tag_registry(content_root: &std::path::Path) -> Result<tags::TagRegistry, i32> {
match tags::TagRegistry::load(content_root) {
Ok(reg) => Ok(reg),
Err(errors) => {
for err in &errors {
eprintln!("tags: {err}");
}
Err(1)
}
}
}
/// Resolve case file paths from path argument.
/// If path is a file, returns just that file.
/// If path is a directory (or None), auto-discovers `cases/**/*.md`.
pub fn resolve_case_files(
path: Option<&str>,
content_root: &std::path::Path,
) -> Result<Vec<String>, i32> {
if let Some(p) = path {
let p_path = std::path::Path::new(p);
if p_path.is_file() {
return Ok(vec![p.to_string()]);
}
if !p_path.is_dir() {
eprintln!("{p}: not a file or directory");
return Err(2);
}
}
let cases_dir = content_root.join("cases");
if !cases_dir.is_dir() {
return Ok(Vec::new());
}
let mut files = Vec::new();
discover_md_files(&cases_dir, &mut files, 0);
files.sort();
Ok(files)
}
/// Recursively discover .md files in a directory (max 5 levels deep for cases/country/category/year/).
fn discover_md_files(dir: &std::path::Path, files: &mut Vec<String>, depth: usize) {
const MAX_DEPTH: usize = 5;
if depth > MAX_DEPTH {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
entries.sort_by_key(std::fs::DirEntry::file_name);
for entry in entries {
let path = entry.path();
if path.is_dir() {
discover_md_files(&path, files, depth + 1);
} else if path.extension().and_then(|e| e.to_str()) == Some("md")
&& let Some(s) = path.to_str()
{
files.push(s.to_string());
}
}
}
/// Extract country code (or special scope) from a case slug like `cases/id/corruption/2024/...`
/// or `cases/multinational/money-laundering/2023/...`.
/// Returns `Some("id")` for `cases/id/...`, `Some("multinational")` for
/// `cases/multinational/...`, `None` if the slug doesn't match.
fn extract_country_code(slug: &str) -> Option<String> {
let parts: Vec<&str> = slug.split('/').collect();
// slug format: "cases/{country_or_scope}/..." — at index 1
if parts.len() >= 2 {
let candidate = parts[1];
let upper = candidate.to_uppercase();
if countries::is_valid_jurisdiction_code(&upper) {
return Some(candidate.to_string());
}
}
None
}
/// Generate static HTML files, sitemap, tag pages, and NULID index from built case outputs.
///
/// Writes output to `{output_dir}/html/`. Returns `0` on success, non-zero on error.
///
/// This is the high-level orchestrator that calls individual `html::render_*` functions
/// and writes the results to disk.
pub fn generate_html_output(
output_dir: &str,
cases: &[CaseOutput],
base_url: &str,
thumbnail_base_url: Option<&str>,
) -> i32 {
let html_dir = format!("{output_dir}/html");
let config = html::HtmlConfig {
thumbnail_base_url: thumbnail_base_url.map(String::from),
};
let mut nulid_index: BTreeMap<String, String> = BTreeMap::new();
let (all_people, all_orgs, person_cases, org_cases) =
match generate_case_pages(&html_dir, cases, &config, &mut nulid_index) {
Ok(collections) => collections,
Err(code) => return code,
};
if let Err(code) = generate_entity_pages(
&html_dir,
&all_people,
&person_cases,
&config,
&mut nulid_index,
"person",
html::render_person,
) {
return code;
}
eprintln!("html: {} person pages", all_people.len());
if let Err(code) = generate_entity_pages(
&html_dir,
&all_orgs,
&org_cases,
&config,
&mut nulid_index,
"organization",
html::render_organization,
) {
return code;
}
eprintln!("html: {} organization pages", all_orgs.len());
if let Err(code) = generate_tag_pages(&html_dir, cases) {
return code;
}
if let Err(code) = generate_country_pages(&html_dir, cases) {
return code;
}
// Collect unique tags and country codes for sitemap and index pages
let mut all_tags: BTreeSet<String> = BTreeSet::new();
let mut country_case_counts: BTreeMap<String, usize> = BTreeMap::new();
let mut tag_case_counts: BTreeMap<String, usize> = BTreeMap::new();
for case in cases {
let case_slug = case.slug.as_deref().unwrap_or(&case.case_id);
if let Some(cc) = extract_country_code(case_slug) {
*country_case_counts.entry(cc).or_default() += 1;
}
for tag in &case.tags {
all_tags.insert(tag.clone());
*tag_case_counts.entry(tag.clone()).or_default() += 1;
}
}
let tag_list: Vec<String> = all_tags.into_iter().collect();
if let Err(code) = generate_sitemap(
&html_dir,
cases,
&all_people,
&all_orgs,
&tag_list,
base_url,
) {
return code;
}
if let Err(code) = generate_index_pages(&html_dir, &country_case_counts, &tag_case_counts) {
return code;
}
if let Err(code) = write_nulid_index(&html_dir, &nulid_index) {
return code;
}
0
}
/// Write an HTML fragment to disk, creating parent directories as needed.
fn write_html_file(path: &str, fragment: &str) -> Result<(), i32> {
if let Some(parent) = std::path::Path::new(path).parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
eprintln!("error creating directory {}: {e}", parent.display());
return Err(2);
}
if let Err(e) = std::fs::write(path, fragment) {
eprintln!("error writing {path}: {e}");
return Err(2);
}
Ok(())
}
/// Render all case pages and collect entity references for person/org pages.
#[allow(clippy::type_complexity)]
fn generate_case_pages<'a>(
html_dir: &str,
cases: &'a [CaseOutput],
config: &html::HtmlConfig,
nulid_index: &mut BTreeMap<String, String>,
) -> Result<
(
HashMap<String, &'a NodeOutput>,
HashMap<String, &'a NodeOutput>,
HashMap<String, Vec<(String, String)>>,
HashMap<String, Vec<(String, String)>>,
),
i32,
> {
let mut person_cases: HashMap<String, Vec<(String, String)>> = HashMap::new();
let mut org_cases: HashMap<String, Vec<(String, String)>> = HashMap::new();
let mut all_people: HashMap<String, &NodeOutput> = HashMap::new();
let mut all_orgs: HashMap<String, &NodeOutput> = HashMap::new();
for case in cases {
let rel_path = case.slug.as_deref().unwrap_or(&case.case_id);
let path = format!("{html_dir}/{rel_path}.html");
match html::render_case(case, config) {
Ok(fragment) => {
write_html_file(&path, &fragment)?;
eprintln!("html: {path}");
}
Err(e) => {
eprintln!("error rendering case {}: {e}", case.case_id);
return Err(2);
}
}
if let Some(slug) = &case.slug {
nulid_index.insert(case.id.clone(), slug.clone());
}
let case_link_slug = case.slug.as_deref().unwrap_or(&case.case_id).to_string();
for node in &case.nodes {
match node.label.as_str() {
"person" => {
person_cases
.entry(node.id.clone())
.or_default()
.push((case_link_slug.clone(), case.title.clone()));
all_people.entry(node.id.clone()).or_insert(node);
}
"organization" => {
org_cases
.entry(node.id.clone())
.or_default()
.push((case_link_slug.clone(), case.title.clone()));
all_orgs.entry(node.id.clone()).or_insert(node);
}
_ => {}
}
}
}
Ok((all_people, all_orgs, person_cases, org_cases))
}
/// Render entity pages (person or organization) and update the NULID index.
fn generate_entity_pages<F>(
html_dir: &str,
entities: &HashMap<String, &NodeOutput>,
entity_cases: &HashMap<String, Vec<(String, String)>>,
config: &html::HtmlConfig,
nulid_index: &mut BTreeMap<String, String>,
label: &str,
render_fn: F,
) -> Result<(), i32>
where
F: Fn(&NodeOutput, &[(String, String)], &html::HtmlConfig) -> Result<String, String>,
{
for (id, node) in entities {
let case_list = entity_cases.get(id).cloned().unwrap_or_default();
match render_fn(node, &case_list, config) {
Ok(fragment) => {
let rel_path = node.slug.as_deref().unwrap_or(id.as_str());
let path = format!("{html_dir}/{rel_path}.html");
write_html_file(&path, &fragment)?;
}
Err(e) => {
eprintln!("error rendering {label} {id}: {e}");
return Err(2);
}
}
if let Some(slug) = &node.slug {
nulid_index.insert(id.clone(), slug.clone());
}
}
Ok(())
}
/// Generate the sitemap.xml file.
fn generate_sitemap(
html_dir: &str,
cases: &[CaseOutput],
all_people: &HashMap<String, &NodeOutput>,
all_orgs: &HashMap<String, &NodeOutput>,
tags: &[String],
base_url: &str,
) -> Result<(), i32> {
let case_entries: Vec<(String, String)> = cases
.iter()
.map(|c| {
let slug = c.slug.as_deref().unwrap_or(&c.case_id).to_string();
(slug, c.title.clone())
})
.collect();
let people_entries: Vec<(String, String)> = all_people
.iter()
.map(|(id, n)| {
let slug = n.slug.as_deref().unwrap_or(id.as_str()).to_string();
(slug, n.name.clone())
})
.collect();
let org_entries: Vec<(String, String)> = all_orgs
.iter()
.map(|(id, n)| {
let slug = n.slug.as_deref().unwrap_or(id.as_str()).to_string();
(slug, n.name.clone())
})
.collect();
let country_codes: Vec<String> = cases
.iter()
.filter_map(|c| {
let slug = c.slug.as_deref().unwrap_or(&c.case_id);
extract_country_code(slug)
})
.collect::<BTreeSet<String>>()
.into_iter()
.collect();
let sitemap = html::render_sitemap(
&case_entries,
&people_entries,
&org_entries,
&country_codes,
tags,
base_url,
);
let sitemap_path = format!("{html_dir}/sitemap.xml");
if let Err(e) = std::fs::write(&sitemap_path, &sitemap) {
eprintln!("error writing {sitemap_path}: {e}");
return Err(2);
}
eprintln!("html: {sitemap_path}");
Ok(())
}
/// Generate tag pages (global and country-scoped).
fn generate_tag_pages(html_dir: &str, cases: &[CaseOutput]) -> Result<(), i32> {
let mut tag_cases: BTreeMap<String, Vec<html::TagCaseEntry>> = BTreeMap::new();
let mut country_tag_cases: BTreeMap<String, BTreeMap<String, Vec<html::TagCaseEntry>>> =
BTreeMap::new();
for case in cases {
let case_slug = case.slug.as_deref().unwrap_or(&case.case_id).to_string();
let country = extract_country_code(&case_slug);
let entry = html::TagCaseEntry {
slug: case_slug.clone(),
title: case.title.clone(),
amounts: case.amounts.clone(),
};
for tag in &case.tags {
tag_cases
.entry(tag.clone())
.or_default()
.push(html::TagCaseEntry {
slug: case_slug.clone(),
title: case.title.clone(),
amounts: case.amounts.clone(),
});
if let Some(cc) = &country {
country_tag_cases
.entry(cc.clone())
.or_default()
.entry(tag.clone())
.or_default()
.push(html::TagCaseEntry {
slug: entry.slug.clone(),
title: entry.title.clone(),
amounts: entry.amounts.clone(),
});
}
}
}
let mut tag_page_count = 0usize;
for (tag, entries) in &tag_cases {
let fragment = html::render_tag_page(tag, entries).map_err(|e| {
eprintln!("error rendering tag page {tag}: {e}");
2
})?;
let path = format!("{html_dir}/tags/{tag}.html");
write_html_file(&path, &fragment)?;
tag_page_count += 1;
}
let mut country_tag_page_count = 0usize;
for (country, tags) in &country_tag_cases {
for (tag, entries) in tags {
let fragment = html::render_tag_page_scoped(tag, country, entries).map_err(|e| {
eprintln!("error rendering tag page {country}/{tag}: {e}");
2
})?;
let path = format!("{html_dir}/tags/{country}/{tag}.html");
write_html_file(&path, &fragment)?;
country_tag_page_count += 1;
}
}
eprintln!(
"html: {} tag pages ({} global, {} country-scoped)",
tag_page_count + country_tag_page_count,
tag_page_count,
country_tag_page_count
);
Ok(())
}
fn generate_country_pages(html_dir: &str, cases: &[CaseOutput]) -> Result<(), i32> {
let mut country_cases: BTreeMap<String, Vec<html::TagCaseEntry>> = BTreeMap::new();
for case in cases {
let case_slug = case.slug.as_deref().unwrap_or(&case.case_id).to_string();
if let Some(cc) = extract_country_code(&case_slug) {
country_cases
.entry(cc)
.or_default()
.push(html::TagCaseEntry {
slug: case_slug,
title: case.title.clone(),
amounts: case.amounts.clone(),
});
}
}
let mut count = 0usize;
for (cc, entries) in &country_cases {
let fragment = html::render_country_page(cc, entries).map_err(|e| {
eprintln!("error rendering country page {cc}: {e}");
2
})?;
let path = format!("{html_dir}/countries/{cc}.html");
write_html_file(&path, &fragment)?;
count += 1;
}
eprintln!("html: {count} country pages");
Ok(())
}
/// Generate countries and tags index pages.
fn generate_index_pages(
html_dir: &str,
country_counts: &BTreeMap<String, usize>,
tag_counts: &BTreeMap<String, usize>,
) -> Result<(), i32> {
use html::countries::country_name;
let country_entries: Vec<html::CountryIndexEntry> = country_counts
.iter()
.map(|(code, &count)| html::CountryIndexEntry {
code: code.clone(),
name: country_name(code),
case_count: count,
})
.collect();
let fragment = html::render_countries_index(&country_entries).map_err(|e| {
eprintln!("error rendering countries index: {e}");
2
})?;
write_html_file(&format!("{html_dir}/countries.html"), &fragment)?;
eprintln!(
"html: countries index ({} countries)",
country_entries.len()
);
let tag_entries: Vec<html::TagIndexEntry> = tag_counts
.iter()
.map(|(tag, &count)| html::TagIndexEntry {
tag: tag.clone(),
case_count: count,
})
.collect();
let fragment = html::render_tags_index(&tag_entries).map_err(|e| {
eprintln!("error rendering tags index: {e}");
2
})?;
write_html_file(&format!("{html_dir}/tags.html"), &fragment)?;
eprintln!("html: tags index ({} tags)", tag_entries.len());
Ok(())
}
/// Write the NULID-to-slug index JSON file.
fn write_nulid_index(html_dir: &str, nulid_index: &BTreeMap<String, String>) -> Result<(), i32> {
let index_path = format!("{html_dir}/index.json");
let json = serde_json::to_string_pretty(nulid_index).map_err(|e| {
eprintln!("error serializing index.json: {e}");
2
})?;
if let Err(e) = std::fs::write(&index_path, &json) {
eprintln!("error writing {index_path}: {e}");
return Err(2);
}
eprintln!("html: {index_path} ({} entries)", nulid_index.len());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn front_matter_has_id_present() {
let content = "---\nid: 01JABC000000000000000000AA\n---\n\n# Test\n";
assert!(front_matter_has_id(content));
}
#[test]
fn front_matter_has_id_absent() {
let content = "---\n---\n\n# Test\n";
assert!(!front_matter_has_id(content));
}
#[test]
fn front_matter_has_id_with_other_fields() {
let content = "---\nother: value\nid: 01JABC000000000000000000AA\n---\n\n# Test\n";
assert!(front_matter_has_id(content));
}
#[test]
fn front_matter_has_id_no_front_matter() {
let content = "# Test\n\nNo front matter here.\n";
assert!(!front_matter_has_id(content));
}
#[test]
fn front_matter_has_id_outside_front_matter() {
// `id:` appearing in the body should not count
let content = "---\n---\n\n# Test\n\n- id: some-value\n";
assert!(!front_matter_has_id(content));
}
}