forked from EduCraft/curriculum-project-hub
feat(cph): implement nested outline manifest and batch/combined export
ADR-0029 — nested outline manifest, supersedes ADR-0008's flat [[parts]]:
- cph-model: recursive loader over manifest.toml containers / element.toml
leaves; Lesson.parts (pure elements, DFS order) + Lesson.outline (elements
interleaved with section headings at their DFS-open position); rejects
ambiguous/incomplete folders and root-vs-container table misplacement
- cph-diag: new DiagCode::ManifestMalformed for carrier-document structure
errors (discharges an existing TODO)
- cph-typst: augmented manifest now serializes the outline (element/section
entries) instead of a flat parts array
- render/lib.typ: render-lesson renders section headings at their depth
- examples/TH-141 migrated to 5 nested section containers + 3 root segments,
byte-identical element order; smoke-verified via cph check/build + pdftotext
ADR-0030 — batch & combined export, extends ADR-0009/0011:
- cph build with no --target batches every declared target (repeatable
--target for an explicit subset); any target failure => non-zero exit,
per-target ledger, independent per-target execution
- cph-model: bundle.toml loader (directory + [info]/[targets.*]/ordered
lessons with per-lesson target overrides)
- cph-typst: augmented bundle manifest (path-prefixed member outlines),
Engine::{compile_check_bundle,build_bundle_pdf}
- render/lib.typ: render-bundle assembles member lessons under per-lesson
headings, depth-shifts their own section headings, resets example/lemma
counters at each lesson boundary by default
- cph-cli: `cph bundle <path> --target <name>` subcommand, same batching
contract as `cph build`
- new bundle fixtures/tests (cph-model unit + cph-typst through-template PDF
compile), smoke-verified via a real 2-lesson merged PDF
Verification: cargo fmt/clippy/test clean across the workspace (68 tests);
real cph check/build/bundle runs against TH-141 and a bundle fixture, PDF
content inspected via pdftotext.
This commit is contained in:
+173
-14
@@ -35,14 +35,38 @@ enum Command {
|
||||
/// Path to the engineering-file root (the folder with `manifest.toml`).
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Build a PDF for a render target. Exits 1 if the build fails.
|
||||
/// Build one or more render targets. Exits 1 if any target fails.
|
||||
///
|
||||
/// With no `--target`, batches every target the lesson declares
|
||||
/// (ADR-0030): each target builds independently — one failing does not
|
||||
/// stop the rest — and the exit code is non-zero if any target failed.
|
||||
/// Repeat `--target` to build an explicit ordered subset instead.
|
||||
Build {
|
||||
/// Path to the engineering-file root (the folder with `manifest.toml`).
|
||||
path: PathBuf,
|
||||
/// Render target to export.
|
||||
#[arg(long, default_value = "student")]
|
||||
target: String,
|
||||
/// Output PDF path. Defaults to `<PATH>/build/<target>.pdf`.
|
||||
/// Render target(s) to export. Repeatable. Defaults to every target
|
||||
/// the lesson declares (or `student` if it declares none).
|
||||
#[arg(long = "target")]
|
||||
targets: Vec<String>,
|
||||
/// Output path for a *single*-target build. Defaults to
|
||||
/// `<PATH>/build/<target>.pdf`. Rejected when building more than one
|
||||
/// target (ambiguous: which target would it name?).
|
||||
#[arg(short = 'o', long, value_name = "OUT")]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Build one or more bundle targets (ADR-0030): combine an ordered
|
||||
/// arrangement of self-contained lessons (`bundle.toml`) into one
|
||||
/// artifact. Same batching/exit-code contract as `build`.
|
||||
Bundle {
|
||||
/// Path to the bundle root (the folder with `bundle.toml`).
|
||||
path: PathBuf,
|
||||
/// Bundle target(s) to export. Repeatable. Defaults to every target
|
||||
/// the bundle declares (or `student` if it declares none).
|
||||
#[arg(long = "target")]
|
||||
targets: Vec<String>,
|
||||
/// Output path for a *single*-target build. Defaults to
|
||||
/// `<PATH>/build/<target>.pdf`. Rejected when building more than one
|
||||
/// target.
|
||||
#[arg(short = 'o', long, value_name = "OUT")]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
@@ -74,7 +98,8 @@ fn main() -> ExitCode {
|
||||
|
||||
match cli.command {
|
||||
Command::Check { path } => run_check(&path, &engine),
|
||||
Command::Build { path, target, out } => run_build(&path, &engine, &target, out),
|
||||
Command::Build { path, targets, out } => run_build_command(&path, &engine, targets, out),
|
||||
Command::Bundle { path, targets, out } => run_bundle_command(&path, &engine, targets, out),
|
||||
Command::Completions { shell } => run_completions(shell),
|
||||
}
|
||||
}
|
||||
@@ -132,25 +157,76 @@ fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_build(
|
||||
/// Dispatch `cph build` (ADR-0030): with an explicit `--target` (repeatable),
|
||||
/// build exactly that ordered set; with none, batch every target the lesson
|
||||
/// declares. Each target builds **independently** — one failing does not stop
|
||||
/// the rest — and prints a per-target ledger when building more than one.
|
||||
/// Exits non-zero iff **any** target failed to produce its artifact (a build
|
||||
/// failure is a real defect, distinct from the non-blocking `renderIgnored`
|
||||
/// warning class — ADR-0030).
|
||||
fn run_build_command(
|
||||
path: &std::path::Path,
|
||||
engine: &Engine,
|
||||
targets: Vec<String>,
|
||||
out: Option<PathBuf>,
|
||||
) -> ExitCode {
|
||||
let target_list = if targets.is_empty() {
|
||||
cph_check::declared_target_names(path)
|
||||
} else {
|
||||
targets
|
||||
};
|
||||
|
||||
if target_list.len() > 1 && out.is_some() {
|
||||
eprintln!(
|
||||
"error: -o/--out only applies to a single-target build; pass exactly one --target with -o"
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let mut results: Vec<(String, bool)> = Vec::with_capacity(target_list.len());
|
||||
for target in &target_list {
|
||||
if target_list.len() > 1 {
|
||||
eprintln!("=== target '{target}' ===");
|
||||
}
|
||||
let ok = run_build_one(path, engine, target, out.clone());
|
||||
results.push((target.clone(), ok));
|
||||
}
|
||||
|
||||
if target_list.len() > 1 {
|
||||
eprintln!("--- build summary ---");
|
||||
for (target, ok) in &results {
|
||||
eprintln!("{target}: {}", if *ok { "ok" } else { "failed" });
|
||||
}
|
||||
}
|
||||
|
||||
if results.iter().any(|(_, ok)| !ok) {
|
||||
ExitCode::FAILURE
|
||||
} else {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one target, returning whether it succeeded. Routes to the shell,
|
||||
/// markdown-assemble, or typst-compile path per the target's step shape.
|
||||
fn run_build_one(
|
||||
path: &std::path::Path,
|
||||
engine: &Engine,
|
||||
target: &str,
|
||||
out: Option<PathBuf>,
|
||||
) -> ExitCode {
|
||||
) -> bool {
|
||||
// A target whose steps are shell commands (a tool-generated asset bundle,
|
||||
// ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by
|
||||
// executing those commands, not by compiling a typst template. Detect that
|
||||
// shape up front and route accordingly.
|
||||
if cph_check::target_is_shell(path, target) {
|
||||
return run_shell_build(path, engine, target);
|
||||
return run_shell_build(path, engine, target) == ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
// A target whose steps assemble markdown (ADR-0015: slides outline / 逐字稿
|
||||
// transcript surfaces) is built by concatenating per-element `<field>.md`
|
||||
// files in parts order, not by compiling a typst template.
|
||||
if cph_check::target_is_markdown_assemble(path, target) {
|
||||
return run_markdown_assemble_build(path, engine, target);
|
||||
return run_markdown_assemble_build(path, engine, target) == ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
|
||||
@@ -166,19 +242,102 @@ fn run_build(
|
||||
"error: cannot create output directory '{}': {e}",
|
||||
parent.display()
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::write(&out_path, &bytes) {
|
||||
eprintln!("error: cannot write '{}': {e}", out_path.display());
|
||||
return ExitCode::FAILURE;
|
||||
return false;
|
||||
}
|
||||
println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
|
||||
ExitCode::SUCCESS
|
||||
true
|
||||
}
|
||||
None => {
|
||||
eprintln!("build failed: {} errors", report.error_count());
|
||||
ExitCode::FAILURE
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch `cph bundle` (ADR-0030) — same batching/exit-code contract as
|
||||
/// [`run_build_command`], over a bundle's own declared targets. MVP bundle
|
||||
/// targets are `typst-compile` only (no shell/markdown-assemble routing —
|
||||
/// ADR-0030 did not extend those step kinds to bundles).
|
||||
fn run_bundle_command(
|
||||
path: &std::path::Path,
|
||||
engine: &Engine,
|
||||
targets: Vec<String>,
|
||||
out: Option<PathBuf>,
|
||||
) -> ExitCode {
|
||||
let target_list = if targets.is_empty() {
|
||||
cph_check::declared_bundle_target_names(path)
|
||||
} else {
|
||||
targets
|
||||
};
|
||||
|
||||
if target_list.len() > 1 && out.is_some() {
|
||||
eprintln!(
|
||||
"error: -o/--out only applies to a single-target build; pass exactly one --target with -o"
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let mut results: Vec<(String, bool)> = Vec::with_capacity(target_list.len());
|
||||
for target in &target_list {
|
||||
if target_list.len() > 1 {
|
||||
eprintln!("=== target '{target}' ===");
|
||||
}
|
||||
let ok = run_bundle_one(path, engine, target, out.clone());
|
||||
results.push((target.clone(), ok));
|
||||
}
|
||||
|
||||
if target_list.len() > 1 {
|
||||
eprintln!("--- build summary ---");
|
||||
for (target, ok) in &results {
|
||||
eprintln!("{target}: {}", if *ok { "ok" } else { "failed" });
|
||||
}
|
||||
}
|
||||
|
||||
if results.iter().any(|(_, ok)| !ok) {
|
||||
ExitCode::FAILURE
|
||||
} else {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one bundle target, returning whether it succeeded.
|
||||
fn run_bundle_one(
|
||||
path: &std::path::Path,
|
||||
engine: &Engine,
|
||||
target: &str,
|
||||
out: Option<PathBuf>,
|
||||
) -> bool {
|
||||
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
|
||||
|
||||
let (pdf, report) = cph_check::build_bundle(path, engine, target);
|
||||
print_diagnostics(&report);
|
||||
|
||||
match pdf {
|
||||
Some(bytes) => {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
eprintln!(
|
||||
"error: cannot create output directory '{}': {e}",
|
||||
parent.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::write(&out_path, &bytes) {
|
||||
eprintln!("error: cannot write '{}': {e}", out_path.display());
|
||||
return false;
|
||||
}
|
||||
println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
|
||||
true
|
||||
}
|
||||
None => {
|
||||
eprintln!("build failed: {} errors", report.error_count());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user