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
+70 -13
View File
@@ -42,12 +42,12 @@ mod world;
use std::path::PathBuf;
use cph_diag::{DiagCode, Diagnostic};
use cph_model::{Artifact, Lesson, Step, TargetConfig};
use cph_model::{Artifact, Bundle, Lesson, Step, TargetConfig};
use typst_kit::fonts::{self, FontStore};
use typst_layout::PagedDocument;
use typst_pdf::PdfOptions;
pub use manifest::build_augmented_manifest;
pub use manifest::{build_augmented_bundle_manifest, build_augmented_manifest};
pub use world::{render_package_spec, LessonWorld, MANIFEST_VPATH};
/// The compile/PDF engine: holds the shared font store and the on-disk location
@@ -142,7 +142,7 @@ impl Engine {
/// Build the [`LessonWorld`] for `(lesson, target)`, or `Err(blocking)` when
/// the request cannot be honored (see [`target_precheck`]).
fn world_for(&self, lesson: &Lesson, target: &str) -> Result<LessonWorld, Vec<Diagnostic>> {
let template = target_precheck(lesson, target)?;
let template = target_precheck(target, &lesson.targets)?;
let manifest_src = build_augmented_manifest(lesson);
Ok(LessonWorld::new(
lesson.root.clone(),
@@ -152,6 +152,60 @@ impl Engine {
self.fonts.clone(),
))
}
/// Compile-check `bundle` for `target` (ADR-0030) — same contract as
/// [`Engine::compile_check`], but over a [`Bundle`]'s own declared targets
/// and the augmented **bundle** manifest (each member lesson's outline,
/// path-prefixed to resolve against the bundle root).
pub fn compile_check_bundle(&self, bundle: &Bundle, target: &str) -> Vec<Diagnostic> {
let world = match self.world_for_bundle(bundle, target) {
Ok(world) => world,
Err(blocking) => return blocking,
};
let warned = typst::compile::<PagedDocument>(&world);
let mut out = Vec::new();
if let Err(errors) = &warned.output {
out.extend(map_all(&world, errors));
}
out.extend(map_all(&world, &warned.warnings));
out
}
/// Build a PDF for `bundle` / `target` (ADR-0030) — same contract as
/// [`Engine::build_pdf`], over a [`Bundle`]'s own declared targets.
pub fn build_bundle_pdf(
&self,
bundle: &Bundle,
target: &str,
) -> Result<Vec<u8>, Vec<Diagnostic>> {
let world = self.world_for_bundle(bundle, target)?;
let warned = typst::compile::<PagedDocument>(&world);
let doc = match warned.output {
Ok(doc) => doc,
Err(errors) => return Err(map_all(&world, &errors)),
};
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors))
}
/// Build the [`LessonWorld`] for `(bundle, target)`: same shape as
/// [`Engine::world_for`], main file resolved under the **bundle** root and
/// the injected manifest built by [`build_augmented_bundle_manifest`].
fn world_for_bundle(
&self,
bundle: &Bundle,
target: &str,
) -> Result<LessonWorld, Vec<Diagnostic>> {
let template = target_precheck(target, &bundle.targets)?;
let manifest_src = build_augmented_bundle_manifest(bundle);
Ok(LessonWorld::new(
bundle.root.clone(),
self.render_dir.clone(),
&template,
manifest_src,
self.fonts.clone(),
))
}
}
impl Default for Engine {
@@ -160,13 +214,16 @@ impl Default for Engine {
}
}
/// Validate a `(lesson, target)` request and resolve the template path to
/// compile. Returns `Ok(template_path)` (relative to the lesson root) when the
/// request is buildable, or `Err(blocking_diagnostics)` when it is not:
/// Validate a `(targets, target)` request and resolve the template path to
/// compile. Shared by [`Engine::world_for`] (a lesson's `targets`) and
/// [`Engine::world_for_bundle`] (a bundle's own `targets` — ADR-0030 gives a
/// bundle target the exact same build/artifact/step shape). Returns
/// `Ok(template_path)` (relative to the lesson/bundle root) when the request is
/// buildable, or `Err(blocking_diagnostics)` when it is not:
///
/// - **Unknown target** (the `--target` name isn't in `lesson.targets`, and the
/// lesson declares at least one target): a `SchemaViolation` error — a target
/// must be declared in the manifest to be built (ADR-0009).
/// - **Unknown target** (the `--target` name isn't in `targets`, and `targets`
/// is non-empty): a `SchemaViolation` error — a target must be declared in
/// the manifest to be built (ADR-0009).
/// - **No declared targets at all**: not an error — callers (e.g. `cph-check`)
/// may compile-check a defaulted `"student"` target the lesson never declared.
/// The stock template path `exports/<target>.typ` is used (the framework
@@ -177,10 +234,10 @@ impl Default for Engine {
/// [`Step::Shell`], returns a clear "not yet implemented" `SchemaViolation`
/// rather than wrong output. The template is taken from the **first**
/// `TypstCompile` step (MVP: one step per target).
fn target_precheck(lesson: &Lesson, target: &str) -> Result<PathBuf, Vec<Diagnostic>> {
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else {
if lesson.targets.is_empty() {
// Lesson declares no targets; the orchestrator compiles a defaulted
fn target_precheck(target: &str, targets: &[TargetConfig]) -> Result<PathBuf, Vec<Diagnostic>> {
let Some(tc) = targets.iter().find(|t| t.name == target) else {
if targets.is_empty() {
// Declares no targets; the orchestrator compiles a defaulted
// target. Use the stock template path (matches cph-model's default).
return Ok(PathBuf::from(format!("exports/{target}.typ")));
}