forked from EduCraft/curriculum-project-hub
5dc29fe558
一节课可多人署名(教研组),故 canonical author 是**有序列表**而非单值。on-disk 形式接受裸字符串(单作者)或数组(多作者),但此"字符串或数组"二态**只活在加载边界**: RawInfo 经归一化折叠成 canonical Info,其后系统只见 List。 Lean 母本(ADR-0008 范围):新增 Model/Info.lean,把 Info(canonical,authors: List String)与 RawInfo/RawAuthor(授权便利 surface)+ 归一化 RawInfo.toInfo 钉死—— canonical 接收端恒为列表,raw 形式不泄漏。ADR-0008 [info] 块补 author-as-list 决策。 impl 对齐:cph-model Info.author: Option<String> → authors: Vec<String>;RawAuthor untagged enum(One|Many)+ into_vec 归一化;augmented manifest 发射 author 数组 (typst document(author:) 与模板已接受 string/array)。doc 两端互引 spec。 测试:load.rs 单作者→单元素列表;target-configs fixture 加数组作者断言多作者。 验证:lake build 绿(25 jobs);cargo test 全绿;clippy 静默;TH-141(单字符串作者) check+build PDF 无回归。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
132 lines
5.3 KiB
Rust
132 lines
5.3 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 !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<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/开场对照导言");
|
|
}
|
|
}
|