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 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;
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,
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 root_typ = out_book.join(format!("{}.typ", book_node.slug));
let root_body = build_root_typ(book_node, &typst_root_index_body);
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,
})
}
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,
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 {
match child.kind {
NodeKind::Paragraph => {
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,
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"));
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 => unreachable!(),
};
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 => unreachable!(),
};
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) -> 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");
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,
}
}
#[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}");
}
}