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:
+70
-13
@@ -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")));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user