Merge pull request 'feat(cph): implement nested outline manifest and batch/combined export' (#96) from feat/adr-0029-0030-nested-manifest-export into main

Reviewed-on: EduCraft/curriculum-project-hub#96
This commit is contained in:
2026-08-05 18:34:13 +08:00
175 changed files with 3644 additions and 731 deletions
Generated
+2
View File
@@ -410,7 +410,9 @@ dependencies = [
"clap_complete", "clap_complete",
"cph-check", "cph-check",
"cph-diag", "cph-diag",
"cph-model",
"cph-typst", "cph-typst",
"serde_json",
] ]
[[package]] [[package]]
+10
View File
@@ -20,6 +20,16 @@ cph check <工程目录> # 校验合法性(7 类诊断)
cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF 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)。 **版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。
Shell 补全(可选): Shell 补全(可选):
+7 -5
View File
@@ -3,10 +3,12 @@
These crates implement the rule-based lesson checker whose semantics are 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, pinned by the ADRs in `docs/adr/`: it reads an engineering-file (one lesson,
ADR-0005) ADR-0005)
laid out per ADR-0008 (declarative `manifest.toml` + per-element laid out per ADR-0029 (a nested outline manifest — every container folder
`element.toml`), validates structure and content, and emits diagnostics. carries `manifest.toml`, every leaf carries `element.toml`; supersedes
`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader), ADR-0008's flat `[[parts]]`), validates structure and content, and emits
and `cph-typst` (the typst `World` / compile / span-mapping layer) are 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 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 they live in this repo-wide `crates/` directory rather than under any single
component; `cph-schema` (kind JSON Schemas + validation), `cph-check` component; `cph-schema` (kind JSON Schemas + validation), `cph-check`
@@ -18,7 +20,7 @@ entrypoint) are the checker proper.
| crate | owner | role | | crate | owner | role |
|---------------|-------|------| |---------------|-------|------|
| `cph-diag` | WU-1 | shared diagnostic vocabulary (`Severity`, `DiagCode`, `Diagnostic`, `SourceSpan`) — reusable | | `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-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-typst` | WU-4 | typst `World`, driver generation, compile, PDF, span mapping — reusable |
| `cph-check` | WU-5 | orchestration: render-coverage and the full check pipeline | | `cph-check` | WU-5 | orchestration: render-coverage and the full check pipeline |
+126 -2
View File
@@ -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<cph_model::OutlineDocument>, 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`. /// Build a PDF for `target`.
/// ///
/// Runs the check phases **(a)(c)** (load → structural → schema). If those /// 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<Vec<u8>>, 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<Vec<u8>>, 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<String> {
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<String> {
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`]). /// One shell step's execution outcome (for [`run_shell_target`]).
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct ShellStepOutcome { pub struct ShellStepOutcome {
@@ -441,8 +564,9 @@ pub struct MarkdownAssembleReport {
} }
/// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each /// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each
/// element's `<field>.md` markdown content file in `[[parts]]` order into the /// element's `<field>.md` markdown content file in `parts` order (ADR-0029's
/// target's single-file artifact. This is the **third typed step**: unlike /// 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 /// [`build`] (typst template → PDF) the framework owns the read/concatenate/write
/// itself (not a typst compile, not an external tool like [`run_shell_target`]). /// itself (not a typst compile, not an external tool like [`run_shell_target`]).
/// ///
+28 -11
View File
@@ -45,6 +45,23 @@ fn good_fixture_has_no_errors() {
assert!(!report.has_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] #[test]
fn unknown_kind_is_an_error() { fn unknown_kind_is_an_error() {
// Build a throwaway lesson whose part declares kind "frob". // Build a throwaway lesson whose part declares kind "frob".
@@ -59,7 +76,7 @@ name = "broken"
[info] [info]
title = "broken" title = "broken"
[[parts]] [[children]]
kind = "frob" kind = "frob"
path = "elements/widget" path = "elements/widget"
"#, "#,
@@ -123,7 +140,7 @@ name = "broken"
[info] [info]
title = "broken" title = "broken"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/does-not-exist" path = "segments/does-not-exist"
"#, "#,
@@ -150,7 +167,7 @@ name = "cov"
[info] [info]
title = "cov" title = "cov"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
@@ -276,7 +293,7 @@ name = "sh"
[info] [info]
title = "sh" title = "sh"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
@@ -350,7 +367,7 @@ name = "sh"
[info] [info]
title = "sh" title = "sh"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/missing" path = "segments/missing"
@@ -390,7 +407,7 @@ fn write_markdown_assemble_target_lesson(tmp: &Path, slides: &[(&str, &str)]) {
parts.push('\n'); parts.push('\n');
} }
parts.push_str(&format!( parts.push_str(&format!(
"[[parts]]\nkind = \"segment\"\npath = \"segments/{name}\"\n" "[[children]]\nkind = \"segment\"\npath = \"segments/{name}\"\n"
)); ));
} }
std::fs::write( 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). // Only the first segment has a slides.md; the second is skipped (optional).
let tmp = tempdir(); let tmp = tempdir();
let mut parts = String::new(); let mut parts = String::new();
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n\n"); parts.push_str("[[children]]\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/b\"\n");
std::fs::write( std::fs::write(
tmp.join("manifest.toml"), tmp.join("manifest.toml"),
format!( format!(
@@ -527,7 +544,7 @@ name = "md"
[info] [info]
title = "md" title = "md"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/a" path = "segments/a"
@@ -584,7 +601,7 @@ name = "md"
[info] [info]
title = "md" title = "md"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/a" path = "segments/a"
@@ -630,7 +647,7 @@ name = "md"
[info] [info]
title = "md" title = "md"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/missing" path = "segments/missing"
+2
View File
@@ -11,6 +11,8 @@ path = "src/main.rs"
[dependencies] [dependencies]
cph-check = { path = "../cph-check" } cph-check = { path = "../cph-check" }
cph-diag = { workspace = true } cph-diag = { workspace = true }
cph-model = { workspace = true }
cph-typst = { path = "../cph-typst" } cph-typst = { path = "../cph-typst" }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
clap_complete = "4" clap_complete = "4"
serde_json = "1"
+342 -13
View File
@@ -7,11 +7,14 @@
//! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit //! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit
//! 0); `build` exits 1 when the PDF could not be produced. //! 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::path::PathBuf;
use std::process::ExitCode; use std::process::ExitCode;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use cph_check::CheckReport; use cph_check::CheckReport;
use cph_model::OutlineDocument;
use cph_typst::Engine; use cph_typst::Engine;
/// The `cph` checker for curriculum engineering files. /// 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 to the engineering-file root (the folder with `manifest.toml`).
path: PathBuf, 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 { Build {
/// Path to the engineering-file root (the folder with `manifest.toml`). /// Path to the engineering-file root (the folder with `manifest.toml`).
path: PathBuf, path: PathBuf,
/// Render target to export. /// Render target(s) to export. Repeatable. Defaults to every target
#[arg(long, default_value = "student")] /// the lesson declares (or `student` if it declares none).
target: String, #[arg(long = "target")]
/// Output PDF path. Defaults to `<PATH>/build/<target>.pdf`. targets: Vec<String>,
/// Output path for a *single*-target build. Defaults to
/// `<PATH>/build/<target>.pdf`. Rejected when building more than one
/// target (ambiguous: which target would it name?).
#[arg(short = 'o', long, value_name = "OUT")] #[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>, out: Option<PathBuf>,
}, },
/// 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<String>,
/// Output path for a *single*-target build. Defaults to
/// `<PATH>/build/<target>.pdf`. Rejected when building more than one
/// target.
#[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>,
},
/// 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 `<PATH>/outline.<format>`.
#[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>,
/// Allow replacing an existing output file.
#[arg(long)]
force: bool,
},
/// Print a shell-completion script to stdout (clap_complete; ADR-0013 opt-in /// 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 /// sibling: a local convenience, no lesson involved). Pipe to your shell's
/// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`. /// 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)] #[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum CompletionTarget { enum CompletionTarget {
Bash, Bash,
@@ -74,7 +133,14 @@ fn main() -> ExitCode {
match cli.command { match cli.command {
Command::Check { path } => run_check(&path, &engine), 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), 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<PathBuf>,
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<String>,
out: Option<PathBuf>,
) -> 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, path: &std::path::Path,
engine: &Engine, engine: &Engine,
target: &str, target: &str,
out: Option<PathBuf>, out: Option<PathBuf>,
) -> ExitCode { ) -> bool {
// A target whose steps are shell commands (a tool-generated asset bundle, // 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 // ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by
// executing those commands, not by compiling a typst template. Detect that // executing those commands, not by compiling a typst template. Detect that
// shape up front and route accordingly. // shape up front and route accordingly.
if cph_check::target_is_shell(path, target) { 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 / 逐字稿 // A target whose steps assemble markdown (ADR-0015: slides outline / 逐字稿
// transcript surfaces) is built by concatenating per-element `<field>.md` // transcript surfaces) is built by concatenating per-element `<field>.md`
// files in parts order, not by compiling a typst template. // files in parts order, not by compiling a typst template.
if cph_check::target_is_markdown_assemble(path, target) { 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"))); 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}", "error: cannot create output directory '{}': {e}",
parent.display() parent.display()
); );
return ExitCode::FAILURE; return false;
} }
} }
if let Err(e) = std::fs::write(&out_path, &bytes) { if let Err(e) = std::fs::write(&out_path, &bytes) {
eprintln!("error: cannot write '{}': {e}", out_path.display()); eprintln!("error: cannot write '{}': {e}", out_path.display());
return ExitCode::FAILURE; return false;
} }
println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
ExitCode::SUCCESS true
} }
None => { None => {
eprintln!("build failed: {} errors", report.error_count()); eprintln!("build failed: {} errors", report.error_count());
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<String>,
out: Option<PathBuf>,
) -> 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 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<PathBuf>,
) -> 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
} }
} }
} }
+12 -1
View File
@@ -65,7 +65,8 @@ pub struct SourceSpan {
/// Do not invent codes outside this enum without a deliberate decision. /// Do not invent codes outside this enum without a deliberate decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum DiagCode { 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, PartPathMissing,
/// An element declares a `kind` that is not a known kind. /// An element declares a `kind` that is not a known kind.
UnknownKind, UnknownKind,
@@ -86,6 +87,15 @@ pub enum DiagCode {
/// The engineering file's `.cph-version` is not compatible with the running /// The engineering file's `.cph-version` is not compatible with the running
/// CLI's version (ADR-0016). Decided at load time; `error` severity. /// CLI's version (ADR-0016). Decided at load time; `error` severity.
CphVersionMismatch, 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 { impl DiagCode {
@@ -102,6 +112,7 @@ impl DiagCode {
DiagCode::TypstCompile => "E-TYPST-COMPILE", DiagCode::TypstCompile => "E-TYPST-COMPILE",
DiagCode::RenderIgnored => "W-RENDER-IGNORED", DiagCode::RenderIgnored => "W-RENDER-IGNORED",
DiagCode::CphVersionMismatch => "E-CPH-VERSION", DiagCode::CphVersionMismatch => "E-CPH-VERSION",
DiagCode::ManifestMalformed => "E-MANIFEST",
} }
} }
} }
File diff suppressed because it is too large Load Diff
+83
View File
@@ -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);
}
@@ -0,0 +1,12 @@
[project]
id = "fixture-both"
name = "both"
[info]
title = "文件夹既是容器又是叶子"
[[children]]
kind = "segment"
path = "segments/broken"
[targets.student]
@@ -0,0 +1 @@
[[children]]
@@ -0,0 +1,2 @@
[info
this is broken
@@ -0,0 +1,5 @@
[info]
title = "缺失课时的合集"
[[lessons]]
path = "does-not-exist"
@@ -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"
@@ -0,0 +1,12 @@
[project]
id = "lesson-a"
name = "lesson-a"
[info]
title = "课时A"
[[children]]
kind = "segment"
path = "segments/a"
[targets.student]
@@ -0,0 +1 @@
A.
@@ -0,0 +1,12 @@
[project]
id = "lesson-b"
name = "lesson-b"
[info]
title = "课时B"
[[children]]
kind = "segment"
path = "segments/b"
[targets.teacher]
@@ -0,0 +1 @@
B.
@@ -0,0 +1,12 @@
[project]
id = "fixture-container-root-tables"
name = "container-root-tables"
[info]
title = "容器错误声明了根级表"
[[children]]
kind = "section"
path = "section"
[targets.student]
@@ -0,0 +1,5 @@
[project]
id = "should-not-be-here"
name = "should-not-be-here"
children = []
@@ -5,7 +5,7 @@ name = "kind-mismatch"
[info] [info]
title = "kind 不一致测试" title = "kind 不一致测试"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
+2 -2
View File
@@ -5,11 +5,11 @@ name = "missing-part"
[info] [info]
title = "缺部件测试" title = "缺部件测试"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
[[parts]] [[children]]
kind = "lemma" kind = "lemma"
path = "lemmas/does-not-exist" path = "lemmas/does-not-exist"
@@ -0,0 +1,12 @@
[project]
id = "fixture-neither"
name = "neither"
[info]
title = "文件夹既不是容器也不是叶子"
[[children]]
kind = "segment"
path = "segments/broken"
[targets.student]
+21
View File
@@ -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]
@@ -0,0 +1 @@
开场白。
@@ -0,0 +1,14 @@
[group]
title = "导言簇"
[[children]]
kind = "segment"
path = "segments/子段一"
[[children]]
kind = "section"
path = "嵌套子节"
[[children]]
kind = "segment"
path = "segments/子段二"
@@ -0,0 +1 @@
子段一。
@@ -0,0 +1 @@
子段二。
@@ -0,0 +1 @@
子引理陈述。
@@ -0,0 +1,3 @@
[[children]]
kind = "lemma"
path = "lemmas/子引理"
@@ -0,0 +1,6 @@
[group]
title = "收束簇"
[[children]]
kind = "segment"
path = "segments/总结"
@@ -0,0 +1 @@
总结
@@ -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]
@@ -0,0 +1 @@
a.
+3 -2
View File
@@ -6,11 +6,12 @@ name = "valid-2-part"
title = "测试课:两个部件" title = "测试课:两个部件"
author = "范式教育教研组" author = "范式教育教研组"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
notes = "这一节补充一个直观例题"
[[parts]] [[children]]
kind = "lemma" kind = "lemma"
path = "lemmas/young" path = "lemmas/young"
+209 -6
View File
@@ -1,11 +1,12 @@
//! Integration tests for `cph_model::load`, driven by static fixtures under //! Integration tests for `cph_model::load`, driven by static fixtures under
//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0008 //! `tests/fixtures/`. The fixtures double as documentation of the ADR-0029
//! on-disk format. //! on-disk format (a nested outline manifest; supersedes ADR-0008's flat
//! `[[parts]]`).
use std::path::PathBuf; use std::path::PathBuf;
use cph_diag::DiagCode; use cph_diag::DiagCode;
use cph_model::load; use cph_model::{load, OutlineEntry};
/// Absolute path to a fixture engineering-file root. /// Absolute path to a fixture engineering-file root.
fn fixture(name: &str) -> PathBuf { 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.len(), 2);
assert_eq!(lesson.parts[0].kind, "segment"); assert_eq!(lesson.parts[0].kind, "segment");
assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro")); 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[0].descriptor.kind, "segment");
assert_eq!(lesson.parts[1].kind, "lemma"); assert_eq!(lesson.parts[1].kind, "lemma");
assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young")); assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young"));
assert_eq!(lesson.parts[1].descriptor.kind, "lemma"); 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. // `source` scalar survives on the lemma descriptor; `kind` is removed.
let scalars = &lesson.parts[1].descriptor.scalars; let scalars = &lesson.parts[1].descriptor.scalars;
assert_eq!( 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] #[test]
fn missing_part_folder_yields_part_path_missing() { fn missing_part_folder_yields_part_path_missing() {
let (lesson, diags) = load(&fixture("missing-part")); 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)" "malformed manifest must be a hard failure (None)"
); );
assert_eq!(diags.len(), 1, "one hard-failure diagnostic expected"); 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); 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")); let (lesson, diags) = load(&fixture("does-not-exist-at-all"));
assert!(lesson.is_none()); assert!(lesson.is_none());
assert_eq!(diags.len(), 1); 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] #[test]
@@ -290,7 +493,7 @@ fn tmp_lesson_with_version(version: Option<&str>) -> tempfile::TempDir {
let p = tmp.path(); let p = tmp.path();
std::fs::write( std::fs::write(
p.join("manifest.toml"), 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(); .unwrap();
let seg = p.join("segments").join("a"); let seg = p.join("segments").join("a");
+28 -13
View File
@@ -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. /// extracting the embedded copy to a per-user cache dir if needed.
/// ///
/// Resolution order: /// Resolution order:
/// 1. `CPH_RENDER_DIR` env var — an explicit override (dev convenience: point /// 1. `CPH_RENDER_DIR` — an explicit override (dev convenience: point at the
/// at the live repo `render/`). /// live repo `render/`).
/// 2. The extracted embedded copy under the user cache dir /// 2. The extracted embedded copy under the user cache dir
/// (`<cache>/cph/render-<version>/`). Extracted once per crate version; /// (`<cache>/cph/render-<version>/`).
/// subsequent runs reuse it.
/// ///
/// On any failure to locate a cache dir or extract, falls back to a temp-dir /// 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). /// 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(|_| { ensure_extracted().unwrap_or_else(|_| {
// Last-resort: extract under the OS temp dir. Still correct, just not // Last-resort: extract under the OS temp dir. Still correct, just not
// cached across processes. // cached across processes.
let fallback = let fallback = std::env::temp_dir().join(format!(
std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION"))); "cph-render-{}-{}",
env!("CARGO_PKG_VERSION"),
RENDER_CACHE_REVISION
));
let _ = extract_to(&fallback); let _ = extract_to(&fallback);
fallback fallback
}) })
} }
/// The version-keyed cache location and a guarantee the embedded tree is present /// Bump when the embedded render package changes without a cph crate-version
/// there. Returns the directory the World should use. /// 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<PathBuf> { fn ensure_extracted() -> std::io::Result<PathBuf> {
let base = dirs::cache_dir() let base = dirs::cache_dir()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?; .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?;
let dest = base let dest = base
.join("cph") .join("cph")
.join(format!("render-{}", env!("CARGO_PKG_VERSION"))); .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"); 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); 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)?; extract_to(&dest)?;
std::fs::write(&sentinel, env!("CARGO_PKG_VERSION"))?; std::fs::write(&sentinel, expected)?;
Ok(dest) Ok(dest)
} }
+99 -14
View File
@@ -39,15 +39,15 @@ mod embedded;
mod manifest; mod manifest;
mod world; mod world;
use cph_diag::{DiagCode, Diagnostic};
use std::path::PathBuf; use std::path::PathBuf;
use cph_diag::{DiagCode, Diagnostic}; use cph_model::{Artifact, Bundle, Lesson, OutlineDocument, Step, TargetConfig};
use cph_model::{Artifact, Lesson, Step, TargetConfig};
use typst_kit::fonts::{self, FontStore}; use typst_kit::fonts::{self, FontStore};
use typst_layout::PagedDocument; use typst_layout::PagedDocument;
use typst_pdf::PdfOptions; 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}; pub use world::{render_package_spec, LessonWorld, MANIFEST_VPATH};
/// The compile/PDF engine: holds the shared font store and the on-disk location /// 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)) 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<u8>, Vec<Diagnostic>> {
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::<PagedDocument>(&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 /// Build the [`LessonWorld`] for `(lesson, target)`, or `Err(blocking)` when
/// the request cannot be honored (see [`target_precheck`]). /// the request cannot be honored (see [`target_precheck`]).
fn world_for(&self, lesson: &Lesson, target: &str) -> Result<LessonWorld, Vec<Diagnostic>> { fn world_for(&self, lesson: &Lesson, target: &str) -> Result<LessonWorld, Vec<Diagnostic>> {
let template = target_precheck(lesson, target)?; let template = target_precheck(target, &lesson.targets)?;
let manifest_src = build_augmented_manifest(lesson); let manifest_src = build_augmented_manifest(lesson);
Ok(LessonWorld::new( Ok(LessonWorld::new(
lesson.root.clone(), lesson.root.clone(),
@@ -152,6 +180,60 @@ impl Engine {
self.fonts.clone(), 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<Diagnostic> {
let world = match self.world_for_bundle(bundle, target) {
Ok(world) => world,
Err(blocking) => return blocking,
};
let warned = typst::compile::<PagedDocument>(&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<u8>, Vec<Diagnostic>> {
let world = self.world_for_bundle(bundle, target)?;
let warned = typst::compile::<PagedDocument>(&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<LessonWorld, Vec<Diagnostic>> {
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 { impl Default for Engine {
@@ -160,13 +242,16 @@ impl Default for Engine {
} }
} }
/// Validate a `(lesson, target)` request and resolve the template path to /// Validate a `(targets, target)` request and resolve the template path to
/// compile. Returns `Ok(template_path)` (relative to the lesson root) when the /// compile. Shared by [`Engine::world_for`] (a lesson's `targets`) and
/// request is buildable, or `Err(blocking_diagnostics)` when it is not: /// [`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 /// - **Unknown target** (the `--target` name isn't in `targets`, and `targets`
/// lesson declares at least one target): a `SchemaViolation` error — a target /// is non-empty): a `SchemaViolation` error — a target must be declared in
/// must be declared in the manifest to be built (ADR-0009). /// the manifest to be built (ADR-0009).
/// - **No declared targets at all**: not an error — callers (e.g. `cph-check`) /// - **No declared targets at all**: not an error — callers (e.g. `cph-check`)
/// may compile-check a defaulted `"student"` target the lesson never declared. /// may compile-check a defaulted `"student"` target the lesson never declared.
/// The stock template path `exports/<target>.typ` is used (the framework /// The stock template path `exports/<target>.typ` is used (the framework
@@ -177,10 +262,10 @@ impl Default for Engine {
/// [`Step::Shell`], returns a clear "not yet implemented" `SchemaViolation` /// [`Step::Shell`], returns a clear "not yet implemented" `SchemaViolation`
/// rather than wrong output. The template is taken from the **first** /// rather than wrong output. The template is taken from the **first**
/// `TypstCompile` step (MVP: one step per target). /// `TypstCompile` step (MVP: one step per target).
fn target_precheck(lesson: &Lesson, target: &str) -> Result<PathBuf, Vec<Diagnostic>> { fn target_precheck(target: &str, targets: &[TargetConfig]) -> Result<PathBuf, Vec<Diagnostic>> {
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else { let Some(tc) = targets.iter().find(|t| t.name == target) else {
if lesson.targets.is_empty() { if targets.is_empty() {
// Lesson declares no targets; the orchestrator compiles a defaulted // Declares no targets; the orchestrator compiles a defaulted
// target. Use the stock template path (matches cph-model's default). // target. Use the stock template path (matches cph-model's default).
return Ok(PathBuf::from(format!("exports/{target}.typ"))); return Ok(PathBuf::from(format!("exports/{target}.typ")));
} }
+156 -34
View File
@@ -1,18 +1,28 @@
//! Augmented-manifest construction (ADR-0011). //! Augmented-manifest construction (ADR-0011, outline shape per ADR-0029).
//! //!
//! The template (`exports/<target>.typ`) reads the manifest via //! The template (`exports/<target>.typ`) reads the manifest via
//! `toml(sys.inputs.manifest)`, then for each part `include`s its content fields //! `toml(sys.inputs.manifest)`, then for each **element** outline entry
//! by a **computed** path and reads scalar fields from `<path>/element.toml`. //! `include`s its content fields by a **computed** path and reads scalar
//! For *optional* content fields the template must know whether the file exists //! fields from `<path>/element.toml`. For *optional* content fields the
//! on disk — typst has no file-exists primitive and a missing `include` is a //! template must know whether the file exists on disk — typst has no
//! hard error (see the OPEN contract point in `render/templates/student.typ`). //! 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 //! The ENGINE has filesystem access, so it closes that gap: it builds an
//! **augmented manifest** = the lesson's `[info]` + ordered `[[parts]]`, with a //! **augmented manifest** = the lesson's `[info]` + the ordered `[[outline]]`
//! per-part **`fields` array** listing the content fields whose `<field>.typ` //! (ADR-0029's depth-first rendering order — elements interleaved with section
//! actually exists under the lesson root. The augmented manifest is served as an //! headings at their DFS-open position). Each `[[outline]]` entry carries a
//! in-memory virtual file in the [`crate::world::LessonWorld`] (it is **never** //! `type` discriminator (`"element"` | `"section"`):
//! written to the user's tree), and injected via `sys.inputs.manifest`. //!
//! - `type = "element"`: `kind`, `path`, and a per-part **`fields` array**
//! listing the content fields whose `<field>.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` //! ## `fields` is computed from `cph-schema`
//! //!
@@ -20,23 +30,104 @@
//! ([`cph_schema::KindSchema::content_field_names`]) — the same knowledge the //! ([`cph_schema::KindSchema::content_field_names`]) — the same knowledge the
//! render package exposes as `part-fields`. We reuse it here rather than //! 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 //! 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 `<root>/<part.path>/<field>.typ` is a real file. //! `fields` iff `<root>/<part.path>/<field>.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`. /// Build the augmented-manifest TOML source for `lesson`.
/// ///
/// The result is a self-contained TOML document the template's /// The result is a self-contained TOML document the template's
/// `toml(sys.inputs.manifest)` reads. It carries `[info]` (title + optional /// `toml(sys.inputs.manifest)` reads. It carries `[info]` (title + optional
/// author) and the ordered `[[parts]]`, each with `kind`, `path`, and a /// author) and the ordered `[[outline]]` (ADR-0029's depth-first rendering
/// `fields = [...]` array of the content fields present on disk (per /// order), each entry typed `"element"` or `"section"` per the module docs. It
/// [`present_fields`]). It does **not** reproduce `[project]` or `[targets.*]` /// does **not** reproduce `[project]` or `[targets.*]` — the template only
/// — the template only consumes `info` and `parts`. /// consumes `info` and `outline`.
pub fn build_augmented_manifest(lesson: &Lesson) -> String { pub fn build_augmented_manifest(lesson: &Lesson) -> String {
let mut doc = toml::Table::new(); 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<toml::Value> = 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/<target>.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<toml::Value> = 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<toml::Value> = 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(); let mut info = toml::Table::new();
info.insert( info.insert(
"title".to_string(), "title".to_string(),
@@ -52,30 +143,61 @@ pub fn build_augmented_manifest(lesson: &Lesson) -> String {
.collect(); .collect();
info.insert("author".to_string(), toml::Value::Array(authors)); 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. /// Build one `[[outline]]` entry's table for either variant of
let parts: Vec<toml::Value> = lesson /// [`OutlineEntry`]. `bundle_prefix`, when set (ADR-0030's bundle case), is
.parts /// joined onto the emitted `path` so the bundle template's computed include
.iter() /// resolves against the bundle root rather than the lesson's own root.
.map(|part| { fn outline_entry_table(
let mut entry = toml::Table::new(); lesson: &Lesson,
entry.insert("kind".to_string(), toml::Value::String(part.kind.clone())); entry: &OutlineEntry,
entry.insert( 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(), "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) let fields = present_fields(lesson, part)
.into_iter() .into_iter()
.map(toml::Value::String) .map(toml::Value::String)
.collect(); .collect();
entry.insert("fields".to_string(), toml::Value::Array(fields)); e.insert("fields".to_string(), toml::Value::Array(fields));
toml::Value::Table(entry) }
}) OutlineEntry::Section {
.collect(); kind,
doc.insert("parts".to_string(), toml::Value::Array(parts)); 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 `<root>/<part.path>/<field>.typ` /// The content fields of `part`'s kind whose `<root>/<part.path>/<field>.typ`
+68 -26
View File
@@ -48,13 +48,14 @@ use typst::{Library, LibraryExt, World};
use typst_kit::fonts::FontStore; use typst_kit::fonts::FontStore;
/// Root-relative vpath the augmented manifest is served at (in-memory only). /// 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"; 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`. /// The package spec the template imports and the World mounts from `render_dir`.
pub fn render_package_spec() -> PackageSpec { pub fn render_package_spec() -> PackageSpec {
PackageSpec { PackageSpec {
@@ -76,13 +77,11 @@ pub struct LessonWorld {
render_dir: PathBuf, render_dir: PathBuf,
/// The render package spec (`@local/cph-render:0.1.0`). /// The render package spec (`@local/cph-render:0.1.0`).
render_spec: PackageSpec, render_spec: PackageSpec,
/// FileId of the template entrypoint (a real file under `root`). /// FileId of the entrypoint.
main: FileId, main: FileId,
/// FileId of the in-memory augmented manifest. /// In-memory project files (manifest, or the outline entrypoint/data).
manifest_id: FileId, virtual_sources: HashMap<FileId, Source>,
/// The augmented-manifest source (in-memory; never on disk). /// Standard library inputs exposed to the Typst source.
manifest_source: Source,
/// Standard library, with `sys.inputs.manifest` set.
library: LazyHash<Library>, library: LazyHash<Library>,
/// Shared font store (book + lazily-loaded fonts). /// Shared font store (book + lazily-loaded fonts).
fonts: Arc<FontStore>, fonts: Arc<FontStore>,
@@ -95,8 +94,8 @@ impl LessonWorld {
/// whose injected manifest is `manifest_src` (served virtually at /// whose injected manifest is `manifest_src` (served virtually at
/// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there). /// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there).
/// ///
/// `template` is the lesson-root-relative template path (e.g. /// `template` is the lesson-root-relative path taken from the target's
/// `exports/student.typ`), taken from the target's `Step::TypstCompile`. /// `Step::TypstCompile`.
pub fn new( pub fn new(
root: PathBuf, root: PathBuf,
render_dir: PathBuf, render_dir: PathBuf,
@@ -107,16 +106,55 @@ impl LessonWorld {
let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template))) let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template)))
.expect("template vpath is a valid virtual path"); .expect("template vpath is a valid virtual path");
let main = FileId::new(RootedPath::new(VirtualRoot::Project, main_vpath)); 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 = /// Build a world for a fully virtual outline document and its TOML data.
VirtualPath::new(MANIFEST_VPATH).expect("manifest vpath is a valid virtual path"); /// The caller never has to create temporary files in the engineering file.
let manifest_id = FileId::new(RootedPath::new(VirtualRoot::Project, manifest_vpath)); pub fn new_outline(
let manifest_source = Source::new(manifest_id, manifest_src); root: PathBuf,
render_dir: PathBuf,
source: String,
outline_src: String,
fonts: Arc<FontStore>,
) -> 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 fn with_virtual_files(
// `toml(sys.inputs.manifest)` reads the augmented manifest. root: PathBuf,
render_dir: PathBuf,
main: FileId,
virtual_sources: HashMap<FileId, Source>,
input_files: &[(&str, &str)],
fonts: Arc<FontStore>,
) -> Self {
let mut inputs = Dict::new(); 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(); let library = Library::builder().with_inputs(inputs).build();
Self { Self {
@@ -124,8 +162,7 @@ impl LessonWorld {
render_dir, render_dir,
render_spec: render_package_spec(), render_spec: render_package_spec(),
main, main,
manifest_id, virtual_sources,
manifest_source,
library: LazyHash::new(library), library: LazyHash::new(library),
fonts, fonts,
sources: Mutex::new(HashMap::new()), sources: Mutex::new(HashMap::new()),
@@ -185,8 +222,8 @@ impl World for LessonWorld {
} }
fn source(&self, id: FileId) -> FileResult<Source> { fn source(&self, id: FileId) -> FileResult<Source> {
if id == self.manifest_id { if let Some(source) = self.virtual_sources.get(&id) {
return Ok(self.manifest_source.clone()); return Ok(source.clone());
} }
// Cache hit? // Cache hit?
if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) { 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<Bytes> { fn file(&self, id: FileId) -> FileResult<Bytes> {
if id == self.manifest_id { if let Some(source) = self.virtual_sources.get(&id) {
return Ok(Bytes::from_string(self.manifest_source.text().to_string())); return Ok(Bytes::from_string(source.text().to_string()));
} }
let bytes = self.read_bytes(id)?; let bytes = self.read_bytes(id)?;
Ok(Bytes::new(bytes)) 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 /// Render a relative `Path` as a forward-slash string, dropping any leading
/// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim. /// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim.
fn path_to_forward_slash(path: &Path) -> String { fn path_to_forward_slash(path: &Path) -> String {
+122
View File
@@ -0,0 +1,122 @@
//! Integration tests for the bundle build path (ADR-0030): compiling a bundle
//! target's template (`exports/<target>.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:#?}"
);
}
+73 -37
View File
@@ -39,10 +39,12 @@ fn load_mini() -> cph_model::Lesson {
} }
/// PURE UNIT TEST (no fonts, no render package): the augmented manifest carries /// PURE UNIT TEST (no fonts, no render package): the augmented manifest carries
/// `[info]`, the ordered `[[parts]]`, and a per-part `fields` array listing the /// `[info]` and the ordered `[[outline]]` (ADR-0029) — elements (with a
/// content fields present on disk. /// 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] #[test]
fn augmented_manifest_has_per_part_fields() { fn augmented_manifest_has_outline_with_section_and_fields() {
let lesson = load_mini(); let lesson = load_mini();
let src = build_augmented_manifest(&lesson); 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.title present:\n{src}");
assert!(src.contains("测试作者"), "info.author 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 doc: toml::Value = toml::from_str(&src).expect("augmented manifest is valid TOML");
let parts = doc let outline = doc
.get("parts") .get("outline")
.and_then(|p| p.as_array()) .and_then(|p| p.as_array())
.expect("parts array present"); .expect("outline array present");
assert_eq!(parts.len(), 4, "four parts in declared order:\n{src}"); // 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 let entry_type = |idx: usize| {
// not load-bearing; sort for a stable assertion. outline[idx]
let fields_of = |idx: usize| -> Vec<String> { .get("type")
let mut v: Vec<String> = 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")
.unwrap() .unwrap()
.as_str() .as_str()
.unwrap() .unwrap()
.to_string() .to_string()
}; };
let kind_of = |idx: usize| { let kind_of = |idx: usize| {
parts[idx] outline[idx]
.get("kind") .get("kind")
.unwrap() .unwrap()
.as_str() .as_str()
.unwrap() .unwrap()
.to_string() .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<String> {
let mut v: Vec<String> = 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(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(0), "segments/开场对照导言");
assert_eq!(path_of(2), "lemmas/无证明引理");
// segment: only `textbook` exists.
assert_eq!(fields_of(0), vec!["textbook"]); 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). // 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). // lemma WITHOUT proof.typ: only stmt present (the OPTIONAL-content path).
assert_eq!( assert_eq!(
fields_of(2), fields_of(3),
vec!["stmt"], vec!["stmt"],
"proof must be omitted when absent" "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). // 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 /// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the
@@ -202,6 +236,7 @@ fn file_tree_artifact_is_deferred() {
authors: vec![], authors: vec![],
}, },
parts: vec![], parts: vec![],
outline: vec![],
targets: vec![TargetConfig { targets: vec![TargetConfig {
name: "web".into(), name: "web".into(),
artifact: Artifact::FileTree { artifact: Artifact::FileTree {
@@ -241,6 +276,7 @@ fn shell_only_target_is_deferred() {
authors: vec![], authors: vec![],
}, },
parts: vec![], parts: vec![],
outline: vec![],
targets: vec![TargetConfig { targets: vec![TargetConfig {
name: "packaged".into(), name: "packaged".into(),
artifact: Artifact::SingleFile { artifact: Artifact::SingleFile {
+16
View File
@@ -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"
@@ -0,0 +1,76 @@
// DEFAULT BUNDLE TEMPLATE (ADR-0030, outline shape ADR-0029).
//
// Lives in a bundle at `<bundle-root>/exports/<target>.typ`, e.g.
// `exports/merged.typ`. Compiled AS MAIN with the augmented BUNDLE manifest
// injected:
// typst compile --root <bundle-root> --input manifest=<path-rel-to-root> exports/merged.typ <out>
//
// 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,
)
@@ -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]
@@ -0,0 +1 @@
= 课时A导言
@@ -0,0 +1 @@
引理甲陈述。
@@ -0,0 +1,6 @@
[group]
title = "小节"
[[children]]
kind = "lemma"
path = "lemmas/引理甲"
@@ -0,0 +1,12 @@
[project]
id = "lesson-b"
name = "lesson-b"
[info]
title = "课时B"
[[children]]
kind = "segment"
path = "segments/b"
[targets.teacher]
@@ -0,0 +1 @@
= 课时B导言
+28 -14
View File
@@ -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 // 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 // `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 // its own virtual root — an include inside cph-render would resolve against the
// PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE // PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE
// (this template lives under `--root`) resolves against `--root`. So the // (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 // OPEN CONTRACT POINT — optional-content presence. typst has no "does this file
// exist" primitive (a missing `include` is a hard compile error). So the // 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`. // 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, // 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 // (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 // omits `fields`, optional content is skipped (conservative). The exact shape of
// this declaration is for the manifest/Rust contract to pin. // this declaration is for the manifest/Rust contract to pin.
@@ -35,27 +36,39 @@
// Read the injected manifest (a path string relative to typst --root). // Read the injected manifest (a path string relative to typst --root).
#let manifest = toml(sys.inputs.manifest) #let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:)) #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, // Assemble each outline entry:
// - an "element" entry: include its content fields (computed absolute paths,
// resolved against --root) and read scalar fields from <path>/element.toml. // resolved against --root) and read scalar fields from <path>/element.toml.
// `part-fields` (from cph-render) is the single source of truth for kind->fields. // `part-fields` (from cph-render) is the single source of truth for
#let parts = raw-parts.map(raw => { // 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 kind = raw.at("kind", default: none)
let path = raw.at("path", default: none) let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared). // Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ()) let present = raw.at("fields", default: ())
let part = (kind: kind) let entry = (entry-type: "element", kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative). // Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content { for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ") entry.insert(field, include "/" + path + "/" + field + ".typ")
} }
// Optional content fields: only when the manifest says the file exists. // Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content { for field in spec.optional-content {
if field in present { if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ") entry.insert(field, include "/" + path + "/" + field + ".typ")
} }
} }
// Scalar fields come from <path>/element.toml. // Scalar fields come from <path>/element.toml.
@@ -63,10 +76,11 @@
let element = toml("/" + path + "/element.toml") let element = toml("/" + path + "/element.toml")
for field in spec.scalars { for field in spec.scalars {
let v = element.at(field, default: none) let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) } if v != none and v != "" { entry.insert(field, v) }
} }
} }
part entry
}
}) })
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
@@ -74,6 +88,6 @@
#render-lesson( #render-lesson(
info: info, info: info,
target: target, target: target,
parts: parts, outline: outline,
heading-numbering: default-heading-numbering, heading-numbering: default-heading-numbering,
) )
+21 -12
View File
@@ -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 // Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with
// the manifest injected: // the manifest injected:
@@ -18,27 +18,35 @@
// Read the injected manifest (a path string relative to typst --root). // Read the injected manifest (a path string relative to typst --root).
#let manifest = toml(sys.inputs.manifest) #let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:)) #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, // Assemble each outline entry: an "element" entry includes its content fields
// resolved against --root) and read scalar fields from <path>/element.toml. // and reads scalars from element.toml; a "section" entry (ADR-0029) passes
// `part-fields` (from cph-render) is the single source of truth for kind->fields. // title/depth straight through as a heading, no content to load.
#let parts = raw-parts.map(raw => { #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 kind = raw.at("kind", default: none)
let path = raw.at("path", default: none) let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared). // Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ()) let present = raw.at("fields", default: ())
let part = (kind: kind) let entry = (entry-type: "element", kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative). // Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content { for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ") entry.insert(field, include "/" + path + "/" + field + ".typ")
} }
// Optional content fields: only when the manifest says the file exists. // Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content { for field in spec.optional-content {
if field in present { if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ") entry.insert(field, include "/" + path + "/" + field + ".typ")
} }
} }
// Scalar fields come from <path>/element.toml. // Scalar fields come from <path>/element.toml.
@@ -46,10 +54,11 @@
let element = toml("/" + path + "/element.toml") let element = toml("/" + path + "/element.toml")
for field in spec.scalars { for field in spec.scalars {
let v = element.at(field, default: none) let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) } if v != none and v != "" { entry.insert(field, v) }
} }
} }
part entry
}
}) })
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
@@ -57,6 +66,6 @@
#render-lesson( #render-lesson(
info: info, info: info,
target: target, target: target,
parts: parts, outline: outline,
heading-numbering: default-heading-numbering, heading-numbering: default-heading-numbering,
) )
+5 -9
View File
@@ -6,19 +6,15 @@ name = "迷你课时"
title = "迷你示例课时" title = "迷你示例课时"
author = "测试作者" author = "测试作者"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/开场对照导言" path = "segments/开场对照导言"
[[parts]] [[children]]
kind = "lemma" kind = "section"
path = "lemmas/量纲分析估计" path = "引理组"
[[parts]] [[children]]
kind = "lemma"
path = "lemmas/无证明引理"
[[parts]]
kind = "example" kind = "example"
path = "examples/自由落体" path = "examples/自由落体"
@@ -0,0 +1,10 @@
[group]
title = "引理组"
[[children]]
kind = "lemma"
path = "lemmas/量纲分析估计"
[[children]]
kind = "lemma"
path = "lemmas/无证明引理"
@@ -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.
+167
View File
@@ -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/<target>.{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 <lesson>` → all declared targets (the batch default).
- `cph build <lesson> --target student --target teacher` → the named subset
(the multi-version batch), in the given order.
- `cph bundle <bundle-path> --target <name>` → 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.
+28 -14
View File
@@ -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 // 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 // `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 // its own virtual root — an include inside cph-render would resolve against the
// PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE // PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE
// (this template lives under `--root`) resolves against `--root`. So the // (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 // OPEN CONTRACT POINT — optional-content presence. typst has no "does this file
// exist" primitive (a missing `include` is a hard compile error). So the // 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`. // 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, // 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 // (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 // omits `fields`, optional content is skipped (conservative). The exact shape of
// this declaration is for the manifest/Rust contract to pin. // this declaration is for the manifest/Rust contract to pin.
@@ -35,27 +36,39 @@
// Read the injected manifest (a path string relative to typst --root). // Read the injected manifest (a path string relative to typst --root).
#let manifest = toml(sys.inputs.manifest) #let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:)) #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, // Assemble each outline entry:
// - an "element" entry: include its content fields (computed absolute paths,
// resolved against --root) and read scalar fields from <path>/element.toml. // resolved against --root) and read scalar fields from <path>/element.toml.
// `part-fields` (from cph-render) is the single source of truth for kind->fields. // `part-fields` (from cph-render) is the single source of truth for
#let parts = raw-parts.map(raw => { // 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 kind = raw.at("kind", default: none)
let path = raw.at("path", default: none) let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared). // Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ()) let present = raw.at("fields", default: ())
let part = (kind: kind) let entry = (entry-type: "element", kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative). // Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content { for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ") entry.insert(field, include "/" + path + "/" + field + ".typ")
} }
// Optional content fields: only when the manifest says the file exists. // Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content { for field in spec.optional-content {
if field in present { if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ") entry.insert(field, include "/" + path + "/" + field + ".typ")
} }
} }
// Scalar fields come from <path>/element.toml. // Scalar fields come from <path>/element.toml.
@@ -63,10 +76,11 @@
let element = toml("/" + path + "/element.toml") let element = toml("/" + path + "/element.toml")
for field in spec.scalars { for field in spec.scalars {
let v = element.at(field, default: none) let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) } if v != none and v != "" { entry.insert(field, v) }
} }
} }
part entry
}
}) })
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
@@ -74,6 +88,6 @@
#render-lesson( #render-lesson(
info: info, info: info,
target: target, target: target,
parts: parts, outline: outline,
heading-numbering: default-heading-numbering, heading-numbering: default-heading-numbering,
) )
+21 -12
View File
@@ -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 // Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with
// the manifest injected: // the manifest injected:
@@ -18,27 +18,35 @@
// Read the injected manifest (a path string relative to typst --root). // Read the injected manifest (a path string relative to typst --root).
#let manifest = toml(sys.inputs.manifest) #let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:)) #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, // Assemble each outline entry: an "element" entry includes its content fields
// resolved against --root) and read scalar fields from <path>/element.toml. // and reads scalars from element.toml; a "section" entry (ADR-0029) passes
// `part-fields` (from cph-render) is the single source of truth for kind->fields. // title/depth straight through as a heading, no content to load.
#let parts = raw-parts.map(raw => { #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 kind = raw.at("kind", default: none)
let path = raw.at("path", default: none) let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared). // Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ()) let present = raw.at("fields", default: ())
let part = (kind: kind) let entry = (entry-type: "element", kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative). // Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content { for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ") entry.insert(field, include "/" + path + "/" + field + ".typ")
} }
// Optional content fields: only when the manifest says the file exists. // Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content { for field in spec.optional-content {
if field in present { if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ") entry.insert(field, include "/" + path + "/" + field + ".typ")
} }
} }
// Scalar fields come from <path>/element.toml. // Scalar fields come from <path>/element.toml.
@@ -46,10 +54,11 @@
let element = toml("/" + path + "/element.toml") let element = toml("/" + path + "/element.toml")
for field in spec.scalars { for field in spec.scalars {
let v = element.at(field, default: none) let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) } if v != none and v != "" { entry.insert(field, v) }
} }
} }
part entry
}
}) })
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
@@ -57,6 +66,6 @@
#render-lesson( #render-lesson(
info: info, info: info,
target: target, target: target,
parts: parts, outline: outline,
heading-numbering: default-heading-numbering, heading-numbering: default-heading-numbering,
) )
+23 -142
View File
@@ -6,161 +6,42 @@ name = "TH-141_表面张力的严肃理论"
title = "TH-141:表面张力的严肃理论" title = "TH-141:表面张力的严肃理论"
author = "范式教育教研组" author = "范式教育教研组"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/开场对照导言" path = "segments/开场对照导言"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/胡克唯象模型回顾" path = "segments/胡克唯象模型回顾"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/液面拉伸的本质" path = "segments/液面拉伸的本质"
[[parts]] [[children]]
kind = "segment" kind = "section"
path = "segments/液气界面导言" path = "液气界面"
notes = "先从液面拉伸的宏观图像切入,再用缺键模型和 LJ 对势逐步建立微观解释;可补充一个数量级估算例题。"
[[parts]] [[children]]
kind = "segment" kind = "section"
path = "segments/微观建模的共同骨架" path = "固气界面"
notes = "把液体表面能与固体表面应力放在同一张对照表中,强调固体表面能的晶面各向异性。"
[[parts]] [[children]]
kind = "lemma" kind = "section"
path = "lemmas/量纲分析估计" path = "固液界面"
notes = "围绕界面能的物理图像,串起 Dupré、Girifalco-Good、Fowkes 与 Young 方程;可安排一个浸润判据例题。"
[[parts]] [[children]]
kind = "segment" kind = "section"
path = "segments/缺键模型导言" path = "σTp态函数建模"
notes = "这一节是温度依赖建模主线,先回顾 σT,再解释微观模型和经验规则之间的联系;进阶学生可比较不同模型的适用范围。"
[[parts]] [[children]]
kind = "lemma" kind = "section"
path = "lemmas/缺键模型一般公式" path = "收束"
notes = "最后对照各模型对水的预测,回收本节主线,并明确为什么实际数值可能算不准。"
[[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/算不准背后的真实物理"
# Export targets (ADR-0009/0011): each target is a build producing a typed # 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 # artifact, run as an ordered list of typed steps. A `typst-compile` step names a
@@ -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-混注石油"

Some files were not shown because too many files have changed in this diff Show More