1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::cli::ExportFormat;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::export;
use crate::project::ProjectLayout;
use crate::store::Store;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
#[allow(clippy::too_many_arguments)]
pub fn run(
project: &Path,
format: ExportFormat,
output: Option<&Path>,
book_name: Option<&str>,
status_floor: Option<&str>,
tag: Option<&str>,
profiles: &[(String, String)],
templates: Option<&Path>,
eject_templates: Option<&Path>,
blind: bool,
bundle: Option<&Path>,
) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg)?;
let h = Hierarchy::load(&store)?;
let scope = resolve_export_scope(&h, book_name)?;
let floor_idx = parse_status_floor(status_floor)?;
let combined = build_combined(&layout, &h, scope.root_id, floor_idx, tag, profiles)?;
let epub_title = scope.title_for_epub(project);
// PAPER (1.6.15+): prepend the front-matter title block (authors,
// affiliations, abstract, keywords, funding) to the Typst-based exports.
// Empty front matter renders "" so non-paper books are unchanged. Markdown
// / EPUB / HTML derive their own front matter and are left as-is.
let front = cfg
.frontmatter
.to_typst_block(&cfg.language, &epub_title, blind);
let with_front = |body: &str| -> String {
if front.is_empty() {
body.to_string()
} else {
format!("{front}{body}")
}
};
// ARXIV-1 (1.6.16+): `--bundle` writes a self-contained LaTeX submission
// (implies tex; composes with `--blind` via `with_front`). Handled before the
// format match so `export tex --bundle` and `export pdf --bundle` both work.
if let Some(bundle_path) = bundle {
let report = export::bundle::build_bundle(
&layout,
&h,
&cfg,
&with_front(&combined),
&epub_title,
bundle_path,
)
.map_err(|e| Error::Store(format!("bundle: {e:#}")))?;
eprintln!(
"wrote {} bundle to {} — {}, {} bib {}, {} figure(s){}",
if report.zipped { "zip" } else { "directory" },
bundle_path.display(),
report.tex_name,
report.bib_entries,
if report.bib_entries == 1 { "entry" } else { "entries" },
report.figures.len(),
if report.missing_figures.is_empty() {
String::new()
} else {
format!(" · {} figure(s) NOT found (see MANIFEST)", report.missing_figures.len())
},
);
return Ok(());
}
match format {
ExportFormat::Typst => write_typst(&with_front(&combined), output),
ExportFormat::Pdf => write_pdf(&with_front(&combined), output),
ExportFormat::Markdown => write_artefact(
export::build_markdown(&combined),
output,
"markdown",
),
ExportFormat::Tex => write_artefact(
export::build_tex(&with_front(&combined), &cfg.tex_export),
output,
"tex",
),
ExportFormat::Epub => {
// XP-3 — the CLI EPUB export now uses the rich, multi-chapter builder
// (a `nav` entry per chapter, embedded `NodeKind::Image` figures,
// EPUB3 footnote popups, cover) — the same path as `inkhaven epub` —
// instead of the old single-chapter markdown→epub converter. That
// builder re-reads the book from the store, so the export-pipeline
// filters below (which shape `combined`) don't apply to EPUB.
if status_floor.is_some() || tag.is_some() || !profiles.is_empty() || blind {
eprintln!(
"note: EPUB uses the full-book exporter — --status / --tag / --profile / --blind don't apply here."
);
}
crate::cli::epub::run(project, book_name, output, None, None)
}
ExportFormat::Html => {
// `--eject-templates <dir>` scaffolds the bundled defaults and exits.
if let Some(dir) = eject_templates {
export::html::eject_templates(dir)
.map_err(|e| Error::Store(format!("eject templates: {e:#}")))?;
eprintln!(
"wrote default HTML templates to {} — edit them and export with --templates {}",
dir.display(),
dir.display()
);
Ok(())
} else {
// TDOC-4 — a directory of HTML, not a single artefact; the exporter
// reads bodies itself (it does not use `combined`).
let out = output.ok_or_else(|| {
Error::Store("HTML export needs --output <dir>".into())
})?;
export::html::export_html(&layout, &store, &h, &cfg, scope.root_id, profiles, floor_idx, out, templates)
.map_err(|e| Error::Store(format!("html export: {e:#}")))?;
eprintln!("wrote HTML site to {}", out.display());
Ok(())
}
}
}
}
/// Resolved subtree the exporter walks. `root_id = None` means
/// "whole project" (legacy 1.2.2 behaviour); `Some(id)` is a
/// single book picked via `--book-name`.
struct ExportScope<'a> {
/// `None` → whole project; `Some(id)` → only paragraphs under
/// that book in DFS preorder.
root_id: Option<uuid::Uuid>,
/// Display title used by the EPUB writer's metadata. Borrowed
/// from the matched book when scope is single-book, otherwise
/// derived from the project directory name at the call site.
book_title: Option<&'a str>,
}
impl<'a> ExportScope<'a> {
fn title_for_epub(&self, project: &Path) -> String {
if let Some(t) = self.book_title {
return t.to_string();
}
project
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("inkhaven book")
.to_string()
}
}
/// Match `--book-name` against the hierarchy. Without the flag,
/// "whole project" is OK only when the project has at most one
/// user book — otherwise we refuse and list the available books so
/// the user knows what to pass.
///
/// Matching tries (in order):
/// 1. Case-insensitive title equality.
/// 2. Case-insensitive slug equality.
/// System books (Help / Scripts / Typst / …) are excluded from
/// both the match list and the disambiguation list — those don't
/// contain the user's manuscript content.
fn resolve_export_scope<'a>(
h: &'a Hierarchy,
book_name: Option<&str>,
) -> Result<ExportScope<'a>> {
let user_books: Vec<&Node> = h
.children_of(None)
.into_iter()
.filter(|n| n.kind == NodeKind::Book && n.system_tag.is_none())
.collect();
match book_name {
Some(name) => {
let needle = name.trim().to_ascii_lowercase();
let pick = user_books.iter().copied().find(|b| {
b.title.to_ascii_lowercase() == needle
|| b.slug.to_ascii_lowercase() == needle
});
match pick {
Some(book) => Ok(ExportScope {
root_id: Some(book.id),
book_title: Some(book.title.as_str()),
}),
None => {
let listing = user_books
.iter()
.map(|b| format!("`{}` (slug: {})", b.title, b.slug))
.collect::<Vec<_>>()
.join(", ");
let listing = if listing.is_empty() {
"no user books in this project".into()
} else {
listing
};
Err(Error::Store(format!(
"export: no book matches `--book-name {name}`. Available: {listing}"
)))
}
}
}
None => {
if user_books.len() > 1 {
let listing = user_books
.iter()
.map(|b| format!("`{}`", b.title))
.collect::<Vec<_>>()
.join(", ");
return Err(Error::Store(format!(
"export: project has {n} user books — pass --book-name <name>. Available: {listing}",
n = user_books.len(),
)));
}
// Zero or one user books: scope to the whole project,
// preserving 1.2.2's behaviour for single-book setups
// (and "just dump what's there" for projects that only
// contain system books / orphans).
let book_title = user_books.first().map(|b| b.title.as_str());
Ok(ExportScope {
root_id: user_books.first().map(|b| b.id),
book_title,
})
}
}
}
fn write_artefact(
artefact: export::Artefact,
output: Option<&Path>,
fmt_label: &str,
) -> Result<()> {
match output {
Some(path) => {
artefact.write_to(path).map_err(|e| {
Error::Store(format!("write {fmt_label}: {e:#}"))
})?;
eprintln!("wrote {} ({fmt_label})", path.display());
}
None => match &artefact {
export::Artefact::Markdown(s) | export::Artefact::Tex(s) => {
print!("{s}");
}
export::Artefact::Epub(_) => {
return Err(Error::Store(
"epub export needs --output <path.epub> (binary archive)".into(),
));
}
},
}
Ok(())
}
/// Concatenate every paragraph's `.typ` file in DFS preorder. Branch nodes
/// don't emit anything themselves — paragraphs carry the headings via the
/// `= Title` template `inkhaven add paragraph` writes. The user controls
/// document structure by ordering paragraphs at each level (book-level
/// paragraphs come first → that's where Typst config like `#set page(...)`
/// belongs).
///
/// `root_id = None` walks the whole hierarchy. `Some(id)` restricts the
/// walk to that book's subtree — used by `--book-name` to keep system
/// books and sibling user books out of the export.
fn build_combined(
layout: &ProjectLayout,
h: &Hierarchy,
root_id: Option<uuid::Uuid>,
status_floor: Option<usize>,
tag: Option<&str>,
profiles: &[(String, String)],
) -> Result<String> {
export::assemble_typst_source_profiled(layout, h, root_id, status_floor, tag, profiles)
.map_err(|e| Error::Store(format!("assemble: {e:#}")))
}
/// Parse `--status` against the canonical workflow ladder.
/// Lowercased; returns the **index** into [`STATUS_LADDER`] (a
/// higher index = more advanced). None → no floor applied.
fn parse_status_floor(s: Option<&str>) -> Result<Option<usize>> {
let Some(raw) = s else { return Ok(None) };
let lowered = raw.trim().to_ascii_lowercase();
match STATUS_LADDER
.iter()
.position(|name| *name == lowered.as_str())
{
Some(i) => Ok(Some(i)),
None => Err(Error::Store(format!(
"export: unknown --status `{raw}`. Valid: {}",
STATUS_LADDER.join(", ")
))),
}
}
/// Canonical status ladder, lowest → highest. Index used by
/// `--status` to compare a paragraph's status against the floor.
/// `none` is the implicit zero rung — paragraphs with no status
/// set sit there.
const STATUS_LADDER: &[&str] = &[
"none", "napkin", "first", "second", "third", "final", "ready",
];
fn write_typst(combined: &str, output: Option<&Path>) -> Result<()> {
match output {
Some(path) => {
std::fs::write(path, combined.as_bytes()).map_err(Error::Io)?;
eprintln!("wrote {} bytes to {}", combined.len(), path.display());
}
None => {
print!("{combined}");
}
}
Ok(())
}
fn write_pdf(combined: &str, output: Option<&Path>) -> Result<()> {
let output = output.ok_or_else(|| {
Error::Store("PDF export needs --output <path.pdf>".into())
})?;
if crate::typst_compile::typst_external_path().is_none() {
return Err(Error::Store(
"the `typst` binary is not on PATH — install it from https://typst.app/ \
or run `inkhaven export typst -o file.typ` and compile manually"
.into(),
));
}
// Write the intermediate .typ alongside the requested PDF so the user can
// inspect / re-compile manually if something is off.
let typ_path: PathBuf = output.with_extension("typ");
std::fs::write(&typ_path, combined.as_bytes()).map_err(Error::Io)?;
let status = Command::new("typst")
.arg("compile")
.arg(&typ_path)
.arg(output)
.status()
.map_err(|e| Error::Store(format!("failed to spawn `typst`: {e}")))?;
if !status.success() {
return Err(Error::Store(format!(
"`typst compile` exited with {status}; intermediate source kept at {}",
typ_path.display()
)));
}
eprintln!("wrote {} (source: {})", output.display(), typ_path.display());
Ok(())
}