forked from EduCraft/curriculum-project-hub
feat(cph): implement nested outline manifest and batch/combined export
ADR-0029 — nested outline manifest, supersedes ADR-0008's flat [[parts]]:
- cph-model: recursive loader over manifest.toml containers / element.toml
leaves; Lesson.parts (pure elements, DFS order) + Lesson.outline (elements
interleaved with section headings at their DFS-open position); rejects
ambiguous/incomplete folders and root-vs-container table misplacement
- cph-diag: new DiagCode::ManifestMalformed for carrier-document structure
errors (discharges an existing TODO)
- cph-typst: augmented manifest now serializes the outline (element/section
entries) instead of a flat parts array
- render/lib.typ: render-lesson renders section headings at their depth
- examples/TH-141 migrated to 5 nested section containers + 3 root segments,
byte-identical element order; smoke-verified via cph check/build + pdftotext
ADR-0030 — batch & combined export, extends ADR-0009/0011:
- cph build with no --target batches every declared target (repeatable
--target for an explicit subset); any target failure => non-zero exit,
per-target ledger, independent per-target execution
- cph-model: bundle.toml loader (directory + [info]/[targets.*]/ordered
lessons with per-lesson target overrides)
- cph-typst: augmented bundle manifest (path-prefixed member outlines),
Engine::{compile_check_bundle,build_bundle_pdf}
- render/lib.typ: render-bundle assembles member lessons under per-lesson
headings, depth-shifts their own section headings, resets example/lemma
counters at each lesson boundary by default
- cph-cli: `cph bundle <path> --target <name>` subcommand, same batching
contract as `cph build`
- new bundle fixtures/tests (cph-model unit + cph-typst through-template PDF
compile), smoke-verified via a real 2-lesson merged PDF
Verification: cargo fmt/clippy/test clean across the workspace (68 tests);
real cph check/build/bundle runs against TH-141 and a bundle fixture, PDF
content inspected via pdftotext.
This commit is contained in:
+7
-5
@@ -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 |
|
||||||
|
|||||||
@@ -196,6 +196,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 +531,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`]).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ name = "broken"
|
|||||||
[info]
|
[info]
|
||||||
title = "broken"
|
title = "broken"
|
||||||
|
|
||||||
[[parts]]
|
[[children]]
|
||||||
kind = "frob"
|
kind = "frob"
|
||||||
path = "elements/widget"
|
path = "elements/widget"
|
||||||
"#,
|
"#,
|
||||||
@@ -123,7 +123,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 +150,7 @@ name = "cov"
|
|||||||
[info]
|
[info]
|
||||||
title = "cov"
|
title = "cov"
|
||||||
|
|
||||||
[[parts]]
|
[[children]]
|
||||||
kind = "segment"
|
kind = "segment"
|
||||||
path = "segments/intro"
|
path = "segments/intro"
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ name = "sh"
|
|||||||
[info]
|
[info]
|
||||||
title = "sh"
|
title = "sh"
|
||||||
|
|
||||||
[[parts]]
|
[[children]]
|
||||||
kind = "segment"
|
kind = "segment"
|
||||||
path = "segments/intro"
|
path = "segments/intro"
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ name = "sh"
|
|||||||
[info]
|
[info]
|
||||||
title = "sh"
|
title = "sh"
|
||||||
|
|
||||||
[[parts]]
|
[[children]]
|
||||||
kind = "segment"
|
kind = "segment"
|
||||||
path = "segments/missing"
|
path = "segments/missing"
|
||||||
|
|
||||||
@@ -390,7 +390,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 +468,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 +527,7 @@ name = "md"
|
|||||||
[info]
|
[info]
|
||||||
title = "md"
|
title = "md"
|
||||||
|
|
||||||
[[parts]]
|
[[children]]
|
||||||
kind = "segment"
|
kind = "segment"
|
||||||
path = "segments/a"
|
path = "segments/a"
|
||||||
|
|
||||||
@@ -584,7 +584,7 @@ name = "md"
|
|||||||
[info]
|
[info]
|
||||||
title = "md"
|
title = "md"
|
||||||
|
|
||||||
[[parts]]
|
[[children]]
|
||||||
kind = "segment"
|
kind = "segment"
|
||||||
path = "segments/a"
|
path = "segments/a"
|
||||||
|
|
||||||
@@ -630,7 +630,7 @@ name = "md"
|
|||||||
[info]
|
[info]
|
||||||
title = "md"
|
title = "md"
|
||||||
|
|
||||||
[[parts]]
|
[[children]]
|
||||||
kind = "segment"
|
kind = "segment"
|
||||||
path = "segments/missing"
|
path = "segments/missing"
|
||||||
|
|
||||||
|
|||||||
+172
-13
@@ -35,14 +35,38 @@ 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")]
|
||||||
|
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")]
|
#[arg(short = 'o', long, value_name = "OUT")]
|
||||||
out: Option<PathBuf>,
|
out: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
@@ -74,7 +98,8 @@ 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::Completions { shell } => run_completions(shell),
|
Command::Completions { shell } => run_completions(shell),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,25 +157,76 @@ fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_build(
|
/// 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 +242,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+634
-124
@@ -1,58 +1,96 @@
|
|||||||
//! `cph-model` — load the ADR-0008 declarative layout into an in-memory lesson.
|
//! `cph-model` — load the ADR-0029 nested outline manifest into an in-memory
|
||||||
|
//! lesson.
|
||||||
//!
|
//!
|
||||||
//! This crate is the **loader**, not the full checker. It reads
|
//! This crate is the **loader**, not the full checker. A lesson's structure is
|
||||||
//! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared
|
//! a folder tree (ADR-0007): every folder that groups children carries a
|
||||||
//! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an
|
//! `manifest.toml` (root or internal — see [`load`]); every leaf carries an
|
||||||
//! ordered [`Lesson`], where the order of `parts` carries teaching semantics.
|
//! `element.toml` (ADR-0008's element descriptor, unchanged). The loader walks
|
||||||
|
//! that tree depth-first and produces:
|
||||||
|
//!
|
||||||
|
//! - [`Lesson::parts`] — the **ordered element sequence** (ADR-0005): pure
|
||||||
|
//! elements, no containers. This is what `cph-check`/`cph-schema`/the render
|
||||||
|
//! pipeline validate and include content from — unaffected by nesting.
|
||||||
|
//! - [`Lesson::outline`] — the **full rendering-order sequence**: elements
|
||||||
|
//! interleaved with section headings, at the depth-first traversal position
|
||||||
|
//! they open at (ADR-0029). This is what the augmented manifest (built by
|
||||||
|
//! `cph-typst`) walks to hand the template a rendering order that includes
|
||||||
|
//! headings.
|
||||||
//!
|
//!
|
||||||
//! Scope boundaries (deliberately staying in lane):
|
//! Scope boundaries (deliberately staying in lane):
|
||||||
//! - It validates **structure** only: manifest shape, element.toml shape, and
|
//! - It validates **structure** only: manifest/outline shape, element.toml
|
||||||
//! the cross-check `part.kind == element.toml kind`.
|
//! shape, the leaf/container discriminator, and the cross-check
|
||||||
|
//! `part.kind == element.toml kind`.
|
||||||
//! - It does **not** validate instance data against a kind's JSON Schema (that
|
//! - It does **not** validate instance data against a kind's JSON Schema (that
|
||||||
//! is WU-3 / `cph-schema`), does **not** check that `content` `.typ` files
|
//! is `cph-schema`), does **not** check that `content` `.typ` files exist
|
||||||
//! exist (also schema-driven, WU-3), and does **not** compile typst (WU-4).
|
//! (also schema-driven), does **not** compile typst, and does **not**
|
||||||
|
//! validate a container's declared kind against the known container-kind set
|
||||||
|
//! (that is `cph-check`'s job, mirroring how it also owns the known
|
||||||
|
//! *element*-kind check).
|
||||||
//!
|
//!
|
||||||
//! Entry point: [`load`].
|
//! Entry point: [`load`].
|
||||||
|
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
use cph_diag::{DiagCode, Diagnostic};
|
use cph_diag::{DiagCode, Diagnostic, Severity};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// An ordered, in-memory lesson loaded from an engineering file.
|
/// An in-memory lesson loaded from an engineering file (ADR-0029).
|
||||||
///
|
|
||||||
/// `parts` is an ordered
|
|
||||||
/// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest
|
|
||||||
/// is declarative" — the `[[parts]]` array order is the single source of truth).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
pub struct Lesson {
|
pub struct Lesson {
|
||||||
/// `[project]` from the manifest (id, name).
|
/// `[project]` from the root manifest (id, name).
|
||||||
pub project: Project,
|
pub project: Project,
|
||||||
/// `[info]` from the manifest (title, optional author).
|
/// `[info]` from the root manifest (title, optional author).
|
||||||
pub info: Info,
|
pub info: Info,
|
||||||
/// The ordered parts — the lesson's element sequence.
|
/// The ordered **element** sequence — the lesson's element order (ADR-0005).
|
||||||
|
/// Contains only leaves; containers never appear here (a container
|
||||||
|
/// "contributes no element of its own", ADR-0029). Index-stable: an
|
||||||
|
/// [`OutlineEntry::Element`] names a position in this `Vec` by index.
|
||||||
pub parts: Vec<Part>,
|
pub parts: Vec<Part>,
|
||||||
/// Declared export targets, collected from the `[targets.<name>]` tables.
|
/// The full depth-first rendering order: elements (by index into `parts`)
|
||||||
///
|
/// interleaved with section headings, at the position they open in the
|
||||||
/// Per ADR-0009/0011 an export target is a *build* producing a typed
|
/// tree (ADR-0029). Consumed by the augmented-manifest builder so a
|
||||||
/// [`Artifact`] via an ordered list of typed [`Step`]s. This carries those
|
/// template can render headings in their real position; `cph-check`'s
|
||||||
/// in declared (TOML document) order. See [`TargetConfig`].
|
/// structural/schema/coverage phases do not need it (they use `parts`).
|
||||||
|
pub outline: Vec<OutlineEntry>,
|
||||||
|
/// Declared export targets, collected from the root manifest's
|
||||||
|
/// `[targets.<name>]` tables (ADR-0009/0011).
|
||||||
pub targets: Vec<TargetConfig>,
|
pub targets: Vec<TargetConfig>,
|
||||||
/// Engineering-file root (absolute), for resolving part paths.
|
/// Engineering-file root (absolute), for resolving part/container paths.
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Lesson {
|
impl Lesson {
|
||||||
/// The declared export-target names, in declared order.
|
/// The declared export-target names, in declared order.
|
||||||
///
|
|
||||||
/// Convenience for callers that only need the names (the shape this crate
|
|
||||||
/// exposed before ADR-0009 turned `targets` into structured
|
|
||||||
/// [`TargetConfig`]s).
|
|
||||||
pub fn target_names(&self) -> Vec<&str> {
|
pub fn target_names(&self) -> Vec<&str> {
|
||||||
self.targets.iter().map(|t| t.name.as_str()).collect()
|
self.targets.iter().map(|t| t.name.as_str()).collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One entry in the lesson's full rendering-order sequence (ADR-0029).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
|
pub enum OutlineEntry {
|
||||||
|
/// An element at this position: `part_index` into [`Lesson::parts`].
|
||||||
|
Element {
|
||||||
|
/// Index into `Lesson::parts`.
|
||||||
|
part_index: usize,
|
||||||
|
},
|
||||||
|
/// A section container opens here. Contributes no element; it is a
|
||||||
|
/// heading, not a part (ADR-0029). `depth` is the section's nesting depth
|
||||||
|
/// (1 = a section directly under the engineering-file root).
|
||||||
|
Section {
|
||||||
|
/// The container's declared kind (MVP: always `"section"`; validated
|
||||||
|
/// against the known container-kind set by `cph-check`, not here).
|
||||||
|
kind: String,
|
||||||
|
/// Heading text: the container's `[group].title` if present and
|
||||||
|
/// non-empty, else the folder's basename.
|
||||||
|
title: String,
|
||||||
|
/// Nesting depth (1-based) — the heading level a renderer should use.
|
||||||
|
depth: u32,
|
||||||
|
/// The container folder's path, relative to the engineering-file root.
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/// One declared export target's build config (ADR-0009/0011).
|
/// One declared export target's build config (ADR-0009/0011).
|
||||||
///
|
///
|
||||||
/// An export target is a **build** producing a typed [`Artifact`] via an
|
/// An export target is a **build** producing a typed [`Artifact`] via an
|
||||||
@@ -185,7 +223,7 @@ pub enum Step {
|
|||||||
run: String,
|
run: String,
|
||||||
},
|
},
|
||||||
/// Assemble a single-file markdown deliverable by concatenating each
|
/// Assemble a single-file markdown deliverable by concatenating each
|
||||||
/// element's `field` markdown content file in `[[parts]]` order. ADR-0011
|
/// element's `field` markdown content file in `parts` order. ADR-0011
|
||||||
/// `assembleMarkdown` (ADR-0015). Not a typst build — the
|
/// `assembleMarkdown` (ADR-0015). Not a typst build — the
|
||||||
/// framework owns the read/concatenate/write itself.
|
/// framework owns the read/concatenate/write itself.
|
||||||
AssembleMarkdown {
|
AssembleMarkdown {
|
||||||
@@ -205,7 +243,7 @@ impl Step {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[project]` table.
|
/// `[project]` table (root manifest only).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
/// Stable project id.
|
/// Stable project id.
|
||||||
@@ -214,7 +252,8 @@ pub struct Project {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[info]` table (passed through to render targets verbatim).
|
/// `[info]` table (root manifest only; passed through to render targets
|
||||||
|
/// verbatim).
|
||||||
///
|
///
|
||||||
/// The *canonical* model whose
|
/// The *canonical* model whose
|
||||||
/// `authors` is always a list. The authoring-surface form (string-or-array
|
/// `authors` is always a list. The authoring-surface form (string-or-array
|
||||||
@@ -232,13 +271,23 @@ pub struct Info {
|
|||||||
pub authors: Vec<String>,
|
pub authors: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One `[[parts]]` entry plus its loaded element descriptor.
|
/// One element leaf, plus its loaded `element.toml` descriptor.
|
||||||
|
///
|
||||||
|
/// `path` is the element folder's path relative to the engineering-file root,
|
||||||
|
/// regardless of how deeply nested it is in the outline tree (ADR-0029) — the
|
||||||
|
/// loader accumulates full root-relative paths during the depth-first walk, so
|
||||||
|
/// every downstream consumer (schema validation, augmented-manifest include
|
||||||
|
/// paths) keeps working against a root-relative path exactly as before ADR-0029.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
pub struct Part {
|
pub struct Part {
|
||||||
/// Declared kind from the manifest (one of the known kinds; ADR-0006).
|
/// Declared kind from the containing outline entry (ADR-0006). This is the
|
||||||
|
/// **declared** kind, which may disagree with the `element.toml` kind (a
|
||||||
|
/// disagreement is reported as [`DiagCode::UnknownKind`], but the declared
|
||||||
|
/// kind — not the descriptor's — is what `cph-check`'s known-kind check
|
||||||
|
/// tests, matching pre-ADR-0029 behavior).
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
/// Element folder path **as written in the manifest** (relative to root),
|
/// Element folder path, root-relative, kept verbatim (forward/back slashes
|
||||||
/// kept verbatim for diagnostics / display.
|
/// as the OS provides) for diagnostics/display.
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
/// The element's self-description loaded from its `element.toml`.
|
/// The element's self-description loaded from its `element.toml`.
|
||||||
pub descriptor: ElementDescriptor,
|
pub descriptor: ElementDescriptor,
|
||||||
@@ -250,26 +299,32 @@ pub struct Part {
|
|||||||
/// directory), so a folder is self-describing.
|
/// directory), so a folder is self-describing.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
pub struct ElementDescriptor {
|
pub struct ElementDescriptor {
|
||||||
/// `kind` from `element.toml`. Must equal the part's `kind`; a mismatch is
|
/// `kind` from `element.toml`. Must equal the part's declared `kind`; a
|
||||||
/// reported as a diagnostic (see [`load`]).
|
/// mismatch is reported as a diagnostic (see [`load`]).
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
/// Element folder, absolute path.
|
/// Element folder, absolute path.
|
||||||
pub dir: PathBuf,
|
pub dir: PathBuf,
|
||||||
/// The remaining `element.toml` keys (the scalar fields). Schema validation
|
/// The remaining `element.toml` keys (the scalar fields). Schema validation
|
||||||
/// of these is WU-3's job, not this loader's.
|
/// of these is `cph-schema`'s job, not this loader's.
|
||||||
pub scalars: toml::Table,
|
pub scalars: toml::Table,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- raw deserialization shapes (mirror the on-disk TOML) ---------------------
|
// --- raw deserialization shapes (mirror the on-disk TOML) ---------------------
|
||||||
|
|
||||||
|
/// One `manifest.toml`, root or container (ADR-0029). Root-only tables
|
||||||
|
/// (`project`/`info`/`targets`) and the container-only `group` table coexist in
|
||||||
|
/// one shape; [`load`]/[`load_children`] enforce which is expected where and
|
||||||
|
/// flag misplacement rather than rejecting parse outright (non-fatal, like
|
||||||
|
/// every other structural defect this loader collects).
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct RawManifest {
|
struct RawManifest {
|
||||||
project: Option<RawProject>,
|
project: Option<RawProject>,
|
||||||
info: Option<RawInfo>,
|
info: Option<RawInfo>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
parts: Vec<RawPart>,
|
|
||||||
#[serde(default)]
|
|
||||||
targets: toml::Table,
|
targets: toml::Table,
|
||||||
|
group: Option<RawGroup>,
|
||||||
|
#[serde(default)]
|
||||||
|
children: Vec<RawChild>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -309,8 +364,20 @@ impl RawAuthor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A container's optional `[group]` table (ADR-0029): presentation metadata for
|
||||||
|
/// a section, kept minimal per the ADR's recommended default.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct RawPart {
|
struct RawGroup {
|
||||||
|
title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `children` entry (ADR-0029): a `kind` + a **parent-relative** `path`.
|
||||||
|
/// The loader resolves on disk whether the named folder is a leaf
|
||||||
|
/// (`element.toml`) or a container (`manifest.toml`) — `kind` is the declared
|
||||||
|
/// label, cross-checked against the leaf's `element.toml` kind (unchanged from
|
||||||
|
/// ADR-0008) but never used to pick the leaf/container branch itself.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RawChild {
|
||||||
kind: String,
|
kind: String,
|
||||||
path: String,
|
path: String,
|
||||||
}
|
}
|
||||||
@@ -318,30 +385,33 @@ struct RawPart {
|
|||||||
/// Load the engineering file at `root` into a [`Lesson`].
|
/// Load the engineering file at `root` into a [`Lesson`].
|
||||||
///
|
///
|
||||||
/// Returns `(Option<Lesson>, Vec<Diagnostic>)`:
|
/// Returns `(Option<Lesson>, Vec<Diagnostic>)`:
|
||||||
/// - `Some(lesson)` whenever the manifest parses into a `Lesson` at all. The
|
/// - `Some(lesson)` whenever the root manifest parses into a `Lesson` at all.
|
||||||
/// structure is produced even when individual parts have problems, so the
|
/// The structure is produced even when individual parts/containers have
|
||||||
/// WU-5 orchestrator gets **both** the partial lesson and the collected
|
/// problems, so the orchestrator gets **both** the partial lesson and the
|
||||||
/// diagnostics.
|
/// collected diagnostics.
|
||||||
/// - `None` only on a hard failure where no `Lesson` can be built: the
|
/// - `None` only on a hard failure where no `Lesson` can be built: the root
|
||||||
/// `manifest.toml` is missing/unreadable, is not valid TOML, or lacks the
|
/// `manifest.toml` is missing/unreadable, is not valid TOML, or lacks the
|
||||||
/// required `[project]` / `[info]` tables. In that case the diagnostics
|
/// required `[project]` / `[info]` tables. In that case the diagnostics
|
||||||
/// describe the hard failure.
|
/// describe the hard failure.
|
||||||
///
|
///
|
||||||
/// Collected (non-fatal) diagnostics include: a part path that does not exist
|
/// Collected (non-fatal) diagnostics include: a child path that does not exist
|
||||||
/// or escapes the root via `..`, a missing/malformed `element.toml`, and a
|
/// or escapes its container via `..`, a folder that is neither a container nor
|
||||||
|
/// a leaf (or ambiguously both), a container manifest misplacing root-only
|
||||||
|
/// tables, a missing/malformed `element.toml`, and a
|
||||||
/// `part.kind != element.toml kind` mismatch.
|
/// `part.kind != element.toml kind` mismatch.
|
||||||
///
|
///
|
||||||
/// ## Diagnostic-code mapping (judgment call, WU-1)
|
/// ## Diagnostic-code mapping
|
||||||
///
|
///
|
||||||
/// `cph-diag`'s code set is closed and has **no** dedicated "manifest
|
/// [`DiagCode::ManifestMalformed`] is for the **carrier document's own**
|
||||||
/// malformed" code. We deliberately do not invent one here. Until such a code
|
/// structure being broken: unreadable/invalid-TOML `manifest.toml` (root or
|
||||||
/// is added, manifest-level structural errors (bad TOML, missing `[project]` /
|
/// container), a required root table missing, a container manifest misplacing
|
||||||
/// `[info]`) are mapped to the closest existing code, [`DiagCode::SchemaViolation`],
|
/// `[project]`/`[info]`/`[targets]`, a folder that is neither/both a
|
||||||
/// with a message making the real cause clear. A genuinely missing **part
|
/// container and a leaf, and an unreadable/invalid/kindless `element.toml`.
|
||||||
/// path** uses [`DiagCode::PartPathMissing`] (its actual meaning); a missing /
|
/// [`DiagCode::SchemaViolation`] stays reserved for *instance data* not
|
||||||
/// unreadable / malformed `element.toml` also maps to `SchemaViolation`.
|
/// conforming to a schema (a kind's JSON Schema, or a target's
|
||||||
// TODO(cph-diag): consider adding a dedicated `ManifestMalformed` code so
|
/// artifact/step config shape) — this loader never emits it. A genuinely
|
||||||
// manifest-structure errors don't overload `SchemaViolation`.
|
/// missing **child path** uses [`DiagCode::PartPathMissing`]; a declared kind
|
||||||
|
/// disagreeing with `element.toml`'s kind uses [`DiagCode::UnknownKind`].
|
||||||
pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
||||||
let mut diags = Vec::new();
|
let mut diags = Vec::new();
|
||||||
|
|
||||||
@@ -352,7 +422,7 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
diags.push(
|
diags.push(
|
||||||
Diagnostic::error(
|
Diagnostic::error(
|
||||||
DiagCode::SchemaViolation,
|
DiagCode::ManifestMalformed,
|
||||||
format!("cannot read manifest.toml: {e}"),
|
format!("cannot read manifest.toml: {e}"),
|
||||||
)
|
)
|
||||||
.with_hint(format!(
|
.with_hint(format!(
|
||||||
@@ -369,7 +439,7 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
diags.push(
|
diags.push(
|
||||||
Diagnostic::error(
|
Diagnostic::error(
|
||||||
DiagCode::SchemaViolation,
|
DiagCode::ManifestMalformed,
|
||||||
format!("manifest.toml is not valid TOML: {e}"),
|
format!("manifest.toml is not valid TOML: {e}"),
|
||||||
)
|
)
|
||||||
.with_hint("fix the TOML syntax in manifest.toml"),
|
.with_hint("fix the TOML syntax in manifest.toml"),
|
||||||
@@ -395,7 +465,7 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
|||||||
None => {
|
None => {
|
||||||
diags.push(
|
diags.push(
|
||||||
Diagnostic::error(
|
Diagnostic::error(
|
||||||
DiagCode::SchemaViolation,
|
DiagCode::ManifestMalformed,
|
||||||
"manifest.toml is missing the required [project] table",
|
"manifest.toml is missing the required [project] table",
|
||||||
)
|
)
|
||||||
.with_hint("add a [project] table with `id` and `name`"),
|
.with_hint("add a [project] table with `id` and `name`"),
|
||||||
@@ -412,7 +482,7 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
|||||||
None => {
|
None => {
|
||||||
diags.push(
|
diags.push(
|
||||||
Diagnostic::error(
|
Diagnostic::error(
|
||||||
DiagCode::SchemaViolation,
|
DiagCode::ManifestMalformed,
|
||||||
"manifest.toml is missing the required [info] table",
|
"manifest.toml is missing the required [info] table",
|
||||||
)
|
)
|
||||||
.with_hint("add an [info] table with at least `title`"),
|
.with_hint("add an [info] table with at least `title`"),
|
||||||
@@ -421,6 +491,21 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The root is the implicit top container (ADR-0029): it must not declare
|
||||||
|
// `[group]` (that is container-only presentation metadata).
|
||||||
|
if raw.group.is_some() {
|
||||||
|
diags.push(
|
||||||
|
Diagnostic::error(
|
||||||
|
DiagCode::ManifestMalformed,
|
||||||
|
"the root manifest.toml must not declare [group]; [group] is \
|
||||||
|
container-only presentation metadata",
|
||||||
|
)
|
||||||
|
.with_hint(
|
||||||
|
"remove [group] from the root manifest.toml, or move this content into a container",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Parse each [targets.<name>] table into a structured build config
|
// Parse each [targets.<name>] table into a structured build config
|
||||||
// (ADR-0009/0011). Order is the TOML document order, as before. Malformed
|
// (ADR-0009/0011). Order is the TOML document order, as before. Malformed
|
||||||
// target config is non-fatal: it is reported and the target is kept with
|
// target config is non-fatal: it is reported and the target is kept with
|
||||||
@@ -431,76 +516,263 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
|||||||
.map(|(name, value)| parse_target(name, value, &mut diags))
|
.map(|(name, value)| parse_target(name, value, &mut diags))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Load each part. Problems are collected, not fatal: we still build the
|
// Walk the outline tree depth-first (ADR-0029), starting at the root's own
|
||||||
// Part (with a best-effort descriptor) so order/membership is observable.
|
// children. A direct child section of the root opens at depth 1.
|
||||||
let mut parts = Vec::with_capacity(raw.parts.len());
|
let mut parts = Vec::new();
|
||||||
for raw_part in raw.parts {
|
let mut outline = Vec::new();
|
||||||
let rel_path = PathBuf::from(&raw_part.path);
|
load_children(
|
||||||
|
root,
|
||||||
// Reject `..` traversal: a part path must stay within the root.
|
root,
|
||||||
if has_parent_traversal(&rel_path) {
|
Path::new(""),
|
||||||
diags.push(
|
raw.children,
|
||||||
Diagnostic::error(
|
1,
|
||||||
DiagCode::PartPathMissing,
|
&mut diags,
|
||||||
format!(
|
&mut parts,
|
||||||
"part path '{}' escapes the engineering-file root via '..'",
|
&mut outline,
|
||||||
raw_part.path
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with_hint("part paths must be relative folders inside the engineering file"),
|
|
||||||
);
|
);
|
||||||
// Still record the part with an empty descriptor so order is kept.
|
|
||||||
parts.push(Part {
|
|
||||||
kind: raw_part.kind.clone(),
|
|
||||||
path: rel_path.clone(),
|
|
||||||
descriptor: ElementDescriptor {
|
|
||||||
kind: raw_part.kind,
|
|
||||||
dir: root.join(&rel_path),
|
|
||||||
scalars: toml::Table::new(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dir = root.join(&rel_path);
|
|
||||||
|
|
||||||
let descriptor = if !dir.is_dir() {
|
|
||||||
diags.push(
|
|
||||||
Diagnostic::error(
|
|
||||||
DiagCode::PartPathMissing,
|
|
||||||
format!("part folder '{}' does not exist", raw_part.path),
|
|
||||||
)
|
|
||||||
.with_hint(format!(
|
|
||||||
"create the folder '{}' or fix the `path` in manifest.toml",
|
|
||||||
raw_part.path
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
ElementDescriptor {
|
|
||||||
kind: raw_part.kind.clone(),
|
|
||||||
dir,
|
|
||||||
scalars: toml::Table::new(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
load_descriptor(&dir, &rel_path, &raw_part.kind, &mut diags)
|
|
||||||
};
|
|
||||||
|
|
||||||
parts.push(Part {
|
|
||||||
kind: raw_part.kind,
|
|
||||||
path: rel_path,
|
|
||||||
descriptor,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let lesson = Lesson {
|
let lesson = Lesson {
|
||||||
project,
|
project,
|
||||||
info,
|
info,
|
||||||
parts,
|
parts,
|
||||||
|
outline,
|
||||||
targets,
|
targets,
|
||||||
root: root.to_path_buf(),
|
root: root.to_path_buf(),
|
||||||
};
|
};
|
||||||
(Some(lesson), diags)
|
(Some(lesson), diags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recursively load one container's ordered `children` into `parts`/`outline`
|
||||||
|
/// (ADR-0029). `container_dir` is the container's own absolute directory;
|
||||||
|
/// `rel_prefix` is that container's own root-relative path (empty for the
|
||||||
|
/// engineering-file root). `next_section_depth` is the depth a **direct**
|
||||||
|
/// section child of this container would open at (1 for the root's children).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn load_children(
|
||||||
|
container_dir: &Path,
|
||||||
|
root: &Path,
|
||||||
|
rel_prefix: &Path,
|
||||||
|
children: Vec<RawChild>,
|
||||||
|
next_section_depth: u32,
|
||||||
|
diags: &mut Vec<Diagnostic>,
|
||||||
|
parts: &mut Vec<Part>,
|
||||||
|
outline: &mut Vec<OutlineEntry>,
|
||||||
|
) {
|
||||||
|
for child in children {
|
||||||
|
let local_path = PathBuf::from(&child.path);
|
||||||
|
let full_rel_path = join_rel(rel_prefix, &local_path);
|
||||||
|
|
||||||
|
// Reject `..` traversal: a child path must stay within its container.
|
||||||
|
if has_parent_traversal(&local_path) {
|
||||||
|
diags.push(
|
||||||
|
Diagnostic::error(
|
||||||
|
DiagCode::PartPathMissing,
|
||||||
|
format!("child path '{}' escapes its container via '..'", child.path),
|
||||||
|
)
|
||||||
|
.with_hint("child paths must be relative folders inside the containing folder"),
|
||||||
|
);
|
||||||
|
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let abs_dir = container_dir.join(&local_path);
|
||||||
|
|
||||||
|
if !abs_dir.is_dir() {
|
||||||
|
diags.push(
|
||||||
|
Diagnostic::error(
|
||||||
|
DiagCode::PartPathMissing,
|
||||||
|
format!("child folder '{}' does not exist", full_rel_path.display()),
|
||||||
|
)
|
||||||
|
.with_hint(format!(
|
||||||
|
"create the folder '{}' or fix the `path` in its container's manifest.toml",
|
||||||
|
full_rel_path.display()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_manifest = abs_dir.join("manifest.toml").is_file();
|
||||||
|
let has_element = abs_dir.join("element.toml").is_file();
|
||||||
|
|
||||||
|
match (has_manifest, has_element) {
|
||||||
|
// A leaf: exactly ADR-0008's element folder.
|
||||||
|
(false, true) => {
|
||||||
|
let descriptor = load_descriptor(&abs_dir, &full_rel_path, &child.kind, diags);
|
||||||
|
let idx = parts.len();
|
||||||
|
parts.push(Part {
|
||||||
|
kind: child.kind,
|
||||||
|
path: full_rel_path,
|
||||||
|
descriptor,
|
||||||
|
});
|
||||||
|
outline.push(OutlineEntry::Element { part_index: idx });
|
||||||
|
}
|
||||||
|
// A container: recurse into its own manifest.toml.
|
||||||
|
(true, false) => {
|
||||||
|
let Some(raw_container) = read_container_manifest(&abs_dir, diags) else {
|
||||||
|
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if raw_container.project.is_some()
|
||||||
|
|| raw_container.info.is_some()
|
||||||
|
|| !raw_container.targets.is_empty()
|
||||||
|
{
|
||||||
|
diags.push(
|
||||||
|
Diagnostic::error(
|
||||||
|
DiagCode::ManifestMalformed,
|
||||||
|
format!(
|
||||||
|
"container manifest.toml at '{}' must not declare \
|
||||||
|
[project]/[info]/[targets]; those are root-only",
|
||||||
|
full_rel_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_hint(
|
||||||
|
"remove [project]/[info]/[targets] from this container's \
|
||||||
|
manifest.toml — they belong only at the engineering-file root",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = raw_container
|
||||||
|
.group
|
||||||
|
.and_then(|g| g.title)
|
||||||
|
.filter(|t| !t.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| folder_name(&abs_dir));
|
||||||
|
|
||||||
|
outline.push(OutlineEntry::Section {
|
||||||
|
kind: child.kind,
|
||||||
|
title,
|
||||||
|
depth: next_section_depth,
|
||||||
|
path: full_rel_path.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
load_children(
|
||||||
|
&abs_dir,
|
||||||
|
root,
|
||||||
|
&full_rel_path,
|
||||||
|
raw_container.children,
|
||||||
|
next_section_depth + 1,
|
||||||
|
diags,
|
||||||
|
parts,
|
||||||
|
outline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Ambiguous: both a container and a leaf.
|
||||||
|
(true, true) => {
|
||||||
|
diags.push(
|
||||||
|
Diagnostic::error(
|
||||||
|
DiagCode::ManifestMalformed,
|
||||||
|
format!(
|
||||||
|
"folder '{}' has both manifest.toml and element.toml; a folder \
|
||||||
|
must be exactly one of a container or a leaf",
|
||||||
|
full_rel_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_hint("remove one of manifest.toml or element.toml from this folder"),
|
||||||
|
);
|
||||||
|
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
|
||||||
|
}
|
||||||
|
// Incomplete: neither a container nor a leaf.
|
||||||
|
(false, false) => {
|
||||||
|
diags.push(
|
||||||
|
Diagnostic::error(
|
||||||
|
DiagCode::ManifestMalformed,
|
||||||
|
format!(
|
||||||
|
"folder '{}' has neither manifest.toml nor element.toml",
|
||||||
|
full_rel_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_hint(
|
||||||
|
"add an element.toml (leaf) or a manifest.toml with `children` \
|
||||||
|
(container) to this folder",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and parse a container's own `manifest.toml`. On any read/parse failure,
|
||||||
|
/// reports a [`DiagCode::ManifestMalformed`] diagnostic and returns `None` (the
|
||||||
|
/// caller falls back to a broken-leaf placeholder so order/count stays stable).
|
||||||
|
fn read_container_manifest(dir: &Path, diags: &mut Vec<Diagnostic>) -> Option<RawManifest> {
|
||||||
|
let path = dir.join("manifest.toml");
|
||||||
|
let src = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
diags.push(
|
||||||
|
Diagnostic::error(
|
||||||
|
DiagCode::ManifestMalformed,
|
||||||
|
format!("cannot read {}: {e}", path.display()),
|
||||||
|
)
|
||||||
|
.with_hint("expected a container manifest.toml with a `children` array"),
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match toml::from_str(&src) {
|
||||||
|
Ok(r) => Some(r),
|
||||||
|
Err(e) => {
|
||||||
|
diags.push(
|
||||||
|
Diagnostic::error(
|
||||||
|
DiagCode::ManifestMalformed,
|
||||||
|
format!("{} is not valid TOML: {e}", path.display()),
|
||||||
|
)
|
||||||
|
.with_hint("fix the TOML syntax in this container's manifest.toml"),
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a broken child as a placeholder leaf (empty descriptor) so that
|
||||||
|
/// order/count stays observable even when the folder could not be resolved to
|
||||||
|
/// either a leaf or a container. Mirrors the pre-ADR-0029 behavior for a
|
||||||
|
/// missing part folder.
|
||||||
|
fn push_broken_leaf(
|
||||||
|
parts: &mut Vec<Part>,
|
||||||
|
outline: &mut Vec<OutlineEntry>,
|
||||||
|
kind: String,
|
||||||
|
path: PathBuf,
|
||||||
|
root: &Path,
|
||||||
|
) {
|
||||||
|
let idx = parts.len();
|
||||||
|
let dir = root.join(&path);
|
||||||
|
parts.push(Part {
|
||||||
|
kind: kind.clone(),
|
||||||
|
path,
|
||||||
|
descriptor: ElementDescriptor {
|
||||||
|
kind,
|
||||||
|
dir,
|
||||||
|
scalars: toml::Table::new(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
outline.push(OutlineEntry::Element { part_index: idx });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join a container-relative child path onto that container's own
|
||||||
|
/// root-relative prefix, producing a full root-relative path. An empty prefix
|
||||||
|
/// (the engineering-file root itself) returns `local` unchanged.
|
||||||
|
fn join_rel(prefix: &Path, local: &Path) -> PathBuf {
|
||||||
|
if prefix.as_os_str().is_empty() {
|
||||||
|
local.to_path_buf()
|
||||||
|
} else {
|
||||||
|
prefix.join(local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder's basename as a `String` (used as a section's default title when
|
||||||
|
/// `[group].title` is absent). Falls back to the folder's full path string in
|
||||||
|
/// the (pathological) case it has no file-name component.
|
||||||
|
fn folder_name(dir: &Path) -> String {
|
||||||
|
dir.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| dir.display().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// The cph version the running CLI was built with (ADR-0016). Pulled from the
|
/// The cph version the running CLI was built with (ADR-0016). Pulled from the
|
||||||
/// crate's `CARGO_PKG_VERSION` at compile time.
|
/// crate's `CARGO_PKG_VERSION` at compile time.
|
||||||
pub const CPH_VERSION: &str = env!("CARGO_PKG_VERSION");
|
pub const CPH_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
@@ -570,7 +842,7 @@ fn load_descriptor(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
diags.push(
|
diags.push(
|
||||||
Diagnostic::error(
|
Diagnostic::error(
|
||||||
DiagCode::SchemaViolation,
|
DiagCode::ManifestMalformed,
|
||||||
format!(
|
format!(
|
||||||
"cannot read element.toml for part '{}': {e}",
|
"cannot read element.toml for part '{}': {e}",
|
||||||
rel_path.display()
|
rel_path.display()
|
||||||
@@ -594,7 +866,7 @@ fn load_descriptor(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
diags.push(
|
diags.push(
|
||||||
Diagnostic::error(
|
Diagnostic::error(
|
||||||
DiagCode::SchemaViolation,
|
DiagCode::ManifestMalformed,
|
||||||
format!(
|
format!(
|
||||||
"element.toml for part '{}' is not valid TOML: {e}",
|
"element.toml for part '{}' is not valid TOML: {e}",
|
||||||
rel_path.display()
|
rel_path.display()
|
||||||
@@ -617,7 +889,7 @@ fn load_descriptor(
|
|||||||
Some(_) => {
|
Some(_) => {
|
||||||
diags.push(
|
diags.push(
|
||||||
Diagnostic::error(
|
Diagnostic::error(
|
||||||
DiagCode::SchemaViolation,
|
DiagCode::ManifestMalformed,
|
||||||
format!(
|
format!(
|
||||||
"element.toml for part '{}' has a non-string `kind`",
|
"element.toml for part '{}' has a non-string `kind`",
|
||||||
rel_path.display()
|
rel_path.display()
|
||||||
@@ -630,7 +902,7 @@ fn load_descriptor(
|
|||||||
None => {
|
None => {
|
||||||
diags.push(
|
diags.push(
|
||||||
Diagnostic::error(
|
Diagnostic::error(
|
||||||
DiagCode::SchemaViolation,
|
DiagCode::ManifestMalformed,
|
||||||
format!(
|
format!(
|
||||||
"element.toml for part '{}' is missing the required `kind` key",
|
"element.toml for part '{}' is missing the required `kind` key",
|
||||||
rel_path.display()
|
rel_path.display()
|
||||||
@@ -1065,6 +1337,228 @@ fn has_parent_traversal(path: &Path) -> bool {
|
|||||||
path.components().any(|c| matches!(c, Component::ParentDir))
|
path.components().any(|c| matches!(c, Component::ParentDir))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- bundle: an ordered arrangement of lessons (ADR-0030) --------------------
|
||||||
|
|
||||||
|
/// A **bundle** (ADR-0030): a directory carrying `bundle.toml`, which arranges
|
||||||
|
/// an ordered list of already-authored, self-contained lessons into one export
|
||||||
|
/// unit. Discharges ADR-0005's deferred "course = arrangement of lessons" for
|
||||||
|
/// the export purpose, without inventing a full course-authoring model.
|
||||||
|
///
|
||||||
|
/// A bundle target (declared exactly like a lesson's `[targets.*]`,
|
||||||
|
/// ADR-0009/0011) assembles its member lessons at build time; this crate only
|
||||||
|
/// loads the *arrangement* — which lessons, in what order, with which
|
||||||
|
/// per-lesson overrides. See [`load_bundle`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
|
pub struct Bundle {
|
||||||
|
/// `[info]` from `bundle.toml` — the 合集's own title/author.
|
||||||
|
pub info: Info,
|
||||||
|
/// The ordered member lessons.
|
||||||
|
pub lessons: Vec<BundleLesson>,
|
||||||
|
/// Declared export targets, collected from `bundle.toml`'s
|
||||||
|
/// `[targets.<name>]` tables (ADR-0009/0011) — reusing the exact same
|
||||||
|
/// build/artifact/step shape a lesson's targets use.
|
||||||
|
pub targets: Vec<TargetConfig>,
|
||||||
|
/// Bundle root (absolute) — the directory containing `bundle.toml`.
|
||||||
|
pub root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bundle {
|
||||||
|
/// The declared export-target names, in declared order (mirrors
|
||||||
|
/// [`Lesson::target_names`]).
|
||||||
|
pub fn target_names(&self) -> Vec<&str> {
|
||||||
|
self.targets.iter().map(|t| t.name.as_str()).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `[[lessons]]` entry in `bundle.toml`, plus that lesson's loaded
|
||||||
|
/// [`Lesson`] (loaded exactly as [`load`] would from its own directory — a
|
||||||
|
/// bundle never re-derives lesson-loading logic).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
|
pub struct BundleLesson {
|
||||||
|
/// The lesson directory's path, relative to the bundle root, as written in
|
||||||
|
/// `bundle.toml` (kept verbatim for diagnostics/display).
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// Which of that lesson's own declared targets to render into the bundle
|
||||||
|
/// (ADR-0030: the bundle assembles a lesson's already-authored content, it
|
||||||
|
/// does not re-target it). Defaults to the lesson's first declared target,
|
||||||
|
/// or `"student"` if the lesson declares none.
|
||||||
|
pub target: String,
|
||||||
|
/// The member lesson, loaded from `<bundle root>/<path>`.
|
||||||
|
pub lesson: Lesson,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RawBundleManifest {
|
||||||
|
info: Option<RawInfo>,
|
||||||
|
#[serde(default)]
|
||||||
|
targets: toml::Table,
|
||||||
|
#[serde(default)]
|
||||||
|
lessons: Vec<RawBundleLesson>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RawBundleLesson {
|
||||||
|
path: String,
|
||||||
|
target: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the bundle at `root` (a directory containing `bundle.toml`) into a
|
||||||
|
/// [`Bundle`].
|
||||||
|
///
|
||||||
|
/// Returns `(Option<Bundle>, Vec<Diagnostic>)`, mirroring [`load`]'s contract:
|
||||||
|
/// - `Some(bundle)` whenever `bundle.toml` parses at all — even when a member
|
||||||
|
/// lesson fails to load, so the orchestrator sees both the partial bundle
|
||||||
|
/// and every collected diagnostic (that member's `Lesson` is still present,
|
||||||
|
/// just with its own load errors alongside).
|
||||||
|
/// - `None` only on a hard failure: `bundle.toml` missing/unreadable, not
|
||||||
|
/// valid TOML, or missing the required `[info]` table.
|
||||||
|
///
|
||||||
|
/// Each `[[lessons]]` entry's path must stay within the bundle root (no `..`
|
||||||
|
/// traversal) and must resolve to a real directory; violations are
|
||||||
|
/// [`DiagCode::PartPathMissing`], matching a lesson's own child-path
|
||||||
|
/// diagnostics. A member lesson's own load diagnostics are folded in verbatim
|
||||||
|
/// (they already carry their own context).
|
||||||
|
pub fn load_bundle(root: &Path) -> (Option<Bundle>, Vec<Diagnostic>) {
|
||||||
|
let mut diags = Vec::new();
|
||||||
|
|
||||||
|
let manifest_path = root.join("bundle.toml");
|
||||||
|
let manifest_src = match std::fs::read_to_string(&manifest_path) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
diags.push(
|
||||||
|
Diagnostic::error(
|
||||||
|
DiagCode::ManifestMalformed,
|
||||||
|
format!("cannot read bundle.toml: {e}"),
|
||||||
|
)
|
||||||
|
.with_hint(format!(
|
||||||
|
"expected a bundle manifest at {}",
|
||||||
|
manifest_path.display()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
return (None, diags);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let raw: RawBundleManifest = match toml::from_str(&manifest_src) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
diags.push(
|
||||||
|
Diagnostic::error(
|
||||||
|
DiagCode::ManifestMalformed,
|
||||||
|
format!("bundle.toml is not valid TOML: {e}"),
|
||||||
|
)
|
||||||
|
.with_hint("fix the TOML syntax in bundle.toml"),
|
||||||
|
);
|
||||||
|
return (None, diags);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let info = match raw.info {
|
||||||
|
Some(i) => Info {
|
||||||
|
title: i.title,
|
||||||
|
authors: i.author.map(RawAuthor::into_vec).unwrap_or_default(),
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
diags.push(
|
||||||
|
Diagnostic::error(
|
||||||
|
DiagCode::ManifestMalformed,
|
||||||
|
"bundle.toml is missing the required [info] table",
|
||||||
|
)
|
||||||
|
.with_hint("add an [info] table with at least `title`"),
|
||||||
|
);
|
||||||
|
return (None, diags);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let targets: Vec<TargetConfig> = raw
|
||||||
|
.targets
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, value)| parse_target(name, value, &mut diags))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut lessons = Vec::with_capacity(raw.lessons.len());
|
||||||
|
for raw_lesson in raw.lessons {
|
||||||
|
let rel_path = PathBuf::from(&raw_lesson.path);
|
||||||
|
|
||||||
|
if has_parent_traversal(&rel_path) {
|
||||||
|
diags.push(
|
||||||
|
Diagnostic::error(
|
||||||
|
DiagCode::PartPathMissing,
|
||||||
|
format!(
|
||||||
|
"bundle lesson path '{}' escapes the bundle root via '..'",
|
||||||
|
raw_lesson.path
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_hint("bundle lesson paths must be relative folders inside the bundle root"),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let abs_dir = root.join(&rel_path);
|
||||||
|
if !abs_dir.is_dir() {
|
||||||
|
diags.push(
|
||||||
|
Diagnostic::error(
|
||||||
|
DiagCode::PartPathMissing,
|
||||||
|
format!(
|
||||||
|
"bundle lesson folder '{}' does not exist",
|
||||||
|
rel_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_hint(format!(
|
||||||
|
"create the folder '{}' or fix the `path` in bundle.toml",
|
||||||
|
rel_path.display()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (lesson, lesson_diags) = load(&abs_dir);
|
||||||
|
diags.extend(lesson_diags);
|
||||||
|
let Some(lesson) = lesson else {
|
||||||
|
// The member lesson's own hard-failure diagnostic already explains
|
||||||
|
// why; skip it from the bundle rather than fabricate a placeholder
|
||||||
|
// Lesson (unlike a broken element child, there is no lighter-weight
|
||||||
|
// stand-in for "an entire unloadable lesson").
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let target = raw_lesson.target.unwrap_or_else(|| {
|
||||||
|
lesson
|
||||||
|
.targets
|
||||||
|
.first()
|
||||||
|
.map(|t| t.name.clone())
|
||||||
|
.unwrap_or_else(|| DEFAULT_LESSON_TARGET.to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
lessons.push(BundleLesson {
|
||||||
|
path: rel_path,
|
||||||
|
target,
|
||||||
|
lesson,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let bundle = Bundle {
|
||||||
|
info,
|
||||||
|
lessons,
|
||||||
|
targets,
|
||||||
|
root: root.to_path_buf(),
|
||||||
|
};
|
||||||
|
(Some(bundle), diags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The target name a bundle member falls back to when its `bundle.toml` entry
|
||||||
|
/// gives no explicit `target` and the member lesson itself declares no targets
|
||||||
|
/// (mirrors [`Step::default_for`]'s "student" convention).
|
||||||
|
const DEFAULT_LESSON_TARGET: &str = "student";
|
||||||
|
|
||||||
|
/// Whether any diagnostic in `diags` is `Error`-severity — the shared
|
||||||
|
/// legality predicate `load`/`load_bundle` callers use (mirrors
|
||||||
|
/// `cph-check::CheckReport::has_errors`, kept local here so this crate never
|
||||||
|
/// depends on `cph-check`).
|
||||||
|
pub fn has_error_diagnostic(diags: &[Diagnostic]) -> bool {
|
||||||
|
diags.iter().any(|d| d.severity == Severity::Error)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1076,4 +1570,20 @@ mod tests {
|
|||||||
assert!(!has_parent_traversal(Path::new("a/b/c")));
|
assert!(!has_parent_traversal(Path::new("a/b/c")));
|
||||||
assert!(!has_parent_traversal(Path::new("segments/intro")));
|
assert!(!has_parent_traversal(Path::new("segments/intro")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_rel_empty_prefix_returns_local() {
|
||||||
|
assert_eq!(
|
||||||
|
join_rel(Path::new(""), Path::new("a/b")),
|
||||||
|
PathBuf::from("a/b")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_rel_nonempty_prefix_joins() {
|
||||||
|
assert_eq!(
|
||||||
|
join_rel(Path::new("导言簇"), Path::new("开场白")),
|
||||||
|
PathBuf::from("导言簇/开场白")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
+1
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[project]
|
||||||
|
id = "fixture-nested"
|
||||||
|
name = "nested"
|
||||||
|
|
||||||
|
[info]
|
||||||
|
title = "嵌套结构测试"
|
||||||
|
|
||||||
|
[[children]]
|
||||||
|
kind = "segment"
|
||||||
|
path = "segments/开场白"
|
||||||
|
|
||||||
|
[[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,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.
|
||||||
+2
-2
@@ -6,11 +6,11 @@ name = "valid-2-part"
|
|||||||
title = "测试课:两个部件"
|
title = "测试课:两个部件"
|
||||||
author = "范式教育教研组"
|
author = "范式教育教研组"
|
||||||
|
|
||||||
[[parts]]
|
[[children]]
|
||||||
kind = "segment"
|
kind = "segment"
|
||||||
path = "segments/intro"
|
path = "segments/intro"
|
||||||
|
|
||||||
[[parts]]
|
[[children]]
|
||||||
kind = "lemma"
|
kind = "lemma"
|
||||||
path = "lemmas/young"
|
path = "lemmas/young"
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -39,6 +40,16 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
|
|||||||
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 },
|
||||||
|
OutlineEntry::Element { part_index: 1 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// `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 +85,58 @@ 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/子段二"),
|
||||||
|
],
|
||||||
|
"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 }, // segments/开场白
|
||||||
|
OutlineEntry::Section {
|
||||||
|
kind: "section".to_string(),
|
||||||
|
title: "导言簇".to_string(),
|
||||||
|
depth: 1,
|
||||||
|
path: PathBuf::from("导言簇"),
|
||||||
|
},
|
||||||
|
OutlineEntry::Element { part_index: 1 }, // 导言簇/segments/子段一
|
||||||
|
OutlineEntry::Section {
|
||||||
|
kind: "section".to_string(),
|
||||||
|
title: "嵌套子节".to_string(),
|
||||||
|
depth: 2,
|
||||||
|
path: PathBuf::from("导言簇/嵌套子节"),
|
||||||
|
},
|
||||||
|
OutlineEntry::Element { part_index: 2 }, // 导言簇/嵌套子节/lemmas/子引理
|
||||||
|
OutlineEntry::Element { part_index: 3 }, // 导言簇/segments/子段二
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 +189,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 +199,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 +444,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");
|
||||||
|
|||||||
+70
-13
@@ -42,12 +42,12 @@ mod world;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use cph_diag::{DiagCode, Diagnostic};
|
use cph_diag::{DiagCode, Diagnostic};
|
||||||
use cph_model::{Artifact, Lesson, Step, TargetConfig};
|
use cph_model::{Artifact, Bundle, 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
|
||||||
@@ -142,7 +142,7 @@ impl Engine {
|
|||||||
/// 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 +152,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 +214,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 +234,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")));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,60 @@ 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,
|
||||||
|
} => {
|
||||||
|
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`
|
||||||
|
|||||||
@@ -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:#?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,86 @@ 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"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the
|
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the
|
||||||
@@ -202,6 +224,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 +264,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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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/无证明引理"
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+18
-142
@@ -6,161 +6,37 @@ 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 = "液气界面"
|
||||||
|
|
||||||
[[parts]]
|
[[children]]
|
||||||
kind = "segment"
|
kind = "section"
|
||||||
path = "segments/微观建模的共同骨架"
|
path = "固气界面"
|
||||||
|
|
||||||
[[parts]]
|
[[children]]
|
||||||
kind = "lemma"
|
kind = "section"
|
||||||
path = "lemmas/量纲分析估计"
|
path = "固液界面"
|
||||||
|
|
||||||
[[parts]]
|
[[children]]
|
||||||
kind = "segment"
|
kind = "section"
|
||||||
path = "segments/缺键模型导言"
|
path = "σTp态函数建模"
|
||||||
|
|
||||||
[[parts]]
|
[[children]]
|
||||||
kind = "lemma"
|
kind = "section"
|
||||||
path = "lemmas/缺键模型一般公式"
|
path = "收束"
|
||||||
|
|
||||||
[[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-混注石油"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[group]
|
||||||
|
title = "固气界面"
|
||||||
|
|
||||||
|
[[children]]
|
||||||
|
kind = "segment"
|
||||||
|
path = "segments/固气界面导言"
|
||||||
|
|
||||||
|
[[children]]
|
||||||
|
kind = "segment"
|
||||||
|
path = "segments/表面能γ与表面应力f"
|
||||||
|
|
||||||
|
[[children]]
|
||||||
|
kind = "lemma"
|
||||||
|
path = "lemmas/拉伸固体的总应力"
|
||||||
|
|
||||||
|
[[children]]
|
||||||
|
kind = "lemma"
|
||||||
|
path = "lemmas/缺键模型迁移到固气"
|
||||||
|
|
||||||
|
[[children]]
|
||||||
|
kind = "lemma"
|
||||||
|
path = "lemmas/固体表面能的晶面各向异性"
|
||||||
|
|
||||||
|
[[children]]
|
||||||
|
kind = "segment"
|
||||||
|
path = "segments/不同物质γ量级对比"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user