use std::path::{Path, PathBuf};
use crate::config::Config;
use crate::error::{Error, Result};
use crate::project::ProjectLayout;
use crate::store::Store;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
use crate::store::SYSTEM_TAG_TYPST;
pub type ProgressFn<'a> = dyn FnMut(usize, usize, &Path) + 'a;
#[derive(Debug, Default)]
pub struct AssemblyReport {
pub files_written: usize,
pub root_typ: PathBuf,
pub bibliography_entries: usize,
}
pub fn assemble_book(
store: &Store,
layout: &ProjectLayout,
cfg: &Config,
book_node: &Node,
progress: &mut ProgressFn,
) -> Result<AssemblyReport> {
if book_node.kind != NodeKind::Book || book_node.parent_id.is_some() {
return Err(Error::Store(format!(
"assemble: `{}` is not a root-level book",
book_node.title
)));
}
if book_node.system_tag.is_some() {
return Err(Error::Store(format!(
"assemble: `{}` is a system book — pick a user book",
book_node.title
)));
}
let hierarchy = Hierarchy::load(store)?;
let artefacts_root = store.resolve_artefacts_dir(cfg);
let out_book = artefacts_root.join(&book_node.slug);
let out_book_subtree = out_book.join("book");
let total = count_work(&hierarchy, book_node);
let mut done: usize = 0;
let jinja_env = build_jinja_environment(layout, &hierarchy)?;
if out_book.exists() {
std::fs::remove_dir_all(&out_book).map_err(Error::Io)?;
}
std::fs::create_dir_all(&out_book_subtree).map_err(Error::Io)?;
write_branch(
store,
layout,
&hierarchy,
cfg,
&jinja_env,
book_node,
&out_book_subtree,
BranchLevel::BookRoot,
&mut done,
total,
&artefacts_root,
progress,
)?;
let typst_root_index_body =
copy_typst_skeleton_files(store, cfg, layout, &hierarchy, book_node, &out_book, &artefacts_root, &mut done, total, progress)?;
let bibliography_entries =
collect_and_emit_sources(store, layout, cfg, &hierarchy, book_node, &out_book)?;
let _snippets_written =
emit_snippets_directory(layout, &hierarchy, &out_book, cfg, &jinja_env)?;
let root_typ = out_book.join(format!("{}.typ", book_node.slug));
let bib_style = (bibliography_entries > 0 && cfg.sources.auto_bibliography)
.then(|| cfg.sources.bibliography_style.as_str());
let root_body = build_root_typ(book_node, &typst_root_index_body, bib_style);
std::fs::write(&root_typ, root_body.as_bytes()).map_err(Error::Io)?;
done += 1;
progress(done, total, &PathBuf::from(format!("{}.typ", book_node.slug)));
Ok(AssemblyReport {
files_written: done,
root_typ,
bibliography_entries,
})
}
fn collect_and_emit_sources(
store: &Store,
layout: &ProjectLayout,
cfg: &Config,
hierarchy: &Hierarchy,
book_node: &Node,
out_book: &Path,
) -> Result<usize> {
let _ = store; let Some(sources_book) = hierarchy.iter().find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_SOURCES)
}) else {
return Ok(0);
};
let mut entries: Vec<crate::sources::BibEntry> = Vec::new();
for id in hierarchy.collect_subtree(sources_book.id) {
let Some(n) = hierarchy.get(id) else { continue };
if n.kind != NodeKind::Paragraph {
continue;
}
if !cfg.sources.all {
let chapter_title = {
let mut cur: Option<&Node> = Some(n);
let mut found: Option<&str> = None;
while let Some(node) = cur {
if node.parent_id == Some(sources_book.id) {
found = Some(node.title.as_str());
break;
}
cur = node.parent_id.and_then(|pid| hierarchy.get(pid));
}
found
};
if chapter_title != Some(book_node.title.as_str()) {
continue;
}
}
let Some(rel) = &n.file else { continue };
let Ok(raw) = std::fs::read_to_string(layout.root.join(rel)) else {
continue;
};
let body = strip_leading_heading(&raw);
if let Some(e) = crate::sources::BibEntry::from_hjson(&body) {
if e.is_valid() {
entries.push(e);
}
}
}
let (text, count) = crate::sources::compile_bibtex(&entries);
if count == 0 {
return Ok(0);
}
std::fs::write(out_book.join("sources.bib"), text.as_bytes()).map_err(Error::Io)?;
Ok(count)
}
fn emit_snippets_directory(
layout: &ProjectLayout,
hierarchy: &Hierarchy,
out_book: &Path,
cfg: &Config,
jinja_env: &minijinja::Environment<'static>,
) -> Result<usize> {
let Some(snippets_book) = hierarchy.iter().find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_SNIPPETS)
}) else {
return Ok(0);
};
let nodes: Vec<&Node> = hierarchy
.collect_subtree(snippets_book.id)
.into_iter()
.filter_map(|id| hierarchy.get(id))
.filter(|n| n.kind == NodeKind::Paragraph && n.file.is_some())
.collect();
if nodes.is_empty() {
return Ok(0);
}
let dir = out_book.join("snippets");
std::fs::create_dir_all(&dir).map_err(Error::Io)?;
let mut count = 0usize;
for n in nodes {
let dst = dir.join(format!("{}.typ", n.slug));
if n.content_type.as_deref() == Some("jinja") {
render_jinja_paragraph(layout, hierarchy, cfg, jinja_env, n, &dst)?;
} else {
let Some(rel) = &n.file else { continue };
let Ok(raw) = std::fs::read_to_string(layout.root.join(rel)) else {
continue;
};
let body = strip_leading_heading(&raw);
std::fs::write(&dst, body.as_bytes()).map_err(Error::Io)?;
}
count += 1;
}
Ok(count)
}
fn build_jinja_environment(
layout: &ProjectLayout,
hierarchy: &Hierarchy,
) -> Result<minijinja::Environment<'static>> {
let mut env = minijinja::Environment::new();
let Some(snippets_book) = hierarchy.iter().find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_SNIPPETS)
}) else {
return Ok(env);
};
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for id in hierarchy.collect_subtree(snippets_book.id) {
let Some(n) = hierarchy.get(id) else { continue };
if n.kind != NodeKind::Paragraph || n.content_type.as_deref() != Some("jinja") {
continue;
}
let Some(rel) = &n.file else { continue };
let Ok(raw) = std::fs::read_to_string(layout.root.join(rel)) else {
continue;
};
let body = strip_leading_heading(&raw);
let name = jinja_template_name(hierarchy, n);
if !seen.insert(name.clone()) {
tracing::warn!(
target: "inkhaven::assemble",
"jinja: duplicate template name `{name}` — keeping first, ignoring `{}`",
n.title
);
continue; }
env.add_template_owned(name.clone(), body).map_err(|e| {
Error::Store(format!(
"jinja: failed to register snippet template `{name}` ({}): {e}",
n.title
))
})?;
}
Ok(env)
}
fn jinja_template_name(hierarchy: &Hierarchy, node: &Node) -> String {
format!("{}.jinja", hierarchy.slug_path(node).to_lowercase())
}
fn jinja_context_for_node(
layout: &ProjectLayout,
hierarchy: &Hierarchy,
cfg: &Config,
node: &Node,
) -> minijinja::Value {
let ancestors = hierarchy.ancestors(node); let book = ancestors.iter().find(|n| n.kind == NodeKind::Book);
let chapter = ancestors.iter().find(|n| n.kind == NodeKind::Chapter);
let mut linked = serde_json::Map::new();
for lid in &node.linked_paragraphs {
let Some(ln) = hierarchy.get(*lid) else { continue };
if ln.content_type.as_deref() != Some("hjson") {
continue;
}
let Some(rel) = &ln.file else { continue };
let Ok(raw) = std::fs::read_to_string(layout.root.join(rel)) else {
continue;
};
match serde_hjson::from_str::<serde_json::Value>(&raw) {
Ok(v) => {
linked.insert(ln.slug.clone(), v);
}
Err(e) => {
tracing::warn!(
target: "inkhaven::assemble",
"jinja: linked HJSON `{}` did not parse — skipped: {e}",
ln.title
);
}
}
}
let ctx = serde_json::json!({
"title": node.title,
"slug": node.slug,
"book": book.map(|b| serde_json::json!({
"title": b.title,
"slug": b.slug,
"genre": cfg.genre,
})),
"chapter": chapter.map(|c| serde_json::json!({
"title": c.title,
"slug": c.slug,
})),
"linked": serde_json::Value::Object(linked),
"language": cfg.language,
"genre": cfg.genre,
});
minijinja::Value::from_serialize(&ctx)
}
fn render_jinja_paragraph(
layout: &ProjectLayout,
hierarchy: &Hierarchy,
cfg: &Config,
env: &minijinja::Environment<'static>,
node: &Node,
dst: &Path,
) -> Result<()> {
let Some(rel) = &node.file else {
return Err(Error::Store(format!(
"assemble: jinja paragraph `{}` has no file on disk",
node.title
)));
};
let raw = std::fs::read_to_string(layout.root.join(rel)).map_err(Error::Io)?;
let body = strip_leading_heading(&raw);
let ctx = jinja_context_for_node(layout, hierarchy, cfg, node);
match env.render_str(&body, ctx) {
Ok(rendered) => {
std::fs::write(dst, rendered.as_bytes()).map_err(Error::Io)?;
Ok(())
}
Err(e) if cfg.jinja.continue_on_error => {
let block = format!(
"// JINJA RENDER ERROR in {title}: {e}\n\
#block(fill: rgb(\"#ffdddd\"), inset: 8pt, radius: 4pt, width: 100%)[\
*JINJA RENDER ERROR* — {title_lit}: {err_lit}]\n",
title = node.title,
title_lit = escape_typst_string(&node.title),
err_lit = escape_typst_string(&e.to_string()),
);
std::fs::write(dst, block.as_bytes()).map_err(Error::Io)?;
tracing::warn!(
target: "inkhaven::assemble",
"jinja render error in `{}` (continuing): {e}",
node.title
);
Ok(())
}
Err(e) => Err(Error::Store(format!(
"jinja render failed in `{}`: {e}",
node.title
))),
}
}
fn count_work(hierarchy: &Hierarchy, book: &Node) -> usize {
let mut count: usize = 1; count += 3; for id in hierarchy.collect_subtree(book.id) {
let Some(n) = hierarchy.get(id) else { continue };
match n.kind {
NodeKind::Book => count += 1, NodeKind::Chapter | NodeKind::Subchapter => count += 1,
NodeKind::Paragraph | NodeKind::Image => count += 1,
NodeKind::Script => {}
}
}
count
}
#[derive(Clone, Copy)]
enum BranchLevel {
BookRoot,
Chapter,
Subchapter,
}
fn write_branch(
store: &Store,
layout: &ProjectLayout,
hierarchy: &Hierarchy,
cfg: &Config,
jinja_env: &minijinja::Environment<'static>,
branch: &Node,
out_dir: &Path,
level: BranchLevel,
done: &mut usize,
total: usize,
artefacts_root: &Path,
progress: &mut ProgressFn,
) -> Result<()> {
std::fs::create_dir_all(out_dir).map_err(Error::Io)?;
let children = hierarchy.children_of(Some(branch.id));
let mut child_refs: Vec<ChildRef> = Vec::new();
for child in &children {
if child.kind == NodeKind::Chapter
&& child.system_tag.as_deref()
== Some(crate::store::SYSTEM_TAG_BOOK_TIMELINE)
{
continue;
}
if child.kind == NodeKind::Paragraph && child.event.is_some() {
continue;
}
match child.kind {
NodeKind::Paragraph => {
if child.content_type.as_deref() == Some("jinja") {
let out_fname = format!("{:02}-{}.typ", child.order, child.slug);
let dst = out_dir.join(&out_fname);
render_jinja_paragraph(layout, hierarchy, cfg, jinja_env, child, &dst)?;
*done += 1;
let rel = dst.strip_prefix(artefacts_root).unwrap_or(&dst);
progress(*done, total, rel);
child_refs.push(ChildRef::Paragraph { fname: out_fname });
} else {
let fname = child.fs_name(); let dst = out_dir.join(&fname);
copy_paragraph_file(layout, child, &dst)?;
*done += 1;
let rel = dst.strip_prefix(artefacts_root).unwrap_or(&dst);
progress(*done, total, rel);
child_refs.push(ChildRef::Paragraph { fname });
}
}
NodeKind::Chapter | NodeKind::Subchapter => {
let dname = child.fs_name(); let dst_dir = out_dir.join(&dname);
let next_level = if child.kind == NodeKind::Chapter {
BranchLevel::Chapter
} else {
BranchLevel::Subchapter
};
write_branch(
store,
layout,
hierarchy,
cfg,
jinja_env,
child,
&dst_dir,
next_level,
done,
total,
artefacts_root,
progress,
)?;
child_refs.push(ChildRef::Branch { dname });
}
NodeKind::Image => {
let fname = child.fs_name(); let dst = out_dir.join(&fname);
copy_image_file(store, child, &dst)?;
*done += 1;
let rel = dst.strip_prefix(artefacts_root).unwrap_or(&dst);
progress(*done, total, rel);
child_refs.push(ChildRef::Image {
fname,
title: child.title.clone(),
caption: child.image_caption.clone(),
alt: child.image_alt.clone(),
});
}
NodeKind::Book => {
}
NodeKind::Script => {
}
}
}
let index_path = out_dir.join("index.typ");
let depth = match level {
BranchLevel::BookRoot => 1, BranchLevel::Chapter => 2, BranchLevel::Subchapter => 3, };
let globals_rel = "../".repeat(depth) + "globals.typ";
let body = build_branch_index(branch, level, &child_refs, &globals_rel);
std::fs::write(&index_path, body.as_bytes()).map_err(Error::Io)?;
*done += 1;
let rel = index_path.strip_prefix(artefacts_root).unwrap_or(&index_path);
progress(*done, total, rel);
Ok(())
}
enum ChildRef {
Paragraph { fname: String },
Branch { dname: String },
Image {
fname: String,
title: String,
caption: Option<String>,
alt: Option<String>,
},
}
fn build_branch_index(
branch: &Node,
level: BranchLevel,
children: &[ChildRef],
globals_rel: &str,
) -> String {
let mut out = String::new();
out.push_str("// Auto-generated by inkhaven Book assembly.\n");
out.push_str(&format!("#import \"{globals_rel}\": *\n\n"));
out.push_str(&format!("#metadata((node_id: \"{}\"))\n", branch.id));
match level {
BranchLevel::BookRoot => {
if children.is_empty() {
out.push_str("// (empty book)\n");
}
for child in children {
match child {
ChildRef::Paragraph { fname } => {
out.push_str(&format!(
"#wrap_paragraph(include \"{fname}\")\n"
));
}
ChildRef::Branch { dname } => {
out.push_str(&format!(
"#include \"{dname}/index.typ\"\n"
));
}
ChildRef::Image {
fname,
title,
caption,
alt,
} => {
out.push_str(&render_image_call(
"wrap_image_book",
fname,
title,
caption.as_deref(),
alt.as_deref(),
true,
));
}
}
}
}
BranchLevel::Chapter | BranchLevel::Subchapter => {
let mut body = String::new();
for child in children {
match child {
ChildRef::Paragraph { fname } => {
body.push_str(&format!(
" wrap_paragraph(include \"{fname}\")\n"
));
}
ChildRef::Branch { dname } => {
body.push_str(&format!(
" include \"{dname}/index.typ\"\n"
));
}
ChildRef::Image {
fname,
title,
caption,
alt,
} => {
let wrap_fn = match level {
BranchLevel::Chapter => "wrap_image_chapter",
BranchLevel::Subchapter => "wrap_image_subchapter",
BranchLevel::BookRoot => {
tracing::warn!(
target: "inkhaven::assemble",
"image render reached BookRoot level — caller filter missed it; skipping",
);
continue;
}
};
body.push_str(" ");
body.push_str(&render_image_call(
wrap_fn,
fname,
title,
caption.as_deref(),
alt.as_deref(),
false,
));
}
}
}
if body.is_empty() {
body.push_str(" []\n"); }
let title = escape_typst_string(&branch.title);
let wrap_fn = match level {
BranchLevel::Chapter => "wrap_chapter",
BranchLevel::Subchapter => "wrap_subchapter",
BranchLevel::BookRoot => {
tracing::warn!(
target: "inkhaven::assemble",
"branch render reached BookRoot level — caller filter missed it; returning partial index",
);
return out;
}
};
out.push_str(&format!("#{wrap_fn}(\"{title}\", {{\n"));
out.push_str(&body);
out.push_str("})\n");
}
}
out
}
fn render_image_call(
wrap_fn: &str,
fname: &str,
title: &str,
caption: Option<&str>,
alt: Option<&str>,
markup_prefix: bool,
) -> String {
let title_lit = quote_or_none(Some(title));
let caption_lit = quote_or_none(caption);
let alt_lit = quote_or_none(alt);
let prefix = if markup_prefix { "#" } else { "" };
format!(
"{prefix}{wrap_fn}(\"{}\", {title_lit}, {caption_lit}, alt: {alt_lit})\n",
fname.replace('\\', "\\\\").replace('"', "\\\""),
)
}
fn quote_or_none(s: Option<&str>) -> String {
match s.and_then(|t| if t.is_empty() { None } else { Some(t) }) {
None => "none".to_string(),
Some(t) => format!(
"\"{}\"",
t.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', " ")
),
}
}
fn copy_image_file(store: &Store, node: &Node, dst: &Path) -> Result<()> {
let bytes = match store.image_bytes(node.id)? {
Some(b) => b,
None => {
return Err(Error::Store(format!(
"assemble: image `{}` has no bytes in bdslib",
node.title
)));
}
};
std::fs::write(dst, &bytes).map_err(Error::Io)?;
Ok(())
}
fn copy_paragraph_file(layout: &ProjectLayout, node: &Node, dst: &Path) -> Result<()> {
let Some(rel) = &node.file else {
return Err(Error::Store(format!(
"assemble: paragraph `{}` has no file on disk",
node.title
)));
};
let src = layout.root.join(rel);
let body = std::fs::read_to_string(&src).map_err(Error::Io)?;
let body = strip_leading_heading(&body);
std::fs::write(dst, body.as_bytes()).map_err(Error::Io)?;
Ok(())
}
fn strip_leading_heading(body: &str) -> String {
let mut lines: Vec<&str> = body.lines().collect();
if let Some(first) = lines.first() {
if first.trim_start().starts_with('=') {
lines.remove(0);
while lines.first().is_some_and(|l| l.trim().is_empty()) {
lines.remove(0);
}
}
}
lines.join("\n")
}
fn copy_typst_skeleton_files(
_store: &Store,
cfg: &Config,
layout: &ProjectLayout,
hierarchy: &Hierarchy,
book: &Node,
out_book: &Path,
artefacts_root: &Path,
done: &mut usize,
total: usize,
progress: &mut ProgressFn,
) -> Result<String> {
let typst_book = hierarchy
.iter()
.find(|n| {
n.kind == NodeKind::Book && n.system_tag.as_deref() == Some(SYSTEM_TAG_TYPST)
})
.cloned()
.ok_or_else(|| Error::Store("assemble: Typst system book not found".into()))?;
let chapter = hierarchy
.iter()
.find(|n| {
n.kind == NodeKind::Chapter
&& n.parent_id == Some(typst_book.id)
&& n.title == book.title
})
.cloned()
.ok_or_else(|| {
Error::Store(format!(
"assemble: no Typst chapter named `{}` — open the book once \
to seed it, or re-create it under Typst",
book.title
))
})?;
let mut index_body = String::new();
for child in hierarchy.children_of(Some(chapter.id)) {
if child.kind != NodeKind::Paragraph {
continue;
}
let Some(rel) = &child.file else { continue };
let src = layout.root.join(rel);
let body = std::fs::read_to_string(&src).map_err(Error::Io)?;
let stripped = strip_leading_heading(&body);
match child.title.as_str() {
"globals.typ" => {
let dst = out_book.join("globals.typ");
std::fs::write(&dst, stripped.as_bytes()).map_err(Error::Io)?;
*done += 1;
let rel = dst.strip_prefix(artefacts_root).unwrap_or(&dst);
progress(*done, total, rel);
}
"settings.typ" => {
let mut composed = cfg.synthesised_settings_typ_header();
if !stripped.trim().is_empty() {
composed.push('\n');
composed.push_str(&stripped);
if !composed.ends_with('\n') {
composed.push('\n');
}
}
let dst = out_book.join("settings.typ");
std::fs::write(&dst, composed.as_bytes()).map_err(Error::Io)?;
*done += 1;
let rel = dst.strip_prefix(artefacts_root).unwrap_or(&dst);
progress(*done, total, rel);
}
"index.typ" => {
index_body = stripped;
*done += 1;
progress(*done, total, &PathBuf::from("(typst-chapter index.typ)"));
}
_ => {}
}
}
Ok(index_body)
}
fn build_root_typ(
book: &Node,
typst_chapter_index_body: &str,
bibliography_style: Option<&str>,
) -> String {
let mut out = String::new();
out.push_str("// Auto-generated by inkhaven Book assembly.\n");
out.push_str(&format!("// Book: {}\n\n", book.title));
out.push_str("#import \"globals.typ\": *\n");
out.push_str("#import \"settings.typ\": *\n\n");
let chapter_setup = typst_chapter_index_body.trim();
if !chapter_setup.is_empty() {
out.push_str("// User setup from Typst -> ");
out.push_str(&book.title);
out.push_str(" -> index.typ\n");
out.push_str(chapter_setup);
out.push_str("\n\n");
}
out.push_str("#wrap_book(include \"book/index.typ\")\n");
if let Some(style) = bibliography_style {
out.push_str(&format!(
"\n#bibliography(\"sources.bib\", style: \"{}\")\n",
escape_typst_string(style)
));
}
out
}
fn escape_typst_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' | '\r' => out.push(' '),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_handles_quotes_and_backslashes() {
assert_eq!(escape_typst_string("plain"), "plain");
assert_eq!(escape_typst_string("a\"b"), "a\\\"b");
assert_eq!(escape_typst_string("path\\sub"), "path\\\\sub");
assert_eq!(escape_typst_string("line1\nline2"), "line1 line2");
}
#[test]
fn strip_leading_heading_drops_title_and_blank() {
let s = "= Chapter\n\nFirst line.\nSecond line.\n";
assert_eq!(strip_leading_heading(s), "First line.\nSecond line.");
}
#[test]
fn strip_leading_heading_keeps_body_without_heading() {
let s = "First line.\nSecond line.\n";
assert_eq!(strip_leading_heading(s), "First line.\nSecond line.");
}
fn mk_node(kind: NodeKind, title: &str, slug: &str, order: u32) -> Node {
Node {
id: uuid::Uuid::nil(),
kind,
title: title.into(),
slug: slug.into(),
path: Vec::new(),
parent_id: None,
order,
file: None,
word_count: 0,
modified_at: chrono::Utc::now(),
protected: false,
system_tag: None,
image_ext: None,
image_caption: None,
image_alt: None,
content_type: None,
status: None,
target_words: None,
target_hit_at_status: None,
linked_paragraphs: Vec::new(),
bookmark: false,
tags: Vec::new(),
ai_memory: Vec::new(),
event: None,
}
}
#[test]
fn emit_snippets_writes_sidecar_and_strips_heading() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let book_id = uuid::Uuid::new_v4();
let book = Node {
id: book_id,
system_tag: Some("snippets".into()),
..mk_node(NodeKind::Book, "Snippets", "snippets", 0)
};
let rel = "books/snippets/01-warn.typ".to_string();
std::fs::create_dir_all(root.join("books/snippets")).unwrap();
std::fs::write(root.join(&rel), "= warn\n\n#block[Careful here.]\n").unwrap();
let para = Node {
id: uuid::Uuid::new_v4(),
parent_id: Some(book_id),
file: Some(rel),
..mk_node(NodeKind::Paragraph, "warn", "warn", 0)
};
let h = Hierarchy::from_nodes_for_test(vec![book, para]);
let out = root.join("out");
let n = emit_snippets_directory(
&ProjectLayout::new(root),
&h,
&out,
&Config::default(),
&minijinja::Environment::new(),
)
.unwrap();
assert_eq!(n, 1);
let written = std::fs::read_to_string(out.join("snippets/warn.typ")).unwrap();
assert!(written.contains("#block[Careful here.]"), "{written}");
assert!(!written.contains("= warn"), "heading must be stripped: {written}");
}
#[test]
fn emit_snippets_absent_or_empty_book_is_noop() {
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join("out");
let h = Hierarchy::from_nodes_for_test(vec![]);
assert_eq!(
emit_snippets_directory(
&ProjectLayout::new(tmp.path()),
&h,
&out,
&Config::default(),
&minijinja::Environment::new(),
)
.unwrap(),
0
);
assert!(!out.join("snippets").exists(), "no dir when no snippets");
let book = Node {
id: uuid::Uuid::new_v4(),
system_tag: Some("snippets".into()),
..mk_node(NodeKind::Book, "Snippets", "snippets", 0)
};
let h2 = Hierarchy::from_nodes_for_test(vec![book]);
assert_eq!(
emit_snippets_directory(
&ProjectLayout::new(tmp.path()),
&h2,
&out,
&Config::default(),
&minijinja::Environment::new(),
)
.unwrap(),
0
);
assert!(!out.join("snippets").exists());
}
#[test]
fn jinja_renders_includes_linked_and_metadata() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let l = ProjectLayout::new(root);
std::fs::create_dir_all(root.join("books/snippets")).unwrap();
std::fs::create_dir_all(root.join("books/mybook/intro")).unwrap();
let sb_id = uuid::Uuid::new_v4();
let sb = Node {
id: sb_id,
system_tag: Some("snippets".into()),
..mk_node(NodeKind::Book, "Snippets", "snippets", 0)
};
let warn_rel = "books/snippets/01-warning.jinja".to_string();
std::fs::write(root.join(&warn_rel), "= warning\n#block[Heads up.]\n").unwrap();
let warn = Node {
id: uuid::Uuid::new_v4(),
parent_id: Some(sb_id),
file: Some(warn_rel),
content_type: Some("jinja".into()),
..mk_node(NodeKind::Paragraph, "warning", "warning", 0)
};
let ub_id = uuid::Uuid::new_v4();
let ub = mk_node(NodeKind::Book, "My Book", "mybook", 0);
let ub = Node { id: ub_id, ..ub };
let ch_id = uuid::Uuid::new_v4();
let ch = Node {
id: ch_id,
parent_id: Some(ub_id),
..mk_node(NodeKind::Chapter, "Intro", "intro", 0)
};
let aria_rel = "books/mybook/intro/01-aria.hjson".to_string();
std::fs::write(root.join(&aria_rel), "{ name: \"Aria\", species: \"fox\" }").unwrap();
let aria_id = uuid::Uuid::new_v4();
let aria = Node {
id: aria_id,
parent_id: Some(ch_id),
file: Some(aria_rel),
content_type: Some("hjson".into()),
..mk_node(NodeKind::Paragraph, "aria", "aria", 0)
};
let side_rel = "books/mybook/intro/02-sidebar.jinja".to_string();
std::fs::write(
root.join(&side_rel),
"= Sidebar\n{% include \"snippets/warning.jinja\" %}\nName: {{ linked[\"aria\"].name }} ({{ linked[\"aria\"].species }})\nBook: {{ book.title }}\nLang: {{ language }}\n",
)
.unwrap();
let side = Node {
id: uuid::Uuid::new_v4(),
parent_id: Some(ch_id),
file: Some(side_rel),
content_type: Some("jinja".into()),
linked_paragraphs: vec![aria_id],
..mk_node(NodeKind::Paragraph, "sidebar", "sidebar", 1)
};
let side_id = side.id;
let h = Hierarchy::from_nodes_for_test(vec![sb, warn, ub, ch, aria, side]);
let env = build_jinja_environment(&l, &h).unwrap();
let cfg = Config::default();
let out = root.join("02-sidebar.typ");
let side_node = h.get(side_id).unwrap();
render_jinja_paragraph(&l, &h, &cfg, &env, side_node, &out).unwrap();
let r = std::fs::read_to_string(&out).unwrap();
assert!(r.contains("#block[Heads up.]"), "include not resolved: {r}");
assert!(r.contains("Name: Aria (fox)"), "linked HJSON not injected: {r}");
assert!(r.contains("Book: My Book"), "book metadata missing: {r}");
assert!(r.contains("Lang: english"), "language missing: {r}");
assert!(!r.contains("= Sidebar"), "heading must be stripped: {r}");
}
#[test]
fn jinja_render_error_aborts_by_default_and_continues_when_opted_in() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let l = ProjectLayout::new(root);
std::fs::create_dir_all(root.join("books/mybook")).unwrap();
let bad_rel = "books/mybook/01-bad.jinja".to_string();
std::fs::write(root.join(&bad_rel), "= bad\n{{ oops").unwrap();
let node = Node {
id: uuid::Uuid::new_v4(),
file: Some(bad_rel),
content_type: Some("jinja".into()),
..mk_node(NodeKind::Paragraph, "bad", "bad", 0)
};
let h = Hierarchy::from_nodes_for_test(vec![node.clone()]);
let env = minijinja::Environment::new();
let out = root.join("01-bad.typ");
let cfg = Config::default();
assert!(cfg.jinja.continue_on_error == false);
let err = render_jinja_paragraph(&l, &h, &cfg, &env, &node, &out).unwrap_err();
assert!(err.to_string().contains("jinja render failed"), "{err}");
let mut cfg2 = Config::default();
cfg2.jinja.continue_on_error = true;
render_jinja_paragraph(&l, &h, &cfg2, &env, &node, &out).unwrap();
let r = std::fs::read_to_string(&out).unwrap();
assert!(r.contains("JINJA RENDER ERROR"), "{r}");
}
#[test]
fn jinja_passes_through_non_ascii_linked_values() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let l = ProjectLayout::new(root);
std::fs::create_dir_all(root.join("books/kniga")).unwrap();
let geroy_rel = "books/kniga/01-geroy.hjson".to_string();
std::fs::write(
root.join(&geroy_rel),
"{ name: \"Ария\", species: \"лиса\", роль: \"разведчик\" }",
)
.unwrap();
let geroy_id = uuid::Uuid::new_v4();
let geroy = Node {
id: geroy_id,
file: Some(geroy_rel),
content_type: Some("hjson".into()),
..mk_node(NodeKind::Paragraph, "geroy", "geroy", 0)
};
let card_rel = "books/kniga/02-card.jinja".to_string();
std::fs::write(
root.join(&card_rel),
"Имя: {{ linked[\"geroy\"].name }} ({{ linked[\"geroy\"].species }}, {{ linked[\"geroy\"][\"роль\"] }})\n",
)
.unwrap();
let card_id = uuid::Uuid::new_v4();
let card = Node {
id: card_id,
file: Some(card_rel),
content_type: Some("jinja".into()),
linked_paragraphs: vec![geroy_id],
..mk_node(NodeKind::Paragraph, "card", "card", 1)
};
let h = Hierarchy::from_nodes_for_test(vec![geroy, card]);
let env = minijinja::Environment::new();
let cfg = Config::default();
let out = root.join("02-card.typ");
let card_node = h.get(card_id).unwrap();
render_jinja_paragraph(&l, &h, &cfg, &env, card_node, &out).unwrap();
let r = std::fs::read_to_string(&out).unwrap();
assert!(r.contains("Имя: Ария (лиса, разведчик)"), "cyrillic mangled: {r}");
}
#[test]
fn build_root_typ_bibliography_line_is_style_gated() {
let book = mk_node(NodeKind::Book, "My Book", "my-book", 0);
let with = build_root_typ(&book, "", Some("ieee"));
assert!(
with.contains("#bibliography(\"sources.bib\", style: \"ieee\")"),
"{with}"
);
let wrap = with.find("#wrap_book").unwrap();
let bib = with.find("#bibliography").unwrap();
assert!(bib > wrap, "bibliography must follow wrap_book");
let without = build_root_typ(&book, "", None);
assert!(!without.contains("#bibliography"), "{without}");
}
#[test]
fn book_root_index_emits_markup_mode_statements() {
let book = mk_node(NodeKind::Book, "Novel", "novel", 0);
let children = vec![
ChildRef::Branch { dname: "01-prologue".into() },
ChildRef::Paragraph { fname: "02-stand-alone.typ".into() },
];
let out = build_branch_index(&book, BranchLevel::BookRoot, &children, "../globals.typ");
assert!(out.contains("#include \"01-prologue/index.typ\""), "got:\n{out}");
assert!(out.contains("#wrap_paragraph(include \"02-stand-alone.typ\")"));
for line in out.lines() {
assert!(
!line.starts_with('{'),
"BookRoot index must not open a bare code block: `{line}`\n--full--\n{out}"
);
}
}
#[test]
fn chapter_index_wraps_with_function_call() {
let chap = mk_node(NodeKind::Chapter, "Prologue", "prologue", 1);
let children = vec![ChildRef::Paragraph {
fname: "01-first.typ".into(),
}];
let out = build_branch_index(&chap, BranchLevel::Chapter, &children, "../../globals.typ");
assert!(out.contains("#wrap_chapter(\"Prologue\""), "got:\n{out}");
assert!(out.contains("wrap_paragraph(include \"01-first.typ\")"));
}
#[test]
fn render_image_call_omits_none_caption_alt() {
let s = render_image_call(
"wrap_image_chapter",
"01-cover.png",
"Cover Art",
None,
None,
false,
);
assert!(s.starts_with("wrap_image_chapter("), "got: {s}");
assert!(s.contains("\"Cover Art\""));
assert!(s.contains(", none"), "expected `none` for caption: {s}");
assert!(s.contains("alt: none"), "expected `alt: none`: {s}");
}
#[test]
fn render_image_call_markup_prefix_for_book_root() {
let s = render_image_call(
"wrap_image_book",
"01-frontispiece.png",
"Frontispiece",
Some("Lighthouse at dawn"),
Some("alt text"),
true,
);
assert!(s.starts_with("#wrap_image_book("), "got: {s}");
assert!(s.contains("\"01-frontispiece.png\""));
assert!(s.contains("\"Lighthouse at dawn\""));
assert!(s.contains("alt: \"alt text\""));
}
#[test]
fn build_book_root_emits_wrap_image_book() {
let book = mk_node(NodeKind::Book, "Novel", "novel", 0);
let children = vec![ChildRef::Image {
fname: "01-cover.png".into(),
title: "Cover".into(),
caption: Some("By Vladimir".into()),
alt: None,
}];
let out = build_branch_index(
&book,
BranchLevel::BookRoot,
&children,
"../globals.typ",
);
assert!(out.contains("#wrap_image_book(\"01-cover.png\""), "got:\n{out}");
assert!(out.contains("\"By Vladimir\""));
}
#[test]
fn build_chapter_emits_wrap_image_chapter_in_code_mode() {
let chap = mk_node(NodeKind::Chapter, "Prologue", "prologue", 1);
let children = vec![ChildRef::Image {
fname: "01-opener.jpg".into(),
title: "Opener".into(),
caption: None,
alt: None,
}];
let out = build_branch_index(
&chap,
BranchLevel::Chapter,
&children,
"../../globals.typ",
);
assert!(out.contains("#wrap_chapter(\"Prologue\""));
assert!(
out.contains(" wrap_image_chapter(\"01-opener.jpg\""),
"got:\n{out}"
);
}
#[test]
fn build_subchapter_uses_wrap_image_subchapter() {
let sub = mk_node(NodeKind::Subchapter, "Vista", "vista", 1);
let children = vec![ChildRef::Image {
fname: "01-vista.webp".into(),
title: "Vista".into(),
caption: None,
alt: None,
}];
let out = build_branch_index(
&sub,
BranchLevel::Subchapter,
&children,
"../../../globals.typ",
);
assert!(out.contains("#wrap_subchapter(\"Vista\""));
assert!(out.contains(" wrap_image_subchapter(\"01-vista.webp\""));
}
#[test]
fn empty_chapter_emits_placeholder_content() {
let chap = mk_node(NodeKind::Chapter, "Empty", "empty", 1);
let out = build_branch_index(&chap, BranchLevel::Chapter, &[], "../../globals.typ");
assert!(out.contains("#wrap_chapter(\"Empty\""));
assert!(out.contains("[]"), "got:\n{out}");
}
}