Files
curriculum-project-hub/crates/cph-typst/src/world.rs
T
sjfhsjfh d76f9f9a54 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>
2026-06-22 10:05:03 +08:00

237 lines
9.4 KiB
Rust

//! [`LessonWorld`] — a typst [`World`] over an engineering-file directory tree
//! 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 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>/`.
//! 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.
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use typst::diag::{FileError, FileResult};
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};
use typst::utils::LazyHash;
use typst::{Library, LibraryExt, World};
use typst_kit::fonts::FontStore;
/// 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 template imports and the World mounts from `render_dir`.
pub fn render_package_spec() -> PackageSpec {
PackageSpec {
namespace: "local".into(),
name: "cph-render".into(),
version: PackageVersion {
major: 0,
minor: 1,
patch: 0,
},
}
}
/// A typst `World` for one lesson + target compilation.
pub struct LessonWorld {
/// Engineering-file root (the typst project root).
root: PathBuf,
/// On-disk location of the `cph-render` package.
render_dir: PathBuf,
/// The render package spec (`@local/cph-render:0.1.0`).
render_spec: PackageSpec,
/// FileId of the template entrypoint (a real file under `root`).
main: FileId,
/// 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>,
/// Source cache for on-disk files.
sources: Mutex<HashMap<FileId, Source>>,
}
impl LessonWorld {
/// 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,
template: &Path,
manifest_src: String,
fonts: Arc<FontStore>,
) -> Self {
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,
manifest_id,
manifest_source,
library: LazyHash::new(library),
fonts,
sources: Mutex::new(HashMap::new()),
}
}
/// Map a `FileId` to its on-disk path, honoring the project / render-package
/// / 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() {
VirtualRoot::Project => vpath.realize(&self.root).map_err(Into::into),
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()),
)),
}
}
/// 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))
}
}
impl World for LessonWorld {
fn library(&self) -> &LazyHash<Library> {
&self.library
}
fn book(&self) -> &LazyHash<FontBook> {
self.fonts.book()
}
fn main(&self) -> FileId {
self.main
}
fn source(&self, id: FileId) -> FileResult<Source> {
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) {
return Ok(src.clone());
}
let bytes = self.read_bytes(id)?;
let text = String::from_utf8(bytes).map_err(|_| FileError::InvalidUtf8)?;
let source = Source::new(id, text);
self.sources
.lock()
.expect("sources mutex")
.insert(id, source.clone());
Ok(source)
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
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))
}
fn font(&self, index: usize) -> Option<Font> {
self.fonts.font(index)
}
fn today(&self, _offset: Option<Duration>) -> Option<Datetime> {
// No clock dependence needed; lessons are reproducible.
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
}