feat(checker): compile template as main, augmented manifest closes optional-content (WU-D', ADR-0011)

Replace generated-driver compilation with the template model.

cph-typst:
- driver.rs DELETED (generate_driver, static-include driver, numbering-threading).
- New manifest.rs: build_augmented_manifest(&Lesson) emits a TOML doc with [info]
  + ordered [[parts]] each carrying a `fields` array = the kind's content fields
  whose <root>/<path>/<field>.typ exists on disk. Reuses cph-schema's
  content_field_names (new dep; no cycle). This closes the OPEN point WU-C'
  surfaced: typst has no file-exists primitive, so the engine (which has disk
  access) tells the template which optional content (lemma proof) is present.
- World main = the real engineering-file template (<root>/<template>); the
  augmented manifest is served as an in-memory virtual file at /.cph/manifest.toml
  (never written to the tree), injected via sys.inputs.manifest. render_dir kept
  only for @local/cph-render + vendored @preview/numbly resolution.
- Artifact handling: SingleFile+TypstCompile compiles; FileTree, shell-only, and
  undeclared-target are blocking "deferred/not-declared" diagnostics (ADR-0011).
- Engine public signatures unchanged; LessonWorld::new takes template+manifest_src.

cph-check: no source change needed (only uses lesson.targets/target.name +
unchanged Engine methods); pipeline + Legal alignment intact.

Fixtures: mini gets exports/{student,teacher}.typ (from render/templates/) + v2
manifest + a second proof-less lemma to exercise optional content. render-stub
retired (tests use the real render/). Through-template PDFs offline: student 44KB,
teacher 56KB; proof-less lemma compiles clean (optional-content confirmed).
Workspace: fmt + clippy -D warnings + all tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 10:05:03 +08:00
parent 7e15cf34f6
commit d76f9f9a54
15 changed files with 705 additions and 573 deletions
+125 -68
View File
@@ -1,16 +1,32 @@
//! `cph-typst` — typst `World`, driver generation, compile, PDF, span mapping.
//! `cph-typst` — typst `World`, augmented-manifest injection, compile, PDF,
//! span mapping.
//!
//! Owned by **WU-4**. Embeds the typst 0.15 compiler as a library to:
//! - **compile-check** a [`cph_model::Lesson`] (collect typst errors+warnings as
//! [`cph_diag::Diagnostic`]s), and
//! - **compile-check** a [`cph_model::Lesson`] for a target (collect typst
//! errors+warnings as [`cph_diag::Diagnostic`]s), and
//! - **export a PDF** for a render target.
//!
//! The lesson's engineering-file directory tree (ADR-0007) is mounted as the
//! typst project root; a generated *driver* entrypoint (one per target) lives
//! in-memory under `.cph/driver-<target>.typ` and `include`s each part's
//! content files (ADR-0006: a `content` value denotes the module **body**, so
//! the driver uses `include`, never `import`). The driver hands an ordered
//! `parts` array to the `@local/cph-render` package's `display` entry.
//! ## Model (ADR-0011): compile a template, inject a manifest
//!
//! There is **no framework-generated driver** any more. An export target is a
//! build with a typed [`cph_model::Step::TypstCompile`] naming a **template
//! file** (e.g. `exports/student.typ`) that lives in the engineering file. The
//! engine:
//!
//! 1. mounts the lesson's engineering-file tree (ADR-0007) as the typst project
//! root (`--root`);
//! 2. sets the World's `main` to that **real template file** under the root, so
//! diagnostic spans resolve to an authored file;
//! 3. builds an **augmented manifest** (the lesson's `info` + ordered `parts`,
//! each with a `fields` array of the content fields present on disk — see
//! [`manifest`]) and serves it as a virtual in-memory file at
//! [`world::MANIFEST_VPATH`], injecting `sys.inputs.manifest` to point there;
//! 4. lets the template `toml()`-read that manifest and `include` each part's
//! content via computed root-relative paths (legal per ADR-0011).
//!
//! The augmented manifest closes the OPEN optional-content point: typst has no
//! file-exists primitive, so the engine (which has filesystem access) tells the
//! template which optional content fields exist via the per-part `fields` array.
//!
//! ## typst version
//!
@@ -19,25 +35,31 @@
//! VirtualPath)`, and a diagnostic span is a `DiagSpan` (not a bare `Span`).
mod diag;
mod driver;
mod manifest;
mod world;
use std::path::PathBuf;
use cph_diag::{DiagCode, Diagnostic};
use cph_model::{ArtifactKind, Lesson};
use cph_model::{Artifact, Lesson, Step, TargetConfig};
use typst_kit::fonts::{self, FontStore};
use typst_layout::PagedDocument;
use typst_pdf::PdfOptions;
pub use driver::{generate_driver, DRIVER_DIR};
pub use world::{render_package_spec, LessonWorld};
pub use 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
/// of the `cph-render` package.
///
/// Fonts are discovered once (system + embedded) and shared across every
/// compilation via an [`std::sync::Arc`] inside the per-compile [`LessonWorld`].
///
/// `render_dir` is the directory the World resolves `@local/cph-render` (and the
/// vendored `@preview/*`) from. It is **not** where the compiled template comes
/// from: the template is a real file under the lesson root (`<root>/<template>`),
/// found via the normal project-file mapping. `render_dir` only matters for
/// package resolution.
pub struct Engine {
fonts: std::sync::Arc<FontStore>,
render_dir: PathBuf,
@@ -45,15 +67,16 @@ pub struct Engine {
impl Engine {
/// Construct an engine, discovering system + embedded fonts. The render
/// package directory is taken from the `CPH_RENDER_DIR` environment
/// variable when set, otherwise defaults to `<repo>/render` resolved
/// relative to this crate (`CARGO_MANIFEST_DIR/../../render`).
/// package directory is taken from the `CPH_RENDER_DIR` environment variable
/// when set, otherwise defaults to `<repo>/render` resolved relative to this
/// crate (`CARGO_MANIFEST_DIR/../../render`).
pub fn new() -> Self {
Self::with_render_dir(default_render_dir())
}
/// Construct an engine pointing at an explicit `cph-render` package
/// directory (the folder containing `lib.typ` / `typst.toml`).
/// directory (the folder containing `lib.typ` / `typst.toml`, and the
/// vendored `@preview/*` tree under `vendor/`).
pub fn with_render_dir(render_dir: PathBuf) -> Self {
let mut store = FontStore::new();
// System fonts first (so the host's CJK fonts are preferred), then the
@@ -74,14 +97,17 @@ impl Engine {
/// Compile-check `lesson` for `target`, returning every typst diagnostic
/// (errors **and** warnings) mapped to [`Diagnostic`]s. An empty `Vec` means
/// a clean compile. Spans are resolved to files relative to `lesson.root`;
/// spans landing in the generated driver are re-pointed with a hint marking
/// them as driver-internal (a driver-gen bug indicator) rather than dropped.
/// a clean compile. Spans are resolved to files relative to `lesson.root`.
///
/// A request the engine cannot honor (unknown target, or an MVP-unsupported
/// artifact/step shape) short-circuits to a single blocking diagnostic; see
/// [`target_precheck`].
pub fn compile_check(&self, lesson: &Lesson, target: &str) -> Vec<Diagnostic> {
if let Some(blocking) = target_precheck(lesson, target) {
return blocking;
}
let world = self.world_for(lesson, target);
let template = match self.world_for(lesson, target) {
Ok(world) => world,
Err(blocking) => return blocking,
};
let world = template;
let warned = typst::compile::<PagedDocument>(&world);
let mut out = Vec::new();
@@ -93,13 +119,16 @@ impl Engine {
}
/// Build a PDF for `lesson` / `target`. On a clean compile returns the PDF
/// bytes; on fatal errors returns the mapped diagnostics. Compile warnings
/// are not surfaced here (use [`Engine::compile_check`] for those).
/// bytes; on a blocking precheck failure or fatal compile errors returns the
/// mapped diagnostics. Compile warnings are not surfaced here (use
/// [`Engine::compile_check`] for those).
///
/// The bytes are returned, not written: for a [`Artifact::SingleFile`] the
/// artifact's `filepath` is the *default* output location, but the caller
/// (cph-check / the CLI) owns where the PDF lands (and may override with
/// `-o`).
pub fn build_pdf(&self, lesson: &Lesson, target: &str) -> Result<Vec<u8>, Vec<Diagnostic>> {
if let Some(blocking) = target_precheck(lesson, target) {
return Err(blocking);
}
let world = self.world_for(lesson, target);
let world = self.world_for(lesson, target)?;
let warned = typst::compile::<PagedDocument>(&world);
let doc = match warned.output {
Ok(doc) => doc,
@@ -108,15 +137,18 @@ impl Engine {
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors))
}
fn world_for(&self, lesson: &Lesson, target: &str) -> LessonWorld {
let driver_src = generate_driver(lesson, target);
LessonWorld::new(
/// 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 manifest_src = build_augmented_manifest(lesson);
Ok(LessonWorld::new(
lesson.root.clone(),
self.render_dir.clone(),
target,
driver_src,
&template,
manifest_src,
self.fonts.clone(),
)
))
}
}
@@ -126,47 +158,72 @@ impl Default for Engine {
}
}
/// Validate a `(lesson, target)` request before compiling. Returns
/// `Some(blocking_diagnostics)` when the request cannot be honored, or `None`
/// when it is fine to proceed:
/// 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:
///
/// - **Unknown target** (the `--target` name isn't in `lesson.targets`): a
/// `SchemaViolation` error — a target must be declared in the manifest to be
/// built (ADR-0009: an export target is a declared build).
/// - **`FileTree` artifact**: deferred for MVP. ADR-0009 makes the artifact kind
/// decide assembly; only `SingleFile` (one bundled PDF) is implemented, so a
/// `FileTree` target returns a clear `SchemaViolation` rather than wrong
/// output. `SingleFile` proceeds to the normal one-PDF path.
///
/// A target with **no** declared `[targets.*]` config at all is *not* an error
/// here: callers (e.g. `cph-check`) may compile-check a defaulted `"student"`
/// target that the lesson never declared. Only a name that *is* declared but is
/// `FileTree` is blocked; an undeclared name is the "unknown target" error.
fn target_precheck(lesson: &Lesson, target: &str) -> Option<Vec<Diagnostic>> {
match lesson.targets.iter().find(|t| t.name == target) {
None if lesson.targets.is_empty() => {
/// - **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).
/// - **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
/// default; ADR-0011 [`cph_model::Step::default_for`]).
/// - **MVP-unsupported shape**: per ADR-0011 only a [`Artifact::SingleFile`] with
/// a [`Step::TypstCompile`] step is implemented this round. A
/// [`Artifact::FileTree`] artifact, or a target whose (only) step is a
/// [`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
// target. Proceed with render-package defaults (SingleFile).
None
// target. Use the stock template path (matches cph-model's default).
return Ok(PathBuf::from(format!("exports/{target}.typ")));
}
None => Some(vec![Diagnostic::error(
return Err(vec![Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{target}' not declared in manifest"),
)
.with_hint(format!(
"add a `[targets.{target}]` table to manifest.toml, or build a declared target"
))]),
Some(tc) => match tc.artifact {
ArtifactKind::SingleFile => None,
ArtifactKind::FileTree => Some(vec![Diagnostic::error(
DiagCode::SchemaViolation,
"file-tree artifact not yet implemented (MVP supports single-file)",
)
.with_hint(format!(
"set `[targets.{target}].artifact` to \"single-file\" for now"
))]),
},
))]);
};
// MVP supports a single-file artifact only.
if let Artifact::FileTree { .. } = tc.artifact {
return Err(vec![Diagnostic::error(
DiagCode::SchemaViolation,
"file-tree artifact not yet implemented (MVP supports single-file typst-compile)",
)
.with_hint(format!(
"set `[targets.{target}].artifact` to a single-file artifact for now"
))]);
}
template_step(tc).ok_or_else(|| {
vec![Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{target}' has no typst-compile step; only single-file \
typst-compile builds are implemented (MVP)"
),
)
.with_hint(format!(
"add a `typst-compile` step with `template = \"exports/{target}.typ\"`, \
or defer this target (shell/file-tree builds are not yet implemented)"
))]
})
}
/// The template of the first [`Step::TypstCompile`] in `tc`, if any (MVP: a
/// target has one step; a `Shell`-only target yields `None`).
fn template_step(tc: &TargetConfig) -> Option<PathBuf> {
tc.steps.iter().find_map(|s| match s {
Step::TypstCompile { template } => Some(template.clone()),
Step::Shell { .. } => None,
})
}
/// Map a batch of typst diagnostics to `cph-diag` ones against `world`.