forked from EduCraft/curriculum-project-hub
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:
@@ -0,0 +1,124 @@
|
||||
//! 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/开场对照导言");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user