mod assets;
mod companions;
mod labels;
mod markdown_html;
mod render;
mod search;
mod templates;
mod toc;
mod world_html;
use std::collections::{BTreeSet, HashSet};
use std::path::Path;
use anyhow::{bail, Result};
use uuid::Uuid;
use crate::config::Config;
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
use labels::Labels;
pub use templates::eject as eject_templates;
#[allow(clippy::too_many_arguments)]
pub fn export_html(
layout: &ProjectLayout,
h: &Hierarchy,
cfg: &Config,
root_id: Option<Uuid>,
profiles: &[(String, String)],
status_floor: Option<usize>,
out_dir: &Path,
templates_override: Option<&Path>,
) -> Result<()> {
let book = resolve_book(h, root_id)?;
let hcfg = &cfg.docs.html;
let timeline_ids: HashSet<Uuid> = h
.iter()
.filter(|n| {
n.kind == NodeKind::Chapter
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_BOOK_TIMELINE)
})
.map(|n| n.id)
.collect();
let model = toc::build(h, book.id, &timeline_ids);
let template_base = match templates_override {
Some(p) => layout.root.join(p),
None => layout.root.join(&hcfg.template_dir),
};
let tmpl = templates::load(&template_base)?;
let site = load_site_vars(layout, &hcfg.variables_file);
let labels = Labels::for_language(&cfg.language);
let html_lang = Labels::html_lang(&cfg.language);
let variables = &cfg.docs.variables;
let book_title = hcfg.site_title.clone().unwrap_or_else(|| book.title.clone());
let book_lang = cfg.language.clone();
std::fs::create_dir_all(out_dir)?;
std::fs::write(out_dir.join("theme.css"), &tmpl.css)?;
struct Page {
file: String,
title: String,
content: String,
is_index: bool,
}
let mut pages: Vec<Page> = Vec::new();
let mut index_html = String::new();
for id in &model.front_matter {
let Some(node) = h.get(*id) else { continue };
if !super::profile_matches(&node.tags, profiles) || below_floor(node, status_floor) {
continue;
}
if let Some(body) = read_body(layout, node) {
index_html.push_str(&render::render_body(&node.tags, &body, variables));
}
}
pages.push(Page {
file: "index.html".into(),
title: labels.home.into(),
content: index_html,
is_index: true,
});
for ch in &model.chapters {
let mut content = format!(
"<h1 id=\"{}\">{}</h1>\n",
markdown_html::slugify(&ch.title),
markdown_html::escape_html(&ch.title)
);
for cnode in &ch.content {
let Some(n) = h.get(cnode.id) else { continue };
if !super::profile_matches(&n.tags, profiles) {
continue;
}
match cnode.kind {
NodeKind::Subchapter => {
let anchor = cnode.anchor.clone().unwrap_or_default();
content.push_str(&format!(
"<h2 id=\"{anchor}\">{}</h2>\n",
markdown_html::escape_html(&cnode.title)
));
}
NodeKind::Paragraph => {
if below_floor(n, status_floor) {
continue;
}
if let Some(body) = read_body(layout, n) {
content.push_str(&render::render_body(&n.tags, &body, variables));
}
}
_ => {}
}
}
pages.push(Page {
file: ch.file.clone(),
title: ch.title.clone(),
content,
is_index: false,
});
}
let companion_pages = companions::build(layout, h, cfg);
for cp in companion_pages {
pages.push(Page {
file: cp.file,
title: cp.title,
content: cp.content,
is_index: false,
});
}
let mut base_nav: Vec<toc::NavItem> = toc::nav(&model.chapters, "");
for p in pages.iter().skip(1 + model.chapters.len()) {
base_nav.push(toc::NavItem {
title: p.title.clone(),
href: p.file.clone(),
current: false,
sections: Vec::new(),
});
}
let mut collected = BTreeSet::new();
let mut search_docs: Vec<search::SearchDoc> = Vec::new();
for i in 0..pages.len() {
let content = assets::rewrite_images(&pages[i].content, &mut collected);
if hcfg.search {
search_docs.push(search::SearchDoc {
title: pages[i].title.clone(),
url: pages[i].file.clone(),
text: search::strip_html(&content),
});
}
let nav: Vec<toc::NavItem> = base_nav
.iter()
.map(|it| toc::NavItem { current: it.href == pages[i].file, ..it.clone() })
.collect();
let prev = if i > 0 {
Some(minijinja::context! { title => pages[i - 1].title, href => pages[i - 1].file })
} else {
None
};
let next = pages.get(i + 1).map(|p| minijinja::context! { title => p.title, href => p.file });
let ctx = minijinja::context! {
lang => html_lang,
book => minijinja::context! { title => book_title, language => book_lang },
site => site,
vars => variables,
labels => labels,
search => hcfg.search,
nav => nav,
page => minijinja::context! {
title => pages[i].title,
content => content,
root => "",
is_index => pages[i].is_index,
prev => prev,
next => next,
},
};
let rendered = tmpl.render_page(ctx)?;
std::fs::write(out_dir.join(&pages[i].file), rendered)?;
}
if hcfg.search {
std::fs::write(out_dir.join("search-index.js"), search::build_index_js(&search_docs))?;
std::fs::write(out_dir.join("search.js"), search::SEARCH_JS)?;
}
assets::copy_assets(&layout.root, out_dir, &collected)?;
Ok(())
}
fn resolve_book(h: &Hierarchy, root_id: Option<Uuid>) -> Result<&Node> {
if let Some(id) = root_id {
if let Some(n) = h.get(id) {
if n.kind == NodeKind::Book {
return Ok(n);
}
}
}
let books: Vec<&Node> = h
.children_of(None)
.into_iter()
.filter(|n| n.kind == NodeKind::Book && n.system_tag.is_none())
.collect();
match books.len() {
1 => Ok(books[0]),
0 => bail!("export html: no user book to export"),
_ => bail!("export html: multiple user books — pass --book-name"),
}
}
fn read_body(layout: &ProjectLayout, node: &Node) -> Option<String> {
let rel = node.file.as_ref()?;
std::fs::read_to_string(layout.root.join(rel)).ok()
}
fn below_floor(node: &Node, floor: Option<usize>) -> bool {
match floor {
Some(f) => super::status_ladder_index(node.status.as_deref()) < f,
None => false,
}
}
fn load_site_vars(layout: &ProjectLayout, file: &str) -> serde_json::Value {
let path = layout.root.join(file);
match std::fs::read_to_string(&path) {
Ok(raw) => serde_hjson::from_str::<serde_json::Value>(&raw).unwrap_or_else(|e| {
tracing::warn!(target: "inkhaven::html", "html: `{file}` did not parse ({e}) — ignored");
serde_json::json!({})
}),
Err(_) => serde_json::json!({}),
}
}