//! Augmented-manifest construction (ADR-0011). //! //! The template (`exports/.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 `/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 `.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 `//.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 !lesson.info.authors.is_empty() { let authors = lesson .info .authors .iter() .cloned() .map(toml::Value::String) .collect(); info.insert("author".to_string(), toml::Value::Array(authors)); } doc.insert("info".to_string(), toml::Value::Table(info)); // [[parts]] — preserve declared order; attach the on-disk `fields` array. let parts: Vec = 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 `//.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 { 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/开场对照导言"); } }