forked from bai/curriculum-project-hub
d76f9f9a54
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>
237 lines
9.4 KiB
Rust
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
|
|
}
|