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:
2026-08-04 20:31:05 +08:00
committed by 洪佳荣
parent e0bd6120ec
commit 9927d38c18
165 changed files with 2638 additions and 691 deletions
+173 -14
View File
@@ -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
}
}
}