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
+93 -2
View File
@@ -196,6 +196,96 @@ pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, Ch
}
}
/// Build a PDF for a **bundle** target (ADR-0030): the multi-lesson combined
/// artifact. Mirrors [`build`]'s contract and gating, but runs phases (a)(c)
/// over **every member lesson independently** (ADR-0030's invariant: each
/// lesson stays independently checkable; the bundle only reads them for
/// assembly). Any member's structural/schema error refuses the whole bundle
/// build — a broken member lesson makes the combined artifact invalid too.
pub fn build_bundle(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, CheckReport) {
let mut diags = Vec::new();
let (bundle, load_diags) = cph_model::load_bundle(root);
diags.extend(load_diags);
let Some(bundle) = bundle else {
return (
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: false,
},
);
};
let known = cph_schema::known_kinds();
for member in &bundle.lessons {
run_structural_and_schema(&member.lesson, known, &mut diags);
}
if diags.iter().any(|d| d.severity == Severity::Error) {
return (
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
);
}
match engine.build_bundle_pdf(&bundle, target) {
Ok(bytes) => (
Some(bytes),
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
),
Err(compile_diags) => {
diags.extend(compile_diags);
(
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
)
}
}
}
/// The bundle's declared export-target names, in declared order — or
/// `[DEFAULT_TARGET]` if it declares none (ADR-0030 batch default, mirroring
/// [`declared_target_names`]).
pub fn declared_bundle_target_names(root: &Path) -> Vec<String> {
let (bundle, _) = cph_model::load_bundle(root);
match bundle {
Some(b) if !b.targets.is_empty() => {
b.target_names().into_iter().map(String::from).collect()
}
_ => vec![DEFAULT_TARGET.to_string()],
}
}
/// The lesson's declared export-target names, in declared order — or
/// `[DEFAULT_TARGET]` if it declares none (ADR-0030 batch default: `cph build`
/// with no `--target` builds every declared target).
///
/// Loads the lesson read-only, ignoring diagnostics: an unloadable lesson (or
/// one with a malformed root manifest) still yields `[DEFAULT_TARGET]` here so
/// the caller's subsequent per-target build attempt is what surfaces the real
/// load error — this helper only resolves *which names to attempt*, never
/// gates on lesson validity.
pub fn declared_target_names(root: &Path) -> Vec<String> {
let (lesson, _) = cph_model::load(root);
match lesson {
Some(l) if !l.targets.is_empty() => {
l.target_names().into_iter().map(String::from).collect()
}
_ => vec![DEFAULT_TARGET.to_string()],
}
}
/// One shell step's execution outcome (for [`run_shell_target`]).
#[derive(Debug, Clone, PartialEq)]
pub struct ShellStepOutcome {
@@ -441,8 +531,9 @@ pub struct MarkdownAssembleReport {
}
/// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each
/// element's `<field>.md` markdown content file in `[[parts]]` order into the
/// target's single-file artifact. This is the **third typed step**: unlike
/// element's `<field>.md` markdown content file in `parts` order (ADR-0029's
/// depth-first element sequence) into the target's single-file artifact.
/// This is the **third typed step**: unlike
/// [`build`] (typst template → PDF) the framework owns the read/concatenate/write
/// itself (not a typst compile, not an external tool like [`run_shell_target`]).
///
+11 -11
View File
@@ -59,7 +59,7 @@ name = "broken"
[info]
title = "broken"
[[parts]]
[[children]]
kind = "frob"
path = "elements/widget"
"#,
@@ -123,7 +123,7 @@ name = "broken"
[info]
title = "broken"
[[parts]]
[[children]]
kind = "segment"
path = "segments/does-not-exist"
"#,
@@ -150,7 +150,7 @@ name = "cov"
[info]
title = "cov"
[[parts]]
[[children]]
kind = "segment"
path = "segments/intro"
@@ -276,7 +276,7 @@ name = "sh"
[info]
title = "sh"
[[parts]]
[[children]]
kind = "segment"
path = "segments/intro"
@@ -350,7 +350,7 @@ name = "sh"
[info]
title = "sh"
[[parts]]
[[children]]
kind = "segment"
path = "segments/missing"
@@ -390,7 +390,7 @@ fn write_markdown_assemble_target_lesson(tmp: &Path, slides: &[(&str, &str)]) {
parts.push('\n');
}
parts.push_str(&format!(
"[[parts]]\nkind = \"segment\"\npath = \"segments/{name}\"\n"
"[[children]]\nkind = \"segment\"\npath = \"segments/{name}\"\n"
));
}
std::fs::write(
@@ -468,8 +468,8 @@ fn run_markdown_assemble_target_skips_parts_without_the_field() {
// Only the first segment has a slides.md; the second is skipped (optional).
let tmp = tempdir();
let mut parts = String::new();
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n\n");
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/b\"\n");
parts.push_str("[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n\n");
parts.push_str("[[children]]\nkind = \"segment\"\npath = \"segments/b\"\n");
std::fs::write(
tmp.join("manifest.toml"),
format!(
@@ -527,7 +527,7 @@ name = "md"
[info]
title = "md"
[[parts]]
[[children]]
kind = "segment"
path = "segments/a"
@@ -584,7 +584,7 @@ name = "md"
[info]
title = "md"
[[parts]]
[[children]]
kind = "segment"
path = "segments/a"
@@ -630,7 +630,7 @@ name = "md"
[info]
title = "md"
[[parts]]
[[children]]
kind = "segment"
path = "segments/missing"