feat(checker): align cph-typst + cph-check to export-as-build model (WU-D)

Integrate the WU-B (structured targets) and WU-C (numbly, config) changes.

cph-typst:
- World resolves any @preview/* package from the in-repo vendored dir
  (<render_dir>/vendor/typst-packages/preview/<name>/<version>/), fully offline —
  no typst-kit download. Proven: build_pdf_with_real_render compiles the real
  render package (which imports @preview/numbly:0.1.0) through the embedded
  engine, PDF out.
- Driver threads each target's presentation config: looks up the TargetConfig by
  name, emits `config: (numbering: (heading: (...)))` (escaped) when an override
  is present, else `config: (:)` (render default).
- target_precheck guards artifact/target: FileTree → blocking diagnostic (MVP is
  single-file, deferred per ADR-0009); --target not declared → blocking
  diagnostic; no-targets lesson still defaults through.

cph-check:
- Migrated to Lesson.targets: Vec<TargetConfig> (target_names() / target.name at
  the 6 sites). Pipeline unchanged (already matches ADR-0010 phases). Documented
  that !has_errors() implements Spec.Courseware.Legal (no error-level diagnostic).

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 08:47:13 +08:00
parent 6f46aa708a
commit bd1699a4c0
6 changed files with 193 additions and 11 deletions
+34
View File
@@ -21,6 +21,11 @@
use cph_model::{Lesson, Part};
/// Locate the [`cph_model::TargetConfig`] for `target` by name, if declared.
fn target_config<'a>(lesson: &'a Lesson, target: &str) -> Option<&'a cph_model::TargetConfig> {
lesson.targets.iter().find(|t| t.name == target)
}
/// In-memory directory the generated driver is mounted under (relative to the
/// lesson root). Not written to disk.
pub const DRIVER_DIR: &str = ".cph";
@@ -101,10 +106,39 @@ pub fn generate_driver(lesson: &Lesson, target: &str) -> String {
// Trailing comma already present per element; ensure a 1-element array is
// still an array (typst needs the trailing comma, which we always emit).
s.push_str(" ),\n");
s.push_str(&format!(" config: {},\n", config_dict(lesson, target)));
s.push_str(")\n");
s
}
/// The `config:` dict for `target` (ADR-0009 presentation overrides).
///
/// Looks up the matching [`cph_model::TargetConfig`] by name and emits the
/// file-resident presentation slice the render package understands. For MVP
/// that is `numbering.heading` (an array of numbly pattern strings):
/// `config: (numbering: (heading: ("…", "…")))`. When the target declares no
/// `numbering.heading` override (or the target isn't declared at all), an empty
/// dict `(:)` is emitted so the render package applies its own correct default.
fn config_dict(lesson: &Lesson, target: &str) -> String {
let heading = target_config(lesson, target)
.and_then(|tc| tc.numbering.as_ref())
.and_then(|n| n.heading.as_ref());
match heading {
Some(patterns) => {
let arr = patterns
.iter()
.map(|p| typst_str(p))
.collect::<Vec<_>>()
.join(", ");
// A 1-element typst array needs a trailing comma; always emitting
// one keeps any arity a valid array.
format!("(numbering: (heading: ({arr},)))")
}
None => "(:)".to_string(),
}
}
/// `(title: "…", author: "…")` — author key omitted when absent.
fn info_dict(lesson: &Lesson) -> String {
let mut parts = vec![format!("title: {}", typst_str(&lesson.info.title))];
+51 -2
View File
@@ -24,8 +24,8 @@ mod world;
use std::path::PathBuf;
use cph_diag::Diagnostic;
use cph_model::Lesson;
use cph_diag::{DiagCode, Diagnostic};
use cph_model::{ArtifactKind, Lesson};
use typst_kit::fonts::{self, FontStore};
use typst_layout::PagedDocument;
use typst_pdf::PdfOptions;
@@ -78,6 +78,9 @@ impl Engine {
/// 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.
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 warned = typst::compile::<PagedDocument>(&world);
@@ -93,6 +96,9 @@ impl Engine {
/// bytes; on fatal errors returns the mapped diagnostics. Compile warnings
/// are not surfaced here (use [`Engine::compile_check`] for those).
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 warned = typst::compile::<PagedDocument>(&world);
let doc = match warned.output {
@@ -120,6 +126,49 @@ 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:
///
/// - **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() => {
// Lesson declares no targets; the orchestrator compiles a defaulted
// target. Proceed with render-package defaults (SingleFile).
None
}
None => Some(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"
))]),
},
}
}
/// Map a batch of typst diagnostics to `cph-diag` ones against `world`.
fn map_all(world: &LessonWorld, diags: &[typst::diag::SourceDiagnostic]) -> Vec<Diagnostic> {
diags
+26 -3
View File
@@ -12,8 +12,15 @@
//! - **`@local/cph-render`**: a `FileId` whose root is the render package spec
//! is read from `render_dir.join(vpath.realize)`. This avoids installing the
//! package into the typst local-package directory (MVP: no environment
//! coupling). Any *other* `@package` id yields a `NotFound` error (the MVP
//! render package has no external `@preview` dependencies).
//! coupling).
//! - **`@preview/<name>:<version>`**: resolved offline from the in-repo vendored
//! preview tree at `<render_dir>/vendor/typst-packages/preview/<name>/<version>/`
//! (mirroring typst's preview-cache layout). The render package imports
//! `@preview/numbly:0.1.0` (per-level heading numbering); reading it from the
//! vendored dir keeps compilation network-free with no `typst-kit` package
//! download. This is general — any `@preview/*` resolves from the vendored
//! preview dir; numbly 0.1.0 is the only one currently present.
//! - Any *other* `@package` namespace yields a `NotFound` error.
//!
//! Sources are cached in a `Mutex<HashMap<FileId, Source>>` so repeated
//! accesses during one compilation are cheap, as the `World` contract expects.
@@ -94,7 +101,8 @@ impl LessonWorld {
}
/// Map a `FileId` to its on-disk path, honoring the project / render-package
/// roots. Returns a `FileError` for any other package root.
/// / vendored-`@preview` roots. Returns a `FileError` for any other package
/// root.
fn resolve(&self, id: FileId) -> FileResult<PathBuf> {
let vpath = id.vpath();
match id.root() {
@@ -102,6 +110,21 @@ impl LessonWorld {
VirtualRoot::Package(spec) if *spec == self.render_spec => {
vpath.realize(&self.render_dir).map_err(Into::into)
}
// `@preview/<name>:<version>` → vendored preview tree under the
// render dir. Offline, no download: mirrors typst's preview-cache
// layout so e.g. `@preview/numbly:0.1.0` (imported by the render
// package) reads from
// `<render_dir>/vendor/typst-packages/preview/numbly/0.1.0/`.
VirtualRoot::Package(spec) if spec.namespace == "preview" => {
let pkg_root = self
.render_dir
.join("vendor")
.join("typst-packages")
.join("preview")
.join(spec.name.as_str())
.join(spec.version.to_string());
vpath.realize(&pkg_root).map_err(Into::into)
}
VirtualRoot::Package(spec) => Err(FileError::Package(
typst::diag::PackageError::NotFound(spec.clone()),
)),