diff --git a/.gitignore b/.gitignore index 3acbb49..a28e9e8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,9 @@ **/*.pdf .cph/ +# Test-only @local package resolution convenience (self-referential symlink); +# regenerable, not for VCS. The embedded engine mounts cph-render directly. +render/vendor/local-packages/ + # OS / editor .DS_Store diff --git a/render/examples/smoke-config.typ b/render/examples/smoke-config.typ deleted file mode 100644 index 9847344..0000000 --- a/render/examples/smoke-config.typ +++ /dev/null @@ -1,14 +0,0 @@ -// Smoke test — exercises the per-target presentation `config` override path -// (ADR-0009). Here the engineering-file-supplied `config.numbering.heading` -// overrides the framework-default heading numbering. The patterns happen to -// match the correct per-level scheme (level-1 `一、`, level-2 `1.1`), proving -// the override is wired AND yields correct (NOT `二、一、`) output. -#import "../lib.typ": display -#import "smoke-parts.typ": info, parts - -#display( - info: info, - target: "teacher", - parts: parts, - config: (numbering: (heading: ("{1:一}、", "{1:1}.{2:1}"))), -) diff --git a/render/examples/smoke-eng/examples/坐标数量积/element.toml b/render/examples/smoke-eng/examples/坐标数量积/element.toml new file mode 100644 index 0000000..1c09cee --- /dev/null +++ b/render/examples/smoke-eng/examples/坐标数量积/element.toml @@ -0,0 +1 @@ +source = "2024 高考甲卷" diff --git a/render/examples/smoke-eng/examples/坐标数量积/problem.typ b/render/examples/smoke-eng/examples/坐标数量积/problem.typ new file mode 100644 index 0000000..b12b104 --- /dev/null +++ b/render/examples/smoke-eng/examples/坐标数量积/problem.typ @@ -0,0 +1 @@ +已知 $arrow(a) = (1, 2)$,$arrow(b) = (3, -1)$,求 $arrow(a) dot arrow(b)$。 diff --git a/render/examples/smoke-eng/examples/坐标数量积/solution.typ b/render/examples/smoke-eng/examples/坐标数量积/solution.typ new file mode 100644 index 0000000..8dfdfe8 --- /dev/null +++ b/render/examples/smoke-eng/examples/坐标数量积/solution.typ @@ -0,0 +1 @@ +由坐标公式,$arrow(a) dot arrow(b) = 1 times 3 + 2 times (-1) = 3 - 2 = 1$。 diff --git a/render/examples/smoke-eng/examples/求模长/element.toml b/render/examples/smoke-eng/examples/求模长/element.toml new file mode 100644 index 0000000..e4311ae --- /dev/null +++ b/render/examples/smoke-eng/examples/求模长/element.toml @@ -0,0 +1 @@ +# no source scalar — exercises the absent-scalar path diff --git a/render/examples/smoke-eng/examples/求模长/problem.typ b/render/examples/smoke-eng/examples/求模长/problem.typ new file mode 100644 index 0000000..1bfe87e --- /dev/null +++ b/render/examples/smoke-eng/examples/求模长/problem.typ @@ -0,0 +1 @@ +求向量 $arrow(a) = (3, 4)$ 的模长 $|arrow(a)|$。 diff --git a/render/examples/smoke-eng/examples/求模长/solution.typ b/render/examples/smoke-eng/examples/求模长/solution.typ new file mode 100644 index 0000000..87e8a5f --- /dev/null +++ b/render/examples/smoke-eng/examples/求模长/solution.typ @@ -0,0 +1 @@ +$|arrow(a)| = sqrt(3^2 + 4^2) = sqrt(25) = 5$。 diff --git a/render/examples/smoke-eng/exports/student.typ b/render/examples/smoke-eng/exports/student.typ new file mode 100644 index 0000000..a33d25d --- /dev/null +++ b/render/examples/smoke-eng/exports/student.typ @@ -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 --input manifest= exports/student.typ +// +// 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 `//.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 /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: /.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 /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, +) diff --git a/render/examples/smoke-eng/exports/teacher.typ b/render/examples/smoke-eng/exports/teacher.typ new file mode 100644 index 0000000..b976b1f --- /dev/null +++ b/render/examples/smoke-eng/exports/teacher.typ @@ -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 --input manifest= exports/teacher.typ +// +// 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 /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: /.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 /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, +) diff --git a/render/examples/smoke-eng/lemmas/垂直判据/element.toml b/render/examples/smoke-eng/lemmas/垂直判据/element.toml new file mode 100644 index 0000000..73f7af4 --- /dev/null +++ b/render/examples/smoke-eng/lemmas/垂直判据/element.toml @@ -0,0 +1 @@ +# lemma WITHOUT proof — proof.typ is absent on disk; manifest omits it from fields diff --git a/render/examples/smoke-eng/lemmas/垂直判据/stmt.typ b/render/examples/smoke-eng/lemmas/垂直判据/stmt.typ new file mode 100644 index 0000000..c5f0534 --- /dev/null +++ b/render/examples/smoke-eng/lemmas/垂直判据/stmt.typ @@ -0,0 +1 @@ +两个非零向量垂直当且仅当其数量积为零,即 $arrow(a) perp arrow(b) <==> arrow(a) dot arrow(b) = 0$。 diff --git a/render/examples/smoke-eng/lemmas/柯西不等式/element.toml b/render/examples/smoke-eng/lemmas/柯西不等式/element.toml new file mode 100644 index 0000000..5b79235 --- /dev/null +++ b/render/examples/smoke-eng/lemmas/柯西不等式/element.toml @@ -0,0 +1 @@ +# lemma scalars: none diff --git a/render/examples/smoke-eng/lemmas/柯西不等式/proof.typ b/render/examples/smoke-eng/lemmas/柯西不等式/proof.typ new file mode 100644 index 0000000..7248a39 --- /dev/null +++ b/render/examples/smoke-eng/lemmas/柯西不等式/proof.typ @@ -0,0 +1,3 @@ +由数量积定义 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$, +又 $|cos theta| <= 1$,故 $|arrow(a) dot arrow(b)| = |arrow(a)| |arrow(b)| |cos theta| <= |arrow(a)| |arrow(b)|$。 +$qed$ diff --git a/render/examples/smoke-eng/lemmas/柯西不等式/stmt.typ b/render/examples/smoke-eng/lemmas/柯西不等式/stmt.typ new file mode 100644 index 0000000..b397fda --- /dev/null +++ b/render/examples/smoke-eng/lemmas/柯西不等式/stmt.typ @@ -0,0 +1 @@ +对任意向量 $arrow(a)$、$arrow(b)$,有 $|arrow(a) dot arrow(b)| <= |arrow(a)| |arrow(b)|$。 diff --git a/render/examples/smoke-eng/manifest.toml b/render/examples/smoke-eng/manifest.toml new file mode 100644 index 0000000..bb64c9d --- /dev/null +++ b/render/examples/smoke-eng/manifest.toml @@ -0,0 +1,64 @@ +# Throwaway smoke engineering file for the cph-render template round (ADR-0011). +# Exercises all 4 kinds + nested headings (per-level numbering) + an example with +# a `source` scalar and one without + a lemma with proof and one without. +# +# The per-part `fields` array lists the content fields present ON DISK. The +# template uses it to decide whether to load OPTIONAL content (lemma `proof`), +# because typst has no file-exists primitive. (OPEN: the exact manifest shape for +# declaring optional-field presence is for the Rust/manifest contract to pin.) + +[project] +id = "smoke-eng" +name = "cph-render template smoke" + +[info] +title = "向量与几何 · 示例讲义" +author = ["张老师", "李老师"] + +# segment — nested headings exercise per-level numbering (一、 / 1.1 / 1.1.1). +[[parts]] +kind = "segment" +path = "segments/向量数量积" +fields = ["textbook"] + +# example WITH source scalar +[[parts]] +kind = "example" +path = "examples/坐标数量积" +fields = ["problem", "solution"] + +# example WITHOUT source scalar +[[parts]] +kind = "example" +path = "examples/求模长" +fields = ["problem", "solution"] + +# lemma WITH proof (proof.typ present on disk; declared in fields) +[[parts]] +kind = "lemma" +path = "lemmas/柯西不等式" +fields = ["stmt", "proof"] + +# lemma WITHOUT proof (proof.typ absent; fields omits it) +[[parts]] +kind = "lemma" +path = "lemmas/垂直判据" +fields = ["stmt"] + +# sop +[[parts]] +kind = "sop" +path = "sops/求夹角步骤" +fields = ["sop"] + +[targets.student] +artifact = "single-file" +[[targets.student.steps]] +type = "typst-compile" +template = "exports/student.typ" + +[targets.teacher] +artifact = "single-file" +[[targets.teacher.steps]] +type = "typst-compile" +template = "exports/teacher.typ" diff --git a/render/examples/smoke-eng/segments/向量数量积/textbook.typ b/render/examples/smoke-eng/segments/向量数量积/textbook.typ new file mode 100644 index 0000000..9ab574b --- /dev/null +++ b/render/examples/smoke-eng/segments/向量数量积/textbook.typ @@ -0,0 +1,14 @@ += 平面向量的数量积 + +本节研究平面向量的基本运算。设 $arrow(a)$、$arrow(b)$ 为平面内两个向量, +其数量积定义为 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$, +其中 $theta$ 为两向量的夹角。 + +== 坐标表示 + +在直角坐标系下,若 $arrow(a) = (x_1, y_1)$、$arrow(b) = (x_2, y_2)$,则 +$arrow(a) dot arrow(b) = x_1 x_2 + y_1 y_2$。 + +== 几何意义 + +数量积等于一个向量的模与另一向量在其方向上投影之积。 diff --git a/render/examples/smoke-eng/sops/求夹角步骤/element.toml b/render/examples/smoke-eng/sops/求夹角步骤/element.toml new file mode 100644 index 0000000..5d987cc --- /dev/null +++ b/render/examples/smoke-eng/sops/求夹角步骤/element.toml @@ -0,0 +1 @@ +# sop scalars: none diff --git a/render/examples/smoke-eng/sops/求夹角步骤/sop.typ b/render/examples/smoke-eng/sops/求夹角步骤/sop.typ new file mode 100644 index 0000000..2c9b75a --- /dev/null +++ b/render/examples/smoke-eng/sops/求夹角步骤/sop.typ @@ -0,0 +1,4 @@ +求两向量夹角的标准步骤: ++ 计算数量积 $arrow(a) dot arrow(b)$; ++ 分别计算模长 $|arrow(a)|$、$|arrow(b)|$; ++ 代入 $cos theta = (arrow(a) dot arrow(b)) / (|arrow(a)| |arrow(b)|)$ 求出 $theta$。 diff --git a/render/examples/smoke-parts.typ b/render/examples/smoke-parts.typ deleted file mode 100644 index 2a118c1..0000000 --- a/render/examples/smoke-parts.typ +++ /dev/null @@ -1,83 +0,0 @@ -// Shared hand-written sample lesson exercising all 4 kinds. -// Content field values are plain content blocks — exactly what the Rust driver -// would hand us via `include`. - -#let info = ( - title: "向量与几何 · 示例讲义", - author: ("张老师", "李老师"), -) - -#let parts = ( - // segment — with NESTED headings so per-level numbering is visible: - // level-1 `=` should render `一、`, level-2 `==` should render `1.1` - // (NOT the old buggy `二、一、`). - ( - kind: "segment", - textbook: [ - = 平面向量的数量积 - - 本节研究平面向量的基本运算。设 $arrow(a)$、$arrow(b)$ 为平面内两个向量, - 其数量积定义为 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$, - 其中 $theta$ 为两向量的夹角。 - - == 坐标表示 - - 在直角坐标系下,若 $arrow(a) = (x_1, y_1)$、$arrow(b) = (x_2, y_2)$,则 - $arrow(a) dot arrow(b) = x_1 x_2 + y_1 y_2$。 - - == 几何意义 - - 数量积等于一个向量的模与另一向量在其方向上投影之积。 - ], - ), - // example WITH source - ( - kind: "example", - source: "2024 高考甲卷", - problem: [ - 已知 $arrow(a) = (1, 2)$,$arrow(b) = (3, -1)$,求 $arrow(a) dot arrow(b)$。 - ], - solution: [ - 由坐标公式,$arrow(a) dot arrow(b) = 1 times 3 + 2 times (-1) = 3 - 2 = 1$。 - ], - ), - // example WITHOUT source - ( - kind: "example", - problem: [ - 求向量 $arrow(a) = (3, 4)$ 的模长 $|arrow(a)|$。 - ], - solution: [ - $|arrow(a)| = sqrt(3^2 + 4^2) = sqrt(25) = 5$。 - ], - ), - // lemma WITH proof - ( - kind: "lemma", - stmt: [ - 对任意向量 $arrow(a)$、$arrow(b)$,有 $|arrow(a) dot arrow(b)| <= |arrow(a)| |arrow(b)|$。 - ], - proof: [ - 由数量积定义 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$, - 又 $|cos theta| <= 1$,故 $|arrow(a) dot arrow(b)| = |arrow(a)| |arrow(b)| |cos theta| <= |arrow(a)| |arrow(b)|$。 - $qed$ - ], - ), - // lemma WITHOUT proof (proof key omitted entirely) - ( - kind: "lemma", - stmt: [ - 两个非零向量垂直当且仅当其数量积为零,即 $arrow(a) perp arrow(b) <==> arrow(a) dot arrow(b) = 0$。 - ], - ), - // sop - ( - kind: "sop", - sop: [ - 求两向量夹角的标准步骤: - + 计算数量积 $arrow(a) dot arrow(b)$; - + 分别计算模长 $|arrow(a)|$、$|arrow(b)|$; - + 代入 $cos theta = (arrow(a) dot arrow(b)) / (|arrow(a)| |arrow(b)|)$ 求出 $theta$。 - ], - ), -) diff --git a/render/examples/smoke-student.typ b/render/examples/smoke-student.typ deleted file mode 100644 index 37c6d68..0000000 --- a/render/examples/smoke-student.typ +++ /dev/null @@ -1,6 +0,0 @@ -// Smoke test — student target. Imports the package by RELATIVE path so it -// compiles in place (no @local install needed). -#import "../lib.typ": display -#import "smoke-parts.typ": info, parts - -#display(info: info, target: "student", parts: parts) diff --git a/render/examples/smoke-teacher.typ b/render/examples/smoke-teacher.typ deleted file mode 100644 index 47daac4..0000000 --- a/render/examples/smoke-teacher.typ +++ /dev/null @@ -1,5 +0,0 @@ -// Smoke test — teacher target. Imports the package by RELATIVE path. -#import "../lib.typ": display -#import "smoke-parts.typ": info, parts - -#display(info: info, target: "teacher", parts: parts) diff --git a/render/lib.typ b/render/lib.typ index ac45b1d..d809220 100644 --- a/render/lib.typ +++ b/render/lib.typ @@ -1,21 +1,40 @@ // cph-render — curriculum lesson render package. // -// Single public entry: `display(info, target, parts)`. +// PUBLIC ENTRY: `render-lesson(info, target, parts, heading-numbering)`. // -// A lesson is an ORDERED array of typed parts. Each part is a dict with a -// `kind` plus that kind's content/scalar fields. Content field VALUES are -// already-evaluated typst content (the driver produces them via `include`) — -// never modules or strings; we render them directly. +// MODEL (ADR-0011). A build compiles a *template file* (e.g. `exports/student.typ`) +// as the typst main, with the manifest injected via `--input manifest=`. +// The template reads the manifest, loads each part's content, and calls +// `render-lesson` here. Presentation (heading numbering) lives in the template, +// not the manifest, not hardcoded-unreachable in this package — we only provide +// the DEFAULT scheme (`default-heading-numbering`) for the template to use or +// override. // -// kind -> fields (MVP): +// WHY THE TEMPLATE LOADS CONTENT, NOT US (the include-resolution finding): +// typst resolves an `include`/`import` path relative to THE PACKAGE/FILE THE +// include LEXICALLY APPEARS IN, and a package (`@local/cph-render`) has its OWN +// virtual root. A dynamic `include` written inside this file resolves against +// the PACKAGE root — even an absolute `/segments/x.typ` lands in the package +// dir, NOT the engineering-file root (`--root`). Verified empirically. +// Therefore the dynamic-include LOOP must live in the TEMPLATE (which lives at +// `/exports/*.typ`, so `//.typ` resolves against +// `--root`). `render-lesson` is content-in: it takes an ALREADY-ASSEMBLED +// `parts` array of dicts and never includes anything itself. +// +// A part dict the template hands us: +// `kind` plus that kind's content/scalar fields. Content field VALUES are +// already-evaluated typst content (the template produced them via `include`). +// +// kind -> fields (MVP) — see `part-fields` below, which the template uses to +// know what to load: // segment : textbook (content, required) // example : problem (content, required), solution (content, required), -// source (string, optional) +// source (string scalar, optional, from /element.toml) // lemma : stmt (content, required), proof (content, optional) // sop : sop (content, required) // // target -> show/hide (the (kind x target) render matrix; defaults per -// ADR-0005/0008, derived internally — caller passes no flags): +// ADR-0005/0008, derived internally — template passes only the target string): // student : example problem only (hide solution); lemma stmt only (hide // proof); segment textbook; sop shown. // teacher : everything shown. @@ -30,6 +49,23 @@ #import "src/elements/sop.typ": display-sop #import "src/elements/common.typ": example-counter, lemma-counter +/// Per-kind field manifest, exported so a TEMPLATE knows exactly what to load +/// for each part without hardcoding the matrix. For each kind: +/// `content`: field names whose `/.typ` the template must +/// `include` (assembling them into the part dict). +/// `optional-content`: content fields that may be absent (template should +/// probe before including; absence is fine). +/// `scalars`: scalar field names the template reads from `/element.toml` +/// (currently only example `source`). +/// Keeping this here (not in the template) keeps kind->fields a single source of +/// truth in the package; the template iterates it generically. +#let part-fields = ( + segment: (content: ("textbook",), optional-content: (), scalars: ()), + example: (content: ("problem", "solution"), optional-content: (), scalars: ("source",)), + lemma: (content: ("stmt",), optional-content: ("proof",), scalars: ()), + sop: (content: ("sop",), optional-content: (), scalars: ()), +) + /// Resolve a target string to the set of show/hide booleans. /// Unknown targets fall back to the conservative (student-like) profile. #let _flags-for(target) = { @@ -70,32 +106,28 @@ } } -/// THE ENTRY POINT. +/// THE ENTRY POINT — called by a template (`exports/*.typ`). /// /// - `info`: dict, e.g. (title: "…", author: "…"). `author` may be absent. +/// Templates typically pass `manifest.at("info", default: (:))`. /// - `target`: string. MVP: "student" | "teacher". Unknown => conservative. -/// - `parts`: ordered array of part dicts (see file header). -/// - `config`: dict carrying the target's build/presentation overrides -/// (ADR-0009). Open/forward-compatible — read keys with -/// `.at(.., default: ..)`. Recognised keys (MVP): -/// `config.numbering.heading`: array of per-level numbly pattern -/// strings, e.g. `("{1:一}、", "{1:1}.{2:1}")`. Optional; when -/// absent the framework default (correct per-level scheme) is -/// used. Future presentation knobs slot in without touching -/// this signature. -#let display(info: (:), target: "student", parts: (), config: (:)) = { +/// Each template hardcodes its own target (student.typ => "student"). +/// - `parts`: ordered array of part dicts, ALREADY ASSEMBLED by the template +/// (content fields included, scalars read). See file header. +/// - `heading-numbering`: array of per-level numbly pattern strings, e.g. +/// `("{1:一}、", "{1:1}.{2:1}", "{1:1}.{2:1}.{3:1}")`. Presentation +/// lives in the template (ADR-0011); the template passes its chosen +/// scheme. Defaults to `default-heading-numbering` (the framework +/// default) when the template omits it. +#let render-lesson( + info: (:), + target: "student", + parts: (), + heading-numbering: default-heading-numbering, +) = { let title = info.at("title", default: []) let author = info.at("author", default: none) - // Resolve presentation config -> framework defaults, file may override. - // `config.numbering.heading` (array of numbly patterns) overrides the - // framework default per-level numbering when present. - let numbering-cfg = config.at("numbering", default: (:)) - let heading-numbering = numbering-cfg.at( - "heading", - default: default-heading-numbering, - ) - // `document` author wants a string/array; normalise the optional field. set document( title: title, diff --git a/render/templates/README.md b/render/templates/README.md new file mode 100644 index 0000000..c4cbd59 --- /dev/null +++ b/render/templates/README.md @@ -0,0 +1,63 @@ +# Default export templates (ADR-0011) + +`student.typ` / `teacher.typ` are the **framework default templates**. In a real +engineering file they live at `exports/student.typ` / `exports/teacher.typ`; the +project-creation flow seeds copies there (out of scope here — see ADR-0011 Open +Questions). They are kept here as the canonical, copyable defaults and for the +offline smoke test below. + +Each template: + +1. reads the injected manifest: `toml(sys.inputs.manifest)`; +2. loops `manifest.parts`, `include`-ing each content field via a computed + **root-relative absolute** path `//.typ`, and reading scalar + fields (example `source`) from `//element.toml`; +3. assembles a `parts` array and calls `cph-render`'s `render-lesson(...)`, + passing the per-level heading numbering (presentation lives in the template). + +## Why the include loop is in the template, not in cph-render + +typst resolves a dynamic `include`/`toml` path relative to **the file the call +lexically appears in**, and a package (`@local/cph-render`) has its **own virtual +root**. A dynamic `include` written inside the package resolves against the +*package* dir — even an absolute `/segments/x.typ` — never the engineering +`--root`. Verified empirically. The template lives under `--root`, so its +`//.typ` resolves against `--root`. Hence the template loads +content and hands `render-lesson` an already-assembled `parts` array; +`render-lesson` never includes anything. + +## Key path facts for the engine + +- **Template is the typst main file.** Compile it directly. +- **`--root` = engineering-file root.** Content `include`s use absolute + root-relative paths. +- **Manifest is injected as a root-relative ABSOLUTE path**: + `--input manifest=/manifest.toml`. `toml(sys.inputs.manifest)` resolves + relative to the *template's* location (`exports/`), so a bare + `manifest=manifest.toml` would look in `exports/`. Pass the leading `/`. +- **Optional content presence** (lemma `proof`): typst has no file-exists + primitive, so the template cannot probe disk. It reads a per-part `fields` + array from the manifest listing the content fields present on disk. *(OPEN: the + exact manifest shape for this is for the Rust/manifest contract to pin.)* + +## Offline smoke test + +`@local/cph-render` is resolved from a local-packages dir (a symlink to the +render package root — regenerable, test-only): + +```sh +cd render +# one-time: make @local/cph-render resolvable offline +mkdir -p vendor/local-packages/local/cph-render +ln -sfn "$(pwd)" vendor/local-packages/local/cph-render/0.1.0 + +# compile a template against the smoke engineering file +typst compile --root examples/smoke-eng \ + --package-path ./vendor/local-packages \ + --package-cache-path ./vendor/typst-packages \ + --input manifest=/manifest.toml \ + examples/smoke-eng/exports/student.typ /tmp/cph-tmpl-student.pdf +``` + +(`examples/smoke-eng/exports/{student,teacher}.typ` are copies of the defaults +here, mirroring how a real engineering file carries its own templates.) diff --git a/render/templates/student.typ b/render/templates/student.typ new file mode 100644 index 0000000..a33d25d --- /dev/null +++ b/render/templates/student.typ @@ -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 --input manifest= exports/student.typ +// +// 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 `//.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 /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: /.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 /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, +) diff --git a/render/templates/teacher.typ b/render/templates/teacher.typ new file mode 100644 index 0000000..b976b1f --- /dev/null +++ b/render/templates/teacher.typ @@ -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 --input manifest= exports/teacher.typ +// +// 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 /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: /.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 /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, +) diff --git a/render/typst.toml b/render/typst.toml index b45f153..07429f9 100644 --- a/render/typst.toml +++ b/render/typst.toml @@ -5,7 +5,7 @@ entrypoint = "lib.typ" compiler = "0.15.0" authors = ["curriculum-project-hub"] license = "MIT" -description = "Curriculum lesson render package: single `display` entry over an ordered list of typed parts (segment/example/lemma/sop), targeting student/teacher handouts." +description = "Curriculum lesson render package: `render-lesson` entry over an ordered list of typed parts (segment/example/lemma/sop), called by export templates (ADR-0011), targeting student/teacher handouts." # External @preview dependencies (resolved via the typst package registry): # @preview/numbly:0.1.0 — per-level heading numbering (src/style.typ).