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
+90 -42
View File
@@ -1,36 +1,45 @@
//! [`LessonWorld`] — a typst [`World`] over an engineering-file directory tree
//! plus an in-memory generated driver and a disk-mounted `cph-render` package.
//! plus an in-memory augmented manifest and a disk-mounted `cph-render` package.
//!
//! ## Model (ADR-0011)
//!
//! The `main` file is the target's **template** (e.g. `<root>/exports/student.typ`),
//! a *real* file under the lesson root — no framework-generated driver. The
//! template `toml()`-reads an injected manifest and `include`s each part's
//! content via computed root-relative paths. The engine injects the manifest as
//! a virtual file (see below) and sets `sys.inputs.manifest` to its vpath.
//!
//! ## File resolution
//!
//! - **The driver** (`main()`): mounted in-memory at `VirtualRoot::Project` +
//! vpath `/.cph/driver-<target>.typ`. `source()` returns it from the field;
//! it is never written to disk. Its root-relative `include "/segments/…"`
//! paths therefore anchor at the lesson root.
//! - **Project files**: any `FileId` with `VirtualRoot::Project` is read from
//! `root.join(vpath.realize)`. Used for content `.typ`, images, `toml()`, etc.
//! - **`@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).
//! - **The template** (`main()`): a `FileId` with `VirtualRoot::Project` whose
//! vpath is the template's path under the lesson root (e.g. `/exports/student.typ`).
//! It is read from disk like any other project file; its `include "/<part>/…"`
//! paths anchor at the lesson root (`--root`).
//! - **The augmented manifest**: served in-memory at `VirtualRoot::Project` +
//! vpath [`MANIFEST_VPATH`] (`/.cph/manifest.toml`). It is **never** written to
//! disk — the engine builds it (original manifest + a per-part `fields` array)
//! and the template reads it via `toml(sys.inputs.manifest)`.
//! - **Project files**: any other `FileId` with `VirtualRoot::Project` is read
//! from `root.join(vpath.realize)`. Used for content `.typ`, images,
//! `element.toml` `toml()`, etc.
//! - **`@local/cph-render`**: a `FileId` whose root is the render package spec is
//! read from `render_dir.join(vpath.realize)`, avoiding installing the package
//! into the typst local-package directory.
//! - **`@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.
//! preview tree at `<render_dir>/vendor/typst-packages/preview/<name>/<version>/`.
//! The render package imports `@preview/numbly:0.1.0`; reading it from the
//! vendored dir keeps compilation network-free.
//! - 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.
//! Sources are cached in a `Mutex<HashMap<FileId, Source>>` so repeated accesses
//! during one compilation are cheap, as the `World` contract expects.
use std::collections::HashMap;
use std::path::PathBuf;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use typst::diag::{FileError, FileResult};
use typst::foundations::{Bytes, Datetime, Duration};
use typst::foundations::{Bytes, Datetime, Dict, Duration, Value};
use typst::syntax::package::{PackageSpec, PackageVersion};
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
use typst::text::{Font, FontBook};
@@ -38,9 +47,15 @@ use typst::utils::LazyHash;
use typst::{Library, LibraryExt, World};
use typst_kit::fonts::FontStore;
use crate::driver::DRIVER_DIR;
/// Root-relative vpath the augmented manifest is served at (in-memory only).
///
/// A **leading slash** is essential: the template lives under `exports/`, and
/// `toml(sys.inputs.manifest)` resolves a relative path against the template's
/// own directory — a bare name would miss. A root-relative absolute path anchors
/// at `--root` (the lesson root) regardless of where the template sits.
pub const MANIFEST_VPATH: &str = "/.cph/manifest.toml";
/// The package spec the driver imports and the World mounts from `render_dir`.
/// The package spec the template imports and the World mounts from `render_dir`.
pub fn render_package_spec() -> PackageSpec {
PackageSpec {
namespace: "local".into(),
@@ -61,11 +76,13 @@ pub struct LessonWorld {
render_dir: PathBuf,
/// The render package spec (`@local/cph-render:0.1.0`).
render_spec: PackageSpec,
/// FileId of the in-memory driver entrypoint.
/// FileId of the template entrypoint (a real file under `root`).
main: FileId,
/// The driver source, parsed once.
driver_source: Source,
/// Standard library (default configuration).
/// FileId of the in-memory augmented manifest.
manifest_id: FileId,
/// The augmented-manifest source (in-memory; never on disk).
manifest_source: Source,
/// Standard library, with `sys.inputs.manifest` set.
library: LazyHash<Library>,
/// Shared font store (book + lazily-loaded fonts).
fonts: Arc<FontStore>,
@@ -74,27 +91,42 @@ pub struct LessonWorld {
}
impl LessonWorld {
/// Build a world. `driver_src` is the generated entrypoint text; it is
/// mounted in-memory at `/.cph/driver-<target>.typ` under the project root.
/// Build a world whose `main` is the template at `<root>/<template>` and
/// whose injected manifest is `manifest_src` (served virtually at
/// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there).
///
/// `template` is the lesson-root-relative template path (e.g.
/// `exports/student.typ`), taken from the target's `Step::TypstCompile`.
pub fn new(
root: PathBuf,
render_dir: PathBuf,
target: &str,
driver_src: String,
template: &Path,
manifest_src: String,
fonts: Arc<FontStore>,
) -> Self {
let driver_vpath = VirtualPath::new(format!("/{DRIVER_DIR}/driver-{target}.typ"))
.expect("driver vpath is a valid virtual path");
let main = FileId::new(RootedPath::new(VirtualRoot::Project, driver_vpath));
let driver_source = Source::new(main, driver_src);
let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template)))
.expect("template vpath is a valid virtual path");
let main = FileId::new(RootedPath::new(VirtualRoot::Project, main_vpath));
let manifest_vpath =
VirtualPath::new(MANIFEST_VPATH).expect("manifest vpath is a valid virtual path");
let manifest_id = FileId::new(RootedPath::new(VirtualRoot::Project, manifest_vpath));
let manifest_source = Source::new(manifest_id, manifest_src);
// Inject `sys.inputs.manifest = "/.cph/manifest.toml"` so the template's
// `toml(sys.inputs.manifest)` reads the augmented manifest.
let mut inputs = Dict::new();
inputs.insert("manifest".into(), Value::Str(MANIFEST_VPATH.into()));
let library = Library::builder().with_inputs(inputs).build();
Self {
root,
render_dir,
render_spec: render_package_spec(),
main,
driver_source,
library: LazyHash::new(Library::default()),
manifest_id,
manifest_source,
library: LazyHash::new(library),
fonts,
sources: Mutex::new(HashMap::new()),
}
@@ -131,7 +163,8 @@ impl LessonWorld {
}
}
/// Read raw bytes for `id` from disk (driver excluded — handled by callers).
/// Read raw bytes for `id` from disk (virtual files excluded — handled by
/// callers).
fn read_bytes(&self, id: FileId) -> FileResult<Vec<u8>> {
let path = self.resolve(id)?;
std::fs::read(&path).map_err(|e| FileError::from_io(e, &path))
@@ -152,8 +185,8 @@ impl World for LessonWorld {
}
fn source(&self, id: FileId) -> FileResult<Source> {
if id == self.main {
return Ok(self.driver_source.clone());
if id == self.manifest_id {
return Ok(self.manifest_source.clone());
}
// Cache hit?
if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) {
@@ -170,8 +203,8 @@ impl World for LessonWorld {
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
if id == self.main {
return Ok(Bytes::from_string(self.driver_source.text().to_string()));
if id == self.manifest_id {
return Ok(Bytes::from_string(self.manifest_source.text().to_string()));
}
let bytes = self.read_bytes(id)?;
Ok(Bytes::new(bytes))
@@ -186,3 +219,18 @@ impl World for LessonWorld {
None
}
}
/// Render a relative `Path` as a forward-slash string, dropping any leading
/// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim.
fn path_to_forward_slash(path: &Path) -> String {
let mut out = String::new();
for comp in path.components() {
if let Component::Normal(s) = comp {
if !out.is_empty() {
out.push('/');
}
out.push_str(&s.to_string_lossy());
}
}
out
}