diff --git a/Cargo.lock b/Cargo.lock index 7a97ef7..11e5355 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,7 +410,9 @@ dependencies = [ "clap_complete", "cph-check", "cph-diag", + "cph-model", "cph-typst", + "serde_json", ] [[package]] diff --git a/README.md b/README.md index 02669f1..9192400 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,16 @@ cph check <工程目录> # 校验合法性(7 类诊断) cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF ``` +```sh +cph outline <工程目录> # 默认写入 <工程目录>/outline.pdf +cph outline <工程目录> --format md # 或 json / pdf +cph outline <工程目录> --format pdf --force # 明确允许覆盖已有 outline.pdf +``` + +大纲节点来自根及各级容器 `manifest.toml` 的 `[[children]]`;可在 child 上填写多行 +`notes = """…"""` 作为教师备课提示。它会进入 outline 的 JSON/Markdown, +并在 PDF 中以独立的“教学提示”区域呈现,不会混入学生/教师讲义正文。 + **版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。 Shell 补全(可选): diff --git a/crates/README.md b/crates/README.md index 237c8a5..e47838a 100644 --- a/crates/README.md +++ b/crates/README.md @@ -3,10 +3,12 @@ These crates implement the rule-based lesson checker whose semantics are pinned by the ADRs in `docs/adr/`: it reads an engineering-file (one lesson, ADR-0005) -laid out per ADR-0008 (declarative `manifest.toml` + per-element -`element.toml`), validates structure and content, and emits diagnostics. -`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader), -and `cph-typst` (the typst `World` / compile / span-mapping layer) are +laid out per ADR-0029 (a nested outline manifest — every container folder +carries `manifest.toml`, every leaf carries `element.toml`; supersedes +ADR-0008's flat `[[parts]]`), validates structure and content, and emits +diagnostics. `cph-diag` (the shared diagnostic vocabulary), `cph-model` (the +ADR-0029 loader, also loading `bundle.toml` arrangements per ADR-0030), and +`cph-typst` (the typst `World` / compile / span-mapping layer) are deliberately reusable by future components such as an `exporter`, which is why they live in this repo-wide `crates/` directory rather than under any single component; `cph-schema` (kind JSON Schemas + validation), `cph-check` @@ -18,7 +20,7 @@ entrypoint) are the checker proper. | crate | owner | role | |---------------|-------|------| | `cph-diag` | WU-1 | shared diagnostic vocabulary (`Severity`, `DiagCode`, `Diagnostic`, `SourceSpan`) — reusable | -| `cph-model` | WU-1 | parses the ADR-0008 layout into an in-memory ordered `Lesson` — reusable | +| `cph-model` | WU-1 | parses the ADR-0029 nested outline layout (+ ADR-0030 bundles) into an in-memory ordered `Lesson`/`Bundle` — reusable | | `cph-schema` | WU-3 | the 4 stdlib kind JSON Schemas + structural validation | | `cph-typst` | WU-4 | typst `World`, driver generation, compile, PDF, span mapping — reusable | | `cph-check` | WU-5 | orchestration: render-coverage and the full check pipeline | diff --git a/crates/cph-check/src/lib.rs b/crates/cph-check/src/lib.rs index c925c63..e9163ce 100644 --- a/crates/cph-check/src/lib.rs +++ b/crates/cph-check/src/lib.rs @@ -130,6 +130,39 @@ pub fn check(root: &Path, engine: &Engine) -> CheckReport { } } +/// Load and validate the lesson, then project it into an outline. +/// +/// Outline output is derived from the manifest and part metadata, not from the +/// rendered lesson body. It therefore runs the same load → structural → schema +/// gates as other non-typst builds, but intentionally does not compile any +/// target. An invalid lesson is never written in any outline format. +pub fn outline(root: &Path) -> (Option, CheckReport) { + let mut diags = Vec::new(); + let (lesson, load_diags) = cph_model::load(root); + diags.extend(load_diags); + + let Some(lesson) = lesson else { + return ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: false, + }, + ); + }; + + run_structural_and_schema(&lesson, cph_schema::known_kinds(), &mut diags); + let report = CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }; + if report.has_errors() { + (None, report) + } else { + (Some(lesson.outline_document()), report) + } +} + /// Build a PDF for `target`. /// /// Runs the check phases **(a)–(c)** (load → structural → schema). If those @@ -196,6 +229,96 @@ pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option>, Ch } } +/// Build a PDF for a **bundle** target (ADR-0030): the multi-lesson combined +/// artifact. Mirrors [`build`]'s contract and gating, but runs phases (a)–(c) +/// over **every member lesson independently** (ADR-0030's invariant: each +/// lesson stays independently checkable; the bundle only reads them for +/// assembly). Any member's structural/schema error refuses the whole bundle +/// build — a broken member lesson makes the combined artifact invalid too. +pub fn build_bundle(root: &Path, engine: &Engine, target: &str) -> (Option>, CheckReport) { + let mut diags = Vec::new(); + + let (bundle, load_diags) = cph_model::load_bundle(root); + diags.extend(load_diags); + + let Some(bundle) = bundle else { + return ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: false, + }, + ); + }; + + let known = cph_schema::known_kinds(); + for member in &bundle.lessons { + run_structural_and_schema(&member.lesson, known, &mut diags); + } + + if diags.iter().any(|d| d.severity == Severity::Error) { + return ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + ); + } + + match engine.build_bundle_pdf(&bundle, target) { + Ok(bytes) => ( + Some(bytes), + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + ), + Err(compile_diags) => { + diags.extend(compile_diags); + ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + ) + } + } +} + +/// The bundle's declared export-target names, in declared order — or +/// `[DEFAULT_TARGET]` if it declares none (ADR-0030 batch default, mirroring +/// [`declared_target_names`]). +pub fn declared_bundle_target_names(root: &Path) -> Vec { + let (bundle, _) = cph_model::load_bundle(root); + match bundle { + Some(b) if !b.targets.is_empty() => { + b.target_names().into_iter().map(String::from).collect() + } + _ => vec![DEFAULT_TARGET.to_string()], + } +} + +/// The lesson's declared export-target names, in declared order — or +/// `[DEFAULT_TARGET]` if it declares none (ADR-0030 batch default: `cph build` +/// with no `--target` builds every declared target). +/// +/// Loads the lesson read-only, ignoring diagnostics: an unloadable lesson (or +/// one with a malformed root manifest) still yields `[DEFAULT_TARGET]` here so +/// the caller's subsequent per-target build attempt is what surfaces the real +/// load error — this helper only resolves *which names to attempt*, never +/// gates on lesson validity. +pub fn declared_target_names(root: &Path) -> Vec { + let (lesson, _) = cph_model::load(root); + match lesson { + Some(l) if !l.targets.is_empty() => { + l.target_names().into_iter().map(String::from).collect() + } + _ => vec![DEFAULT_TARGET.to_string()], + } +} + /// One shell step's execution outcome (for [`run_shell_target`]). #[derive(Debug, Clone, PartialEq)] pub struct ShellStepOutcome { @@ -441,8 +564,9 @@ pub struct MarkdownAssembleReport { } /// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each -/// element's `.md` markdown content file in `[[parts]]` order into the -/// target's single-file artifact. This is the **third typed step**: unlike +/// element's `.md` markdown content file in `parts` order (ADR-0029's +/// depth-first element sequence) into the target's single-file artifact. +/// This is the **third typed step**: unlike /// [`build`] (typst template → PDF) the framework owns the read/concatenate/write /// itself (not a typst compile, not an external tool like [`run_shell_target`]). /// diff --git a/crates/cph-check/tests/pipeline.rs b/crates/cph-check/tests/pipeline.rs index a7e6398..260382c 100644 --- a/crates/cph-check/tests/pipeline.rs +++ b/crates/cph-check/tests/pipeline.rs @@ -45,6 +45,23 @@ fn good_fixture_has_no_errors() { assert!(!report.has_errors()); } +#[test] +fn outline_projects_parts_in_manifest_order() { + let (outline, report) = cph_check::outline(&mini_fixture()); + assert_eq!( + report.error_count(), + 0, + "outline should validate the fixture" + ); + let outline = outline.expect("valid lesson should produce an outline"); + assert_eq!(outline.title, "迷你示例课时"); + assert_eq!(outline.children.len(), 3); + assert_eq!(outline.children[0].title, "开场对照导言"); + assert_eq!(outline.children[1].kind, "section"); + assert_eq!(outline.children[1].children[0].title, "量纲分析估计"); + assert_eq!(outline.children[2].title, "自由落体"); +} + #[test] fn unknown_kind_is_an_error() { // Build a throwaway lesson whose part declares kind "frob". @@ -59,7 +76,7 @@ name = "broken" [info] title = "broken" -[[parts]] +[[children]] kind = "frob" path = "elements/widget" "#, @@ -123,7 +140,7 @@ name = "broken" [info] title = "broken" -[[parts]] +[[children]] kind = "segment" path = "segments/does-not-exist" "#, @@ -150,7 +167,7 @@ name = "cov" [info] title = "cov" -[[parts]] +[[children]] kind = "segment" path = "segments/intro" @@ -276,7 +293,7 @@ name = "sh" [info] title = "sh" -[[parts]] +[[children]] kind = "segment" path = "segments/intro" @@ -350,7 +367,7 @@ name = "sh" [info] title = "sh" -[[parts]] +[[children]] kind = "segment" path = "segments/missing" @@ -390,7 +407,7 @@ fn write_markdown_assemble_target_lesson(tmp: &Path, slides: &[(&str, &str)]) { parts.push('\n'); } parts.push_str(&format!( - "[[parts]]\nkind = \"segment\"\npath = \"segments/{name}\"\n" + "[[children]]\nkind = \"segment\"\npath = \"segments/{name}\"\n" )); } std::fs::write( @@ -468,8 +485,8 @@ fn run_markdown_assemble_target_skips_parts_without_the_field() { // Only the first segment has a slides.md; the second is skipped (optional). let tmp = tempdir(); let mut parts = String::new(); - parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n\n"); - parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/b\"\n"); + parts.push_str("[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n\n"); + parts.push_str("[[children]]\nkind = \"segment\"\npath = \"segments/b\"\n"); std::fs::write( tmp.join("manifest.toml"), format!( @@ -527,7 +544,7 @@ name = "md" [info] title = "md" -[[parts]] +[[children]] kind = "segment" path = "segments/a" @@ -584,7 +601,7 @@ name = "md" [info] title = "md" -[[parts]] +[[children]] kind = "segment" path = "segments/a" @@ -630,7 +647,7 @@ name = "md" [info] title = "md" -[[parts]] +[[children]] kind = "segment" path = "segments/missing" diff --git a/crates/cph-cli/Cargo.toml b/crates/cph-cli/Cargo.toml index b55feb9..2466ff1 100644 --- a/crates/cph-cli/Cargo.toml +++ b/crates/cph-cli/Cargo.toml @@ -11,6 +11,8 @@ path = "src/main.rs" [dependencies] cph-check = { path = "../cph-check" } cph-diag = { workspace = true } +cph-model = { workspace = true } cph-typst = { path = "../cph-typst" } clap = { version = "4", features = ["derive"] } clap_complete = "4" +serde_json = "1" diff --git a/crates/cph-cli/src/main.rs b/crates/cph-cli/src/main.rs index c285028..f4f93a0 100644 --- a/crates/cph-cli/src/main.rs +++ b/crates/cph-cli/src/main.rs @@ -7,11 +7,14 @@ //! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit //! 0); `build` exits 1 when the PDF could not be produced. +use std::fs::OpenOptions; +use std::io::Write; use std::path::PathBuf; use std::process::ExitCode; use clap::{Parser, Subcommand}; use cph_check::CheckReport; +use cph_model::OutlineDocument; use cph_typst::Engine; /// The `cph` checker for curriculum engineering files. @@ -35,17 +38,56 @@ enum Command { /// Path to the engineering-file root (the folder with `manifest.toml`). path: PathBuf, }, - /// Build a PDF for a render target. Exits 1 if the build fails. + /// Build one or more render targets. Exits 1 if any target fails. + /// + /// With no `--target`, batches every target the lesson declares + /// (ADR-0030): each target builds independently — one failing does not + /// stop the rest — and the exit code is non-zero if any target failed. + /// Repeat `--target` to build an explicit ordered subset instead. Build { /// Path to the engineering-file root (the folder with `manifest.toml`). path: PathBuf, - /// Render target to export. - #[arg(long, default_value = "student")] - target: String, - /// Output PDF path. Defaults to `/build/.pdf`. + /// Render target(s) to export. Repeatable. Defaults to every target + /// the lesson declares (or `student` if it declares none). + #[arg(long = "target")] + targets: Vec, + /// Output path for a *single*-target build. Defaults to + /// `/build/.pdf`. Rejected when building more than one + /// target (ambiguous: which target would it name?). #[arg(short = 'o', long, value_name = "OUT")] out: Option, }, + /// Build one or more bundle targets (ADR-0030): combine an ordered + /// arrangement of self-contained lessons (`bundle.toml`) into one + /// artifact. Same batching/exit-code contract as `build`. + Bundle { + /// Path to the bundle root (the folder with `bundle.toml`). + path: PathBuf, + /// Bundle target(s) to export. Repeatable. Defaults to every target + /// the bundle declares (or `student` if it declares none). + #[arg(long = "target")] + targets: Vec, + /// Output path for a *single*-target build. Defaults to + /// `/build/.pdf`. Rejected when building more than one + /// target. + #[arg(short = 'o', long, value_name = "OUT")] + out: Option, + }, + /// Export the teacher-facing outline as Markdown, PDF, or JSON. + Outline { + /// Path to the engineering-file root. Defaults to the current directory. + #[arg(default_value = ".")] + path: PathBuf, + /// Output format. Defaults to PDF. + #[arg(long, value_enum, default_value_t = OutlineFormat::Pdf)] + format: OutlineFormat, + /// Output path. Defaults to `/outline.`. + #[arg(short = 'o', long, value_name = "OUT")] + out: Option, + /// Allow replacing an existing output file. + #[arg(long)] + force: bool, + }, /// Print a shell-completion script to stdout (clap_complete; ADR-0013 opt-in /// sibling: a local convenience, no lesson involved). Pipe to your shell's /// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`. @@ -55,6 +97,23 @@ enum Command { }, } +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +enum OutlineFormat { + Md, + Pdf, + Json, +} + +impl OutlineFormat { + fn extension(self) -> &'static str { + match self { + Self::Md => "md", + Self::Pdf => "pdf", + Self::Json => "json", + } + } +} + #[derive(Debug, Clone, Copy, clap::ValueEnum)] enum CompletionTarget { Bash, @@ -74,7 +133,14 @@ fn main() -> ExitCode { match cli.command { Command::Check { path } => run_check(&path, &engine), - Command::Build { path, target, out } => run_build(&path, &engine, &target, out), + Command::Build { path, targets, out } => run_build_command(&path, &engine, targets, out), + Command::Bundle { path, targets, out } => run_bundle_command(&path, &engine, targets, out), + Command::Outline { + path, + format, + out, + force, + } => run_outline(&path, &engine, format, out, force), Command::Completions { shell } => run_completions(shell), } } @@ -132,25 +198,205 @@ fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode { } } -fn run_build( +fn run_outline( + path: &std::path::Path, + engine: &Engine, + format: OutlineFormat, + out: Option, + force: bool, +) -> ExitCode { + let out_path = out.unwrap_or_else(|| path.join(format!("outline.{}", format.extension()))); + + if out_path.exists() && !force { + eprintln!( + "warning: output '{}' already exists; pass --force to overwrite", + out_path.display() + ); + return ExitCode::FAILURE; + } + + let (outline, report) = cph_check::outline(path); + print_diagnostics(&report); + let Some(outline) = outline else { + eprintln!("outline failed: fix the lesson before exporting"); + return ExitCode::FAILURE; + }; + + let bytes = match format { + OutlineFormat::Md => render_outline_markdown(&outline).into_bytes(), + OutlineFormat::Json => match serde_json::to_vec_pretty(&outline) { + Ok(mut bytes) => { + bytes.push(b'\n'); + bytes + } + Err(e) => { + eprintln!("outline failed: cannot serialize JSON: {e}"); + return ExitCode::FAILURE; + } + }, + OutlineFormat::Pdf => match engine.build_outline_pdf(&outline) { + Ok(bytes) => bytes, + Err(diags) => { + for diagnostic in &diags { + eprintln!("{diagnostic}"); + } + eprintln!("outline failed: PDF compilation failed"); + return ExitCode::FAILURE; + } + }, + }; + + if force && out_path.exists() { + eprintln!( + "warning: overwriting existing output '{}'", + out_path.display() + ); + } + if let Err(e) = write_outline_output(&out_path, &bytes, force) { + eprintln!("outline failed: {e}"); + return ExitCode::FAILURE; + } + println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); + ExitCode::SUCCESS +} + +fn render_outline_markdown(outline: &OutlineDocument) -> String { + let mut body = format!("# {}\n\n", outline.title.trim()); + if !outline.authors.is_empty() { + body.push_str("作者:"); + body.push_str(&outline.authors.join("、")); + body.push_str("\n\n"); + } + for child in &outline.children { + append_outline_markdown(&mut body, child, 2); + } + body +} + +fn append_outline_markdown(body: &mut String, node: &cph_model::OutlineNode, level: usize) { + let level = level.min(6); + body.push_str(&"#".repeat(level)); + body.push(' '); + body.push_str(&node.title); + if !node.kind.is_empty() { + body.push_str(" `["); + body.push_str(&node.kind); + body.push_str("]`"); + } + body.push_str("\n\n"); + + if let Some(notes) = node.notes.as_deref() { + body.push_str("> 教学提示:\n"); + for line in notes.lines() { + body.push_str("> "); + body.push_str(line); + body.push('\n'); + } + body.push('\n'); + } + + for child in &node.children { + append_outline_markdown(body, child, level + 1); + } +} + +fn write_outline_output(path: &std::path::Path, bytes: &[u8], force: bool) -> Result<(), String> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent) + .map_err(|e| format!("cannot create output directory '{}': {e}", parent.display()))?; + } + + if force { + std::fs::write(path, bytes).map_err(|e| format!("cannot write '{}': {e}", path.display())) + } else { + let mut file = match OpenOptions::new().write(true).create_new(true).open(path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(format!( + "output '{}' already exists; pass --force to overwrite", + path.display() + )); + } + Err(e) => return Err(format!("cannot create '{}': {e}", path.display())), + }; + file.write_all(bytes) + .map_err(|e| format!("cannot write '{}': {e}", path.display())) + } +} + +/// Dispatch `cph build` (ADR-0030): with an explicit `--target` (repeatable), +/// build exactly that ordered set; with none, batch every target the lesson +/// declares. Each target builds **independently** — one failing does not stop +/// the rest — and prints a per-target ledger when building more than one. +/// Exits non-zero iff **any** target failed to produce its artifact (a build +/// failure is a real defect, distinct from the non-blocking `renderIgnored` +/// warning class — ADR-0030). +fn run_build_command( + path: &std::path::Path, + engine: &Engine, + targets: Vec, + out: Option, +) -> ExitCode { + let target_list = if targets.is_empty() { + cph_check::declared_target_names(path) + } else { + targets + }; + + if target_list.len() > 1 && out.is_some() { + eprintln!( + "error: -o/--out only applies to a single-target build; pass exactly one --target with -o" + ); + return ExitCode::FAILURE; + } + + let mut results: Vec<(String, bool)> = Vec::with_capacity(target_list.len()); + for target in &target_list { + if target_list.len() > 1 { + eprintln!("=== target '{target}' ==="); + } + let ok = run_build_one(path, engine, target, out.clone()); + results.push((target.clone(), ok)); + } + + if target_list.len() > 1 { + eprintln!("--- build summary ---"); + for (target, ok) in &results { + eprintln!("{target}: {}", if *ok { "ok" } else { "failed" }); + } + } + + if results.iter().any(|(_, ok)| !ok) { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } +} + +/// Build one target, returning whether it succeeded. Routes to the shell, +/// markdown-assemble, or typst-compile path per the target's step shape. +fn run_build_one( path: &std::path::Path, engine: &Engine, target: &str, out: Option, -) -> ExitCode { +) -> bool { // A target whose steps are shell commands (a tool-generated asset bundle, // ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by // executing those commands, not by compiling a typst template. Detect that // shape up front and route accordingly. if cph_check::target_is_shell(path, target) { - return run_shell_build(path, engine, target); + return run_shell_build(path, engine, target) == ExitCode::SUCCESS; } // A target whose steps assemble markdown (ADR-0015: slides outline / 逐字稿 // transcript surfaces) is built by concatenating per-element `.md` // files in parts order, not by compiling a typst template. if cph_check::target_is_markdown_assemble(path, target) { - return run_markdown_assemble_build(path, engine, target); + return run_markdown_assemble_build(path, engine, target) == ExitCode::SUCCESS; } let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf"))); @@ -166,19 +412,102 @@ fn run_build( "error: cannot create output directory '{}': {e}", parent.display() ); - return ExitCode::FAILURE; + return false; } } if let Err(e) = std::fs::write(&out_path, &bytes) { eprintln!("error: cannot write '{}': {e}", out_path.display()); - return ExitCode::FAILURE; + return false; } println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); - ExitCode::SUCCESS + true } None => { eprintln!("build failed: {} errors", report.error_count()); - ExitCode::FAILURE + false + } + } +} + +/// Dispatch `cph bundle` (ADR-0030) — same batching/exit-code contract as +/// [`run_build_command`], over a bundle's own declared targets. MVP bundle +/// targets are `typst-compile` only (no shell/markdown-assemble routing — +/// ADR-0030 did not extend those step kinds to bundles). +fn run_bundle_command( + path: &std::path::Path, + engine: &Engine, + targets: Vec, + out: Option, +) -> ExitCode { + let target_list = if targets.is_empty() { + cph_check::declared_bundle_target_names(path) + } else { + targets + }; + + if target_list.len() > 1 && out.is_some() { + eprintln!( + "error: -o/--out only applies to a single-target build; pass exactly one --target with -o" + ); + return ExitCode::FAILURE; + } + + let mut results: Vec<(String, bool)> = Vec::with_capacity(target_list.len()); + for target in &target_list { + if target_list.len() > 1 { + eprintln!("=== target '{target}' ==="); + } + let ok = run_bundle_one(path, engine, target, out.clone()); + results.push((target.clone(), ok)); + } + + if target_list.len() > 1 { + eprintln!("--- build summary ---"); + for (target, ok) in &results { + eprintln!("{target}: {}", if *ok { "ok" } else { "failed" }); + } + } + + if results.iter().any(|(_, ok)| !ok) { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } +} + +/// Build one bundle target, returning whether it succeeded. +fn run_bundle_one( + path: &std::path::Path, + engine: &Engine, + target: &str, + out: Option, +) -> bool { + let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf"))); + + let (pdf, report) = cph_check::build_bundle(path, engine, target); + print_diagnostics(&report); + + match pdf { + Some(bytes) => { + if let Some(parent) = out_path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + eprintln!( + "error: cannot create output directory '{}': {e}", + parent.display() + ); + return false; + } + } + if let Err(e) = std::fs::write(&out_path, &bytes) { + eprintln!("error: cannot write '{}': {e}", out_path.display()); + return false; + } + println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); + true + } + None => { + eprintln!("build failed: {} errors", report.error_count()); + false } } } diff --git a/crates/cph-diag/src/lib.rs b/crates/cph-diag/src/lib.rs index 4e2bdbf..d92f825 100644 --- a/crates/cph-diag/src/lib.rs +++ b/crates/cph-diag/src/lib.rs @@ -65,7 +65,8 @@ pub struct SourceSpan { /// Do not invent codes outside this enum without a deliberate decision. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum DiagCode { - /// A `[[parts]]` entry references a folder/path that does not exist. + /// A `[[parts]]`/outline entry references a folder/path that does not + /// exist. PartPathMissing, /// An element declares a `kind` that is not a known kind. UnknownKind, @@ -86,6 +87,15 @@ pub enum DiagCode { /// The engineering file's `.cph-version` is not compatible with the running /// CLI's version (ADR-0016). Decided at load time; `error` severity. CphVersionMismatch, + /// A `manifest.toml`/`bundle.toml` is structurally broken: invalid TOML, a + /// required table missing (root `[project]`/`[info]`), a folder that is + /// neither a container (`manifest.toml`) nor a leaf (`element.toml`) — or + /// is ambiguously both (ADR-0029) — or a bundle `lessons` entry malformed + /// (ADR-0030). Distinct from `SchemaViolation` (instance data vs. its + /// kind's schema): this code is for the *carrier document's own* + /// structure. Added to discharge the manifest-level errors that used to + /// overload `SchemaViolation` before this code existed. + ManifestMalformed, } impl DiagCode { @@ -102,6 +112,7 @@ impl DiagCode { DiagCode::TypstCompile => "E-TYPST-COMPILE", DiagCode::RenderIgnored => "W-RENDER-IGNORED", DiagCode::CphVersionMismatch => "E-CPH-VERSION", + DiagCode::ManifestMalformed => "E-MANIFEST", } } } diff --git a/crates/cph-model/src/lib.rs b/crates/cph-model/src/lib.rs index 09bc2be..4d08155 100644 --- a/crates/cph-model/src/lib.rs +++ b/crates/cph-model/src/lib.rs @@ -1,58 +1,200 @@ -//! `cph-model` — load the ADR-0008 declarative layout into an in-memory lesson. +//! `cph-model` — load the ADR-0029 nested outline manifest into an in-memory +//! lesson. //! -//! This crate is the **loader**, not the full checker. It reads -//! `/manifest.toml` (project / info / ordered `[[parts]]` / declared -//! `[targets.*]`) and each part's `//element.toml`, and produces an -//! ordered [`Lesson`], where the order of `parts` carries teaching semantics. +//! This crate is the **loader**, not the full checker. A lesson's structure is +//! a folder tree (ADR-0007): every folder that groups children carries a +//! `manifest.toml` (root or internal — see [`load`]); every leaf carries an +//! `element.toml` (ADR-0008's element descriptor, unchanged). The loader walks +//! that tree depth-first and produces: +//! +//! - [`Lesson::parts`] — the **ordered element sequence** (ADR-0005): pure +//! elements, no containers. This is what `cph-check`/`cph-schema`/the render +//! pipeline validate and include content from — unaffected by nesting. +//! - [`Lesson::outline`] — the **full rendering-order sequence**: elements +//! interleaved with section headings, at the depth-first traversal position +//! they open at (ADR-0029). This is what the augmented manifest (built by +//! `cph-typst`) walks to hand the template a rendering order that includes +//! headings. //! //! Scope boundaries (deliberately staying in lane): -//! - It validates **structure** only: manifest shape, element.toml shape, and -//! the cross-check `part.kind == element.toml kind`. +//! - It validates **structure** only: manifest/outline shape, element.toml +//! shape, the leaf/container discriminator, and the cross-check +//! `part.kind == element.toml kind`. //! - It does **not** validate instance data against a kind's JSON Schema (that -//! is WU-3 / `cph-schema`), does **not** check that `content` `.typ` files -//! exist (also schema-driven, WU-3), and does **not** compile typst (WU-4). +//! is `cph-schema`), does **not** check that `content` `.typ` files exist +//! (also schema-driven), does **not** compile typst, and does **not** +//! validate a container's declared kind against the known container-kind set +//! (that is `cph-check`'s job, mirroring how it also owns the known +//! *element*-kind check). //! //! Entry point: [`load`]. use std::path::{Component, Path, PathBuf}; -use cph_diag::{DiagCode, Diagnostic}; +use cph_diag::{DiagCode, Diagnostic, Severity}; use serde::{Deserialize, Serialize}; -/// An ordered, in-memory lesson loaded from an engineering file. -/// -/// `parts` is an ordered -/// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest -/// is declarative" — the `[[parts]]` array order is the single source of truth). +/// An in-memory lesson loaded from an engineering file (ADR-0029). #[derive(Debug, Clone, PartialEq, Serialize)] pub struct Lesson { - /// `[project]` from the manifest (id, name). + /// `[project]` from the root manifest (id, name). pub project: Project, - /// `[info]` from the manifest (title, optional author). + /// `[info]` from the root manifest (title, optional author). pub info: Info, - /// The ordered parts — the lesson's element sequence. + /// The ordered **element** sequence — the lesson's element order (ADR-0005). + /// Contains only leaves; containers never appear here (a container + /// "contributes no element of its own", ADR-0029). Index-stable: an + /// [`OutlineEntry::Element`] names a position in this `Vec` by index. pub parts: Vec, - /// Declared export targets, collected from the `[targets.]` tables. - /// - /// Per ADR-0009/0011 an export target is a *build* producing a typed - /// [`Artifact`] via an ordered list of typed [`Step`]s. This carries those - /// in declared (TOML document) order. See [`TargetConfig`]. + /// The full depth-first rendering order: elements (by index into `parts`) + /// interleaved with section headings, at the position they open in the + /// tree (ADR-0029). Consumed by the augmented-manifest builder so a + /// template can render headings in their real position; `cph-check`'s + /// structural/schema/coverage phases do not need it (they use `parts`). + pub outline: Vec, + /// Declared export targets, collected from the root manifest's + /// `[targets.]` tables (ADR-0009/0011). pub targets: Vec, - /// Engineering-file root (absolute), for resolving part paths. + /// Engineering-file root (absolute), for resolving part/container paths. pub root: PathBuf, } impl Lesson { + /// Project the nested depth-first outline into the target-independent + /// document shape used by `cph outline`. + pub fn outline_document(&self) -> OutlineDocument { + let (children, _) = consume_outline_children(self, 0, 0); + OutlineDocument { + title: self.info.title.clone(), + authors: self.info.authors.clone(), + children, + } + } + /// The declared export-target names, in declared order. - /// - /// Convenience for callers that only need the names (the shape this crate - /// exposed before ADR-0009 turned `targets` into structured - /// [`TargetConfig`]s). pub fn target_names(&self) -> Vec<&str> { self.targets.iter().map(|t| t.name.as_str()).collect() } } +/// One entry in the lesson's full rendering-order sequence (ADR-0029). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum OutlineEntry { + /// An element at this position: `part_index` into [`Lesson::parts`]. + Element { + /// Index into `Lesson::parts`. + part_index: usize, + /// Container depth: 0 for a root child, 1 inside a direct section, etc. + depth: u32, + }, + /// A section container opens here. Contributes no element; it is a + /// heading, not a part (ADR-0029). `depth` is the section's nesting depth + /// (1 = a section directly under the engineering-file root). + Section { + /// The container's declared kind (MVP: always `"section"`; validated + /// against the known container-kind set by `cph-check`, not here). + kind: String, + /// Heading text: the container's `[group].title` if present and + /// non-empty, else the folder's basename. + title: String, + /// Optional teacher-facing planning note from the `children` entry. + notes: Option, + /// Nesting depth (1-based) — the heading level a renderer should use. + depth: u32, + /// The container folder's path, relative to the engineering-file root. + path: PathBuf, + }, +} + +/// A target-independent outline projection of a nested lesson. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OutlineDocument { + /// Course title from the root `[info].title`. + pub title: String, + /// Authors from the root `[info].author`. + pub authors: Vec, + /// Ordered root children, preserving nested sections. + pub children: Vec, +} + +/// One section or element in the exported outline tree. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OutlineNode { + /// Heading text shown to a teacher. + pub title: String, + /// Element or container kind. + pub kind: String, + /// Root-relative source path. + pub path: PathBuf, + /// Optional teacher-facing planning note. + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + /// Nested children; empty for an element leaf. + pub children: Vec, +} + +impl OutlineNode { + fn from_part(part: &Part) -> Self { + let title = part + .path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| part.path.to_string_lossy().into_owned()); + Self { + title, + kind: part.kind.clone(), + path: part.path.clone(), + notes: part.notes.clone().filter(|notes| !notes.trim().is_empty()), + children: Vec::new(), + } + } +} + +/// Convert the flat depth-first sequence into nested JSON/format nodes. +fn consume_outline_children( + lesson: &Lesson, + start: usize, + minimum_section_depth: u32, +) -> (Vec, usize) { + let mut children = Vec::new(); + let mut index = start; + while index < lesson.outline.len() { + match &lesson.outline[index] { + OutlineEntry::Element { part_index, depth } => { + if *depth < minimum_section_depth { + break; + } + children.push(OutlineNode::from_part(&lesson.parts[*part_index])); + index += 1; + } + OutlineEntry::Section { + kind, + title, + notes, + depth, + path, + } => { + if *depth <= minimum_section_depth { + break; + } + let section_depth = *depth; + let (nested, next) = consume_outline_children(lesson, index + 1, section_depth); + children.push(OutlineNode { + title: title.clone(), + kind: kind.clone(), + path: path.clone(), + notes: notes.clone().filter(|note| !note.trim().is_empty()), + children: nested, + }); + index = next; + } + } + } + (children, index) +} + /// One declared export target's build config (ADR-0009/0011). /// /// An export target is a **build** producing a typed [`Artifact`] via an @@ -185,7 +327,7 @@ pub enum Step { run: String, }, /// Assemble a single-file markdown deliverable by concatenating each - /// element's `field` markdown content file in `[[parts]]` order. ADR-0011 + /// element's `field` markdown content file in `parts` order. ADR-0011 /// `assembleMarkdown` (ADR-0015). Not a typst build — the /// framework owns the read/concatenate/write itself. AssembleMarkdown { @@ -205,7 +347,7 @@ impl Step { } } -/// `[project]` table. +/// `[project]` table (root manifest only). #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Project { /// Stable project id. @@ -214,7 +356,8 @@ pub struct Project { pub name: String, } -/// `[info]` table (passed through to render targets verbatim). +/// `[info]` table (root manifest only; passed through to render targets +/// verbatim). /// /// The *canonical* model whose /// `authors` is always a list. The authoring-surface form (string-or-array @@ -232,14 +375,27 @@ pub struct Info { pub authors: Vec, } -/// One `[[parts]]` entry plus its loaded element descriptor. +/// One element leaf, plus its loaded `element.toml` descriptor. +/// +/// `path` is the element folder's path relative to the engineering-file root, +/// regardless of how deeply nested it is in the outline tree (ADR-0029) — the +/// loader accumulates full root-relative paths during the depth-first walk, so +/// every downstream consumer (schema validation, augmented-manifest include +/// paths) keeps working against a root-relative path exactly as before ADR-0029. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct Part { - /// Declared kind from the manifest (one of the known kinds; ADR-0006). + /// Declared kind from the containing outline entry (ADR-0006). This is the + /// **declared** kind, which may disagree with the `element.toml` kind (a + /// disagreement is reported as [`DiagCode::UnknownKind`], but the declared + /// kind — not the descriptor's — is what `cph-check`'s known-kind check + /// tests, matching pre-ADR-0029 behavior). pub kind: String, - /// Element folder path **as written in the manifest** (relative to root), - /// kept verbatim for diagnostics / display. + /// Element folder path, root-relative, kept verbatim (forward/back slashes + /// as the OS provides) for diagnostics/display. pub path: PathBuf, + /// Optional teacher-facing planning note from the `children` entry. + /// This is outline metadata, not a content field in `element.toml`. + pub notes: Option, /// The element's self-description loaded from its `element.toml`. pub descriptor: ElementDescriptor, } @@ -250,26 +406,32 @@ pub struct Part { /// directory), so a folder is self-describing. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct ElementDescriptor { - /// `kind` from `element.toml`. Must equal the part's `kind`; a mismatch is - /// reported as a diagnostic (see [`load`]). + /// `kind` from `element.toml`. Must equal the part's declared `kind`; a + /// mismatch is reported as a diagnostic (see [`load`]). pub kind: String, /// Element folder, absolute path. pub dir: PathBuf, /// The remaining `element.toml` keys (the scalar fields). Schema validation - /// of these is WU-3's job, not this loader's. + /// of these is `cph-schema`'s job, not this loader's. pub scalars: toml::Table, } // --- raw deserialization shapes (mirror the on-disk TOML) --------------------- +/// One `manifest.toml`, root or container (ADR-0029). Root-only tables +/// (`project`/`info`/`targets`) and the container-only `group` table coexist in +/// one shape; [`load`]/[`load_children`] enforce which is expected where and +/// flag misplacement rather than rejecting parse outright (non-fatal, like +/// every other structural defect this loader collects). #[derive(Debug, Deserialize)] struct RawManifest { project: Option, info: Option, #[serde(default)] - parts: Vec, - #[serde(default)] targets: toml::Table, + group: Option, + #[serde(default)] + children: Vec, } #[derive(Debug, Deserialize)] @@ -309,39 +471,56 @@ impl RawAuthor { } } +/// A container's optional `[group]` table (ADR-0029): presentation metadata for +/// a section, kept minimal per the ADR's recommended default. #[derive(Debug, Deserialize)] -struct RawPart { +struct RawGroup { + title: Option, +} + +/// One `children` entry (ADR-0029): a `kind` + a **parent-relative** `path`. +/// The loader resolves on disk whether the named folder is a leaf +/// (`element.toml`) or a container (`manifest.toml`) — `kind` is the declared +/// label, cross-checked against the leaf's `element.toml` kind (unchanged from +/// ADR-0008) but never used to pick the leaf/container branch itself. +#[derive(Debug, Deserialize)] +struct RawChild { kind: String, path: String, + #[serde(default)] + notes: Option, } /// Load the engineering file at `root` into a [`Lesson`]. /// /// Returns `(Option, Vec)`: -/// - `Some(lesson)` whenever the manifest parses into a `Lesson` at all. The -/// structure is produced even when individual parts have problems, so the -/// WU-5 orchestrator gets **both** the partial lesson and the collected -/// diagnostics. -/// - `None` only on a hard failure where no `Lesson` can be built: the +/// - `Some(lesson)` whenever the root manifest parses into a `Lesson` at all. +/// The structure is produced even when individual parts/containers have +/// problems, so the orchestrator gets **both** the partial lesson and the +/// collected diagnostics. +/// - `None` only on a hard failure where no `Lesson` can be built: the root /// `manifest.toml` is missing/unreadable, is not valid TOML, or lacks the /// required `[project]` / `[info]` tables. In that case the diagnostics /// describe the hard failure. /// -/// Collected (non-fatal) diagnostics include: a part path that does not exist -/// or escapes the root via `..`, a missing/malformed `element.toml`, and a +/// Collected (non-fatal) diagnostics include: a child path that does not exist +/// or escapes its container via `..`, a folder that is neither a container nor +/// a leaf (or ambiguously both), a container manifest misplacing root-only +/// tables, a missing/malformed `element.toml`, and a /// `part.kind != element.toml kind` mismatch. /// -/// ## Diagnostic-code mapping (judgment call, WU-1) +/// ## Diagnostic-code mapping /// -/// `cph-diag`'s code set is closed and has **no** dedicated "manifest -/// malformed" code. We deliberately do not invent one here. Until such a code -/// is added, manifest-level structural errors (bad TOML, missing `[project]` / -/// `[info]`) are mapped to the closest existing code, [`DiagCode::SchemaViolation`], -/// with a message making the real cause clear. A genuinely missing **part -/// path** uses [`DiagCode::PartPathMissing`] (its actual meaning); a missing / -/// unreadable / malformed `element.toml` also maps to `SchemaViolation`. -// TODO(cph-diag): consider adding a dedicated `ManifestMalformed` code so -// manifest-structure errors don't overload `SchemaViolation`. +/// [`DiagCode::ManifestMalformed`] is for the **carrier document's own** +/// structure being broken: unreadable/invalid-TOML `manifest.toml` (root or +/// container), a required root table missing, a container manifest misplacing +/// `[project]`/`[info]`/`[targets]`, a folder that is neither/both a +/// container and a leaf, and an unreadable/invalid/kindless `element.toml`. +/// [`DiagCode::SchemaViolation`] stays reserved for *instance data* not +/// conforming to a schema (a kind's JSON Schema, or a target's +/// artifact/step config shape) — this loader never emits it. A genuinely +/// missing **child path** uses [`DiagCode::PartPathMissing`]; a declared kind +/// disagreeing with `element.toml`'s kind uses [`DiagCode::UnknownKind`]. pub fn load(root: &Path) -> (Option, Vec) { let mut diags = Vec::new(); @@ -352,7 +531,7 @@ pub fn load(root: &Path) -> (Option, Vec) { Err(e) => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, format!("cannot read manifest.toml: {e}"), ) .with_hint(format!( @@ -369,7 +548,7 @@ pub fn load(root: &Path) -> (Option, Vec) { Err(e) => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, format!("manifest.toml is not valid TOML: {e}"), ) .with_hint("fix the TOML syntax in manifest.toml"), @@ -395,7 +574,7 @@ pub fn load(root: &Path) -> (Option, Vec) { None => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, "manifest.toml is missing the required [project] table", ) .with_hint("add a [project] table with `id` and `name`"), @@ -412,7 +591,7 @@ pub fn load(root: &Path) -> (Option, Vec) { None => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, "manifest.toml is missing the required [info] table", ) .with_hint("add an [info] table with at least `title`"), @@ -421,6 +600,21 @@ pub fn load(root: &Path) -> (Option, Vec) { } }; + // The root is the implicit top container (ADR-0029): it must not declare + // `[group]` (that is container-only presentation metadata). + if raw.group.is_some() { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + "the root manifest.toml must not declare [group]; [group] is \ + container-only presentation metadata", + ) + .with_hint( + "remove [group] from the root manifest.toml, or move this content into a container", + ), + ); + } + // Parse each [targets.] table into a structured build config // (ADR-0009/0011). Order is the TOML document order, as before. Malformed // target config is non-fatal: it is reported and the target is kept with @@ -431,76 +625,315 @@ pub fn load(root: &Path) -> (Option, Vec) { .map(|(name, value)| parse_target(name, value, &mut diags)) .collect(); - // Load each part. Problems are collected, not fatal: we still build the - // Part (with a best-effort descriptor) so order/membership is observable. - let mut parts = Vec::with_capacity(raw.parts.len()); - for raw_part in raw.parts { - let rel_path = PathBuf::from(&raw_part.path); - - // Reject `..` traversal: a part path must stay within the root. - if has_parent_traversal(&rel_path) { - diags.push( - Diagnostic::error( - DiagCode::PartPathMissing, - format!( - "part path '{}' escapes the engineering-file root via '..'", - raw_part.path - ), - ) - .with_hint("part paths must be relative folders inside the engineering file"), - ); - // Still record the part with an empty descriptor so order is kept. - parts.push(Part { - kind: raw_part.kind.clone(), - path: rel_path.clone(), - descriptor: ElementDescriptor { - kind: raw_part.kind, - dir: root.join(&rel_path), - scalars: toml::Table::new(), - }, - }); - continue; - } - - let dir = root.join(&rel_path); - - let descriptor = if !dir.is_dir() { - diags.push( - Diagnostic::error( - DiagCode::PartPathMissing, - format!("part folder '{}' does not exist", raw_part.path), - ) - .with_hint(format!( - "create the folder '{}' or fix the `path` in manifest.toml", - raw_part.path - )), - ); - ElementDescriptor { - kind: raw_part.kind.clone(), - dir, - scalars: toml::Table::new(), - } - } else { - load_descriptor(&dir, &rel_path, &raw_part.kind, &mut diags) - }; - - parts.push(Part { - kind: raw_part.kind, - path: rel_path, - descriptor, - }); - } + // Walk the outline tree depth-first (ADR-0029), starting at the root's own + // children. A direct child section of the root opens at depth 1. + let mut parts = Vec::new(); + let mut outline = Vec::new(); + load_children( + root, + root, + Path::new(""), + raw.children, + 1, + &mut diags, + &mut parts, + &mut outline, + ); let lesson = Lesson { project, info, parts, + outline, targets, root: root.to_path_buf(), }; (Some(lesson), diags) } +/// Recursively load one container's ordered `children` into `parts`/`outline` +/// (ADR-0029). `container_dir` is the container's own absolute directory; +/// `rel_prefix` is that container's own root-relative path (empty for the +/// engineering-file root). `next_section_depth` is the depth a **direct** +/// section child of this container would open at (1 for the root's children). +#[allow(clippy::too_many_arguments)] +fn load_children( + container_dir: &Path, + root: &Path, + rel_prefix: &Path, + children: Vec, + next_section_depth: u32, + diags: &mut Vec, + parts: &mut Vec, + outline: &mut Vec, +) { + for child in children { + let element_depth = next_section_depth.saturating_sub(1); + let local_path = PathBuf::from(&child.path); + let full_rel_path = join_rel(rel_prefix, &local_path); + + // Reject `..` traversal: a child path must stay within its container. + if has_parent_traversal(&local_path) { + diags.push( + Diagnostic::error( + DiagCode::PartPathMissing, + format!("child path '{}' escapes its container via '..'", child.path), + ) + .with_hint("child paths must be relative folders inside the containing folder"), + ); + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); + continue; + } + + let abs_dir = container_dir.join(&local_path); + + if !abs_dir.is_dir() { + diags.push( + Diagnostic::error( + DiagCode::PartPathMissing, + format!("child folder '{}' does not exist", full_rel_path.display()), + ) + .with_hint(format!( + "create the folder '{}' or fix the `path` in its container's manifest.toml", + full_rel_path.display() + )), + ); + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); + continue; + } + + let has_manifest = abs_dir.join("manifest.toml").is_file(); + let has_element = abs_dir.join("element.toml").is_file(); + + match (has_manifest, has_element) { + // A leaf: exactly ADR-0008's element folder. + (false, true) => { + let descriptor = load_descriptor(&abs_dir, &full_rel_path, &child.kind, diags); + let idx = parts.len(); + parts.push(Part { + kind: child.kind, + path: full_rel_path, + notes: child.notes, + descriptor, + }); + outline.push(OutlineEntry::Element { + part_index: idx, + depth: element_depth, + }); + } + // A container: recurse into its own manifest.toml. + (true, false) => { + let Some(raw_container) = read_container_manifest(&abs_dir, diags) else { + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); + continue; + }; + + if raw_container.project.is_some() + || raw_container.info.is_some() + || !raw_container.targets.is_empty() + { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + format!( + "container manifest.toml at '{}' must not declare \ + [project]/[info]/[targets]; those are root-only", + full_rel_path.display() + ), + ) + .with_hint( + "remove [project]/[info]/[targets] from this container's \ + manifest.toml — they belong only at the engineering-file root", + ), + ); + } + + let title = raw_container + .group + .and_then(|g| g.title) + .filter(|t| !t.trim().is_empty()) + .unwrap_or_else(|| folder_name(&abs_dir)); + + outline.push(OutlineEntry::Section { + kind: child.kind, + title, + notes: child.notes, + depth: next_section_depth, + path: full_rel_path.clone(), + }); + + load_children( + &abs_dir, + root, + &full_rel_path, + raw_container.children, + next_section_depth + 1, + diags, + parts, + outline, + ); + } + // Ambiguous: both a container and a leaf. + (true, true) => { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + format!( + "folder '{}' has both manifest.toml and element.toml; a folder \ + must be exactly one of a container or a leaf", + full_rel_path.display() + ), + ) + .with_hint("remove one of manifest.toml or element.toml from this folder"), + ); + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); + } + // Incomplete: neither a container nor a leaf. + (false, false) => { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + format!( + "folder '{}' has neither manifest.toml nor element.toml", + full_rel_path.display() + ), + ) + .with_hint( + "add an element.toml (leaf) or a manifest.toml with `children` \ + (container) to this folder", + ), + ); + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); + } + } + } +} + +/// Read and parse a container's own `manifest.toml`. On any read/parse failure, +/// reports a [`DiagCode::ManifestMalformed`] diagnostic and returns `None` (the +/// caller falls back to a broken-leaf placeholder so order/count stays stable). +fn read_container_manifest(dir: &Path, diags: &mut Vec) -> Option { + let path = dir.join("manifest.toml"); + let src = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + format!("cannot read {}: {e}", path.display()), + ) + .with_hint("expected a container manifest.toml with a `children` array"), + ); + return None; + } + }; + match toml::from_str(&src) { + Ok(r) => Some(r), + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + format!("{} is not valid TOML: {e}", path.display()), + ) + .with_hint("fix the TOML syntax in this container's manifest.toml"), + ); + None + } + } +} + +/// Record a broken child as a placeholder leaf (empty descriptor) so that +/// order/count stays observable even when the folder could not be resolved to +/// either a leaf or a container. Mirrors the pre-ADR-0029 behavior for a +/// missing part folder. +fn push_broken_leaf( + parts: &mut Vec, + outline: &mut Vec, + kind: String, + path: PathBuf, + root: &Path, + notes: Option, + depth: u32, +) { + let idx = parts.len(); + let dir = root.join(&path); + parts.push(Part { + kind: kind.clone(), + path, + notes, + descriptor: ElementDescriptor { + kind, + dir, + scalars: toml::Table::new(), + }, + }); + outline.push(OutlineEntry::Element { + part_index: idx, + depth, + }); +} + +/// Join a container-relative child path onto that container's own +/// root-relative prefix, producing a full root-relative path. An empty prefix +/// (the engineering-file root itself) returns `local` unchanged. +fn join_rel(prefix: &Path, local: &Path) -> PathBuf { + if prefix.as_os_str().is_empty() { + local.to_path_buf() + } else { + prefix.join(local) + } +} + +/// A folder's basename as a `String` (used as a section's default title when +/// `[group].title` is absent). Falls back to the folder's full path string in +/// the (pathological) case it has no file-name component. +fn folder_name(dir: &Path) -> String { + dir.file_name() + .and_then(|n| n.to_str()) + .map(str::to_string) + .unwrap_or_else(|| dir.display().to_string()) +} + /// The cph version the running CLI was built with (ADR-0016). Pulled from the /// crate's `CARGO_PKG_VERSION` at compile time. pub const CPH_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -570,7 +1003,7 @@ fn load_descriptor( Err(e) => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, format!( "cannot read element.toml for part '{}': {e}", rel_path.display() @@ -594,7 +1027,7 @@ fn load_descriptor( Err(e) => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, format!( "element.toml for part '{}' is not valid TOML: {e}", rel_path.display() @@ -617,7 +1050,7 @@ fn load_descriptor( Some(_) => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, format!( "element.toml for part '{}' has a non-string `kind`", rel_path.display() @@ -630,7 +1063,7 @@ fn load_descriptor( None => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, format!( "element.toml for part '{}' is missing the required `kind` key", rel_path.display() @@ -1065,6 +1498,228 @@ fn has_parent_traversal(path: &Path) -> bool { path.components().any(|c| matches!(c, Component::ParentDir)) } +// --- bundle: an ordered arrangement of lessons (ADR-0030) -------------------- + +/// A **bundle** (ADR-0030): a directory carrying `bundle.toml`, which arranges +/// an ordered list of already-authored, self-contained lessons into one export +/// unit. Discharges ADR-0005's deferred "course = arrangement of lessons" for +/// the export purpose, without inventing a full course-authoring model. +/// +/// A bundle target (declared exactly like a lesson's `[targets.*]`, +/// ADR-0009/0011) assembles its member lessons at build time; this crate only +/// loads the *arrangement* — which lessons, in what order, with which +/// per-lesson overrides. See [`load_bundle`]. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Bundle { + /// `[info]` from `bundle.toml` — the 合集's own title/author. + pub info: Info, + /// The ordered member lessons. + pub lessons: Vec, + /// Declared export targets, collected from `bundle.toml`'s + /// `[targets.]` tables (ADR-0009/0011) — reusing the exact same + /// build/artifact/step shape a lesson's targets use. + pub targets: Vec, + /// Bundle root (absolute) — the directory containing `bundle.toml`. + pub root: PathBuf, +} + +impl Bundle { + /// The declared export-target names, in declared order (mirrors + /// [`Lesson::target_names`]). + pub fn target_names(&self) -> Vec<&str> { + self.targets.iter().map(|t| t.name.as_str()).collect() + } +} + +/// One `[[lessons]]` entry in `bundle.toml`, plus that lesson's loaded +/// [`Lesson`] (loaded exactly as [`load`] would from its own directory — a +/// bundle never re-derives lesson-loading logic). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct BundleLesson { + /// The lesson directory's path, relative to the bundle root, as written in + /// `bundle.toml` (kept verbatim for diagnostics/display). + pub path: PathBuf, + /// Which of that lesson's own declared targets to render into the bundle + /// (ADR-0030: the bundle assembles a lesson's already-authored content, it + /// does not re-target it). Defaults to the lesson's first declared target, + /// or `"student"` if the lesson declares none. + pub target: String, + /// The member lesson, loaded from `/`. + pub lesson: Lesson, +} + +#[derive(Debug, Deserialize)] +struct RawBundleManifest { + info: Option, + #[serde(default)] + targets: toml::Table, + #[serde(default)] + lessons: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawBundleLesson { + path: String, + target: Option, +} + +/// Load the bundle at `root` (a directory containing `bundle.toml`) into a +/// [`Bundle`]. +/// +/// Returns `(Option, Vec)`, mirroring [`load`]'s contract: +/// - `Some(bundle)` whenever `bundle.toml` parses at all — even when a member +/// lesson fails to load, so the orchestrator sees both the partial bundle +/// and every collected diagnostic (that member's `Lesson` is still present, +/// just with its own load errors alongside). +/// - `None` only on a hard failure: `bundle.toml` missing/unreadable, not +/// valid TOML, or missing the required `[info]` table. +/// +/// Each `[[lessons]]` entry's path must stay within the bundle root (no `..` +/// traversal) and must resolve to a real directory; violations are +/// [`DiagCode::PartPathMissing`], matching a lesson's own child-path +/// diagnostics. A member lesson's own load diagnostics are folded in verbatim +/// (they already carry their own context). +pub fn load_bundle(root: &Path) -> (Option, Vec) { + let mut diags = Vec::new(); + + let manifest_path = root.join("bundle.toml"); + let manifest_src = match std::fs::read_to_string(&manifest_path) { + Ok(s) => s, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + format!("cannot read bundle.toml: {e}"), + ) + .with_hint(format!( + "expected a bundle manifest at {}", + manifest_path.display() + )), + ); + return (None, diags); + } + }; + + let raw: RawBundleManifest = match toml::from_str(&manifest_src) { + Ok(r) => r, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + format!("bundle.toml is not valid TOML: {e}"), + ) + .with_hint("fix the TOML syntax in bundle.toml"), + ); + return (None, diags); + } + }; + + let info = match raw.info { + Some(i) => Info { + title: i.title, + authors: i.author.map(RawAuthor::into_vec).unwrap_or_default(), + }, + None => { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + "bundle.toml is missing the required [info] table", + ) + .with_hint("add an [info] table with at least `title`"), + ); + return (None, diags); + } + }; + + let targets: Vec = raw + .targets + .into_iter() + .map(|(name, value)| parse_target(name, value, &mut diags)) + .collect(); + + let mut lessons = Vec::with_capacity(raw.lessons.len()); + for raw_lesson in raw.lessons { + let rel_path = PathBuf::from(&raw_lesson.path); + + if has_parent_traversal(&rel_path) { + diags.push( + Diagnostic::error( + DiagCode::PartPathMissing, + format!( + "bundle lesson path '{}' escapes the bundle root via '..'", + raw_lesson.path + ), + ) + .with_hint("bundle lesson paths must be relative folders inside the bundle root"), + ); + continue; + } + + let abs_dir = root.join(&rel_path); + if !abs_dir.is_dir() { + diags.push( + Diagnostic::error( + DiagCode::PartPathMissing, + format!( + "bundle lesson folder '{}' does not exist", + rel_path.display() + ), + ) + .with_hint(format!( + "create the folder '{}' or fix the `path` in bundle.toml", + rel_path.display() + )), + ); + continue; + } + + let (lesson, lesson_diags) = load(&abs_dir); + diags.extend(lesson_diags); + let Some(lesson) = lesson else { + // The member lesson's own hard-failure diagnostic already explains + // why; skip it from the bundle rather than fabricate a placeholder + // Lesson (unlike a broken element child, there is no lighter-weight + // stand-in for "an entire unloadable lesson"). + continue; + }; + + let target = raw_lesson.target.unwrap_or_else(|| { + lesson + .targets + .first() + .map(|t| t.name.clone()) + .unwrap_or_else(|| DEFAULT_LESSON_TARGET.to_string()) + }); + + lessons.push(BundleLesson { + path: rel_path, + target, + lesson, + }); + } + + let bundle = Bundle { + info, + lessons, + targets, + root: root.to_path_buf(), + }; + (Some(bundle), diags) +} + +/// The target name a bundle member falls back to when its `bundle.toml` entry +/// gives no explicit `target` and the member lesson itself declares no targets +/// (mirrors [`Step::default_for`]'s "student" convention). +const DEFAULT_LESSON_TARGET: &str = "student"; + +/// Whether any diagnostic in `diags` is `Error`-severity — the shared +/// legality predicate `load`/`load_bundle` callers use (mirrors +/// `cph-check::CheckReport::has_errors`, kept local here so this crate never +/// depends on `cph-check`). +pub fn has_error_diagnostic(diags: &[Diagnostic]) -> bool { + diags.iter().any(|d| d.severity == Severity::Error) +} + #[cfg(test)] mod tests { use super::*; @@ -1076,4 +1731,20 @@ mod tests { assert!(!has_parent_traversal(Path::new("a/b/c"))); assert!(!has_parent_traversal(Path::new("segments/intro"))); } + + #[test] + fn join_rel_empty_prefix_returns_local() { + assert_eq!( + join_rel(Path::new(""), Path::new("a/b")), + PathBuf::from("a/b") + ); + } + + #[test] + fn join_rel_nonempty_prefix_joins() { + assert_eq!( + join_rel(Path::new("导言簇"), Path::new("开场白")), + PathBuf::from("导言簇/开场白") + ); + } } diff --git a/crates/cph-model/tests/bundle.rs b/crates/cph-model/tests/bundle.rs new file mode 100644 index 0000000..6074b35 --- /dev/null +++ b/crates/cph-model/tests/bundle.rs @@ -0,0 +1,83 @@ +//! Integration tests for `cph_model::load_bundle` (ADR-0030): an ordered +//! arrangement of self-contained lessons, loaded from `bundle.toml`. + +use std::path::PathBuf; + +use cph_diag::DiagCode; +use cph_model::load_bundle; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +#[test] +fn valid_bundle_loads_lessons_in_order_with_overrides() { + let (bundle, diags) = load_bundle(&fixture("bundle-valid")); + let bundle = bundle.expect("valid bundle fixture must produce a Bundle"); + assert!( + diags.is_empty(), + "valid bundle fixture must have no diagnostics, got: {diags:?}" + ); + + assert_eq!(bundle.info.title, "测试合集"); + assert_eq!( + bundle.info.authors, + vec!["张老师".to_string(), "李老师".to_string()] + ); + + assert_eq!(bundle.lessons.len(), 2); + + // lesson-a: no explicit `target` in bundle.toml -> falls back to the + // lesson's own first declared target ("student"). + assert_eq!(bundle.lessons[0].path, PathBuf::from("lesson-a")); + assert_eq!(bundle.lessons[0].target, "student"); + assert_eq!(bundle.lessons[0].lesson.info.title, "课时A"); + + // lesson-b: explicit `target = "teacher"` in bundle.toml, overriding the + // lesson's own single declared target (also "teacher" here, but the point + // is the bundle entry's `target` wins regardless). + assert_eq!(bundle.lessons[1].path, PathBuf::from("lesson-b")); + assert_eq!(bundle.lessons[1].target, "teacher"); + assert_eq!(bundle.lessons[1].lesson.info.title, "课时B"); + + // The bundle's own targets are collected exactly like a lesson's. + assert_eq!(bundle.target_names(), vec!["merged"]); +} + +#[test] +fn missing_lesson_folder_yields_part_path_missing_and_is_skipped() { + let (bundle, diags) = load_bundle(&fixture("bundle-missing-lesson")); + let bundle = bundle.expect("must still produce a best-effort Bundle"); + assert!( + bundle.lessons.is_empty(), + "the missing lesson is skipped, not placeholder'd" + ); + + let missing: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::PartPathMissing) + .collect(); + assert_eq!( + missing.len(), + 1, + "exactly one PartPathMissing expected, got: {diags:?}" + ); +} + +#[test] +fn malformed_bundle_toml_is_a_hard_failure() { + let (bundle, diags) = load_bundle(&fixture("bundle-malformed")); + assert!(bundle.is_none(), "malformed bundle.toml is a hard failure"); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].code, DiagCode::ManifestMalformed); +} + +#[test] +fn missing_bundle_toml_is_a_hard_failure() { + let (bundle, diags) = load_bundle(&fixture("does-not-exist-at-all")); + assert!(bundle.is_none()); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].code, DiagCode::ManifestMalformed); +} diff --git a/crates/cph-model/tests/fixtures/both-manifest-and-element/manifest.toml b/crates/cph-model/tests/fixtures/both-manifest-and-element/manifest.toml new file mode 100644 index 0000000..c8968ec --- /dev/null +++ b/crates/cph-model/tests/fixtures/both-manifest-and-element/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "fixture-both" +name = "both" + +[info] +title = "文件夹既是容器又是叶子" + +[[children]] +kind = "segment" +path = "segments/broken" + +[targets.student] diff --git a/examples/TH-141/segments/Fowkes极性修正/element.toml b/crates/cph-model/tests/fixtures/both-manifest-and-element/segments/broken/element.toml similarity index 100% rename from examples/TH-141/segments/Fowkes极性修正/element.toml rename to crates/cph-model/tests/fixtures/both-manifest-and-element/segments/broken/element.toml diff --git a/crates/cph-model/tests/fixtures/both-manifest-and-element/segments/broken/manifest.toml b/crates/cph-model/tests/fixtures/both-manifest-and-element/segments/broken/manifest.toml new file mode 100644 index 0000000..26149a8 --- /dev/null +++ b/crates/cph-model/tests/fixtures/both-manifest-and-element/segments/broken/manifest.toml @@ -0,0 +1 @@ +[[children]] diff --git a/crates/cph-model/tests/fixtures/bundle-malformed/bundle.toml b/crates/cph-model/tests/fixtures/bundle-malformed/bundle.toml new file mode 100644 index 0000000..e1d887c --- /dev/null +++ b/crates/cph-model/tests/fixtures/bundle-malformed/bundle.toml @@ -0,0 +1,2 @@ +[info +this is broken diff --git a/crates/cph-model/tests/fixtures/bundle-missing-lesson/bundle.toml b/crates/cph-model/tests/fixtures/bundle-missing-lesson/bundle.toml new file mode 100644 index 0000000..2d15c94 --- /dev/null +++ b/crates/cph-model/tests/fixtures/bundle-missing-lesson/bundle.toml @@ -0,0 +1,5 @@ +[info] +title = "缺失课时的合集" + +[[lessons]] +path = "does-not-exist" diff --git a/crates/cph-model/tests/fixtures/bundle-valid/bundle.toml b/crates/cph-model/tests/fixtures/bundle-valid/bundle.toml new file mode 100644 index 0000000..f8b676e --- /dev/null +++ b/crates/cph-model/tests/fixtures/bundle-valid/bundle.toml @@ -0,0 +1,16 @@ +[info] +title = "测试合集" +author = ["张老师", "李老师"] + +[[lessons]] +path = "lesson-a" + +[[lessons]] +path = "lesson-b" +target = "teacher" + +[targets.merged] +artifact = { type = "single-file", filepath = "build/merged.pdf" } +[[targets.merged.steps]] +type = "typst-compile" +template = "exports/merged.typ" diff --git a/crates/cph-model/tests/fixtures/bundle-valid/lesson-a/manifest.toml b/crates/cph-model/tests/fixtures/bundle-valid/lesson-a/manifest.toml new file mode 100644 index 0000000..bd9952d --- /dev/null +++ b/crates/cph-model/tests/fixtures/bundle-valid/lesson-a/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "lesson-a" +name = "lesson-a" + +[info] +title = "课时A" + +[[children]] +kind = "segment" +path = "segments/a" + +[targets.student] diff --git a/examples/TH-141/segments/LJ积分导言/element.toml b/crates/cph-model/tests/fixtures/bundle-valid/lesson-a/segments/a/element.toml similarity index 100% rename from examples/TH-141/segments/LJ积分导言/element.toml rename to crates/cph-model/tests/fixtures/bundle-valid/lesson-a/segments/a/element.toml diff --git a/crates/cph-model/tests/fixtures/bundle-valid/lesson-a/segments/a/textbook.typ b/crates/cph-model/tests/fixtures/bundle-valid/lesson-a/segments/a/textbook.typ new file mode 100644 index 0000000..344f76f --- /dev/null +++ b/crates/cph-model/tests/fixtures/bundle-valid/lesson-a/segments/a/textbook.typ @@ -0,0 +1 @@ +A. diff --git a/crates/cph-model/tests/fixtures/bundle-valid/lesson-b/manifest.toml b/crates/cph-model/tests/fixtures/bundle-valid/lesson-b/manifest.toml new file mode 100644 index 0000000..1e7279d --- /dev/null +++ b/crates/cph-model/tests/fixtures/bundle-valid/lesson-b/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "lesson-b" +name = "lesson-b" + +[info] +title = "课时B" + +[[children]] +kind = "segment" +path = "segments/b" + +[targets.teacher] diff --git a/examples/TH-141/segments/σTp态函数导言/element.toml b/crates/cph-model/tests/fixtures/bundle-valid/lesson-b/segments/b/element.toml similarity index 100% rename from examples/TH-141/segments/σTp态函数导言/element.toml rename to crates/cph-model/tests/fixtures/bundle-valid/lesson-b/segments/b/element.toml diff --git a/crates/cph-model/tests/fixtures/bundle-valid/lesson-b/segments/b/textbook.typ b/crates/cph-model/tests/fixtures/bundle-valid/lesson-b/segments/b/textbook.typ new file mode 100644 index 0000000..3f52430 --- /dev/null +++ b/crates/cph-model/tests/fixtures/bundle-valid/lesson-b/segments/b/textbook.typ @@ -0,0 +1 @@ +B. diff --git a/crates/cph-model/tests/fixtures/container-root-tables/manifest.toml b/crates/cph-model/tests/fixtures/container-root-tables/manifest.toml new file mode 100644 index 0000000..72b7d9e --- /dev/null +++ b/crates/cph-model/tests/fixtures/container-root-tables/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "fixture-container-root-tables" +name = "container-root-tables" + +[info] +title = "容器错误声明了根级表" + +[[children]] +kind = "section" +path = "section" + +[targets.student] diff --git a/crates/cph-model/tests/fixtures/container-root-tables/section/manifest.toml b/crates/cph-model/tests/fixtures/container-root-tables/section/manifest.toml new file mode 100644 index 0000000..44d923f --- /dev/null +++ b/crates/cph-model/tests/fixtures/container-root-tables/section/manifest.toml @@ -0,0 +1,5 @@ +[project] +id = "should-not-be-here" +name = "should-not-be-here" + +children = [] diff --git a/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml b/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml index fedd3aa..7a2b685 100644 --- a/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml +++ b/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml @@ -5,7 +5,7 @@ name = "kind-mismatch" [info] title = "kind 不一致测试" -[[parts]] +[[children]] kind = "segment" path = "segments/intro" diff --git a/crates/cph-model/tests/fixtures/missing-part/manifest.toml b/crates/cph-model/tests/fixtures/missing-part/manifest.toml index d54f585..9099578 100644 --- a/crates/cph-model/tests/fixtures/missing-part/manifest.toml +++ b/crates/cph-model/tests/fixtures/missing-part/manifest.toml @@ -5,11 +5,11 @@ name = "missing-part" [info] title = "缺部件测试" -[[parts]] +[[children]] kind = "segment" path = "segments/intro" -[[parts]] +[[children]] kind = "lemma" path = "lemmas/does-not-exist" diff --git a/crates/cph-model/tests/fixtures/neither-manifest-nor-element/manifest.toml b/crates/cph-model/tests/fixtures/neither-manifest-nor-element/manifest.toml new file mode 100644 index 0000000..5bf320d --- /dev/null +++ b/crates/cph-model/tests/fixtures/neither-manifest-nor-element/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "fixture-neither" +name = "neither" + +[info] +title = "文件夹既不是容器也不是叶子" + +[[children]] +kind = "segment" +path = "segments/broken" + +[targets.student] diff --git a/crates/cph-model/tests/fixtures/neither-manifest-nor-element/segments/broken/.gitkeep b/crates/cph-model/tests/fixtures/neither-manifest-nor-element/segments/broken/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/crates/cph-model/tests/fixtures/nested/manifest.toml b/crates/cph-model/tests/fixtures/nested/manifest.toml new file mode 100644 index 0000000..25e858f --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/manifest.toml @@ -0,0 +1,21 @@ +[project] +id = "fixture-nested" +name = "nested" + +[info] +title = "嵌套结构测试" + +[[children]] +kind = "segment" +path = "segments/开场白" + +[[children]] +kind = "section" +path = "导言簇" +notes = "这里先建立直观图像,再进入分组推导。" + +[[children]] +kind = "section" +path = "收束簇" + +[targets.student] diff --git a/examples/TH-141/segments/σT建模导言/element.toml b/crates/cph-model/tests/fixtures/nested/segments/开场白/element.toml similarity index 100% rename from examples/TH-141/segments/σT建模导言/element.toml rename to crates/cph-model/tests/fixtures/nested/segments/开场白/element.toml diff --git a/crates/cph-model/tests/fixtures/nested/segments/开场白/textbook.typ b/crates/cph-model/tests/fixtures/nested/segments/开场白/textbook.typ new file mode 100644 index 0000000..dde1619 --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/segments/开场白/textbook.typ @@ -0,0 +1 @@ +开场白。 diff --git a/crates/cph-model/tests/fixtures/nested/导言簇/manifest.toml b/crates/cph-model/tests/fixtures/nested/导言簇/manifest.toml new file mode 100644 index 0000000..d1a29e8 --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/导言簇/manifest.toml @@ -0,0 +1,14 @@ +[group] +title = "导言簇" + +[[children]] +kind = "segment" +path = "segments/子段一" + +[[children]] +kind = "section" +path = "嵌套子节" + +[[children]] +kind = "segment" +path = "segments/子段二" diff --git a/examples/TH-141/segments/σ作为态函数的图像/element.toml b/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段一/element.toml similarity index 100% rename from examples/TH-141/segments/σ作为态函数的图像/element.toml rename to crates/cph-model/tests/fixtures/nested/导言簇/segments/子段一/element.toml diff --git a/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段一/textbook.typ b/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段一/textbook.typ new file mode 100644 index 0000000..bd0bb08 --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段一/textbook.typ @@ -0,0 +1 @@ +子段一。 diff --git a/examples/TH-141/segments/三相接触导言/element.toml b/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段二/element.toml similarity index 100% rename from examples/TH-141/segments/三相接触导言/element.toml rename to crates/cph-model/tests/fixtures/nested/导言簇/segments/子段二/element.toml diff --git a/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段二/textbook.typ b/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段二/textbook.typ new file mode 100644 index 0000000..ff49341 --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段二/textbook.typ @@ -0,0 +1 @@ +子段二。 diff --git a/crates/cph-typst/tests/fixtures/mini/lemmas/无证明引理/element.toml b/crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/lemmas/子引理/element.toml similarity index 100% rename from crates/cph-typst/tests/fixtures/mini/lemmas/无证明引理/element.toml rename to crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/lemmas/子引理/element.toml diff --git a/crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/lemmas/子引理/stmt.typ b/crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/lemmas/子引理/stmt.typ new file mode 100644 index 0000000..e1c5373 --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/lemmas/子引理/stmt.typ @@ -0,0 +1 @@ +子引理陈述。 diff --git a/crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/manifest.toml b/crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/manifest.toml new file mode 100644 index 0000000..e6676ea --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/manifest.toml @@ -0,0 +1,3 @@ +[[children]] +kind = "lemma" +path = "lemmas/子引理" diff --git a/crates/cph-model/tests/fixtures/nested/收束簇/manifest.toml b/crates/cph-model/tests/fixtures/nested/收束簇/manifest.toml new file mode 100644 index 0000000..97c6dfd --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/收束簇/manifest.toml @@ -0,0 +1,6 @@ +[group] +title = "收束簇" + +[[children]] +kind = "segment" +path = "segments/总结" diff --git a/examples/TH-141/segments/不同物质γ量级对比/element.toml b/crates/cph-model/tests/fixtures/nested/收束簇/segments/总结/element.toml similarity index 100% rename from examples/TH-141/segments/不同物质γ量级对比/element.toml rename to crates/cph-model/tests/fixtures/nested/收束簇/segments/总结/element.toml diff --git a/crates/cph-model/tests/fixtures/nested/收束簇/segments/总结/textbook.typ b/crates/cph-model/tests/fixtures/nested/收束簇/segments/总结/textbook.typ new file mode 100644 index 0000000..8e38b02 --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/收束簇/segments/总结/textbook.typ @@ -0,0 +1 @@ +总结 diff --git a/crates/cph-model/tests/fixtures/root-group-declared/manifest.toml b/crates/cph-model/tests/fixtures/root-group-declared/manifest.toml new file mode 100644 index 0000000..f5bf565 --- /dev/null +++ b/crates/cph-model/tests/fixtures/root-group-declared/manifest.toml @@ -0,0 +1,15 @@ +[project] +id = "fixture-root-group" +name = "root-group" + +[info] +title = "根级 manifest 错误声明了 group" + +[group] +title = "不该在根级" + +[[children]] +kind = "segment" +path = "segments/a" + +[targets.student] diff --git a/examples/TH-141/segments/各模型对水的预测对照/element.toml b/crates/cph-model/tests/fixtures/root-group-declared/segments/a/element.toml similarity index 100% rename from examples/TH-141/segments/各模型对水的预测对照/element.toml rename to crates/cph-model/tests/fixtures/root-group-declared/segments/a/element.toml diff --git a/crates/cph-model/tests/fixtures/root-group-declared/segments/a/textbook.typ b/crates/cph-model/tests/fixtures/root-group-declared/segments/a/textbook.typ new file mode 100644 index 0000000..2c88a88 --- /dev/null +++ b/crates/cph-model/tests/fixtures/root-group-declared/segments/a/textbook.typ @@ -0,0 +1 @@ +a. diff --git a/crates/cph-model/tests/fixtures/valid/manifest.toml b/crates/cph-model/tests/fixtures/valid/manifest.toml index 227556b..5e858a0 100644 --- a/crates/cph-model/tests/fixtures/valid/manifest.toml +++ b/crates/cph-model/tests/fixtures/valid/manifest.toml @@ -6,11 +6,12 @@ name = "valid-2-part" title = "测试课:两个部件" author = "范式教育教研组" -[[parts]] +[[children]] kind = "segment" path = "segments/intro" +notes = "这一节补充一个直观例题" -[[parts]] +[[children]] kind = "lemma" path = "lemmas/young" diff --git a/crates/cph-model/tests/load.rs b/crates/cph-model/tests/load.rs index 64a4d3c..b635d57 100644 --- a/crates/cph-model/tests/load.rs +++ b/crates/cph-model/tests/load.rs @@ -1,11 +1,12 @@ //! Integration tests for `cph_model::load`, driven by static fixtures under -//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0008 -//! on-disk format. +//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0029 +//! on-disk format (a nested outline manifest; supersedes ADR-0008's flat +//! `[[parts]]`). use std::path::PathBuf; use cph_diag::DiagCode; -use cph_model::load; +use cph_model::{load, OutlineEntry}; /// Absolute path to a fixture engineering-file root. fn fixture(name: &str) -> PathBuf { @@ -34,11 +35,38 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() { assert_eq!(lesson.parts.len(), 2); assert_eq!(lesson.parts[0].kind, "segment"); assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro")); + assert_eq!( + lesson.parts[0].notes.as_deref(), + Some("这一节补充一个直观例题") + ); + let outline = lesson.outline_document(); + assert_eq!(outline.children[0].title, "intro"); + assert_eq!( + outline.children[0].notes.as_deref(), + Some("这一节补充一个直观例题") + ); + assert!(outline.children[0].children.is_empty()); assert_eq!(lesson.parts[0].descriptor.kind, "segment"); assert_eq!(lesson.parts[1].kind, "lemma"); assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young")); assert_eq!(lesson.parts[1].descriptor.kind, "lemma"); + // The outline is a flat sequence of elements-by-index when there are no + // containers. + assert_eq!( + lesson.outline, + vec![ + OutlineEntry::Element { + part_index: 0, + depth: 0, + }, + OutlineEntry::Element { + part_index: 1, + depth: 0, + }, + ] + ); + // `source` scalar survives on the lemma descriptor; `kind` is removed. let scalars = &lesson.parts[1].descriptor.scalars; assert_eq!( @@ -74,6 +102,90 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() { ); } +#[test] +fn nested_sections_flatten_depth_first_with_correct_depths() { + let (lesson, diags) = load(&fixture("nested")); + let lesson = lesson.expect("nested fixture must produce a Lesson"); + assert!( + diags.is_empty(), + "nested fixture must have no diagnostics, got: {diags:?}" + ); + + // DFS pre-order element sequence (ADR-0029): containers contribute no + // element of their own. + let paths: Vec<_> = lesson.parts.iter().map(|p| p.path.clone()).collect(); + assert_eq!( + paths, + vec![ + PathBuf::from("segments/开场白"), + PathBuf::from("导言簇/segments/子段一"), + PathBuf::from("导言簇/嵌套子节/lemmas/子引理"), + PathBuf::from("导言簇/segments/子段二"), + PathBuf::from("收束簇/segments/总结"), + ], + "root-relative paths must accumulate through every nesting level" + ); + + // The outline interleaves section headings at their DFS-open position, + // with depth 1 for a section directly under the root and depth 2 for one + // nested inside another section. + assert_eq!( + lesson.outline, + vec![ + OutlineEntry::Element { + part_index: 0, + depth: 0, + }, // segments/开场白 + OutlineEntry::Section { + kind: "section".to_string(), + title: "导言簇".to_string(), + depth: 1, + notes: Some("这里先建立直观图像,再进入分组推导。".to_string()), + path: PathBuf::from("导言簇"), + }, + OutlineEntry::Element { + part_index: 1, + depth: 1, + }, // 导言簇/segments/子段一 + OutlineEntry::Section { + kind: "section".to_string(), + title: "嵌套子节".to_string(), + depth: 2, + notes: None, + path: PathBuf::from("导言簇/嵌套子节"), + }, + OutlineEntry::Element { + part_index: 2, + depth: 2, + }, // 导言簇/嵌套子节/lemmas/子引理 + OutlineEntry::Element { + part_index: 3, + depth: 1, + }, // 导言簇/segments/子段二 + OutlineEntry::Section { + kind: "section".to_string(), + title: "收束簇".to_string(), + depth: 1, + notes: None, + path: PathBuf::from("收束簇"), + }, + OutlineEntry::Element { + part_index: 4, + depth: 1, + }, // 收束簇/segments/总结 + ] + ); + let document = lesson.outline_document(); + assert_eq!(document.children.len(), 3); + assert_eq!(document.children[1].title, "导言簇"); + assert_eq!(document.children[1].children.len(), 3); + assert_eq!(document.children[2].title, "收束簇"); + assert_eq!(document.children[2].children[0].title, "总结"); + + // The outer section declares [group].title = "导言簇"; the inner section + // has no [group] at all, so its title falls back to the folder basename. +} + #[test] fn missing_part_folder_yields_part_path_missing() { let (lesson, diags) = load(&fixture("missing-part")); @@ -126,7 +238,7 @@ fn malformed_manifest_is_a_hard_failure() { "malformed manifest must be a hard failure (None)" ); assert_eq!(diags.len(), 1, "one hard-failure diagnostic expected"); - assert_eq!(diags[0].code, DiagCode::SchemaViolation); + assert_eq!(diags[0].code, DiagCode::ManifestMalformed); assert_eq!(diags[0].severity, cph_diag::Severity::Error); } @@ -136,7 +248,98 @@ fn missing_manifest_is_a_hard_failure() { let (lesson, diags) = load(&fixture("does-not-exist-at-all")); assert!(lesson.is_none()); assert_eq!(diags.len(), 1); - assert_eq!(diags[0].code, DiagCode::SchemaViolation); + assert_eq!(diags[0].code, DiagCode::ManifestMalformed); +} + +#[test] +fn folder_with_both_manifest_and_element_is_manifest_malformed() { + let (lesson, diags) = load(&fixture("both-manifest-and-element")); + let lesson = lesson.expect("must still produce a best-effort Lesson"); + assert_eq!( + lesson.parts.len(), + 1, + "the ambiguous child is a placeholder" + ); + + let malformed: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::ManifestMalformed) + .collect(); + assert_eq!( + malformed.len(), + 1, + "exactly one ManifestMalformed expected, got: {diags:?}" + ); + assert!( + malformed[0] + .message + .contains("both manifest.toml and element.toml"), + "message should explain the ambiguity, got: {}", + malformed[0].message + ); +} + +#[test] +fn folder_with_neither_manifest_nor_element_is_manifest_malformed() { + let (lesson, diags) = load(&fixture("neither-manifest-nor-element")); + let lesson = lesson.expect("must still produce a best-effort Lesson"); + assert_eq!( + lesson.parts.len(), + 1, + "the incomplete child is a placeholder" + ); + + let malformed: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::ManifestMalformed) + .collect(); + assert_eq!( + malformed.len(), + 1, + "exactly one ManifestMalformed expected, got: {diags:?}" + ); + assert!( + malformed[0] + .message + .contains("neither manifest.toml nor element.toml"), + "message should explain the gap, got: {}", + malformed[0].message + ); +} + +#[test] +fn container_declaring_root_only_tables_is_manifest_malformed() { + let (lesson, diags) = load(&fixture("container-root-tables")); + assert!( + lesson.is_some(), + "a container misplacing root tables is non-fatal" + ); + + let malformed: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::ManifestMalformed && d.message.contains("root-only")) + .collect(); + assert_eq!( + malformed.len(), + 1, + "exactly one root-only-table diagnostic expected, got: {diags:?}" + ); +} + +#[test] +fn root_manifest_declaring_group_is_manifest_malformed() { + let (lesson, diags) = load(&fixture("root-group-declared")); + assert!(lesson.is_some(), "the root declaring [group] is non-fatal"); + + let malformed: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::ManifestMalformed && d.message.contains("[group]")) + .collect(); + assert_eq!( + malformed.len(), + 1, + "exactly one root-[group] diagnostic expected, got: {diags:?}" + ); } #[test] @@ -290,7 +493,7 @@ fn tmp_lesson_with_version(version: Option<&str>) -> tempfile::TempDir { let p = tmp.path(); std::fs::write( p.join("manifest.toml"), - "[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n", + "[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n", ) .unwrap(); let seg = p.join("segments").join("a"); diff --git a/crates/cph-typst/src/embedded.rs b/crates/cph-typst/src/embedded.rs index 0d32ce2..1db9506 100644 --- a/crates/cph-typst/src/embedded.rs +++ b/crates/cph-typst/src/embedded.rs @@ -33,11 +33,10 @@ static RENDER_DIR: Dir<'_> = include_dir!("$CPH_STAGED_RENDER_DIR"); /// extracting the embedded copy to a per-user cache dir if needed. /// /// Resolution order: -/// 1. `CPH_RENDER_DIR` env var — an explicit override (dev convenience: point -/// at the live repo `render/`). +/// 1. `CPH_RENDER_DIR` — an explicit override (dev convenience: point at the +/// live repo `render/`). /// 2. The extracted embedded copy under the user cache dir -/// (`/cph/render-/`). Extracted once per crate version; -/// subsequent runs reuse it. +/// (`/cph/render-/`). /// /// On any failure to locate a cache dir or extract, falls back to a temp-dir /// location so the engine still works (just re-extracting per process). @@ -48,31 +47,47 @@ pub fn resolve_render_dir() -> PathBuf { ensure_extracted().unwrap_or_else(|_| { // Last-resort: extract under the OS temp dir. Still correct, just not // cached across processes. - let fallback = - std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION"))); + let fallback = std::env::temp_dir().join(format!( + "cph-render-{}-{}", + env!("CARGO_PKG_VERSION"), + RENDER_CACHE_REVISION + )); let _ = extract_to(&fallback); fallback }) } -/// The version-keyed cache location and a guarantee the embedded tree is present -/// there. Returns the directory the World should use. +/// Bump when the embedded render package changes without a cph crate-version +/// bump. Otherwise a user's old per-version cache can miss newly added package +/// functions (such as `render-outline`). +const RENDER_CACHE_REVISION: &str = "outline-v2"; + +/// The version/revision-keyed cache location and a guarantee the embedded tree +/// is present there. Returns the directory the World should use. fn ensure_extracted() -> std::io::Result { let base = dirs::cache_dir() .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?; let dest = base .join("cph") .join(format!("render-{}", env!("CARGO_PKG_VERSION"))); - - // A sentinel marks a complete extraction; if present, reuse as-is. (Keyed by - // version, so a new `cph` version re-extracts into a fresh dir.) let sentinel = dest.join(".extracted"); - if sentinel.is_file() { + let expected = format!("{}:{}", env!("CARGO_PKG_VERSION"), RENDER_CACHE_REVISION); + + if std::fs::read_to_string(&sentinel) + .map(|contents| contents.trim_end() == expected) + .unwrap_or(false) + { return Ok(dest); } + // The crate version can stay stable while the embedded render package + // evolves. Remove the old tree before extracting so deleted files do not + // survive a revision refresh. + if dest.exists() { + std::fs::remove_dir_all(&dest)?; + } extract_to(&dest)?; - std::fs::write(&sentinel, env!("CARGO_PKG_VERSION"))?; + std::fs::write(&sentinel, expected)?; Ok(dest) } diff --git a/crates/cph-typst/src/lib.rs b/crates/cph-typst/src/lib.rs index e1bf25f..b0b418f 100644 --- a/crates/cph-typst/src/lib.rs +++ b/crates/cph-typst/src/lib.rs @@ -39,15 +39,15 @@ mod embedded; mod manifest; mod world; +use cph_diag::{DiagCode, Diagnostic}; use std::path::PathBuf; -use cph_diag::{DiagCode, Diagnostic}; -use cph_model::{Artifact, Lesson, Step, TargetConfig}; +use cph_model::{Artifact, Bundle, Lesson, OutlineDocument, Step, TargetConfig}; use typst_kit::fonts::{self, FontStore}; use typst_layout::PagedDocument; use typst_pdf::PdfOptions; -pub use manifest::build_augmented_manifest; +pub use manifest::{build_augmented_bundle_manifest, build_augmented_manifest}; pub use world::{render_package_spec, LessonWorld, MANIFEST_VPATH}; /// The compile/PDF engine: holds the shared font store and the on-disk location @@ -139,10 +139,38 @@ impl Engine { typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors)) } + /// Build a PDF for an outline document without creating files in the lesson. + /// + /// The outline entrypoint and its TOML data are served by an in-memory + /// [`LessonWorld`]. This keeps outline generation independent of any + /// declared lesson export target while reusing the embedded fonts and PDF + /// backend. + pub fn build_outline_pdf(&self, outline: &OutlineDocument) -> Result, Vec> { + const SOURCE: &str = r#"#import "@local/cph-render:0.1.0": render-outline +#let outline = toml(sys.inputs.outline) +#render-outline(outline) +"#; + + let outline_src = toml::to_string(outline).expect("outline serializes to TOML"); + let world = LessonWorld::new_outline( + PathBuf::from("."), + self.render_dir.clone(), + SOURCE.to_owned(), + outline_src, + self.fonts.clone(), + ); + let warned = typst::compile::(&world); + let doc = match warned.output { + Ok(doc) => doc, + Err(errors) => return Err(map_all(&world, &errors)), + }; + typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors)) + } + /// Build the [`LessonWorld`] for `(lesson, target)`, or `Err(blocking)` when /// the request cannot be honored (see [`target_precheck`]). fn world_for(&self, lesson: &Lesson, target: &str) -> Result> { - let template = target_precheck(lesson, target)?; + let template = target_precheck(target, &lesson.targets)?; let manifest_src = build_augmented_manifest(lesson); Ok(LessonWorld::new( lesson.root.clone(), @@ -152,6 +180,60 @@ impl Engine { self.fonts.clone(), )) } + + /// Compile-check `bundle` for `target` (ADR-0030) — same contract as + /// [`Engine::compile_check`], but over a [`Bundle`]'s own declared targets + /// and the augmented **bundle** manifest (each member lesson's outline, + /// path-prefixed to resolve against the bundle root). + pub fn compile_check_bundle(&self, bundle: &Bundle, target: &str) -> Vec { + let world = match self.world_for_bundle(bundle, target) { + Ok(world) => world, + Err(blocking) => return blocking, + }; + let warned = typst::compile::(&world); + + let mut out = Vec::new(); + if let Err(errors) = &warned.output { + out.extend(map_all(&world, errors)); + } + out.extend(map_all(&world, &warned.warnings)); + out + } + + /// Build a PDF for `bundle` / `target` (ADR-0030) — same contract as + /// [`Engine::build_pdf`], over a [`Bundle`]'s own declared targets. + pub fn build_bundle_pdf( + &self, + bundle: &Bundle, + target: &str, + ) -> Result, Vec> { + let world = self.world_for_bundle(bundle, target)?; + let warned = typst::compile::(&world); + let doc = match warned.output { + Ok(doc) => doc, + Err(errors) => return Err(map_all(&world, &errors)), + }; + typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors)) + } + + /// Build the [`LessonWorld`] for `(bundle, target)`: same shape as + /// [`Engine::world_for`], main file resolved under the **bundle** root and + /// the injected manifest built by [`build_augmented_bundle_manifest`]. + fn world_for_bundle( + &self, + bundle: &Bundle, + target: &str, + ) -> Result> { + let template = target_precheck(target, &bundle.targets)?; + let manifest_src = build_augmented_bundle_manifest(bundle); + Ok(LessonWorld::new( + bundle.root.clone(), + self.render_dir.clone(), + &template, + manifest_src, + self.fonts.clone(), + )) + } } impl Default for Engine { @@ -160,13 +242,16 @@ impl Default for Engine { } } -/// Validate a `(lesson, target)` request and resolve the template path to -/// compile. Returns `Ok(template_path)` (relative to the lesson root) when the -/// request is buildable, or `Err(blocking_diagnostics)` when it is not: +/// Validate a `(targets, target)` request and resolve the template path to +/// compile. Shared by [`Engine::world_for`] (a lesson's `targets`) and +/// [`Engine::world_for_bundle`] (a bundle's own `targets` — ADR-0030 gives a +/// bundle target the exact same build/artifact/step shape). Returns +/// `Ok(template_path)` (relative to the lesson/bundle root) when the request is +/// buildable, or `Err(blocking_diagnostics)` when it is not: /// -/// - **Unknown target** (the `--target` name isn't in `lesson.targets`, and the -/// lesson declares at least one target): a `SchemaViolation` error — a target -/// must be declared in the manifest to be built (ADR-0009). +/// - **Unknown target** (the `--target` name isn't in `targets`, and `targets` +/// is non-empty): a `SchemaViolation` error — a target must be declared in +/// the manifest to be built (ADR-0009). /// - **No declared targets at all**: not an error — callers (e.g. `cph-check`) /// may compile-check a defaulted `"student"` target the lesson never declared. /// The stock template path `exports/.typ` is used (the framework @@ -177,10 +262,10 @@ impl Default for Engine { /// [`Step::Shell`], returns a clear "not yet implemented" `SchemaViolation` /// rather than wrong output. The template is taken from the **first** /// `TypstCompile` step (MVP: one step per target). -fn target_precheck(lesson: &Lesson, target: &str) -> Result> { - let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else { - if lesson.targets.is_empty() { - // Lesson declares no targets; the orchestrator compiles a defaulted +fn target_precheck(target: &str, targets: &[TargetConfig]) -> Result> { + let Some(tc) = targets.iter().find(|t| t.name == target) else { + if targets.is_empty() { + // Declares no targets; the orchestrator compiles a defaulted // target. Use the stock template path (matches cph-model's default). return Ok(PathBuf::from(format!("exports/{target}.typ"))); } diff --git a/crates/cph-typst/src/manifest.rs b/crates/cph-typst/src/manifest.rs index cdd4e58..db2f185 100644 --- a/crates/cph-typst/src/manifest.rs +++ b/crates/cph-typst/src/manifest.rs @@ -1,18 +1,28 @@ -//! Augmented-manifest construction (ADR-0011). +//! 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 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`). +//! `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]` + 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`. +//! **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` //! @@ -20,23 +30,104 @@ //! ([`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 +//! kind's content fields are. For each element, a content field is listed in //! `fields` iff `//.typ` is a real file. -use cph_model::Lesson; +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 `[[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`. +/// 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(); - // [info] + 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(), @@ -52,30 +143,61 @@ pub fn build_augmented_manifest(lesson: &Lesson) -> String { .collect(); info.insert("author".to_string(), toml::Value::Array(authors)); } - doc.insert("info".to_string(), toml::Value::Table(info)); + 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( +/// 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(path_to_forward_slash(&part.path)), + toml::Value::String(prefixed_forward_slash(bundle_prefix, &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)); + 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 +} - toml::to_string(&doc).expect("augmented manifest serializes") +/// [`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` diff --git a/crates/cph-typst/src/world.rs b/crates/cph-typst/src/world.rs index 08b08fc..ac0c269 100644 --- a/crates/cph-typst/src/world.rs +++ b/crates/cph-typst/src/world.rs @@ -48,13 +48,14 @@ use typst::{Library, LibraryExt, World}; use typst_kit::fonts::FontStore; /// Root-relative vpath the augmented manifest is served at (in-memory only). -/// -/// A **leading slash** is essential: the template lives under `exports/`, and -/// `toml(sys.inputs.manifest)` resolves a relative path against the template's -/// own directory — a bare name would miss. A root-relative absolute path anchors -/// at `--root` (the lesson root) regardless of where the template sits. pub const MANIFEST_VPATH: &str = "/.cph/manifest.toml"; +/// Root-relative vpath of the virtual outline entrypoint. +pub const OUTLINE_VPATH: &str = "/exports/outline.typ"; + +/// Root-relative vpath of the virtual outline data file. +pub const OUTLINE_DATA_VPATH: &str = "/.cph/outline.toml"; + /// The package spec the template imports and the World mounts from `render_dir`. pub fn render_package_spec() -> PackageSpec { PackageSpec { @@ -76,13 +77,11 @@ pub struct LessonWorld { render_dir: PathBuf, /// The render package spec (`@local/cph-render:0.1.0`). render_spec: PackageSpec, - /// FileId of the template entrypoint (a real file under `root`). + /// FileId of the entrypoint. main: FileId, - /// FileId of the in-memory augmented manifest. - manifest_id: FileId, - /// The augmented-manifest source (in-memory; never on disk). - manifest_source: Source, - /// Standard library, with `sys.inputs.manifest` set. + /// In-memory project files (manifest, or the outline entrypoint/data). + virtual_sources: HashMap, + /// Standard library inputs exposed to the Typst source. library: LazyHash, /// Shared font store (book + lazily-loaded fonts). fonts: Arc, @@ -95,8 +94,8 @@ impl LessonWorld { /// whose injected manifest is `manifest_src` (served virtually at /// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there). /// - /// `template` is the lesson-root-relative template path (e.g. - /// `exports/student.typ`), taken from the target's `Step::TypstCompile`. + /// `template` is the lesson-root-relative path taken from the target's + /// `Step::TypstCompile`. pub fn new( root: PathBuf, render_dir: PathBuf, @@ -107,16 +106,55 @@ impl LessonWorld { let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template))) .expect("template vpath is a valid virtual path"); let main = FileId::new(RootedPath::new(VirtualRoot::Project, main_vpath)); + let manifest_id = project_file_id(MANIFEST_VPATH); + let mut virtual_sources = HashMap::new(); + virtual_sources.insert(manifest_id, Source::new(manifest_id, manifest_src)); + Self::with_virtual_files( + root, + render_dir, + main, + virtual_sources, + &[("manifest", MANIFEST_VPATH)], + fonts, + ) + } - let manifest_vpath = - VirtualPath::new(MANIFEST_VPATH).expect("manifest vpath is a valid virtual path"); - let manifest_id = FileId::new(RootedPath::new(VirtualRoot::Project, manifest_vpath)); - let manifest_source = Source::new(manifest_id, manifest_src); + /// Build a world for a fully virtual outline document and its TOML data. + /// The caller never has to create temporary files in the engineering file. + pub fn new_outline( + root: PathBuf, + render_dir: PathBuf, + source: String, + outline_src: String, + fonts: Arc, + ) -> Self { + let main = project_file_id(OUTLINE_VPATH); + let outline_id = project_file_id(OUTLINE_DATA_VPATH); + let mut virtual_sources = HashMap::new(); + virtual_sources.insert(main, Source::new(main, source)); + virtual_sources.insert(outline_id, Source::new(outline_id, outline_src)); + Self::with_virtual_files( + root, + render_dir, + main, + virtual_sources, + &[("outline", OUTLINE_DATA_VPATH)], + fonts, + ) + } - // Inject `sys.inputs.manifest = "/.cph/manifest.toml"` so the template's - // `toml(sys.inputs.manifest)` reads the augmented manifest. + fn with_virtual_files( + root: PathBuf, + render_dir: PathBuf, + main: FileId, + virtual_sources: HashMap, + input_files: &[(&str, &str)], + fonts: Arc, + ) -> Self { let mut inputs = Dict::new(); - inputs.insert("manifest".into(), Value::Str(MANIFEST_VPATH.into())); + for (name, path) in input_files { + inputs.insert((*name).into(), Value::Str((*path).into())); + } let library = Library::builder().with_inputs(inputs).build(); Self { @@ -124,8 +162,7 @@ impl LessonWorld { render_dir, render_spec: render_package_spec(), main, - manifest_id, - manifest_source, + virtual_sources, library: LazyHash::new(library), fonts, sources: Mutex::new(HashMap::new()), @@ -185,8 +222,8 @@ impl World for LessonWorld { } fn source(&self, id: FileId) -> FileResult { - if id == self.manifest_id { - return Ok(self.manifest_source.clone()); + if let Some(source) = self.virtual_sources.get(&id) { + return Ok(source.clone()); } // Cache hit? if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) { @@ -203,8 +240,8 @@ impl World for LessonWorld { } fn file(&self, id: FileId) -> FileResult { - if id == self.manifest_id { - return Ok(Bytes::from_string(self.manifest_source.text().to_string())); + if let Some(source) = self.virtual_sources.get(&id) { + return Ok(Bytes::from_string(source.text().to_string())); } let bytes = self.read_bytes(id)?; Ok(Bytes::new(bytes)) @@ -220,6 +257,11 @@ impl World for LessonWorld { } } +fn project_file_id(path: &str) -> FileId { + let vpath = VirtualPath::new(path).expect("virtual project path is valid"); + FileId::new(RootedPath::new(VirtualRoot::Project, vpath)) +} + /// Render a relative `Path` as a forward-slash string, dropping any leading /// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim. fn path_to_forward_slash(path: &Path) -> String { diff --git a/crates/cph-typst/tests/bundle.rs b/crates/cph-typst/tests/bundle.rs new file mode 100644 index 0000000..d0fd920 --- /dev/null +++ b/crates/cph-typst/tests/bundle.rs @@ -0,0 +1,122 @@ +//! Integration tests for the bundle build path (ADR-0030): compiling a bundle +//! target's template (`exports/.typ` under a `bundle.toml` root) as +//! main, injecting the augmented **bundle** manifest, against the real +//! `render/` package. + +use std::path::PathBuf; + +use cph_diag::Severity; +use cph_typst::{build_augmented_bundle_manifest, Engine}; + +fn fixture_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bundle") +} + +fn real_render_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("render") +} + +fn load_bundle() -> cph_model::Bundle { + let (bundle, diags) = cph_model::load_bundle(&fixture_root()); + let bundle = bundle.expect("bundle fixture loads into a Bundle"); + let errors: Vec<_> = diags + .iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "fixture has loader errors: {errors:?}"); + bundle +} + +/// PURE UNIT TEST (no fonts, no render package): the augmented bundle manifest +/// carries the bundle's own `[info]` and an ordered `[[lessons]]`, each with +/// that member's own `info`/`target` and a `path`-prefixed outline (ADR-0030). +#[test] +fn augmented_bundle_manifest_prefixes_member_paths() { + let bundle = load_bundle(); + let src = build_augmented_bundle_manifest(&bundle); + + assert!( + src.contains("测试合集"), + "bundle info.title present:\n{src}" + ); + + let doc: toml::Value = toml::from_str(&src).expect("augmented bundle manifest is valid TOML"); + let lessons = doc + .get("lessons") + .and_then(|l| l.as_array()) + .expect("lessons array present"); + assert_eq!(lessons.len(), 2, "two member lessons:\n{src}"); + + // lesson-a: target defaults to its own first declared target ("student"), + // outline paths are prefixed with "lesson-a/". + let a_target = lessons[0].get("target").unwrap().as_str().unwrap(); + assert_eq!(a_target, "student"); + let a_outline = lessons[0].get("outline").unwrap().as_array().unwrap(); + // segment + section heading + lemma = 3 outline entries. + assert_eq!(a_outline.len(), 3); + let a_seg_path = a_outline[0].get("path").unwrap().as_str().unwrap(); + assert_eq!(a_seg_path, "lesson-a/segments/a"); + let a_section_path = a_outline[1].get("path").unwrap().as_str().unwrap(); + assert_eq!(a_section_path, "lesson-a/小节"); + let a_lemma_path = a_outline[2].get("path").unwrap().as_str().unwrap(); + assert_eq!(a_lemma_path, "lesson-a/小节/lemmas/引理甲"); + + // lesson-b: bundle.toml overrides `target = "teacher"`. + let b_target = lessons[1].get("target").unwrap().as_str().unwrap(); + assert_eq!(b_target, "teacher"); + let b_outline = lessons[1].get("outline").unwrap().as_array().unwrap(); + assert_eq!(b_outline.len(), 1); + let b_seg_path = b_outline[0].get("path").unwrap().as_str().unwrap(); + assert_eq!(b_seg_path, "lesson-b/segments/b"); +} + +/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling +/// the bundle's `merged` target as main with the injected augmented bundle +/// manifest is clean. +#[test] +fn compile_check_clean_through_bundle_template() { + let bundle = load_bundle(); + let engine = Engine::with_render_dir(real_render_dir()); + let diags = engine.compile_check_bundle(&bundle, "merged"); + let errors: Vec<_> = diags + .iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "unexpected compile errors: {errors:#?}"); +} + +/// THROUGH-TEMPLATE PDF export, fully offline: a non-trivial combined PDF is +/// produced from the two member lessons through the real bundle template. +#[test] +fn build_bundle_pdf_through_template_offline() { + let bundle = load_bundle(); + let engine = Engine::with_render_dir(real_render_dir()); + + let pdf = engine + .build_bundle_pdf(&bundle, "merged") + .unwrap_or_else(|d| panic!("bundle PDF build failed: {d:#?}")); + assert!(pdf.starts_with(b"%PDF"), "output is a PDF"); + assert!( + pdf.len() > 1024, + "bundle PDF is non-trivial (got {} bytes)", + pdf.len() + ); +} + +/// An undeclared bundle target name is a blocking `SchemaViolation`, exactly +/// like a lesson's own unknown-target path. +#[test] +fn unknown_bundle_target_is_blocking() { + let bundle = load_bundle(); + let engine = Engine::with_render_dir(real_render_dir()); + let diags = engine.compile_check_bundle(&bundle, "nonexistent"); + assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}"); + assert_eq!(diags[0].severity, Severity::Error); + assert!( + diags[0].message.contains("not declared"), + "expected an undeclared-target error: {diags:#?}" + ); +} diff --git a/crates/cph-typst/tests/compile.rs b/crates/cph-typst/tests/compile.rs index 889efdd..bd5314d 100644 --- a/crates/cph-typst/tests/compile.rs +++ b/crates/cph-typst/tests/compile.rs @@ -39,10 +39,12 @@ fn load_mini() -> cph_model::Lesson { } /// PURE UNIT TEST (no fonts, no render package): the augmented manifest carries -/// `[info]`, the ordered `[[parts]]`, and a per-part `fields` array listing the -/// content fields present on disk. +/// `[info]` and the ordered `[[outline]]` (ADR-0029) — elements (with a +/// per-element `fields` array of the content fields present on disk) +/// interleaved with the section heading the mini fixture nests its two lemmas +/// under. #[test] -fn augmented_manifest_has_per_part_fields() { +fn augmented_manifest_has_outline_with_section_and_fields() { let lesson = load_mini(); let src = build_augmented_manifest(&lesson); @@ -50,66 +52,98 @@ fn augmented_manifest_has_per_part_fields() { assert!(src.contains("迷你示例课时"), "info.title present:\n{src}"); assert!(src.contains("测试作者"), "info.author present:\n{src}"); - // Parse it back to inspect the per-part fields precisely. + // Parse it back to inspect the outline entries precisely. let doc: toml::Value = toml::from_str(&src).expect("augmented manifest is valid TOML"); - let parts = doc - .get("parts") + let outline = doc + .get("outline") .and_then(|p| p.as_array()) - .expect("parts array present"); - assert_eq!(parts.len(), 4, "four parts in declared order:\n{src}"); + .expect("outline array present"); + // segment, section, lemma, lemma, example — 5 entries (ADR-0029: the + // section contributes a heading entry, not an element). + assert_eq!(outline.len(), 5, "five outline entries:\n{src}"); - // `fields` is a presence SET (the template tests membership), so order is - // not load-bearing; sort for a stable assertion. - let fields_of = |idx: usize| -> Vec { - let mut v: Vec = parts[idx] - .get("fields") - .and_then(|f| f.as_array()) - .expect("part has a fields array") - .iter() - .map(|v| v.as_str().unwrap().to_string()) - .collect(); - v.sort(); - v - }; - let path_of = |idx: usize| { - parts[idx] - .get("path") + let entry_type = |idx: usize| { + outline[idx] + .get("type") .unwrap() .as_str() .unwrap() .to_string() }; let kind_of = |idx: usize| { - parts[idx] + outline[idx] .get("kind") .unwrap() .as_str() .unwrap() .to_string() }; + let path_of = |idx: usize| { + outline[idx] + .get("path") + .unwrap() + .as_str() + .unwrap() + .to_string() + }; + // `fields` is a presence SET (the template tests membership), so order is + // not load-bearing; sort for a stable assertion. + let fields_of = |idx: usize| -> Vec { + let mut v: Vec = outline[idx] + .get("fields") + .and_then(|f| f.as_array()) + .expect("element entry has a fields array") + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + v.sort(); + v + }; - // Order preserved: segment, lemma (w/ proof), lemma (no proof), example. + // Order preserved: segment, section (引理组), lemma (w/ proof), lemma (no + // proof), example. + assert_eq!(entry_type(0), "element"); assert_eq!(kind_of(0), "segment"); - assert_eq!(kind_of(1), "lemma"); - assert_eq!(kind_of(2), "lemma"); - assert_eq!(kind_of(3), "example"); - - // Paths kept as forward-slash UTF-8. assert_eq!(path_of(0), "segments/开场对照导言"); - assert_eq!(path_of(2), "lemmas/无证明引理"); - - // segment: only `textbook` exists. assert_eq!(fields_of(0), vec!["textbook"]); + + assert_eq!(entry_type(1), "section"); + assert_eq!(outline[1].get("title").unwrap().as_str().unwrap(), "引理组"); + assert_eq!(outline[1].get("depth").unwrap().as_integer().unwrap(), 1); + assert_eq!(path_of(1), "引理组"); + + assert_eq!(entry_type(2), "element"); + assert_eq!(kind_of(2), "lemma"); + assert_eq!(path_of(2), "引理组/lemmas/量纲分析估计"); // lemma WITH proof.typ: both stmt + proof present (sorted). - assert_eq!(fields_of(1), vec!["proof", "stmt"]); + assert_eq!(fields_of(2), vec!["proof", "stmt"]); + + assert_eq!(entry_type(3), "element"); + assert_eq!(kind_of(3), "lemma"); + assert_eq!(path_of(3), "引理组/lemmas/无证明引理"); // lemma WITHOUT proof.typ: only stmt present (the OPTIONAL-content path). assert_eq!( - fields_of(2), + fields_of(3), vec!["stmt"], "proof must be omitted when absent" ); + + assert_eq!(entry_type(4), "element"); + assert_eq!(kind_of(4), "example"); // example: problem + solution present (source is a scalar, not a content field). - assert_eq!(fields_of(3), vec!["problem", "solution"]); + assert_eq!(fields_of(4), vec!["problem", "solution"]); +} + +#[test] +fn outline_pdf_renders_without_lesson_files() { + let lesson = load_mini(); + let outline = lesson.outline_document(); + let engine = Engine::with_render_dir(real_render_dir()); + let pdf = engine + .build_outline_pdf(&outline) + .expect("outline PDF should compile"); + assert!(pdf.starts_with(b"%PDF"), "output should be a PDF"); + assert!(pdf.len() > 1_000, "outline PDF should be non-trivial"); } /// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the @@ -202,6 +236,7 @@ fn file_tree_artifact_is_deferred() { authors: vec![], }, parts: vec![], + outline: vec![], targets: vec![TargetConfig { name: "web".into(), artifact: Artifact::FileTree { @@ -241,6 +276,7 @@ fn shell_only_target_is_deferred() { authors: vec![], }, parts: vec![], + outline: vec![], targets: vec![TargetConfig { name: "packaged".into(), artifact: Artifact::SingleFile { diff --git a/crates/cph-typst/tests/fixtures/bundle/bundle.toml b/crates/cph-typst/tests/fixtures/bundle/bundle.toml new file mode 100644 index 0000000..f87508c --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/bundle.toml @@ -0,0 +1,16 @@ +[info] +title = "测试合集" +author = "测试作者" + +[[lessons]] +path = "lesson-a" + +[[lessons]] +path = "lesson-b" +target = "teacher" + +[targets.merged] +artifact = { type = "single-file", filepath = "build/merged.pdf" } +[[targets.merged.steps]] +type = "typst-compile" +template = "exports/merged.typ" diff --git a/crates/cph-typst/tests/fixtures/bundle/exports/merged.typ b/crates/cph-typst/tests/fixtures/bundle/exports/merged.typ new file mode 100644 index 0000000..5e6c00a --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/exports/merged.typ @@ -0,0 +1,76 @@ +// DEFAULT BUNDLE TEMPLATE (ADR-0030, outline shape ADR-0029). +// +// Lives in a bundle at `/exports/.typ`, e.g. +// `exports/merged.typ`. Compiled AS MAIN with the augmented BUNDLE manifest +// injected: +// typst compile --root --input manifest= exports/merged.typ +// +// Structurally identical to the single-lesson `student.typ`/`teacher.typ` +// templates (see their notes on why the include loop lives in the template, +// not in cph-render), except it reads `manifest.lessons` (an ordered array of +// per-lesson `(info, target, outline)` tables — see +// `cph_typst::build_augmented_bundle_manifest`) instead of a single +// `manifest.outline`, and calls `render-bundle` instead of `render-lesson`. +// +// Every outline entry's `path` in a bundle manifest is ALREADY prefixed with +// that lesson's own bundle-root-relative directory (done by the Rust engine), +// so the same `include "/" + path + "/" + field + ".typ"` computation used by +// a single-lesson template resolves correctly here too — no special-casing +// needed in this loop. + +#import "@local/cph-render:0.1.0": render-bundle, part-fields, default-heading-numbering + +#let manifest = toml(sys.inputs.manifest) +#let info = manifest.at("info", default: (:)) +#let raw-lessons = manifest.at("lessons", default: ()) + +// Assemble one outline entry exactly as a single-lesson template would. +#let assemble-entry(raw) = { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) + + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } + } + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry + } +} + +#let lessons = raw-lessons.map(raw => ( + info: raw.at("info", default: (:)), + target: raw.at("target", default: "student"), + outline: raw.at("outline", default: ()).map(assemble-entry), +)) + +// Presentation: shared per-level heading numbering across the whole bundle, +// and the ADR-0030 recommended default of resetting auto-counters at each +// lesson boundary (override `reset-counters: false` for continuous numbering). +#render-bundle( + info: info, + lessons: lessons, + heading-numbering: default-heading-numbering, + reset-counters: true, +) diff --git a/crates/cph-typst/tests/fixtures/bundle/lesson-a/manifest.toml b/crates/cph-typst/tests/fixtures/bundle/lesson-a/manifest.toml new file mode 100644 index 0000000..35af5ed --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/lesson-a/manifest.toml @@ -0,0 +1,17 @@ +[project] +id = "lesson-a" +name = "lesson-a" + +[info] +title = "课时A" +author = "作者A" + +[[children]] +kind = "segment" +path = "segments/a" + +[[children]] +kind = "section" +path = "小节" + +[targets.student] diff --git a/examples/TH-141/segments/固气界面导言/element.toml b/crates/cph-typst/tests/fixtures/bundle/lesson-a/segments/a/element.toml similarity index 100% rename from examples/TH-141/segments/固气界面导言/element.toml rename to crates/cph-typst/tests/fixtures/bundle/lesson-a/segments/a/element.toml diff --git a/crates/cph-typst/tests/fixtures/bundle/lesson-a/segments/a/textbook.typ b/crates/cph-typst/tests/fixtures/bundle/lesson-a/segments/a/textbook.typ new file mode 100644 index 0000000..fe92a37 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/lesson-a/segments/a/textbook.typ @@ -0,0 +1 @@ += 课时A导言 diff --git a/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/element.toml b/crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/lemmas/引理甲/element.toml similarity index 100% rename from crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/element.toml rename to crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/lemmas/引理甲/element.toml diff --git a/crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/lemmas/引理甲/stmt.typ b/crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/lemmas/引理甲/stmt.typ new file mode 100644 index 0000000..c3983e7 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/lemmas/引理甲/stmt.typ @@ -0,0 +1 @@ +引理甲陈述。 diff --git a/crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/manifest.toml b/crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/manifest.toml new file mode 100644 index 0000000..d655375 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/manifest.toml @@ -0,0 +1,6 @@ +[group] +title = "小节" + +[[children]] +kind = "lemma" +path = "lemmas/引理甲" diff --git a/crates/cph-typst/tests/fixtures/bundle/lesson-b/manifest.toml b/crates/cph-typst/tests/fixtures/bundle/lesson-b/manifest.toml new file mode 100644 index 0000000..1e7279d --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/lesson-b/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "lesson-b" +name = "lesson-b" + +[info] +title = "课时B" + +[[children]] +kind = "segment" +path = "segments/b" + +[targets.teacher] diff --git a/examples/TH-141/segments/固液界面导言/element.toml b/crates/cph-typst/tests/fixtures/bundle/lesson-b/segments/b/element.toml similarity index 100% rename from examples/TH-141/segments/固液界面导言/element.toml rename to crates/cph-typst/tests/fixtures/bundle/lesson-b/segments/b/element.toml diff --git a/crates/cph-typst/tests/fixtures/bundle/lesson-b/segments/b/textbook.typ b/crates/cph-typst/tests/fixtures/bundle/lesson-b/segments/b/textbook.typ new file mode 100644 index 0000000..6762120 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/lesson-b/segments/b/textbook.typ @@ -0,0 +1 @@ += 课时B导言 diff --git a/crates/cph-typst/tests/fixtures/mini/exports/student.typ b/crates/cph-typst/tests/fixtures/mini/exports/student.typ index a33d25d..14d091d 100644 --- a/crates/cph-typst/tests/fixtures/mini/exports/student.typ +++ b/crates/cph-typst/tests/fixtures/mini/exports/student.typ @@ -1,4 +1,4 @@ -// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011). +// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // This is a *real, editable* file that lives in an engineering file at // `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the @@ -15,15 +15,16 @@ // its own virtual root — an include inside cph-render would resolve against the // PACKAGE, not the engineering root. A `//.typ` written HERE // (this template lives under `--root`) resolves against `--root`. So the -// template loads content and hands cph-render an already-assembled `parts` array. +// template loads content and hands cph-render an already-assembled `outline` +// array (elements interleaved with section headings, ADR-0029). // // OPEN CONTRACT POINT — optional-content presence. typst has no "does this file // exist" primitive (a missing `include` is a hard compile error). So the // template CANNOT probe disk the way the old Rust driver did for lemma `proof`. // It relies on the manifest declaring which optional content fields are present, -// via a per-part `fields` array listing the content fields that exist on disk +// via a per-element `fields` array listing the content fields that exist on disk // (the engine knows this — it walks the part dir). Required fields are loaded -// unconditionally; optional fields load only if listed in `fields`. If a part +// unconditionally; optional fields load only if listed in `fields`. If an element // omits `fields`, optional content is skipped (conservative). The exact shape of // this declaration is for the manifest/Rust contract to pin. @@ -35,38 +36,51 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: +// - an "element" entry: include its content fields (computed absolute paths, +// resolved against --root) and read scalar fields from /element.toml. +// `part-fields` (from cph-render) is the single source of truth for +// kind->fields. +// - a "section" entry (ADR-0029): pass its title/depth straight through — no +// content to load, it is a heading. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -74,6 +88,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) diff --git a/crates/cph-typst/tests/fixtures/mini/exports/teacher.typ b/crates/cph-typst/tests/fixtures/mini/exports/teacher.typ index b976b1f..abc5b8a 100644 --- a/crates/cph-typst/tests/fixtures/mini/exports/teacher.typ +++ b/crates/cph-typst/tests/fixtures/mini/exports/teacher.typ @@ -1,4 +1,4 @@ -// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011). +// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with // the manifest injected: @@ -18,38 +18,47 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: an "element" entry includes its content fields +// and reads scalars from element.toml; a "section" entry (ADR-0029) passes +// title/depth straight through as a heading, no content to load. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -57,6 +66,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) diff --git a/crates/cph-typst/tests/fixtures/mini/manifest.toml b/crates/cph-typst/tests/fixtures/mini/manifest.toml index 61c8f62..4b49c2f 100644 --- a/crates/cph-typst/tests/fixtures/mini/manifest.toml +++ b/crates/cph-typst/tests/fixtures/mini/manifest.toml @@ -6,19 +6,15 @@ name = "迷你课时" title = "迷你示例课时" author = "测试作者" -[[parts]] +[[children]] kind = "segment" path = "segments/开场对照导言" -[[parts]] -kind = "lemma" -path = "lemmas/量纲分析估计" +[[children]] +kind = "section" +path = "引理组" -[[parts]] -kind = "lemma" -path = "lemmas/无证明引理" - -[[parts]] +[[children]] kind = "example" path = "examples/自由落体" diff --git a/examples/TH-141/lemmas/Dupré关系/element.toml b/crates/cph-typst/tests/fixtures/mini/引理组/lemmas/无证明引理/element.toml similarity index 100% rename from examples/TH-141/lemmas/Dupré关系/element.toml rename to crates/cph-typst/tests/fixtures/mini/引理组/lemmas/无证明引理/element.toml diff --git a/crates/cph-typst/tests/fixtures/mini/lemmas/无证明引理/stmt.typ b/crates/cph-typst/tests/fixtures/mini/引理组/lemmas/无证明引理/stmt.typ similarity index 100% rename from crates/cph-typst/tests/fixtures/mini/lemmas/无证明引理/stmt.typ rename to crates/cph-typst/tests/fixtures/mini/引理组/lemmas/无证明引理/stmt.typ diff --git a/examples/TH-141/lemmas/Eötvös规则/element.toml b/crates/cph-typst/tests/fixtures/mini/引理组/lemmas/量纲分析估计/element.toml similarity index 100% rename from examples/TH-141/lemmas/Eötvös规则/element.toml rename to crates/cph-typst/tests/fixtures/mini/引理组/lemmas/量纲分析估计/element.toml diff --git a/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/proof.typ b/crates/cph-typst/tests/fixtures/mini/引理组/lemmas/量纲分析估计/proof.typ similarity index 100% rename from crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/proof.typ rename to crates/cph-typst/tests/fixtures/mini/引理组/lemmas/量纲分析估计/proof.typ diff --git a/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/stmt.typ b/crates/cph-typst/tests/fixtures/mini/引理组/lemmas/量纲分析估计/stmt.typ similarity index 100% rename from crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/stmt.typ rename to crates/cph-typst/tests/fixtures/mini/引理组/lemmas/量纲分析估计/stmt.typ diff --git a/crates/cph-typst/tests/fixtures/mini/引理组/manifest.toml b/crates/cph-typst/tests/fixtures/mini/引理组/manifest.toml new file mode 100644 index 0000000..daaf457 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/mini/引理组/manifest.toml @@ -0,0 +1,10 @@ +[group] +title = "引理组" + +[[children]] +kind = "lemma" +path = "lemmas/量纲分析估计" + +[[children]] +kind = "lemma" +path = "lemmas/无证明引理" diff --git a/docs/adr/0029-engineering-file-nested-manifest.md b/docs/adr/0029-engineering-file-nested-manifest.md new file mode 100644 index 0000000..64c25ae --- /dev/null +++ b/docs/adr/0029-engineering-file-nested-manifest.md @@ -0,0 +1,193 @@ +# ADR 0029: Engineering-File Structure Is A Nested Outline Manifest + +## Status + +Accepted. **Supersedes ADR-0008** on the concrete layout of a lesson's +structure (the ordered `[[parts]]` arrangement), and discharges the +"grouping/sectioning" and "manifest richness" gaps ADR-0008 left Open. ADR-0007 +(the engineering file is a real directory tree) stands unchanged. ADR-0005's +"a lesson is an ordered sequence of element instances" stands unchanged — order +and membership are preserved; the *shape* that encodes them is now a tree. + +## Context + +ADR-0008 encodes a lesson's order and membership as a **single flat `[[parts]]` +array** in a root `manifest.toml`, where every `[[parts]]` entry is a `kind` + +`path` to an element folder. Two forces now push against that flat shape: + +1. **Real lessons are internally structured.** TH-144 has a `题目/` tree of + problem/answer pairs and A/B/C sections that exist only in folder names + today. Teachers think of a lesson as an **outline** — sections, sub-sections, + groups of worked examples — not an unbroken flat list of ~40 parts. The + admin/teacher surface (the 老师端 being built against the Hub) is supposed to + show "the project structure, expanding each structural element to the files + inside" — and today that structure is a giant flat scroll. +2. **Outline and file structure should correspond 1:1, not via a separate + index.** The 7.31 design discussion landed on a shape where each level of the + lesson is a folder whose manifest states that level's children — so the + on-disk tree *is* the outline, self-descriptive, with no secondary artifact + to drift out of sync. A flat root `[[parts]]` list, by contrast, names the + whole lesson in one file and forces the folder tree to be a projection of it + (or vice versa) with two sources of truth. + +ADR-0008 itself anticipated this: its Open Questions list "Per-part metadata, +grouping/sectioning (TH-144's A/B/C structure is only in folder names today)" as +explicitly not modeled. This ADR closes that gap. + +## Decision + +### A lesson is a tree of folders; each folder is a self-describing node whose manifest names its children + +The engineering file remains a real directory tree (ADR-0007). The ordering and +membership encoding of ADR-0008 changes from **one flat root `[[parts]]`** to a +**nested, per-folder outline**: + +- The root's `manifest.toml` keeps `[project]`, `[info]`, and the `[targets.*]` + build configuration exactly as ADR-0008/0011 define them. +- The lesson's **structure is expressed as a folder tree**, where every folder + that groups children carries its own small **outline manifest** (per-folder + `manifest.toml`, see *Name and discriminator* below) stating that level's + ordered children. +- A **leaf** is an element folder exactly as ADR-0008 defines it: an + `element.toml` declaring `kind` + scalar fields, plus convention-named + content `.typ` siblings. A leaf has no outline manifest — its own + `element.toml` is its descriptor. +- An **internal folder** (a grouping node) has an outline manifest but no + `element.toml`: it is not an element, it is a container of elements/containers. + It carries only structure and, optionally, group-level scalar metadata. + +### The engineering-file root is itself the implicit top container + +Children live directly in the root `manifest.toml`'s `children` array — there is +no mandated single top-level section folder. Rationale: migration is a pure +flatten of the existing root `[[parts]]` into root `children` (same order, zero +forcing); a mandated wrapper folder would be pure indirection for most lessons. +A lesson that wants a top-level section simply creates one as a child +(consistent with the ADR-0021 folder-tree precedent, which holds direct children +at the root). + +### Name and discriminator: every grouping folder uses `manifest.toml` + +Every folder that groups children uses the same filename, `manifest.toml`, at +every level including the root: + +- Root `manifest.toml`: `[project]`, `[info]`, `[targets.*]`, plus a `children` + array. +- Internal folder `manifest.toml`: `children` (+ optional `[group]` scalars); + never `[project]`/`[info]`/`[targets.*]`. + +The leaf/container discriminator is disjoint and structural: a folder with +`element.toml` is a **leaf** (ADR-0008 descriptor); a folder with `manifest.toml` +and no `element.toml` is a **container**; a folder with neither is a structural +error. `OUTLINE.toml` was rejected (a new reserved name, no benefit over the +uniform name); `info.toml` was rejected because it collides with the model's +`Info` (title/author, folded into root `manifest.toml`'s `[info]` by ADR-0008) +and would blur "metadata vs structure". The 7.31 sketch's intent — each level +self-describes its children — is preserved; the name aligns with current +ADR-0008. + +### Order is encoded per-folder, and the lesson order is the depth-first traversal + +ADR-0005 requires the lesson to be an ordered sequence. In the tree, **order is +declared locally at each folder** by the order of children in that folder's +`manifest.toml`. The canonical lesson order is the **depth-first pre-order +traversal** of the tree: an internal folder contributes no element of its own +(its label is a heading, not a part), and leaves contribute in the order they +appear. The checker materializes this traversal; no part of the lesson order +lives in a typst script (ADR-0008's core rejection of typst-as-order-manifest +stands). + +Concretely, a `segment` that in TH-141 was one flat `[[parts]]` entry can now be +a folder whose outline lists its sub-segments and examples in order — and any +grouping (TH-144's A/B/C, a "导言 cluster", a "例题组") is a folder, transparent +in the element sequence but a real node in the outline. + +### The outline manifest shape + +A folder's `manifest.toml` `children` array holds its ordered children. Each +child entry is either: + +```toml +# a leaf element (ADR-0008 descriptor), by relative path +{ kind = "example", path = "examples/41届复赛三-1-混注石油" } + +# or an internal grouping folder, by relative path (recursed) +{ kind = "section", path = "导言簇" } +``` + +The `kind` of a **leaf** is still read from that folder's `element.toml` +(ADR-0008: the folder is self-describing; the outline entry may restate it for +readability but the `element.toml` is authoritative). The `kind` of an +**internal** child is a container kind — recognized from a small, open set of +container kinds — and selects how that subtree is rendered/grouped. Leaves and +containers are disjoint by construction: a folder is a container iff it has a +`manifest.toml`; a leaf iff it has an `element.toml`. A folder must have exactly +one of the two. + +### Container kinds: MVP ships exactly one — `section`, rendered as a heading + +The demonstrated needs (TH-144's A/B/C, a "导言簇") are all `section`, so the +MVP ships exactly one container kind: + +- A `section` opens a **heading** at its depth in the DFS, then renders its + children in order; it never appears in the element part sequence (the + already-decided DFS semantics). +- The heading uses `[group].title` when present, else the folder name. +- `group` (a heading-less visual grouping) and any other container kind are + **deferred**: added only when a real need appears, honoring ADR-0005's open + universe / "add when needed". + +### Group-level scalars + +An internal folder MAY carry a `[group]` table (e.g. a title distinct from the +folder name, a description) in its `manifest.toml`. Kept minimal — no other +container metadata until a real need appears. + +### The flat `[[parts]]` array at the root is retired for structure + +The root `manifest.toml` no longer needs a root-level `[[parts]]` that names the +whole lesson. Lesson structure lives in the folder tree, rooted at the +engineering-file root's `manifest.toml` `children`. `[targets.*]` and +`[project]`/`[info]` stay at the root `manifest.toml`. + +## Consequences + +- The teacher/admin surface shows the outline: the folder tree *is* the lesson + structure, self-descriptive and 1:1 with files. Opening an element reveals its + files (as the 老师端 requirement asked). This discharges the 7.31 driver + ("项目内部有一套 cph schema 定义的结构,由 manifest 组织,给老师看的应该是这个, + 展开每个结构元素内部才是文件"). +- The checker can still recover the full ordered lesson **without evaluating + typst**: it reads the root `manifest.toml` (project/info/targets) and walks the + folder tree, honoring each folder's `manifest.toml` children order and each + leaf's `element.toml`. Order and membership remain declarative data, greppable + and diffable. +- Grouping (sections) is now a real, checkable structure rather than a folder + naming convention — TH-144's A/B/C can be first-class. +- Every folder is self-describing, so a subtree can be understood/moved on its + own; nothing about a subtree's structure lives only in the root file. +- Migration is mechanical: flatten the existing root `[[parts]]` into root + `children` with the same leaf order (root is the implicit top container, so no + wrapper folder is needed). The element sequence is unchanged, so + `cph check`/`cph build` semantics for leaves carry over. + +## Open Questions / Deferred + +- **Target-scoped container options** (e.g. hide a section in the student + build): deferred, stay out of structure. ADR-0009/0011 field-visibility / + per-target map already handles this in rendered output, not structure; keep it + there unless a concrete need forces it back into the manifest. +- **Additional container kinds** beyond `section` (e.g. a heading-less `group`): + deferred until a real need appears (open universe, ADR-0005). +- **Container metadata beyond `[group]`** (title/description): deferred — only + the minimal `[group]` table ships; richer container scalars await a concrete + authoring need. + +## Supersedes + +ADR-0008's "the ordering manifest is declarative" decision stands; this ADR +replaces its **flat `[[parts]]` encoding of order/membership** with the nested +per-folder outline. ADR-0008's other decisions (declarative `manifest.toml`/ +`element.toml`, folder self-description, content-file naming convention, schema +as source of truth for which `.typ` files exist) are unchanged and carry into +this tree form. diff --git a/docs/adr/0030-batch-and-combined-export.md b/docs/adr/0030-batch-and-combined-export.md new file mode 100644 index 0000000..1a7c8da --- /dev/null +++ b/docs/adr/0030-batch-and-combined-export.md @@ -0,0 +1,167 @@ +# ADR 0030: Batch & Combined Export + +## Status + +Accepted. **Extends/refines ADR-0009 and ADR-0011** (export target = a build +producing a typed artifact) by adding **two** export dimensions that today have +no home: (1) building **multiple targets of one lesson** in one batch, and (2) +**combining multiple lessons into one** artifact — a 讲义合集 / course bundle. +It does **not** redefine the SingleFile vs FileTree artifact distinction +(ADR-0011) or the single-`typstCompile`-step MVP; it adds the *collection* +semantics on top. + +## Context + +Today `cph build --target T` builds exactly one target `T` of one engineering +file into one artifact. Two real needs fall outside that: + +1. **A lesson's multiple versions.** A lesson already declares several targets + (student handout, teacher plan, slides, script). Producing all of them is + today N separate `cph build` invocations with no shared invocation, ordering, + or failure summary. Teachers preparing a lesson want "build the whole lesson + in all needed forms" as one action. + +2. **Combining lessons into one deliverable.** ADR-0005 deferred "course = + arrangement of lessons" — a course/unit is *not* an engineering file; it is + an arrangement of lessons "modeled elsewhere". The elsewhere is empty. A real + deliverable is a **讲义合集 / course bundle** — several lessons ordered into + one document (e.g. "期中复习合集", a term bundle, a topic compilation). This + spans multiple engineering files and currently has no model and no CLI path. + +Both are product-plain features (the 老师端 exports; a bundle is what a teacher +hands a class), not architectural speculation. + +## Decision + +### The existing single-lesson single-target build is the atomic unit + +ADR-0009/0011's model — a target is a build over one lesson producing one +`Artifact` via ordered `Step`s — is unchanged and remains the *unit*. Nothing +below replaces it; the new semantics are **aggregations over that unit**. + +### Dimension 1 — Batch: build a set of targets of one lesson + +`cph build` on a lesson gains the ability to produce **several targets in one +invocation**, as one batched operation: + +- The lesson root `manifest.toml` `[targets.*]` already enumerates the declared + targets and their order (ADR-0008/0011). Building "all declared targets" is the + default batch: each declared target builds to its own artifact + (`build/.{pdf,md}`), in declaration order. +- A batched build is **non-transactional and independent per target**: each + target is a separate build with its own artifact, own diagnostics, own + exit/result. One target failing (e.g. teacher plan PDF) does not block the rest + (student PDF), matching the per-target independent-failure stance of + ADR-0009's "missing render for a used kind ⇒ warning, non-blocking". +- The batch emits a **summary**: a per-target ledger (ok/failed + its artifact + or error), and a **non-zero aggregate exit if any target failed** to produce + its artifact. A target that fails to produce its artifact is a real defect + (this repo's fail-fast stance — don't paper over bugs), distinct from + ADR-0009's "missing render rule ⇒ warning" (a policy-level skip, non-error). + "Don't block the rest" still holds: every target is attempted, but a single + failure makes the aggregate non-zero so CI/observability catch it. This is the + CLI's job; it is the natural "build the whole lesson" affordance. + +### Dimension 2 — Combined: arrange multiple lessons into one artifact + +A **course bundle** is a new, lightweight, second kind of engineering-file-adjacent +unit: an **ordered arrangement of lessons** (ADR-0005's deferred "course = +arrangement of lessons" finally given a concrete export home). + +#### Bundle carrier: a directory containing `bundle.toml` + +A bundle is a **directory containing `bundle.toml`**. A directory gives the +bundle a stable root for relative lesson paths and a home for build output +(echoing ADR-0007's "engineering file = directory"). The `bundle.toml` carries: + +- `[info]` — the bundle's own title/author (of the 合集); +- `[targets.*]` — the bundle's build configuration, reusing ADR-0011's build + mechanism; +- an ordered `lessons` array — each entry: a lesson path (relative to the + bundle root, pointing at each engineering-file root, each a self-contained + directory tree per ADR-0007) plus optional per-lesson per-target overrides. + +A bundle target produces an **ordered concatenation/assembly of the lessons' +artifacts or content** into one `Artifact`, reusing the ADR-0011 artifact ADT: + +- `SingleFile` — a combined document (讲义合集): the lessons' content assembled + in order into one compiled document, with the existing cross-reference / + `@label` machinery working because it is one compiled document (the same + reason ADR-0011 gives for why SingleFile concatenation works at all). +- `FileTree` — each lesson to its own file plus a generated index (ADR-0011's + third-party-archive case, now with a first-class multi-lesson trigger). + +A bundle target's steps are the **ordered typed steps** of ADR-0011, but the +"map" now operates at the level of whole lessons rather than a single lesson's +parts: a step like `assembleLessons` (ordered inclusion of each lesson's +content/artifact) plus the existing `typstCompile`/`shell` steps for assembly +and any post-processing. Concretely the framework provides a +`typstCompile`-style step that pulls each listed lesson's content in order into +one document (mirroring how a single lesson's template pulls its parts). + +#### Renumbering in a `SingleFile` bundle: template-resident, default reset per lesson + +Numbering is presentation, which ADR-0011 already owns to the template file (not +the manifest), so the **bundle target's template decides** whether auto-counters +reset at lesson boundaries; the framework ships a helper to reset counters at a +lesson boundary. The **recommended default resets auto-counters at each lesson +boundary**: lessons are authored self-contained, so an internal "例题3" means +that lesson's 例题3; cross-lesson continuation would silently break author +references. A genuine "全书 continuous numbering" is an explicit template +override. `@label` cross-references stay global (resolved by label name, +independent of counters); only auto-increment counters reset. + +#### Bundles do not nest (MVP) + +A bundle references lessons only, not other bundles, until a real need appears — +mirrors the tree-nesting simplicity and keeps the first bundle target minimal. + +### Invariant: lessons stay self-contained; combination is export-time only + +Opening the door to combining lessons must **not** open the door to cross-lesson +imports inside a lesson's own content (ADR-0006's import boundary: within one +engineering file plus `@package`, never into a sibling lesson). A bundle is +allowed to *assemble already-authored lessons at export time* — reading their +content for the combined artifact — but no lesson's rich content may `import` +another lesson's internals as part of *its own* authoring. Combination is a +**projection over self-contained lessons**, exactly as a render target is a +projection over a lesson. This keeps each engineering file independently +checkable, buildable, and movable, and avoids reintroducing cross-file coupling +ADR-0006 explicitly rejected. + +### CLI surface + +- `cph build ` → all declared targets (the batch default). +- `cph build --target student --target teacher` → the named subset + (the multi-version batch), in the given order. +- `cph bundle --target ` → build one combined bundle target + (`SingleFile` merged doc or `FileTree`), giving the multi-lesson merge. + (`cph build` on a bundle root is the batch-of-bundle-targets equivalent.) + +## Consequences + +- **One lesson, many versions** is one command with a per-target ledger — the + natural 老师端 "导出全部版本" action, and any single failure surfaces as a + non-zero aggregate. +- **Course = arrangement of lessons** gets a concrete, export-focused home (the + bundle), discharging the ADR-0005 deferred item without inventing a full + course-authoring model. +- The **artifact/distinction and build-step machinery (ADR-0011) is reused** — a + bundle target is just a build whose inputs are whole lessons, not a new + parallel export engine. +- **Self-containment stays** (ADR-0006/0007): each lesson remains independently + checkable and buildable; the bundle only reads them for assembly. A lesson and + a bundle can version/evolve independently. +- The teacher surface can offer "export all versions" (batch) and "compile into + a 合集" (combined) as two concrete, productisible actions. + +## Open Questions / Deferred + +- **Bundle-of-bundles / nesting** — no nesting in MVP; re-open only when a real + need appears. +- **Dedup/caching across targets and lessons** — none in MVP, consistent with + ADR-0011's "no caching in MVP". +- **Exact counter-reset semantics in a `SingleFile` bundle** — the default + (reset per lesson) is decided; the precise mechanism (which counters, how the + template override is expressed) is settled with the first bundle template + implementation. diff --git a/examples/TH-141/exports/student.typ b/examples/TH-141/exports/student.typ index a33d25d..14d091d 100644 --- a/examples/TH-141/exports/student.typ +++ b/examples/TH-141/exports/student.typ @@ -1,4 +1,4 @@ -// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011). +// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // This is a *real, editable* file that lives in an engineering file at // `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the @@ -15,15 +15,16 @@ // its own virtual root — an include inside cph-render would resolve against the // PACKAGE, not the engineering root. A `//.typ` written HERE // (this template lives under `--root`) resolves against `--root`. So the -// template loads content and hands cph-render an already-assembled `parts` array. +// template loads content and hands cph-render an already-assembled `outline` +// array (elements interleaved with section headings, ADR-0029). // // OPEN CONTRACT POINT — optional-content presence. typst has no "does this file // exist" primitive (a missing `include` is a hard compile error). So the // template CANNOT probe disk the way the old Rust driver did for lemma `proof`. // It relies on the manifest declaring which optional content fields are present, -// via a per-part `fields` array listing the content fields that exist on disk +// via a per-element `fields` array listing the content fields that exist on disk // (the engine knows this — it walks the part dir). Required fields are loaded -// unconditionally; optional fields load only if listed in `fields`. If a part +// unconditionally; optional fields load only if listed in `fields`. If an element // omits `fields`, optional content is skipped (conservative). The exact shape of // this declaration is for the manifest/Rust contract to pin. @@ -35,38 +36,51 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: +// - an "element" entry: include its content fields (computed absolute paths, +// resolved against --root) and read scalar fields from /element.toml. +// `part-fields` (from cph-render) is the single source of truth for +// kind->fields. +// - a "section" entry (ADR-0029): pass its title/depth straight through — no +// content to load, it is a heading. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -74,6 +88,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) diff --git a/examples/TH-141/exports/teacher.typ b/examples/TH-141/exports/teacher.typ index b976b1f..abc5b8a 100644 --- a/examples/TH-141/exports/teacher.typ +++ b/examples/TH-141/exports/teacher.typ @@ -1,4 +1,4 @@ -// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011). +// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with // the manifest injected: @@ -18,38 +18,47 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: an "element" entry includes its content fields +// and reads scalars from element.toml; a "section" entry (ADR-0029) passes +// title/depth straight through as a heading, no content to load. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -57,6 +66,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) diff --git a/examples/TH-141/manifest.toml b/examples/TH-141/manifest.toml index 3376bf0..7e9b154 100644 --- a/examples/TH-141/manifest.toml +++ b/examples/TH-141/manifest.toml @@ -6,161 +6,42 @@ name = "TH-141_表面张力的严肃理论" title = "TH-141:表面张力的严肃理论" author = "范式教育教研组" -[[parts]] +[[children]] kind = "segment" path = "segments/开场对照导言" -[[parts]] +[[children]] kind = "segment" path = "segments/胡克唯象模型回顾" -[[parts]] +[[children]] kind = "segment" path = "segments/液面拉伸的本质" -[[parts]] -kind = "segment" -path = "segments/液气界面导言" +[[children]] +kind = "section" +path = "液气界面" +notes = "先从液面拉伸的宏观图像切入,再用缺键模型和 LJ 对势逐步建立微观解释;可补充一个数量级估算例题。" -[[parts]] -kind = "segment" -path = "segments/微观建模的共同骨架" +[[children]] +kind = "section" +path = "固气界面" +notes = "把液体表面能与固体表面应力放在同一张对照表中,强调固体表面能的晶面各向异性。" -[[parts]] -kind = "lemma" -path = "lemmas/量纲分析估计" +[[children]] +kind = "section" +path = "固液界面" +notes = "围绕界面能的物理图像,串起 Dupré、Girifalco-Good、Fowkes 与 Young 方程;可安排一个浸润判据例题。" -[[parts]] -kind = "segment" -path = "segments/缺键模型导言" +[[children]] +kind = "section" +path = "σTp态函数建模" +notes = "这一节是温度依赖建模主线,先回顾 σT,再解释微观模型和经验规则之间的联系;进阶学生可比较不同模型的适用范围。" -[[parts]] -kind = "lemma" -path = "lemmas/缺键模型一般公式" - -[[parts]] -kind = "example" -path = "examples/41届复赛三-2-缺键模型" - -[[parts]] -kind = "lemma" -path = "lemmas/Stefan极简估算" - -[[parts]] -kind = "lemma" -path = "lemmas/立方格子下zeta具体值" - -[[parts]] -kind = "segment" -path = "segments/LJ积分导言" - -[[parts]] -kind = "lemma" -path = "lemmas/LJ对势积分标度" - -[[parts]] -kind = "segment" -path = "segments/固气界面导言" - -[[parts]] -kind = "segment" -path = "segments/表面能γ与表面应力f" - -[[parts]] -kind = "lemma" -path = "lemmas/拉伸固体的总应力" - -[[parts]] -kind = "lemma" -path = "lemmas/缺键模型迁移到固气" - -[[parts]] -kind = "lemma" -path = "lemmas/固体表面能的晶面各向异性" - -[[parts]] -kind = "segment" -path = "segments/不同物质γ量级对比" - -[[parts]] -kind = "segment" -path = "segments/固液界面导言" - -[[parts]] -kind = "segment" -path = "segments/固液界面能的物理图像" - -[[parts]] -kind = "lemma" -path = "lemmas/Dupré关系" - -[[parts]] -kind = "lemma" -path = "lemmas/Girifalco-Good公式" - -[[parts]] -kind = "segment" -path = "segments/Fowkes极性修正" - -[[parts]] -kind = "segment" -path = "segments/三相接触导言" - -[[parts]] -kind = "lemma" -path = "lemmas/Young方程" - -[[parts]] -kind = "lemma" -path = "lemmas/GGZ浸润判据" - -[[parts]] -kind = "segment" -path = "segments/浸润全谱与高低能表面" - -[[parts]] -kind = "segment" -path = "segments/σT建模导言" - -[[parts]] -kind = "segment" -path = "segments/微观派Lm下降" - -[[parts]] -kind = "lemma" -path = "lemmas/Eötvös规则" - -[[parts]] -kind = "lemma" -path = "lemmas/Guggenheim-Katayama改良" - -[[parts]] -kind = "lemma" -path = "lemmas/表面熵热力学关系" - -[[parts]] -kind = "segment" -path = "segments/σTp态函数导言" - -[[parts]] -kind = "segment" -path = "segments/σ作为态函数的图像" - -[[parts]] -kind = "example" -path = "examples/41届复赛三-1-混注石油" - -[[parts]] -kind = "segment" -path = "segments/收束导言" - -[[parts]] -kind = "segment" -path = "segments/各模型对水的预测对照" - -[[parts]] -kind = "segment" -path = "segments/算不准背后的真实物理" +[[children]] +kind = "section" +path = "收束" +notes = "最后对照各模型对水的预测,回收本节主线,并明确为什么实际数值可能算不准。" # Export targets (ADR-0009/0011): each target is a build producing a typed # artifact, run as an ordered list of typed steps. A `typst-compile` step names a diff --git a/examples/TH-141/examples/41届复赛三-1-混注石油/element.toml b/examples/TH-141/σTp态函数建模/examples/41届复赛三-1-混注石油/element.toml similarity index 100% rename from examples/TH-141/examples/41届复赛三-1-混注石油/element.toml rename to examples/TH-141/σTp态函数建模/examples/41届复赛三-1-混注石油/element.toml diff --git a/examples/TH-141/examples/41届复赛三-1-混注石油/problem.typ b/examples/TH-141/σTp态函数建模/examples/41届复赛三-1-混注石油/problem.typ similarity index 100% rename from examples/TH-141/examples/41届复赛三-1-混注石油/problem.typ rename to examples/TH-141/σTp态函数建模/examples/41届复赛三-1-混注石油/problem.typ diff --git a/examples/TH-141/examples/41届复赛三-1-混注石油/solution.typ b/examples/TH-141/σTp态函数建模/examples/41届复赛三-1-混注石油/solution.typ similarity index 100% rename from examples/TH-141/examples/41届复赛三-1-混注石油/solution.typ rename to examples/TH-141/σTp态函数建模/examples/41届复赛三-1-混注石油/solution.typ diff --git a/examples/TH-141/lemmas/GGZ浸润判据/element.toml b/examples/TH-141/σTp态函数建模/lemmas/Eötvös规则/element.toml similarity index 100% rename from examples/TH-141/lemmas/GGZ浸润判据/element.toml rename to examples/TH-141/σTp态函数建模/lemmas/Eötvös规则/element.toml diff --git a/examples/TH-141/lemmas/Eötvös规则/stmt.typ b/examples/TH-141/σTp态函数建模/lemmas/Eötvös规则/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/Eötvös规则/stmt.typ rename to examples/TH-141/σTp态函数建模/lemmas/Eötvös规则/stmt.typ diff --git a/examples/TH-141/lemmas/Girifalco-Good公式/element.toml b/examples/TH-141/σTp态函数建模/lemmas/Guggenheim-Katayama改良/element.toml similarity index 100% rename from examples/TH-141/lemmas/Girifalco-Good公式/element.toml rename to examples/TH-141/σTp态函数建模/lemmas/Guggenheim-Katayama改良/element.toml diff --git a/examples/TH-141/lemmas/Guggenheim-Katayama改良/stmt.typ b/examples/TH-141/σTp态函数建模/lemmas/Guggenheim-Katayama改良/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/Guggenheim-Katayama改良/stmt.typ rename to examples/TH-141/σTp态函数建模/lemmas/Guggenheim-Katayama改良/stmt.typ diff --git a/examples/TH-141/lemmas/Guggenheim-Katayama改良/element.toml b/examples/TH-141/σTp态函数建模/lemmas/表面熵热力学关系/element.toml similarity index 100% rename from examples/TH-141/lemmas/Guggenheim-Katayama改良/element.toml rename to examples/TH-141/σTp态函数建模/lemmas/表面熵热力学关系/element.toml diff --git a/examples/TH-141/lemmas/表面熵热力学关系/proof.typ b/examples/TH-141/σTp态函数建模/lemmas/表面熵热力学关系/proof.typ similarity index 100% rename from examples/TH-141/lemmas/表面熵热力学关系/proof.typ rename to examples/TH-141/σTp态函数建模/lemmas/表面熵热力学关系/proof.typ diff --git a/examples/TH-141/lemmas/表面熵热力学关系/stmt.typ b/examples/TH-141/σTp态函数建模/lemmas/表面熵热力学关系/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/表面熵热力学关系/stmt.typ rename to examples/TH-141/σTp态函数建模/lemmas/表面熵热力学关系/stmt.typ diff --git a/examples/TH-141/σTp态函数建模/manifest.toml b/examples/TH-141/σTp态函数建模/manifest.toml new file mode 100644 index 0000000..00d2ff5 --- /dev/null +++ b/examples/TH-141/σTp态函数建模/manifest.toml @@ -0,0 +1,34 @@ +[group] +title = "σTp 态函数建模" + +[[children]] +kind = "segment" +path = "segments/σT建模导言" + +[[children]] +kind = "segment" +path = "segments/微观派Lm下降" + +[[children]] +kind = "lemma" +path = "lemmas/Eötvös规则" + +[[children]] +kind = "lemma" +path = "lemmas/Guggenheim-Katayama改良" + +[[children]] +kind = "lemma" +path = "lemmas/表面熵热力学关系" + +[[children]] +kind = "segment" +path = "segments/σTp态函数导言" + +[[children]] +kind = "segment" +path = "segments/σ作为态函数的图像" + +[[children]] +kind = "example" +path = "examples/41届复赛三-1-混注石油" diff --git a/examples/TH-141/segments/固液界面能的物理图像/element.toml b/examples/TH-141/σTp态函数建模/segments/σTp态函数导言/element.toml similarity index 100% rename from examples/TH-141/segments/固液界面能的物理图像/element.toml rename to examples/TH-141/σTp态函数建模/segments/σTp态函数导言/element.toml diff --git a/examples/TH-141/segments/σTp态函数导言/textbook.typ b/examples/TH-141/σTp态函数建模/segments/σTp态函数导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/σTp态函数导言/textbook.typ rename to examples/TH-141/σTp态函数建模/segments/σTp态函数导言/textbook.typ diff --git a/examples/TH-141/segments/微观建模的共同骨架/element.toml b/examples/TH-141/σTp态函数建模/segments/σT建模导言/element.toml similarity index 100% rename from examples/TH-141/segments/微观建模的共同骨架/element.toml rename to examples/TH-141/σTp态函数建模/segments/σT建模导言/element.toml diff --git a/examples/TH-141/segments/σT建模导言/textbook.typ b/examples/TH-141/σTp态函数建模/segments/σT建模导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/σT建模导言/textbook.typ rename to examples/TH-141/σTp态函数建模/segments/σT建模导言/textbook.typ diff --git a/examples/TH-141/segments/微观派Lm下降/element.toml b/examples/TH-141/σTp态函数建模/segments/σ作为态函数的图像/element.toml similarity index 100% rename from examples/TH-141/segments/微观派Lm下降/element.toml rename to examples/TH-141/σTp态函数建模/segments/σ作为态函数的图像/element.toml diff --git a/examples/TH-141/segments/σ作为态函数的图像/textbook.typ b/examples/TH-141/σTp态函数建模/segments/σ作为态函数的图像/textbook.typ similarity index 100% rename from examples/TH-141/segments/σ作为态函数的图像/textbook.typ rename to examples/TH-141/σTp态函数建模/segments/σ作为态函数的图像/textbook.typ diff --git a/examples/TH-141/segments/收束导言/element.toml b/examples/TH-141/σTp态函数建模/segments/微观派Lm下降/element.toml similarity index 100% rename from examples/TH-141/segments/收束导言/element.toml rename to examples/TH-141/σTp态函数建模/segments/微观派Lm下降/element.toml diff --git a/examples/TH-141/segments/微观派Lm下降/textbook.typ b/examples/TH-141/σTp态函数建模/segments/微观派Lm下降/textbook.typ similarity index 100% rename from examples/TH-141/segments/微观派Lm下降/textbook.typ rename to examples/TH-141/σTp态函数建模/segments/微观派Lm下降/textbook.typ diff --git a/examples/TH-141/lemmas/LJ对势积分标度/element.toml b/examples/TH-141/固气界面/lemmas/固体表面能的晶面各向异性/element.toml similarity index 100% rename from examples/TH-141/lemmas/LJ对势积分标度/element.toml rename to examples/TH-141/固气界面/lemmas/固体表面能的晶面各向异性/element.toml diff --git a/examples/TH-141/lemmas/固体表面能的晶面各向异性/stmt.typ b/examples/TH-141/固气界面/lemmas/固体表面能的晶面各向异性/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/固体表面能的晶面各向异性/stmt.typ rename to examples/TH-141/固气界面/lemmas/固体表面能的晶面各向异性/stmt.typ diff --git a/examples/TH-141/lemmas/Stefan极简估算/element.toml b/examples/TH-141/固气界面/lemmas/拉伸固体的总应力/element.toml similarity index 100% rename from examples/TH-141/lemmas/Stefan极简估算/element.toml rename to examples/TH-141/固气界面/lemmas/拉伸固体的总应力/element.toml diff --git a/examples/TH-141/lemmas/拉伸固体的总应力/proof.typ b/examples/TH-141/固气界面/lemmas/拉伸固体的总应力/proof.typ similarity index 100% rename from examples/TH-141/lemmas/拉伸固体的总应力/proof.typ rename to examples/TH-141/固气界面/lemmas/拉伸固体的总应力/proof.typ diff --git a/examples/TH-141/lemmas/拉伸固体的总应力/stmt.typ b/examples/TH-141/固气界面/lemmas/拉伸固体的总应力/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/拉伸固体的总应力/stmt.typ rename to examples/TH-141/固气界面/lemmas/拉伸固体的总应力/stmt.typ diff --git a/examples/TH-141/lemmas/Young方程/element.toml b/examples/TH-141/固气界面/lemmas/缺键模型迁移到固气/element.toml similarity index 100% rename from examples/TH-141/lemmas/Young方程/element.toml rename to examples/TH-141/固气界面/lemmas/缺键模型迁移到固气/element.toml diff --git a/examples/TH-141/lemmas/缺键模型迁移到固气/stmt.typ b/examples/TH-141/固气界面/lemmas/缺键模型迁移到固气/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/缺键模型迁移到固气/stmt.typ rename to examples/TH-141/固气界面/lemmas/缺键模型迁移到固气/stmt.typ diff --git a/examples/TH-141/固气界面/manifest.toml b/examples/TH-141/固气界面/manifest.toml new file mode 100644 index 0000000..7158a77 --- /dev/null +++ b/examples/TH-141/固气界面/manifest.toml @@ -0,0 +1,26 @@ +[group] +title = "固气界面" + +[[children]] +kind = "segment" +path = "segments/固气界面导言" + +[[children]] +kind = "segment" +path = "segments/表面能γ与表面应力f" + +[[children]] +kind = "lemma" +path = "lemmas/拉伸固体的总应力" + +[[children]] +kind = "lemma" +path = "lemmas/缺键模型迁移到固气" + +[[children]] +kind = "lemma" +path = "lemmas/固体表面能的晶面各向异性" + +[[children]] +kind = "segment" +path = "segments/不同物质γ量级对比" diff --git a/examples/TH-141/segments/浸润全谱与高低能表面/element.toml b/examples/TH-141/固气界面/segments/不同物质γ量级对比/element.toml similarity index 100% rename from examples/TH-141/segments/浸润全谱与高低能表面/element.toml rename to examples/TH-141/固气界面/segments/不同物质γ量级对比/element.toml diff --git a/examples/TH-141/segments/不同物质γ量级对比/textbook.typ b/examples/TH-141/固气界面/segments/不同物质γ量级对比/textbook.typ similarity index 100% rename from examples/TH-141/segments/不同物质γ量级对比/textbook.typ rename to examples/TH-141/固气界面/segments/不同物质γ量级对比/textbook.typ diff --git a/examples/TH-141/segments/液气界面导言/element.toml b/examples/TH-141/固气界面/segments/固气界面导言/element.toml similarity index 100% rename from examples/TH-141/segments/液气界面导言/element.toml rename to examples/TH-141/固气界面/segments/固气界面导言/element.toml diff --git a/examples/TH-141/segments/固气界面导言/textbook.typ b/examples/TH-141/固气界面/segments/固气界面导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/固气界面导言/textbook.typ rename to examples/TH-141/固气界面/segments/固气界面导言/textbook.typ diff --git a/examples/TH-141/segments/算不准背后的真实物理/element.toml b/examples/TH-141/固气界面/segments/表面能γ与表面应力f/element.toml similarity index 100% rename from examples/TH-141/segments/算不准背后的真实物理/element.toml rename to examples/TH-141/固气界面/segments/表面能γ与表面应力f/element.toml diff --git a/examples/TH-141/segments/表面能γ与表面应力f/textbook.typ b/examples/TH-141/固气界面/segments/表面能γ与表面应力f/textbook.typ similarity index 100% rename from examples/TH-141/segments/表面能γ与表面应力f/textbook.typ rename to examples/TH-141/固气界面/segments/表面能γ与表面应力f/textbook.typ diff --git a/examples/TH-141/lemmas/固体表面能的晶面各向异性/element.toml b/examples/TH-141/固液界面/lemmas/Dupré关系/element.toml similarity index 100% rename from examples/TH-141/lemmas/固体表面能的晶面各向异性/element.toml rename to examples/TH-141/固液界面/lemmas/Dupré关系/element.toml diff --git a/examples/TH-141/lemmas/Dupré关系/proof.typ b/examples/TH-141/固液界面/lemmas/Dupré关系/proof.typ similarity index 100% rename from examples/TH-141/lemmas/Dupré关系/proof.typ rename to examples/TH-141/固液界面/lemmas/Dupré关系/proof.typ diff --git a/examples/TH-141/lemmas/Dupré关系/stmt.typ b/examples/TH-141/固液界面/lemmas/Dupré关系/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/Dupré关系/stmt.typ rename to examples/TH-141/固液界面/lemmas/Dupré关系/stmt.typ diff --git a/examples/TH-141/lemmas/拉伸固体的总应力/element.toml b/examples/TH-141/固液界面/lemmas/GGZ浸润判据/element.toml similarity index 100% rename from examples/TH-141/lemmas/拉伸固体的总应力/element.toml rename to examples/TH-141/固液界面/lemmas/GGZ浸润判据/element.toml diff --git a/examples/TH-141/lemmas/GGZ浸润判据/proof.typ b/examples/TH-141/固液界面/lemmas/GGZ浸润判据/proof.typ similarity index 100% rename from examples/TH-141/lemmas/GGZ浸润判据/proof.typ rename to examples/TH-141/固液界面/lemmas/GGZ浸润判据/proof.typ diff --git a/examples/TH-141/lemmas/GGZ浸润判据/stmt.typ b/examples/TH-141/固液界面/lemmas/GGZ浸润判据/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/GGZ浸润判据/stmt.typ rename to examples/TH-141/固液界面/lemmas/GGZ浸润判据/stmt.typ diff --git a/examples/TH-141/lemmas/立方格子下zeta具体值/element.toml b/examples/TH-141/固液界面/lemmas/Girifalco-Good公式/element.toml similarity index 100% rename from examples/TH-141/lemmas/立方格子下zeta具体值/element.toml rename to examples/TH-141/固液界面/lemmas/Girifalco-Good公式/element.toml diff --git a/examples/TH-141/lemmas/Girifalco-Good公式/proof.typ b/examples/TH-141/固液界面/lemmas/Girifalco-Good公式/proof.typ similarity index 100% rename from examples/TH-141/lemmas/Girifalco-Good公式/proof.typ rename to examples/TH-141/固液界面/lemmas/Girifalco-Good公式/proof.typ diff --git a/examples/TH-141/lemmas/Girifalco-Good公式/stmt.typ b/examples/TH-141/固液界面/lemmas/Girifalco-Good公式/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/Girifalco-Good公式/stmt.typ rename to examples/TH-141/固液界面/lemmas/Girifalco-Good公式/stmt.typ diff --git a/examples/TH-141/lemmas/缺键模型一般公式/element.toml b/examples/TH-141/固液界面/lemmas/Young方程/element.toml similarity index 100% rename from examples/TH-141/lemmas/缺键模型一般公式/element.toml rename to examples/TH-141/固液界面/lemmas/Young方程/element.toml diff --git a/examples/TH-141/lemmas/Young方程/proof.typ b/examples/TH-141/固液界面/lemmas/Young方程/proof.typ similarity index 100% rename from examples/TH-141/lemmas/Young方程/proof.typ rename to examples/TH-141/固液界面/lemmas/Young方程/proof.typ diff --git a/examples/TH-141/lemmas/Young方程/stmt.typ b/examples/TH-141/固液界面/lemmas/Young方程/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/Young方程/stmt.typ rename to examples/TH-141/固液界面/lemmas/Young方程/stmt.typ diff --git a/examples/TH-141/固液界面/manifest.toml b/examples/TH-141/固液界面/manifest.toml new file mode 100644 index 0000000..9406a95 --- /dev/null +++ b/examples/TH-141/固液界面/manifest.toml @@ -0,0 +1,38 @@ +[group] +title = "固液界面" + +[[children]] +kind = "segment" +path = "segments/固液界面导言" + +[[children]] +kind = "segment" +path = "segments/固液界面能的物理图像" + +[[children]] +kind = "lemma" +path = "lemmas/Dupré关系" + +[[children]] +kind = "lemma" +path = "lemmas/Girifalco-Good公式" + +[[children]] +kind = "segment" +path = "segments/Fowkes极性修正" + +[[children]] +kind = "segment" +path = "segments/三相接触导言" + +[[children]] +kind = "lemma" +path = "lemmas/Young方程" + +[[children]] +kind = "lemma" +path = "lemmas/GGZ浸润判据" + +[[children]] +kind = "segment" +path = "segments/浸润全谱与高低能表面" diff --git a/examples/TH-141/segments/缺键模型导言/element.toml b/examples/TH-141/固液界面/segments/Fowkes极性修正/element.toml similarity index 100% rename from examples/TH-141/segments/缺键模型导言/element.toml rename to examples/TH-141/固液界面/segments/Fowkes极性修正/element.toml diff --git a/examples/TH-141/segments/Fowkes极性修正/textbook.typ b/examples/TH-141/固液界面/segments/Fowkes极性修正/textbook.typ similarity index 100% rename from examples/TH-141/segments/Fowkes极性修正/textbook.typ rename to examples/TH-141/固液界面/segments/Fowkes极性修正/textbook.typ diff --git a/examples/TH-141/segments/表面能γ与表面应力f/element.toml b/examples/TH-141/固液界面/segments/三相接触导言/element.toml similarity index 100% rename from examples/TH-141/segments/表面能γ与表面应力f/element.toml rename to examples/TH-141/固液界面/segments/三相接触导言/element.toml diff --git a/examples/TH-141/segments/三相接触导言/textbook.typ b/examples/TH-141/固液界面/segments/三相接触导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/三相接触导言/textbook.typ rename to examples/TH-141/固液界面/segments/三相接触导言/textbook.typ diff --git a/examples/TH-141/固液界面/segments/固液界面导言/element.toml b/examples/TH-141/固液界面/segments/固液界面导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/固液界面/segments/固液界面导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/固液界面导言/textbook.typ b/examples/TH-141/固液界面/segments/固液界面导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/固液界面导言/textbook.typ rename to examples/TH-141/固液界面/segments/固液界面导言/textbook.typ diff --git a/examples/TH-141/固液界面/segments/固液界面能的物理图像/element.toml b/examples/TH-141/固液界面/segments/固液界面能的物理图像/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/固液界面/segments/固液界面能的物理图像/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/固液界面能的物理图像/textbook.typ b/examples/TH-141/固液界面/segments/固液界面能的物理图像/textbook.typ similarity index 100% rename from examples/TH-141/segments/固液界面能的物理图像/textbook.typ rename to examples/TH-141/固液界面/segments/固液界面能的物理图像/textbook.typ diff --git a/examples/TH-141/固液界面/segments/浸润全谱与高低能表面/element.toml b/examples/TH-141/固液界面/segments/浸润全谱与高低能表面/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/固液界面/segments/浸润全谱与高低能表面/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/浸润全谱与高低能表面/textbook.typ b/examples/TH-141/固液界面/segments/浸润全谱与高低能表面/textbook.typ similarity index 100% rename from examples/TH-141/segments/浸润全谱与高低能表面/textbook.typ rename to examples/TH-141/固液界面/segments/浸润全谱与高低能表面/textbook.typ diff --git a/examples/TH-141/收束/manifest.toml b/examples/TH-141/收束/manifest.toml new file mode 100644 index 0000000..e08e1d6 --- /dev/null +++ b/examples/TH-141/收束/manifest.toml @@ -0,0 +1,14 @@ +[group] +title = "收束" + +[[children]] +kind = "segment" +path = "segments/收束导言" + +[[children]] +kind = "segment" +path = "segments/各模型对水的预测对照" + +[[children]] +kind = "segment" +path = "segments/算不准背后的真实物理" diff --git a/examples/TH-141/收束/segments/各模型对水的预测对照/element.toml b/examples/TH-141/收束/segments/各模型对水的预测对照/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/收束/segments/各模型对水的预测对照/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/各模型对水的预测对照/textbook.typ b/examples/TH-141/收束/segments/各模型对水的预测对照/textbook.typ similarity index 100% rename from examples/TH-141/segments/各模型对水的预测对照/textbook.typ rename to examples/TH-141/收束/segments/各模型对水的预测对照/textbook.typ diff --git a/examples/TH-141/收束/segments/收束导言/element.toml b/examples/TH-141/收束/segments/收束导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/收束/segments/收束导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/收束导言/textbook.typ b/examples/TH-141/收束/segments/收束导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/收束导言/textbook.typ rename to examples/TH-141/收束/segments/收束导言/textbook.typ diff --git a/examples/TH-141/收束/segments/算不准背后的真实物理/element.toml b/examples/TH-141/收束/segments/算不准背后的真实物理/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/收束/segments/算不准背后的真实物理/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/算不准背后的真实物理/textbook.typ b/examples/TH-141/收束/segments/算不准背后的真实物理/textbook.typ similarity index 100% rename from examples/TH-141/segments/算不准背后的真实物理/textbook.typ rename to examples/TH-141/收束/segments/算不准背后的真实物理/textbook.typ diff --git a/examples/TH-141/examples/41届复赛三-2-缺键模型/element.toml b/examples/TH-141/液气界面/examples/41届复赛三-2-缺键模型/element.toml similarity index 100% rename from examples/TH-141/examples/41届复赛三-2-缺键模型/element.toml rename to examples/TH-141/液气界面/examples/41届复赛三-2-缺键模型/element.toml diff --git a/examples/TH-141/examples/41届复赛三-2-缺键模型/problem.typ b/examples/TH-141/液气界面/examples/41届复赛三-2-缺键模型/problem.typ similarity index 100% rename from examples/TH-141/examples/41届复赛三-2-缺键模型/problem.typ rename to examples/TH-141/液气界面/examples/41届复赛三-2-缺键模型/problem.typ diff --git a/examples/TH-141/examples/41届复赛三-2-缺键模型/solution.typ b/examples/TH-141/液气界面/examples/41届复赛三-2-缺键模型/solution.typ similarity index 100% rename from examples/TH-141/examples/41届复赛三-2-缺键模型/solution.typ rename to examples/TH-141/液气界面/examples/41届复赛三-2-缺键模型/solution.typ diff --git a/examples/TH-141/lemmas/缺键模型迁移到固气/element.toml b/examples/TH-141/液气界面/lemmas/LJ对势积分标度/element.toml similarity index 100% rename from examples/TH-141/lemmas/缺键模型迁移到固气/element.toml rename to examples/TH-141/液气界面/lemmas/LJ对势积分标度/element.toml diff --git a/examples/TH-141/lemmas/LJ对势积分标度/proof.typ b/examples/TH-141/液气界面/lemmas/LJ对势积分标度/proof.typ similarity index 100% rename from examples/TH-141/lemmas/LJ对势积分标度/proof.typ rename to examples/TH-141/液气界面/lemmas/LJ对势积分标度/proof.typ diff --git a/examples/TH-141/lemmas/LJ对势积分标度/stmt.typ b/examples/TH-141/液气界面/lemmas/LJ对势积分标度/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/LJ对势积分标度/stmt.typ rename to examples/TH-141/液气界面/lemmas/LJ对势积分标度/stmt.typ diff --git a/examples/TH-141/lemmas/表面熵热力学关系/element.toml b/examples/TH-141/液气界面/lemmas/Stefan极简估算/element.toml similarity index 100% rename from examples/TH-141/lemmas/表面熵热力学关系/element.toml rename to examples/TH-141/液气界面/lemmas/Stefan极简估算/element.toml diff --git a/examples/TH-141/lemmas/Stefan极简估算/proof.typ b/examples/TH-141/液气界面/lemmas/Stefan极简估算/proof.typ similarity index 100% rename from examples/TH-141/lemmas/Stefan极简估算/proof.typ rename to examples/TH-141/液气界面/lemmas/Stefan极简估算/proof.typ diff --git a/examples/TH-141/lemmas/Stefan极简估算/stmt.typ b/examples/TH-141/液气界面/lemmas/Stefan极简估算/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/Stefan极简估算/stmt.typ rename to examples/TH-141/液气界面/lemmas/Stefan极简估算/stmt.typ diff --git a/examples/TH-141/lemmas/量纲分析估计/element.toml b/examples/TH-141/液气界面/lemmas/立方格子下zeta具体值/element.toml similarity index 100% rename from examples/TH-141/lemmas/量纲分析估计/element.toml rename to examples/TH-141/液气界面/lemmas/立方格子下zeta具体值/element.toml diff --git a/examples/TH-141/lemmas/立方格子下zeta具体值/proof.typ b/examples/TH-141/液气界面/lemmas/立方格子下zeta具体值/proof.typ similarity index 100% rename from examples/TH-141/lemmas/立方格子下zeta具体值/proof.typ rename to examples/TH-141/液气界面/lemmas/立方格子下zeta具体值/proof.typ diff --git a/examples/TH-141/lemmas/立方格子下zeta具体值/stmt.typ b/examples/TH-141/液气界面/lemmas/立方格子下zeta具体值/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/立方格子下zeta具体值/stmt.typ rename to examples/TH-141/液气界面/lemmas/立方格子下zeta具体值/stmt.typ diff --git a/examples/TH-141/液气界面/lemmas/缺键模型一般公式/element.toml b/examples/TH-141/液气界面/lemmas/缺键模型一般公式/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/液气界面/lemmas/缺键模型一般公式/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/缺键模型一般公式/proof.typ b/examples/TH-141/液气界面/lemmas/缺键模型一般公式/proof.typ similarity index 100% rename from examples/TH-141/lemmas/缺键模型一般公式/proof.typ rename to examples/TH-141/液气界面/lemmas/缺键模型一般公式/proof.typ diff --git a/examples/TH-141/lemmas/缺键模型一般公式/stmt.typ b/examples/TH-141/液气界面/lemmas/缺键模型一般公式/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/缺键模型一般公式/stmt.typ rename to examples/TH-141/液气界面/lemmas/缺键模型一般公式/stmt.typ diff --git a/examples/TH-141/液气界面/lemmas/量纲分析估计/element.toml b/examples/TH-141/液气界面/lemmas/量纲分析估计/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/液气界面/lemmas/量纲分析估计/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/量纲分析估计/stmt.typ b/examples/TH-141/液气界面/lemmas/量纲分析估计/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/量纲分析估计/stmt.typ rename to examples/TH-141/液气界面/lemmas/量纲分析估计/stmt.typ diff --git a/examples/TH-141/液气界面/manifest.toml b/examples/TH-141/液气界面/manifest.toml new file mode 100644 index 0000000..2593bed --- /dev/null +++ b/examples/TH-141/液气界面/manifest.toml @@ -0,0 +1,42 @@ +[group] +title = "液气界面" + +[[children]] +kind = "segment" +path = "segments/液气界面导言" + +[[children]] +kind = "segment" +path = "segments/微观建模的共同骨架" + +[[children]] +kind = "lemma" +path = "lemmas/量纲分析估计" + +[[children]] +kind = "segment" +path = "segments/缺键模型导言" + +[[children]] +kind = "lemma" +path = "lemmas/缺键模型一般公式" + +[[children]] +kind = "example" +path = "examples/41届复赛三-2-缺键模型" + +[[children]] +kind = "lemma" +path = "lemmas/Stefan极简估算" + +[[children]] +kind = "lemma" +path = "lemmas/立方格子下zeta具体值" + +[[children]] +kind = "segment" +path = "segments/LJ积分导言" + +[[children]] +kind = "lemma" +path = "lemmas/LJ对势积分标度" diff --git a/examples/TH-141/液气界面/segments/LJ积分导言/element.toml b/examples/TH-141/液气界面/segments/LJ积分导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/液气界面/segments/LJ积分导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/LJ积分导言/textbook.typ b/examples/TH-141/液气界面/segments/LJ积分导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/LJ积分导言/textbook.typ rename to examples/TH-141/液气界面/segments/LJ积分导言/textbook.typ diff --git a/examples/TH-141/液气界面/segments/微观建模的共同骨架/element.toml b/examples/TH-141/液气界面/segments/微观建模的共同骨架/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/液气界面/segments/微观建模的共同骨架/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/微观建模的共同骨架/textbook.typ b/examples/TH-141/液气界面/segments/微观建模的共同骨架/textbook.typ similarity index 100% rename from examples/TH-141/segments/微观建模的共同骨架/textbook.typ rename to examples/TH-141/液气界面/segments/微观建模的共同骨架/textbook.typ diff --git a/examples/TH-141/液气界面/segments/液气界面导言/element.toml b/examples/TH-141/液气界面/segments/液气界面导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/液气界面/segments/液气界面导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/液气界面导言/textbook.typ b/examples/TH-141/液气界面/segments/液气界面导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/液气界面导言/textbook.typ rename to examples/TH-141/液气界面/segments/液气界面导言/textbook.typ diff --git a/examples/TH-141/液气界面/segments/缺键模型导言/element.toml b/examples/TH-141/液气界面/segments/缺键模型导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/液气界面/segments/缺键模型导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/缺键模型导言/textbook.typ b/examples/TH-141/液气界面/segments/缺键模型导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/缺键模型导言/textbook.typ rename to examples/TH-141/液气界面/segments/缺键模型导言/textbook.typ diff --git a/render/examples/smoke-eng/exports/student.typ b/render/examples/smoke-eng/exports/student.typ index a33d25d..14d091d 100644 --- a/render/examples/smoke-eng/exports/student.typ +++ b/render/examples/smoke-eng/exports/student.typ @@ -1,4 +1,4 @@ -// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011). +// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // This is a *real, editable* file that lives in an engineering file at // `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the @@ -15,15 +15,16 @@ // its own virtual root — an include inside cph-render would resolve against the // PACKAGE, not the engineering root. A `//.typ` written HERE // (this template lives under `--root`) resolves against `--root`. So the -// template loads content and hands cph-render an already-assembled `parts` array. +// template loads content and hands cph-render an already-assembled `outline` +// array (elements interleaved with section headings, ADR-0029). // // OPEN CONTRACT POINT — optional-content presence. typst has no "does this file // exist" primitive (a missing `include` is a hard compile error). So the // template CANNOT probe disk the way the old Rust driver did for lemma `proof`. // It relies on the manifest declaring which optional content fields are present, -// via a per-part `fields` array listing the content fields that exist on disk +// via a per-element `fields` array listing the content fields that exist on disk // (the engine knows this — it walks the part dir). Required fields are loaded -// unconditionally; optional fields load only if listed in `fields`. If a part +// unconditionally; optional fields load only if listed in `fields`. If an element // omits `fields`, optional content is skipped (conservative). The exact shape of // this declaration is for the manifest/Rust contract to pin. @@ -35,38 +36,51 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: +// - an "element" entry: include its content fields (computed absolute paths, +// resolved against --root) and read scalar fields from /element.toml. +// `part-fields` (from cph-render) is the single source of truth for +// kind->fields. +// - a "section" entry (ADR-0029): pass its title/depth straight through — no +// content to load, it is a heading. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -74,6 +88,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) diff --git a/render/examples/smoke-eng/exports/teacher.typ b/render/examples/smoke-eng/exports/teacher.typ index b976b1f..abc5b8a 100644 --- a/render/examples/smoke-eng/exports/teacher.typ +++ b/render/examples/smoke-eng/exports/teacher.typ @@ -1,4 +1,4 @@ -// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011). +// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with // the manifest injected: @@ -18,38 +18,47 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: an "element" entry includes its content fields +// and reads scalars from element.toml; a "section" entry (ADR-0029) passes +// title/depth straight through as a heading, no content to load. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -57,6 +66,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) diff --git a/render/examples/smoke-eng/manifest.toml b/render/examples/smoke-eng/manifest.toml index bb64c9d..876632f 100644 --- a/render/examples/smoke-eng/manifest.toml +++ b/render/examples/smoke-eng/manifest.toml @@ -1,11 +1,15 @@ -# Throwaway smoke engineering file for the cph-render template round (ADR-0011). -# Exercises all 4 kinds + nested headings (per-level numbering) + an example with -# a `source` scalar and one without + a lemma with proof and one without. +# Throwaway smoke engineering file for the cph-render template round +# (ADR-0011, outline shape ADR-0029). Exercises all 4 kinds + a section heading +# + nested headings (per-level numbering) + an example with a `source` scalar +# and one without + a lemma with proof and one without. # -# The per-part `fields` array lists the content fields present ON DISK. The -# template uses it to decide whether to load OPTIONAL content (lemma `proof`), -# because typst has no file-exists primitive. (OPEN: the exact manifest shape for -# declaring optional-field presence is for the Rust/manifest contract to pin.) +# This file is compiled DIRECTLY via `typst compile --input manifest=/manifest.toml` +# (see README below), bypassing the Rust engine's augmented-manifest builder +# (`cph_typst::build_augmented_manifest`). So the `[[outline]]` shape here is +# hand-authored to already be what that builder would produce: an ordered array +# of `type = "element"` (kind, path, fields — the content fields present on +# disk, since typst has no file-exists primitive) and `type = "section"` +# (kind, title, depth) entries. [project] id = "smoke-eng" @@ -15,38 +19,54 @@ name = "cph-render template smoke" title = "向量与几何 · 示例讲义" author = ["张老师", "李老师"] -# segment — nested headings exercise per-level numbering (一、 / 1.1 / 1.1.1). -[[parts]] +# segment — exercises per-level numbering (一、 / 1.1 / 1.1.1) alongside the +# section heading below. +[[outline]] +type = "element" kind = "segment" path = "segments/向量数量积" fields = ["textbook"] +# A section heading (ADR-0029): opens depth-1, groups the two examples that +# follow. Contributes no element to the sequence. +[[outline]] +type = "section" +kind = "section" +title = "数量积的坐标计算" +depth = 1 +path = "examples" + # example WITH source scalar -[[parts]] +[[outline]] +type = "element" kind = "example" path = "examples/坐标数量积" fields = ["problem", "solution"] # example WITHOUT source scalar -[[parts]] +[[outline]] +type = "element" kind = "example" path = "examples/求模长" fields = ["problem", "solution"] # lemma WITH proof (proof.typ present on disk; declared in fields) -[[parts]] +[[outline]] +type = "element" kind = "lemma" path = "lemmas/柯西不等式" fields = ["stmt", "proof"] # lemma WITHOUT proof (proof.typ absent; fields omits it) -[[parts]] +[[outline]] +type = "element" kind = "lemma" path = "lemmas/垂直判据" fields = ["stmt"] # sop -[[parts]] +[[outline]] +type = "element" kind = "sop" path = "sops/求夹角步骤" fields = ["sop"] diff --git a/render/lib.typ b/render/lib.typ index d809220..80b4ab4 100644 --- a/render/lib.typ +++ b/render/lib.typ @@ -1,14 +1,14 @@ // cph-render — curriculum lesson render package. // -// PUBLIC ENTRY: `render-lesson(info, target, parts, heading-numbering)`. +// PUBLIC ENTRY: `render-lesson(info, target, outline, heading-numbering)`. // -// MODEL (ADR-0011). A build compiles a *template file* (e.g. `exports/student.typ`) -// as the typst main, with the manifest injected via `--input manifest=`. -// The template reads the manifest, loads each part's content, and calls -// `render-lesson` here. Presentation (heading numbering) lives in the template, -// not the manifest, not hardcoded-unreachable in this package — we only provide -// the DEFAULT scheme (`default-heading-numbering`) for the template to use or -// override. +// MODEL (ADR-0011, outline shape per ADR-0029). A build compiles a *template +// file* (e.g. `exports/student.typ`) as the typst main, with the manifest +// injected via `--input manifest=`. The template reads the manifest, +// loads each **element** entry's content, and calls `render-lesson` here. +// Presentation (heading numbering) lives in the template, not the manifest, +// not hardcoded-unreachable in this package — we only provide the DEFAULT +// scheme (`default-heading-numbering`) for the template to use or override. // // WHY THE TEMPLATE LOADS CONTENT, NOT US (the include-resolution finding): // typst resolves an `include`/`import` path relative to THE PACKAGE/FILE THE @@ -19,11 +19,15 @@ // Therefore the dynamic-include LOOP must live in the TEMPLATE (which lives at // `/exports/*.typ`, so `//.typ` resolves against // `--root`). `render-lesson` is content-in: it takes an ALREADY-ASSEMBLED -// `parts` array of dicts and never includes anything itself. +// `outline` array of entry dicts and never includes anything itself. // -// A part dict the template hands us: -// `kind` plus that kind's content/scalar fields. Content field VALUES are -// already-evaluated typst content (the template produced them via `include`). +// An `outline` entry the template hands us is one of: +// - an ELEMENT: `entry-type: "element"`, `kind` plus that kind's +// content/scalar fields. Content field VALUES are already-evaluated typst +// content (the template produced them via `include`). +// - a SECTION (ADR-0029): `entry-type: "section"`, `title` (heading text), +// `depth` (1-based heading level). Contributes no element; it is a +// heading at its depth-first-open position in the outline. // // kind -> fields (MVP) — see `part-fields` below, which the template uses to // know what to load: @@ -41,7 +45,6 @@ // unknown : rendered conservatively == student (show only required-public // fields). Never crashes. The "no render rule => warning" // diagnostic is the Rust side's job, not ours. - #import "src/style.typ": base-style, default-heading-numbering, title-block, subtitle-block #import "src/elements/segment.typ": display-segment #import "src/elements/example.typ": display-example @@ -106,14 +109,33 @@ } } +/// Render one section-heading outline entry (ADR-0029): `entry.title` at +/// `entry.depth` (1-based heading level). +#let _render-section(entry) = { + heading(level: entry.at("depth", default: 1))[#entry.at("title", default: "")] +} + +/// Render one outline entry: an element dispatches on `kind` via +/// [`_render-part`]; a section (ADR-0029) renders its heading and contributes +/// no element. +#let _render-entry(entry, flags) = { + if entry.at("entry-type", default: "element") == "section" { + _render-section(entry) + } else { + _render-part(entry, flags) + } +} + /// THE ENTRY POINT — called by a template (`exports/*.typ`). /// /// - `info`: dict, e.g. (title: "…", author: "…"). `author` may be absent. /// Templates typically pass `manifest.at("info", default: (:))`. /// - `target`: string. MVP: "student" | "teacher". Unknown => conservative. /// Each template hardcodes its own target (student.typ => "student"). -/// - `parts`: ordered array of part dicts, ALREADY ASSEMBLED by the template -/// (content fields included, scalars read). See file header. +/// - `outline`: ordered array of outline-entry dicts (ADR-0029), ALREADY +/// ASSEMBLED by the template (element content fields included, +/// scalars read; section entries carry `title`/`depth`). See file +/// header for the entry shapes. /// - `heading-numbering`: array of per-level numbly pattern strings, e.g. /// `("{1:一}、", "{1:1}.{2:1}", "{1:1}.{2:1}.{3:1}")`. Presentation /// lives in the template (ADR-0011); the template passes its chosen @@ -122,7 +144,7 @@ #let render-lesson( info: (:), target: "student", - parts: (), + outline: (), heading-numbering: default-heading-numbering, ) = { let title = info.at("title", default: []) @@ -146,7 +168,122 @@ subtitle-block(flags.subtitle) // Render strictly in array order. - for part in parts { - _render-part(part, flags) + for entry in outline { + _render-entry(entry, flags) + } +} + +/// Shift a section entry's `depth` by `delta`; an element entry passes through +/// unchanged (elements carry no depth). Used by [`render-bundle`] to nest each +/// lesson's own outline one level under that lesson's title heading. +#let _shift-depth(entry, delta) = { + if entry.at("entry-type", default: "element") == "section" { + entry.depth = entry.depth + delta + } + entry +} + +/// THE BUNDLE ENTRY POINT (ADR-0030) — called by a bundle target's template +/// (`exports/.typ` under a `bundle.toml` root) to assemble several +/// already-loaded lessons into one combined document. +/// +/// - `info`: the bundle's own `[info]` (title/author of the 合集). +/// - `lessons`: ordered array of `(info: .., target: .., outline: ..)` dicts — +/// one per `bundle.toml` `lessons` entry, each ALREADY ASSEMBLED exactly as +/// a single-lesson template would assemble it for [`render-lesson`]. +/// - `heading-numbering`: shared per-level numbering scheme across the whole +/// bundle (ADR-0011 presentation-in-template stance; same knob as +/// `render-lesson`). +/// - `reset-counters`: whether `example`/`lemma` auto-counters reset at each +/// lesson boundary. **Default `true`** (ADR-0030's recommended default: a +/// lesson's internal "例题3" means that lesson's 例题3, so cross-lesson +/// continuation would silently break author references). Pass `false` for +/// a genuine "全书连续编号" 合集. +/// +/// Each lesson's own outline is rendered under a depth-1 heading naming that +/// lesson (`lesson.info.title`); the lesson's own section headings shift one +/// level deeper (ADR-0029's per-lesson depths are relative to that lesson, so +/// nesting under the lesson-title heading keeps the outline↔structure +/// correspondence meaningful in the combined document). `@label`/`@ref` +/// cross-references stay global across the whole compiled document (typst's +/// ordinary behavior) regardless of `reset-counters`. +#let render-bundle( + info: (:), + lessons: (), + heading-numbering: default-heading-numbering, + reset-counters: true, +) = { + let title = info.at("title", default: []) + let author = info.at("author", default: none) + + set document( + title: title, + author: if author == none { () } else { author }, + ) + + show: base-style.with(heading-numbering: heading-numbering) + + title-block() + v(1.2em, weak: true) + + for lesson in lessons { + if reset-counters { + example-counter.update(0) + lemma-counter.update(0) + } + + let flags = _flags-for(lesson.at("target", default: "student")) + let lesson-info = lesson.at("info", default: (:)) + + heading(level: 1)[#lesson-info.at("title", default: [])] + + for entry in lesson.at("outline", default: ()) { + _render-entry(_shift-depth(entry, 1), flags) + } + } +} + +/// Render a teacher-facing outline. `children` is intentionally separate from +/// the lesson body: each node heading is accompanied by an optional planning +/// note rendered as a visually distinct teaching-tip box. +#let _render-outline-node(node, level) = { + let title = node.at("title", default: "") + heading(level: level)[#title] + + + let notes = node.at("notes", default: none) + if notes != none and notes != "" { + block( + width: 100%, + inset: (x: 1em, y: 0.7em), + fill: rgb("#fff8e7"), + stroke: rgb("#e6c45a") + 0.6pt, + radius: 0.3em, + breakable: true, + { + set text(size: 10.5pt) + text(weight: "bold")[教学提示] + linebreak() + notes + }, + ) + } + + for child in node.at("children", default: ()) { + _render-outline-node(child, level + 1) + } +} + +/// Render the outline document supplied by `cph outline --format pdf`. +#let render-outline(outline) = { + let title = outline.at("title", default: "课程大纲") + let authors = outline.at("authors", default: ()) + set document(title: title, author: authors) + show: base-style.with(heading-numbering: default-heading-numbering) + + title-block() + subtitle-block([课程大纲]) + for child in outline.at("children", default: ()) { + _render-outline-node(child, 1) } } diff --git a/render/templates/README.md b/render/templates/README.md index c4cbd59..510c524 100644 --- a/render/templates/README.md +++ b/render/templates/README.md @@ -1,4 +1,4 @@ -# Default export templates (ADR-0011) +# Default export templates (ADR-0011, outline shape ADR-0029) `student.typ` / `teacher.typ` are the **framework default templates**. In a real engineering file they live at `exports/student.typ` / `exports/teacher.typ`; the @@ -9,11 +9,16 @@ offline smoke test below. Each template: 1. reads the injected manifest: `toml(sys.inputs.manifest)`; -2. loops `manifest.parts`, `include`-ing each content field via a computed - **root-relative absolute** path `//.typ`, and reading scalar - fields (example `source`) from `//element.toml`; -3. assembles a `parts` array and calls `cph-render`'s `render-lesson(...)`, - passing the per-level heading numbering (presentation lives in the template). +2. loops `manifest.outline` (ADR-0029's depth-first rendering order — elements + interleaved with section headings at their DFS-open position). For an + `type = "element"` entry: `include`-ing each content field via a computed + **root-relative absolute** path `//.typ`, and reading + scalar fields (example `source`) from `//element.toml`. For a + `type = "section"` entry: passing its `title`/`depth` straight through — no + content to load, it is a heading; +3. assembles an `outline` array of entry dicts and calls `cph-render`'s + `render-lesson(...)`, passing the per-level heading numbering (presentation + lives in the template). ## Why the include loop is in the template, not in cph-render @@ -23,7 +28,7 @@ root**. A dynamic `include` written inside the package resolves against the *package* dir — even an absolute `/segments/x.typ` — never the engineering `--root`. Verified empirically. The template lives under `--root`, so its `//.typ` resolves against `--root`. Hence the template loads -content and hands `render-lesson` an already-assembled `parts` array; +content and hands `render-lesson` an already-assembled `outline` array; `render-lesson` never includes anything. ## Key path facts for the engine @@ -36,9 +41,13 @@ content and hands `render-lesson` an already-assembled `parts` array; relative to the *template's* location (`exports/`), so a bare `manifest=manifest.toml` would look in `exports/`. Pass the leading `/`. - **Optional content presence** (lemma `proof`): typst has no file-exists - primitive, so the template cannot probe disk. It reads a per-part `fields` - array from the manifest listing the content fields present on disk. *(OPEN: the - exact manifest shape for this is for the Rust/manifest contract to pin.)* + primitive, so the template cannot probe disk. It reads a per-element + `fields` array from the manifest listing the content fields present on + disk. *(OPEN: the exact manifest shape for this is for the Rust/manifest + contract to pin.)* +- **Sections carry no content fields.** A `type = "section"` outline entry + (ADR-0029) has only `kind`/`title`/`depth`/`path`; the template passes it + through untouched — no `include`, no `element.toml` read. ## Offline smoke test @@ -60,4 +69,7 @@ typst compile --root examples/smoke-eng \ ``` (`examples/smoke-eng/exports/{student,teacher}.typ` are copies of the defaults -here, mirroring how a real engineering file carries its own templates.) +here, mirroring how a real engineering file carries its own templates. The +`examples/smoke-eng/manifest.toml` is hand-authored directly in the augmented +`[[outline]]` shape that `cph_typst::build_augmented_manifest` would otherwise +produce, since this smoke test bypasses the Rust engine entirely.) diff --git a/render/templates/merged.typ b/render/templates/merged.typ new file mode 100644 index 0000000..5e6c00a --- /dev/null +++ b/render/templates/merged.typ @@ -0,0 +1,76 @@ +// DEFAULT BUNDLE TEMPLATE (ADR-0030, outline shape ADR-0029). +// +// Lives in a bundle at `/exports/.typ`, e.g. +// `exports/merged.typ`. Compiled AS MAIN with the augmented BUNDLE manifest +// injected: +// typst compile --root --input manifest= exports/merged.typ +// +// Structurally identical to the single-lesson `student.typ`/`teacher.typ` +// templates (see their notes on why the include loop lives in the template, +// not in cph-render), except it reads `manifest.lessons` (an ordered array of +// per-lesson `(info, target, outline)` tables — see +// `cph_typst::build_augmented_bundle_manifest`) instead of a single +// `manifest.outline`, and calls `render-bundle` instead of `render-lesson`. +// +// Every outline entry's `path` in a bundle manifest is ALREADY prefixed with +// that lesson's own bundle-root-relative directory (done by the Rust engine), +// so the same `include "/" + path + "/" + field + ".typ"` computation used by +// a single-lesson template resolves correctly here too — no special-casing +// needed in this loop. + +#import "@local/cph-render:0.1.0": render-bundle, part-fields, default-heading-numbering + +#let manifest = toml(sys.inputs.manifest) +#let info = manifest.at("info", default: (:)) +#let raw-lessons = manifest.at("lessons", default: ()) + +// Assemble one outline entry exactly as a single-lesson template would. +#let assemble-entry(raw) = { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) + + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } + } + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry + } +} + +#let lessons = raw-lessons.map(raw => ( + info: raw.at("info", default: (:)), + target: raw.at("target", default: "student"), + outline: raw.at("outline", default: ()).map(assemble-entry), +)) + +// Presentation: shared per-level heading numbering across the whole bundle, +// and the ADR-0030 recommended default of resetting auto-counters at each +// lesson boundary (override `reset-counters: false` for continuous numbering). +#render-bundle( + info: info, + lessons: lessons, + heading-numbering: default-heading-numbering, + reset-counters: true, +) diff --git a/render/templates/student.typ b/render/templates/student.typ index a33d25d..14d091d 100644 --- a/render/templates/student.typ +++ b/render/templates/student.typ @@ -1,4 +1,4 @@ -// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011). +// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // This is a *real, editable* file that lives in an engineering file at // `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the @@ -15,15 +15,16 @@ // its own virtual root — an include inside cph-render would resolve against the // PACKAGE, not the engineering root. A `//.typ` written HERE // (this template lives under `--root`) resolves against `--root`. So the -// template loads content and hands cph-render an already-assembled `parts` array. +// template loads content and hands cph-render an already-assembled `outline` +// array (elements interleaved with section headings, ADR-0029). // // OPEN CONTRACT POINT — optional-content presence. typst has no "does this file // exist" primitive (a missing `include` is a hard compile error). So the // template CANNOT probe disk the way the old Rust driver did for lemma `proof`. // It relies on the manifest declaring which optional content fields are present, -// via a per-part `fields` array listing the content fields that exist on disk +// via a per-element `fields` array listing the content fields that exist on disk // (the engine knows this — it walks the part dir). Required fields are loaded -// unconditionally; optional fields load only if listed in `fields`. If a part +// unconditionally; optional fields load only if listed in `fields`. If an element // omits `fields`, optional content is skipped (conservative). The exact shape of // this declaration is for the manifest/Rust contract to pin. @@ -35,38 +36,51 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: +// - an "element" entry: include its content fields (computed absolute paths, +// resolved against --root) and read scalar fields from /element.toml. +// `part-fields` (from cph-render) is the single source of truth for +// kind->fields. +// - a "section" entry (ADR-0029): pass its title/depth straight through — no +// content to load, it is a heading. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -74,6 +88,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) diff --git a/render/templates/teacher.typ b/render/templates/teacher.typ index b976b1f..abc5b8a 100644 --- a/render/templates/teacher.typ +++ b/render/templates/teacher.typ @@ -1,4 +1,4 @@ -// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011). +// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with // the manifest injected: @@ -18,38 +18,47 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: an "element" entry includes its content fields +// and reads scalars from element.toml; a "section" entry (ADR-0029) passes +// title/depth straight through as a heading, no content to load. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -57,6 +66,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, )