//! Augmented-manifest construction (ADR-0011, outline shape per ADR-0029). //! //! The template (`exports/.typ`) reads the manifest via //! `toml(sys.inputs.manifest)`, then for each **element** outline entry //! `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]` + the ordered `[[outline]]` //! (ADR-0029's depth-first rendering order — elements interleaved with section //! headings at their DFS-open position). Each `[[outline]]` entry carries a //! `type` discriminator (`"element"` | `"section"`): //! //! - `type = "element"`: `kind`, `path`, and a per-part **`fields` array** //! listing the content fields whose `.typ` actually exists under the //! lesson root (same contract as before ADR-0029). //! - `type = "section"`: `kind`, `title`, `depth`, `path` — a section heading; //! the template renders it without touching any content file. //! //! 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 element, a content field is listed in //! `fields` iff `//.typ` is a real file. use std::path::Path; use cph_model::{Bundle, BundleLesson, Lesson, OutlineEntry}; /// 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 `[[outline]]` (ADR-0029's depth-first rendering /// order), each entry typed `"element"` or `"section"` per the module docs. It /// does **not** reproduce `[project]` or `[targets.*]` — the template only /// consumes `info` and `outline`. pub fn build_augmented_manifest(lesson: &Lesson) -> String { let mut doc = toml::Table::new(); doc.insert("info".to_string(), toml::Value::Table(info_table(lesson))); // [[outline]] — ADR-0029's depth-first rendering order: elements // interleaved with section headings at their DFS-open position. let outline: Vec = lesson .outline .iter() .map(|entry| toml::Value::Table(outline_entry_table(lesson, entry, None))) .collect(); doc.insert("outline".to_string(), toml::Value::Array(outline)); toml::to_string(&doc).expect("augmented manifest serializes") } /// Build the augmented **bundle** manifest TOML source for `bundle` (ADR-0030). /// /// The bundle template (`exports/.typ` under the `bundle.toml` root) /// reads it via `toml(sys.inputs.manifest)`. It carries `[info]` (the bundle's /// own title/author) and the ordered `[[lessons]]`, each a /// `(info, target, outline)` table — the same shape a single-lesson template /// would assemble, except every outline entry's `path` is **prefixed with that /// lesson's own bundle-root-relative directory** (`BundleLesson::path`), since /// the bundle template's computed include paths resolve against the *bundle* /// root, not each lesson's own root (ADR-0030: combination reads /// already-authored lessons at export time; each lesson's `path` bookkeeping /// stays correct because the prefix is applied only here, in the manifest the /// template consumes — never inside a lesson's own authored content). pub fn build_augmented_bundle_manifest(bundle: &Bundle) -> String { let mut doc = toml::Table::new(); let mut info = toml::Table::new(); info.insert( "title".to_string(), toml::Value::String(bundle.info.title.clone()), ); if !bundle.info.authors.is_empty() { let authors = bundle .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)); let lessons: Vec = bundle .lessons .iter() .map(|bl| toml::Value::Table(bundle_lesson_table(bl))) .collect(); doc.insert("lessons".to_string(), toml::Value::Array(lessons)); toml::to_string(&doc).expect("augmented bundle manifest serializes") } /// Build one `[[lessons]]` entry's table: that member lesson's own `info`, /// its selected `target`, and its outline with every entry's `path` prefixed /// by the lesson's bundle-relative directory. fn bundle_lesson_table(bl: &BundleLesson) -> toml::Table { let mut t = toml::Table::new(); t.insert( "info".to_string(), toml::Value::Table(info_table(&bl.lesson)), ); t.insert("target".to_string(), toml::Value::String(bl.target.clone())); let outline: Vec = bl .lesson .outline .iter() .map(|entry| toml::Value::Table(outline_entry_table(&bl.lesson, entry, Some(&bl.path)))) .collect(); t.insert("outline".to_string(), toml::Value::Array(outline)); t } /// Build the `[info]` table shared by a single-lesson manifest and a bundle /// member's `info` entry. fn info_table(lesson: &Lesson) -> toml::Table { 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)); } info } /// Build one `[[outline]]` entry's table for either variant of /// [`OutlineEntry`]. `bundle_prefix`, when set (ADR-0030's bundle case), is /// joined onto the emitted `path` so the bundle template's computed include /// resolves against the bundle root rather than the lesson's own root. fn outline_entry_table( lesson: &Lesson, entry: &OutlineEntry, bundle_prefix: Option<&Path>, ) -> toml::Table { let mut e = toml::Table::new(); match entry { OutlineEntry::Element { part_index, .. } => { let part = &lesson.parts[*part_index]; e.insert("type".to_string(), toml::Value::String("element".into())); e.insert("kind".to_string(), toml::Value::String(part.kind.clone())); e.insert( "path".to_string(), toml::Value::String(prefixed_forward_slash(bundle_prefix, &part.path)), ); let fields = present_fields(lesson, part) .into_iter() .map(toml::Value::String) .collect(); e.insert("fields".to_string(), toml::Value::Array(fields)); } OutlineEntry::Section { kind, title, depth, path, notes: _, } => { e.insert("type".to_string(), toml::Value::String("section".into())); e.insert("kind".to_string(), toml::Value::String(kind.clone())); e.insert("title".to_string(), toml::Value::String(title.clone())); e.insert("depth".to_string(), toml::Value::Integer(i64::from(*depth))); e.insert( "path".to_string(), toml::Value::String(prefixed_forward_slash(bundle_prefix, path)), ); } } e } /// [`path_to_forward_slash`], with `prefix` (a bundle member's own /// bundle-relative directory) joined in front when present. fn prefixed_forward_slash(prefix: Option<&Path>, path: &Path) -> String { match prefix { Some(p) => path_to_forward_slash(&p.join(path)), None => path_to_forward_slash(path), } } /// 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/开场对照导言"); } }