Files
curriculum-project-hub/crates/cph-typst/src/manifest.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

125 lines
5.2 KiB
Rust

//! Augmented-manifest construction (ADR-0011).
//!
//! The template (`exports/<target>.typ`) reads the manifest via
//! `toml(sys.inputs.manifest)`, then for each part `include`s its content fields
//! by a **computed** path and reads scalar fields from `<path>/element.toml`.
//! For *optional* content fields the template must know whether the file exists
//! on disk — typst has no file-exists primitive and a missing `include` is a
//! hard error (see the OPEN contract point in `render/templates/student.typ`).
//!
//! The ENGINE has filesystem access, so it closes that gap: it builds an
//! **augmented manifest** = the lesson's `[info]` + ordered `[[parts]]`, with a
//! per-part **`fields` array** listing the content fields whose `<field>.typ`
//! actually exists under the lesson root. The augmented manifest is served as an
//! in-memory virtual file in the [`crate::world::LessonWorld`] (it is **never**
//! written to the user's tree), and injected via `sys.inputs.manifest`.
//!
//! ## `fields` is computed from `cph-schema`
//!
//! `cph-schema` already encodes each kind's content fields
//! ([`cph_schema::KindSchema::content_field_names`]) — the same knowledge the
//! render package exposes as `part-fields`. We reuse it here rather than
//! re-deriving a kind→fields map, so the engine and the template agree on what a
//! kind's content fields are. For each part, a content field is listed in
//! `fields` iff `<root>/<part.path>/<field>.typ` is a real file.
use cph_model::Lesson;
/// Build the augmented-manifest TOML source for `lesson`.
///
/// The result is a self-contained TOML document the template's
/// `toml(sys.inputs.manifest)` reads. It carries `[info]` (title + optional
/// author) and the ordered `[[parts]]`, each with `kind`, `path`, and a
/// `fields = [...]` array of the content fields present on disk (per
/// [`present_fields`]). It does **not** reproduce `[project]` or `[targets.*]`
/// — the template only consumes `info` and `parts`.
pub fn build_augmented_manifest(lesson: &Lesson) -> String {
let mut doc = toml::Table::new();
// [info]
let mut info = toml::Table::new();
info.insert(
"title".to_string(),
toml::Value::String(lesson.info.title.clone()),
);
if let Some(author) = &lesson.info.author {
info.insert("author".to_string(), toml::Value::String(author.clone()));
}
doc.insert("info".to_string(), toml::Value::Table(info));
// [[parts]] — preserve declared order; attach the on-disk `fields` array.
let parts: Vec<toml::Value> = lesson
.parts
.iter()
.map(|part| {
let mut entry = toml::Table::new();
entry.insert("kind".to_string(), toml::Value::String(part.kind.clone()));
entry.insert(
"path".to_string(),
toml::Value::String(path_to_forward_slash(&part.path)),
);
let fields = present_fields(lesson, part)
.into_iter()
.map(toml::Value::String)
.collect();
entry.insert("fields".to_string(), toml::Value::Array(fields));
toml::Value::Table(entry)
})
.collect();
doc.insert("parts".to_string(), toml::Value::Array(parts));
toml::to_string(&doc).expect("augmented manifest serializes")
}
/// The content fields of `part`'s kind whose `<root>/<part.path>/<field>.typ`
/// file exists on disk, in schema order.
///
/// The kind→content-fields knowledge is reused from `cph-schema`
/// ([`cph_schema::schema_for`]); an unknown kind has no schema and yields an
/// empty list (the render package surfaces an unknown kind on its own). Both
/// required and optional content fields are probed — listing a *required* field
/// here is harmless (the template includes required fields unconditionally), and
/// it keeps `fields` a faithful "what exists on disk" record.
fn present_fields(lesson: &Lesson, part: &cph_model::Part) -> Vec<String> {
let Some(schema) = cph_schema::schema_for(&part.kind) else {
return Vec::new();
};
let part_dir = lesson.root.join(&part.path);
schema
.content_field_names()
.into_iter()
.filter(|field| part_dir.join(format!("{field}.typ")).is_file())
.map(str::to_string)
.collect()
}
/// Render a relative `PathBuf` as a forward-slash string, dropping any leading
/// `./` or `/` and ignoring `..` (already rejected by the cph-model loader).
/// Element folder names are UTF-8 (e.g. Chinese) and kept verbatim. The template
/// rebuilds an absolute root-relative include path as `"/" + path + "/" + field`,
/// so `path` must be a clean relative slash path.
fn path_to_forward_slash(path: &std::path::Path) -> String {
use std::path::Component;
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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn forward_slash_path_keeps_unicode() {
let p = std::path::Path::new("segments/开场对照导言");
assert_eq!(path_to_forward_slash(p), "segments/开场对照导言");
}
}