feat(checker): compile template as main, augmented manifest closes optional-content (WU-D', ADR-0011)

Replace generated-driver compilation with the template model.

cph-typst:
- driver.rs DELETED (generate_driver, static-include driver, numbering-threading).
- New manifest.rs: build_augmented_manifest(&Lesson) emits a TOML doc with [info]
  + ordered [[parts]] each carrying a `fields` array = the kind's content fields
  whose <root>/<path>/<field>.typ exists on disk. Reuses cph-schema's
  content_field_names (new dep; no cycle). This closes the OPEN point WU-C'
  surfaced: typst has no file-exists primitive, so the engine (which has disk
  access) tells the template which optional content (lemma proof) is present.
- World main = the real engineering-file template (<root>/<template>); the
  augmented manifest is served as an in-memory virtual file at /.cph/manifest.toml
  (never written to the tree), injected via sys.inputs.manifest. render_dir kept
  only for @local/cph-render + vendored @preview/numbly resolution.
- Artifact handling: SingleFile+TypstCompile compiles; FileTree, shell-only, and
  undeclared-target are blocking "deferred/not-declared" diagnostics (ADR-0011).
- Engine public signatures unchanged; LessonWorld::new takes template+manifest_src.

cph-check: no source change needed (only uses lesson.targets/target.name +
unchanged Engine methods); pipeline + Legal alignment intact.

Fixtures: mini gets exports/{student,teacher}.typ (from render/templates/) + v2
manifest + a second proof-less lemma to exercise optional content. render-stub
retired (tests use the real render/). Through-template PDFs offline: student 44KB,
teacher 56KB; proof-less lemma compiles clean (optional-content confirmed).
Workspace: fmt + clippy -D warnings + all tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 10:05:03 +08:00
parent 7e15cf34f6
commit d76f9f9a54
15 changed files with 705 additions and 573 deletions
+188 -181
View File
@@ -1,24 +1,25 @@
//! Integration tests for `cph-typst`: driver generation, World, compile-check,
//! and PDF export over the `tests/fixtures/mini` lesson.
//! Integration tests for `cph-typst` under the ADR-0011 model: the engine
//! compiles a **template file** (`exports/<target>.typ`) as main, injecting an
//! **augmented manifest** (per-part `fields` array of on-disk content fields)
//! served as a virtual file, and resolving `@local/cph-render` + vendored
//! `@preview/numbly` from the repo `render/` directory.
//!
//! Render-package dependency: most tests point the [`Engine`] at the bundled
//! **render-stub** (`tests/fixtures/render-stub`) so the World/compile/PDF path
//! is proven independently of WU-2. One `#[ignore]`d test points at the real
//! repo `render/` package for when it is ready / for manual runs.
//! There is no render-stub any more: the stock templates import the real
//! `cph-render` package's full API (`render-lesson`, `part-fields`,
//! `default-heading-numbering`), so the tests point the [`Engine`] at the real
//! repo `render/` package. These tests require system CJK fonts (present on dev
//! and CI via fonts-noto-cjk) and the vendored numbly (committed under
//! `render/vendor/`), so they run fully offline.
use std::path::PathBuf;
use cph_diag::Severity;
use cph_typst::{generate_driver, Engine};
use cph_typst::{build_augmented_manifest, Engine};
fn fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mini")
}
fn stub_render_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/render-stub")
}
fn real_render_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
@@ -37,102 +38,89 @@ fn load_mini() -> cph_model::Lesson {
lesson
}
/// PURE UNIT TEST (no fonts, no render package): the generated driver string
/// has the expected shape, order, includes, and scalar.
/// PURE UNIT TEST (no fonts, no render package): the augmented manifest carries
/// `[info]`, the ordered `[[parts]]`, and a per-part `fields` array listing the
/// content fields present on disk.
#[test]
fn driver_gen_shape_and_order() {
fn augmented_manifest_has_per_part_fields() {
let lesson = load_mini();
let src = generate_driver(&lesson, "student");
let src = build_augmented_manifest(&lesson);
// Imports the render package and calls display.
assert!(src.contains("#import \"@local/cph-render:0.1.0\": display"));
assert!(src.contains("#display("));
assert!(src.contains("target: \"student\""));
assert!(src.contains("title: \"迷你示例课时\""));
assert!(src.contains("author: \"测试作者\""));
// Info round-trips.
assert!(src.contains("迷你示例课时"), "info.title present:\n{src}");
assert!(src.contains("测试作者"), "info.author present:\n{src}");
// Root-relative includes with UTF-8 folder names, one per content field.
assert!(src.contains("include \"/segments/开场对照导言/textbook.typ\""));
assert!(src.contains("include \"/lemmas/量纲分析估计/stmt.typ\""));
assert!(src.contains("include \"/lemmas/量纲分析估计/proof.typ\""));
assert!(src.contains("include \"/examples/自由落体/problem.typ\""));
assert!(src.contains("include \"/examples/自由落体/solution.typ\""));
// Parse it back to inspect the per-part fields precisely.
let doc: toml::Value = toml::from_str(&src).expect("augmented manifest is valid TOML");
let parts = doc
.get("parts")
.and_then(|p| p.as_array())
.expect("parts array present");
assert_eq!(parts.len(), 4, "four parts in declared order:\n{src}");
// Example scalar `source` is emitted from element.toml.
assert!(src.contains("source: \"自拟\""));
// Parts appear in manifest order: segment, then lemma, then example.
let seg = src.find("kind: \"segment\"").expect("segment present");
let lem = src.find("kind: \"lemma\"").expect("lemma present");
let exa = src.find("kind: \"example\"").expect("example present");
assert!(seg < lem && lem < exa, "parts must keep manifest order");
}
/// Optional `proof.typ` is only included when it exists on disk. (It exists in
/// the mini fixture, so we assert presence; absence is covered by construction
/// — a kind without the file would simply omit the binding.)
#[test]
fn optional_proof_included_when_present() {
let lesson = load_mini();
let src = generate_driver(&lesson, "teacher");
assert!(src.contains("_p1_proof = include"));
}
/// The mini fixture's targets carry no `numbering.heading` override, so the
/// driver emits an empty `config: (:)` (render package applies its default).
#[test]
fn driver_emits_empty_config_when_no_override() {
let lesson = load_mini();
let src = generate_driver(&lesson, "student");
assert!(
src.contains("config: (:)"),
"expected empty config for a target with no numbering override:\n{src}"
);
}
/// CONFIG THREADING: a target declaring a `numbering.heading` override produces
/// a driver that passes `config: (numbering: (heading: (...)))` with the
/// patterns as escaped typst string literals, in order.
#[test]
fn driver_threads_numbering_heading_config() {
use cph_model::{ArtifactKind, Info, Lesson, NumberingConfig, Project, TargetConfig};
use std::path::PathBuf;
let lesson = Lesson {
project: Project {
id: "x".into(),
name: "x".into(),
},
info: Info {
title: "t".into(),
author: None,
},
parts: vec![],
targets: vec![TargetConfig {
name: "student".into(),
artifact: ArtifactKind::SingleFile,
numbering: Some(NumberingConfig {
heading: Some(vec!["{1:一}、".into(), "{1:1}.{2:1}".into()]),
}),
}],
root: PathBuf::from("/tmp/does-not-matter"),
// `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> = 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()
.as_str()
.unwrap()
.to_string()
};
let kind_of = |idx: usize| {
parts[idx]
.get("kind")
.unwrap()
.as_str()
.unwrap()
.to_string()
};
let src = generate_driver(&lesson, "student");
assert!(
src.contains(r#"config: (numbering: (heading: ("{1:一}、", "{1:1}.{2:1}",)))"#),
"expected threaded numbering config in driver:\n{src}"
// Order preserved: segment, lemma (w/ proof), lemma (no proof), example.
assert_eq!(kind_of(0), "segment");
assert_eq!(kind_of(1), "lemma");
assert_eq!(kind_of(2), "lemma");
assert_eq!(kind_of(3), "example");
// Paths kept as forward-slash UTF-8.
assert_eq!(path_of(0), "segments/开场对照导言");
assert_eq!(path_of(2), "lemmas/无证明引理");
// segment: only `textbook` exists.
assert_eq!(fields_of(0), vec!["textbook"]);
// lemma WITH proof.typ: both stmt + proof present (sorted).
assert_eq!(fields_of(1), vec!["proof", "stmt"]);
// lemma WITHOUT proof.typ: only stmt present (the OPTIONAL-content path).
assert_eq!(
fields_of(2),
vec!["stmt"],
"proof must be omitted when absent"
);
// example: problem + solution present (source is a scalar, not a content field).
assert_eq!(fields_of(3), vec!["problem", "solution"]);
}
/// FONT-DEPENDENT: compile the mini lesson through the render-stub. Should
/// produce zero Error-severity diagnostics. If this fails purely because the
/// test host lacks fonts, the failure will be a typst font error — see the
/// note in the crate docs.
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the
/// student template as main with the injected augmented manifest is clean (zero
/// Error-severity diagnostics). This proves the template → manifest → include
/// loop → cph-render path, including the optional-content gate (the second lemma
/// has no proof.typ and must NOT cause a missing-include error).
#[test]
fn compile_check_clean_with_stub() {
fn compile_check_clean_through_template() {
let lesson = load_mini();
let engine = Engine::with_render_dir(stub_render_dir());
let engine = Engine::with_render_dir(real_render_dir());
let diags = engine.compile_check(&lesson, "student");
let errors: Vec<_> = diags
.iter()
@@ -141,87 +129,13 @@ fn compile_check_clean_with_stub() {
assert!(errors.is_empty(), "unexpected compile errors: {errors:#?}");
}
/// FONT-DEPENDENT: a real PDF is produced through the render-stub and is
/// non-trivial in size.
/// THROUGH-TEMPLATE PDF export, fully offline (vendored numbly + @local/cph-render):
/// a non-trivial PDF is produced for both targets through the real templates.
/// This is also the offline-`@preview/numbly` proof — `render/src/style.typ`
/// imports `@preview/numbly:0.1.0`, resolved from the vendored dir with no
/// network access; a failure there would surface as a package-not-found error.
#[test]
fn build_pdf_with_stub() {
let lesson = load_mini();
let engine = Engine::with_render_dir(stub_render_dir());
let pdf = engine
.build_pdf(&lesson, "student")
.unwrap_or_else(|d| panic!("PDF build failed: {d:#?}"));
assert!(pdf.starts_with(b"%PDF"), "output is a PDF");
assert!(
pdf.len() > 1024,
"PDF is non-trivial (got {} bytes)",
pdf.len()
);
let out = std::env::temp_dir().join("cph-typst-mini-student.pdf");
std::fs::write(&out, &pdf).expect("write pdf");
assert!(std::fs::metadata(&out).unwrap().len() > 1024);
}
/// A diagnostic that lands inside the generated driver is re-pointed at the
/// driver path with a "generated driver" hint, not dropped. We force this by
/// compiling against a render dir whose `display` does not exist, so the
/// driver's `#display(...)` call is unknown — the error span is in the driver.
#[test]
fn driver_internal_span_is_repointed() {
let lesson = load_mini();
// Point at a render dir with an EMPTY lib.typ (no `display`), so calling
// `#display(...)` errors inside the driver.
let empty = std::env::temp_dir().join("cph-typst-empty-render");
std::fs::create_dir_all(&empty).unwrap();
std::fs::write(
empty.join("typst.toml"),
"[package]\nname=\"cph-render\"\nversion=\"0.1.0\"\nentrypoint=\"lib.typ\"\n\
authors=[\"t\"]\nlicense=\"MIT\"\ndescription=\"empty\"\n",
)
.unwrap();
std::fs::write(empty.join("lib.typ"), "// no display defined\n").unwrap();
let engine = Engine::with_render_dir(empty);
let diags = engine.compile_check(&lesson, "student");
let errors: Vec<_> = diags
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(!errors.is_empty(), "expected an unknown-`display` error");
// At least one error should point at the driver and carry the driver hint.
let driver_err = errors.iter().find(|d| {
d.span
.as_ref()
.is_some_and(|s| s.file.to_string_lossy().contains(".cph/driver-"))
});
assert!(
driver_err.is_some(),
"an error span should be re-pointed at the generated driver: {errors:#?}"
);
assert!(
driver_err
.unwrap()
.hint
.as_deref()
.is_some_and(|h| h.contains("generated driver")),
"driver-internal error should carry the generated-driver hint"
);
}
/// Full end-to-end against the REAL repo `render/` package: load the mini
/// fixture, compile through `cph-render`'s `display`, export a PDF for both
/// targets. This proves the whole World → driver → render-package → PDF path,
/// not just the stub. Requires system CJK fonts (present on dev + CI via
/// fonts-noto-cjk).
///
/// **This is also the offline-`@preview/numbly` proof (Task 1).** The real
/// render package's `src/style.typ` does `#import "@preview/numbly:0.1.0"`, so a
/// clean PDF here means the embedded `World` resolved numbly from the vendored
/// dir (`render/vendor/typst-packages/preview/numbly/0.1.0/`) with no network /
/// package download — had it failed, `build_pdf` would have returned a
/// package-not-found error and this test would panic.
#[test]
fn build_pdf_with_real_render() {
fn build_pdf_through_template_offline() {
let lesson = load_mini();
let render_dir = real_render_dir();
assert!(
@@ -229,7 +143,6 @@ fn build_pdf_with_real_render() {
"real render package missing at {}",
render_dir.display()
);
// Sanity: the numbly the render package imports is actually vendored here.
assert!(
render_dir
.join("vendor/typst-packages/preview/numbly/0.1.0/lib.typ")
@@ -243,13 +156,107 @@ fn build_pdf_with_real_render() {
let pdf = engine
.build_pdf(&lesson, target)
.unwrap_or_else(|d| panic!("{target} PDF build failed: {d:#?}"));
assert!(pdf.starts_with(b"%PDF"));
assert!(pdf.len() > 1024, "{target} PDF too small");
eprintln!(
"build_pdf_with_real_render: {target} PDF = {} bytes (numbly resolved offline)",
assert!(pdf.starts_with(b"%PDF"), "{target} output is a PDF");
assert!(
pdf.len() > 1024,
"{target} PDF is non-trivial (got {} bytes)",
pdf.len()
);
let out = std::env::temp_dir().join(format!("cph-typst-mini-real-{target}.pdf"));
eprintln!(
"build_pdf_through_template_offline: {target} PDF = {} bytes (numbly resolved offline)",
pdf.len()
);
let out = std::env::temp_dir().join(format!("cph-typst-mini-{target}.pdf"));
std::fs::write(&out, &pdf).expect("write pdf");
}
}
/// An undeclared target name (when the lesson declares at least one target) is a
/// blocking `SchemaViolation`, not a compile attempt.
#[test]
fn unknown_target_is_blocking() {
let lesson = load_mini();
let engine = Engine::with_render_dir(real_render_dir());
let diags = engine.compile_check(&lesson, "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:#?}"
);
}
/// A `file-tree` artifact is deferred for MVP: a clear "not yet implemented"
/// blocking diagnostic rather than a wrong build.
#[test]
fn file_tree_artifact_is_deferred() {
use cph_model::{Artifact, Info, Lesson, Project, Step, TargetConfig};
let lesson = Lesson {
project: Project {
id: "x".into(),
name: "x".into(),
},
info: Info {
title: "t".into(),
author: None,
},
parts: vec![],
targets: vec![TargetConfig {
name: "web".into(),
artifact: Artifact::FileTree {
root: PathBuf::from("build/web"),
outputs: "**/*.html".into(),
},
steps: vec![Step::TypstCompile {
template: PathBuf::from("exports/web.typ"),
}],
}],
root: fixture_root(),
};
let engine = Engine::with_render_dir(real_render_dir());
let diags = engine.compile_check(&lesson, "web");
assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}");
assert!(
diags[0].message.contains("file-tree"),
"expected a file-tree deferral: {diags:#?}"
);
}
/// A target whose only step is a `shell` step (no typst-compile) is deferred for
/// MVP with a clear blocking diagnostic.
#[test]
fn shell_only_target_is_deferred() {
use cph_model::{Artifact, Info, Lesson, Project, Step, TargetConfig};
let lesson = Lesson {
project: Project {
id: "x".into(),
name: "x".into(),
},
info: Info {
title: "t".into(),
author: None,
},
parts: vec![],
targets: vec![TargetConfig {
name: "packaged".into(),
artifact: Artifact::SingleFile {
filepath: PathBuf::from("build/packaged.zip"),
},
steps: vec![Step::Shell {
run: "echo hi".into(),
}],
}],
root: fixture_root(),
};
let engine = Engine::with_render_dir(real_render_dir());
let diags = engine.compile_check(&lesson, "packaged");
assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}");
assert!(
diags[0].message.contains("no typst-compile step"),
"expected a no-typst-compile-step deferral: {diags:#?}"
);
}
@@ -0,0 +1,79 @@
// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011).
//
// 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
// manifest injected:
// typst compile --root <eng-root> --input manifest=<path-rel-to-root> exports/student.typ <out>
//
// It is intentionally self-contained (no shared helper import) so it can be
// copied verbatim into a new engineering file's `exports/`. Presentation —
// heading numbering — lives HERE (editable per engineering file), not in the
// manifest and not hardcoded in the cph-render package.
//
// WHY THE INCLUDE LOOP IS HERE AND NOT IN cph-render: typst resolves a dynamic
// `include` path relative to the file it lexically appears in, and a package has
// 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
// (this template lives under `--root`) resolves against `--root`. So the
// template loads content and hands cph-render an already-assembled `parts` array.
//
// OPEN CONTRACT POINT — optional-content presence. typst has no "does this file
// exist" primitive (a missing `include` is a hard compile error). So the
// template CANNOT probe disk the way the old Rust driver did for lemma `proof`.
// It relies on the manifest declaring which optional content fields are present,
// via a per-part `fields` array listing the content fields that exist on disk
// (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
// omits `fields`, optional content is skipped (conservative). The exact shape of
// this declaration is for the manifest/Rust contract to pin.
#import "@local/cph-render:0.1.0": render-lesson, part-fields, default-heading-numbering
// This template IS the student build, so the target is fixed.
#let target = "student"
// Read the injected manifest (a path string relative to typst --root).
#let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:))
#let raw-parts = manifest.at("parts", default: ())
// Assemble each part: include its content fields (computed absolute paths,
// 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.
#let parts = raw-parts.map(raw => {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ())
let part = (kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ")
}
// Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content {
if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ")
}
}
// Scalar fields come from <path>/element.toml.
if spec.scalars.len() > 0 {
let element = toml("/" + path + "/element.toml")
for field in spec.scalars {
let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) }
}
}
part
})
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
// from cph-render; override here per engineering file if desired.
#render-lesson(
info: info,
target: target,
parts: parts,
heading-numbering: default-heading-numbering,
)
@@ -0,0 +1,62 @@
// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011).
//
// Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with
// the manifest injected:
// typst compile --root <eng-root> --input manifest=<path-rel-to-root> exports/teacher.typ <out>
//
// Self-contained (copyable into an engineering file). Identical to student.typ
// except the fixed target is "teacher" (so the (kind x target) render matrix in
// cph-render shows solutions and proofs). See student.typ for the full notes on
// why the include loop lives here (package virtual-root resolution) and on the
// OPEN optional-content (`fields`) contract point.
#import "@local/cph-render:0.1.0": render-lesson, part-fields, default-heading-numbering
// This template IS the teacher build, so the target is fixed.
#let target = "teacher"
// Read the injected manifest (a path string relative to typst --root).
#let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:))
#let raw-parts = manifest.at("parts", default: ())
// Assemble each part: include its content fields (computed absolute paths,
// 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.
#let parts = raw-parts.map(raw => {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ())
let part = (kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ")
}
// Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content {
if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ")
}
}
// Scalar fields come from <path>/element.toml.
if spec.scalars.len() > 0 {
let element = toml("/" + path + "/element.toml")
for field in spec.scalars {
let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) }
}
}
part
})
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
// from cph-render; override here per engineering file if desired.
#render-lesson(
info: info,
target: target,
parts: parts,
heading-numbering: default-heading-numbering,
)
@@ -0,0 +1 @@
kind = "lemma"
@@ -0,0 +1 @@
$f$ 在闭区间上连续,则 $f$ 有最大值与最小值。
+15
View File
@@ -14,10 +14,25 @@ path = "segments/开场对照导言"
kind = "lemma"
path = "lemmas/量纲分析估计"
[[parts]]
kind = "lemma"
path = "lemmas/无证明引理"
[[parts]]
kind = "example"
path = "examples/自由落体"
# v2 target shape (ADR-0011): a typed artifact + an ordered list of typed steps.
# Each target's single `typst-compile` step names the template file to compile
# as main; the framework injects the augmented manifest into it.
[targets.student]
artifact = { type = "single-file", filepath = "build/student.pdf" }
[[targets.student.steps]]
type = "typst-compile"
template = "exports/student.typ"
[targets.teacher]
artifact = { type = "single-file", filepath = "build/teacher.pdf" }
[[targets.teacher.steps]]
type = "typst-compile"
template = "exports/teacher.typ"
-32
View File
@@ -1,32 +0,0 @@
// Minimal stand-in for the real `cph-render` package, used so cph-typst's
// World / compile / PDF path can be proven independently of WU-2.
//
// It exercises the same call shape as the real entry: it reads `info`, walks
// `parts` IN ORDER, and renders each part's content fields by `kind`. Content
// values are already-evaluated content (the driver produced them via include).
//
// `config` mirrors the real entry's ADR-0009 presentation-config parameter; the
// stub accepts it (the driver always passes `config:`) but ignores it.
#let display(info: (:), target: "student", parts: (), config: (:)) = {
set document(title: info.at("title", default: ""))
[= #info.at("title", default: "")]
[target: #target]
for p in parts {
let kind = p.at("kind", default: none)
[== #kind]
if kind == "segment" {
p.textbook
} else if kind == "lemma" {
p.stmt
let proof = p.at("proof", default: none)
if proof != none { proof }
} else if kind == "example" {
p.problem
p.solution
let src = p.at("source", default: none)
if src != none [来源:#src]
} else if kind == "sop" {
p.sop
}
}
}
@@ -1,7 +0,0 @@
[package]
name = "cph-render"
version = "0.1.0"
entrypoint = "lib.typ"
authors = ["cph-typst tests"]
license = "MIT"
description = "Minimal stand-in for cph-render used by cph-typst tests."