chore: Merge spec-driven into main

main 与 spec-driven 无共同祖先(orphan)。本 merge commit 的 tree 取 spec-driven
的全部内容(完全以 spec-driven 为准),main 原有的 seed 文件不再保留。此后在 main
上准备发版本。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:56:37 +08:00
239 changed files with 13019 additions and 2338 deletions
+53
View File
@@ -0,0 +1,53 @@
name: checker check
# Builds and lints the Rust implementation crates under crates/ (the rule-based
# checker that "stands in Lean's position" at product runtime).
#
# Like spec-check, this is an INTERNAL gate on the implementation's own health
# (does it build, pass its tests, satisfy clippy + rustfmt?). It is NOT a
# spec-to-implementation conformance gate — implementations align to the Lean
# contract by human review, not by CI. See the repo README.
on:
push:
pull_request:
workflow_dispatch:
jobs:
checker-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain (stable + clippy + rustfmt)
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Cache cargo registry + build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-${{ runner.os }}-
# Fonts for typst compilation (WU-4 onward): the engineering files are
# CJK-heavy, so the compile-check / build tests need a CJK font present.
# Harmless before those crates exist.
- name: Install CJK fonts
run: |
sudo apt-get update
sudo apt-get install -y fonts-noto-cjk
- name: Format check
run: cargo fmt --all --check
- name: Clippy (deny warnings)
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Test
run: cargo test --all-features --workspace
+20
View File
@@ -0,0 +1,20 @@
name: spec check
# Builds the Lean semantic master spec under spec/.
# This is an INTERNAL well-formedness gate (does the contract type-check?),
# NOT a spec-to-implementation conformance gate — implementations align to the
# contract by human review, not by CI. See repo README.
on:
push:
pull_request:
workflow_dispatch:
jobs:
spec-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: leanprover/lean-action@v1
with:
lake-package-directory: spec
+14 -2
View File
@@ -1,3 +1,15 @@
node_modules/
dist/
# Lean / Lake build artifacts (spec/ has its own .gitignore too)
.lake/
**/.lake/
# Rust / Cargo build artifacts (repo-wide cargo workspace at root)
/target
**/*.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
+20
View File
@@ -0,0 +1,20 @@
# CLAUDE.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
Generated
+3291
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
[workspace]
resolver = "2"
members = [
"crates/cph-diag",
"crates/cph-model",
"crates/cph-schema",
"crates/cph-typst",
"crates/cph-check",
"crates/cph-cli",
]
# Shared metadata for all workspace crates. Individual crates inherit these via
# `version.workspace = true` / `edition.workspace = true`.
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
toml = "0.8"
cph-diag = { path = "crates/cph-diag" }
cph-model = { path = "crates/cph-model" }
+41 -26
View File
@@ -1,35 +1,50 @@
# Curriculum Project Hub
# curriculum-project-hub
教研项目工作台的架构仓库
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**
当前阶段先用 LikeC4 把产品边界、系统职责、核心领域模型和关键运行时流程敲定,再进入实现。
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。**
## Architecture
## 仓库布局
LikeC4 model:
- [docs/architecture/likec4/specification.c4](/Users/jiarong/local/paradigm/curriculum-project-hub/docs/architecture/likec4/specification.c4)
- [docs/architecture/likec4/model.c4](/Users/jiarong/local/paradigm/curriculum-project-hub/docs/architecture/likec4/model.c4)
- [docs/architecture/likec4/views.c4](/Users/jiarong/local/paradigm/curriculum-project-hub/docs/architecture/likec4/views.c4)
Commands:
```sh
npm install
npm run dev
npm run validate
npm run build
```
README.md ← 本文件:总览 + 宪法(下面 5 条)
CLAUDE.md ← 全局 agent 操作手册(管整个 repo)
docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用)
spec/ ← Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md
Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate)
crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md
cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎)
cph-check / cph-cli ← checker 本体 + `cph` 命令行
render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
(hub/ exporter/ …) ← 将来的其他部件,平级于 spec/。尚未创建
```
Key views to review first:
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
未来部件(如 exporter)复用,而非各自重造。
- `index`: system context
- `containers`: target containers and external systems
- `permissions`: Feishu Docs-like permission model
- `domain_model`: Project / ProjectGroupChat / AgentSession / AgentRun / ProjectAgentLock
- `run_lifecycle`: `@Claude` creates a run and holds a project lock
- `context_access`: Claude reads Feishu history on demand through MCP
## 宪法
## Decisions
这 5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。
Architecture decisions live in [docs/architecture/adr](/Users/jiarong/local/paradigm/curriculum-project-hub/docs/architecture/adr).
1. **角色 —— Lean 是研发侧的上游参照。**
`spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来。
2. **对齐机制 —— Lean 只做上游参照。**
不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
3. **资产性 —— 由 review 纪律承载,无机器兜底。**
这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档。
4. **形态 —— 它是人机共识的契约。**
契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞。
5. **深度判据 —— 只收录分歧点。**
一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
## CI
`.gitea/workflows/spec-check.yml` 在每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。
+34
View File
@@ -0,0 +1,34 @@
# crates/
These crates implement the rule-based lesson checker that aligns to the
semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005)
laid out per ADR-0008 (declarative `manifest.toml` + per-element
`element.toml`), validates structure and content, and emits diagnostics.
`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader),
and `cph-typst` (the typst `World` / compile / span-mapping layer) are
deliberately reusable by future components such as an `exporter`, which is why
they live in this repo-wide `crates/` directory rather than under any single
component; `cph-schema` (kind JSON Schemas + validation), `cph-check`
(orchestration + render-coverage) and `cph-cli` (the `cph` command-line
entrypoint) are the checker proper.
## Crate map
| crate | owner | role |
|---------------|-------|------|
| `cph-diag` | WU-1 | shared diagnostic vocabulary (`Severity`, `DiagCode`, `Diagnostic`, `SourceSpan`) — reusable |
| `cph-model` | WU-1 | parses the ADR-0008 layout into an in-memory ordered `Lesson` — reusable |
| `cph-schema` | WU-3 | the 4 stdlib kind JSON Schemas + structural validation |
| `cph-typst` | WU-4 | typst `World`, driver generation, compile, PDF, span mapping — reusable |
| `cph-check` | WU-5 | orchestration: render-coverage and the full check pipeline |
| `cph-cli` | WU-5 | the `cph` command-line entrypoint |
## Build
```sh
# from the repository root (the cargo workspace root)
cargo build # all crates
cargo test # cph-diag + cph-model unit/integration tests
cargo clippy --all-targets -- -D warnings
cargo fmt --check
```
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "cph-check"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
cph-diag = { workspace = true }
cph-model = { workspace = true }
cph-schema = { path = "../cph-schema" }
cph-typst = { path = "../cph-typst" }
+912
View File
@@ -0,0 +1,912 @@
//! `cph-check` — check-pipeline orchestration.
//!
//! Owned by **WU-5**. Ties together `cph-model` (load), `cph-schema`
//! (validate), and `cph-typst` (compile), runs the render-coverage rule
//! (ADR-0005's "missing render ⇒ warning"), and collects all diagnostics.
//!
//! The orchestrator owns *sequencing and gating*, not the individual checks:
//! each phase is implemented by an upstream crate, and `cph-check` decides the
//! order, what gates what, and how the results are merged / deduped. See
//! [`check`] for the full pipeline and [`build`] for the export path.
use std::path::Path;
use cph_diag::{DiagCode, Diagnostic, Severity};
use cph_typst::Engine;
/// The default render target when a lesson declares no `[targets.*]` at all.
const DEFAULT_TARGET: &str = "student";
/// Severity of the render-coverage ("element ignored under a target") diagnostic.
///
/// **PINNED to `warning` by the contract.** Mirrors the Lean master's
/// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning`
/// (`spec/Spec/Courseware/Check/Diagnostic.lean`), itself citing ADR-0005: when a
/// `(kind, target)` pair has no render rule the checker reports that the element
/// is ignored under that target and **does not block the export**. Naming the
/// severity as a const makes "it is a warning, not an error" a greppable,
/// alignable fact rather than an inline literal.
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
/// The result of running [`check`] (or the check phases of [`build`]).
#[derive(Debug, Clone, PartialEq)]
pub struct CheckReport {
/// Every diagnostic collected across all phases, deduplicated.
pub diagnostics: Vec<Diagnostic>,
/// `false` if `cph_model::load` returned `None` (a hard manifest failure
/// that prevents any further phase from running).
pub lesson_loaded: bool,
}
impl CheckReport {
/// How many collected diagnostics are `Error`-severity.
pub fn error_count(&self) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.count()
}
/// How many collected diagnostics are `Warning`-severity.
pub fn warning_count(&self) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == Severity::Warning)
.count()
}
/// Whether any collected diagnostic is `Error`-severity.
///
/// **Legality decision (spec alignment).** `!has_errors()` is the
/// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`):
/// a lesson is *legal* iff its diagnostics contain no error-level diagnostic
/// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI
/// gate enforcing this alignment (repo constitution); it is kept greppable
/// here so a reviewer can tie the orchestrator's gate to the Lean master.
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
.any(|d| d.severity == Severity::Error)
}
}
/// Run the full check pipeline against the engineering file at `root`.
///
/// Phases, in order, with gating:
/// - **(a) load** — `cph_model::load`. Its diagnostics are collected. On a hard
/// failure (`None`) the report is returned immediately with
/// `lesson_loaded == false`; nothing downstream can run.
/// - **(b) structural** — for each part, check `part.kind` is in
/// `cph_schema::known_kinds()`; an unknown kind is an `UnknownKind` error.
/// (cph-model already checked path existence, `..` traversal, and
/// part/element kind *mismatch*; this complements that with the *known-set*
/// check and does not duplicate it.) Parts whose folder cph-model already
/// flagged as missing are skipped for the rest of the pipeline.
/// - **(c) schema** — for each part whose folder exists and whose kind is
/// known, `cph_schema::validate(&part.descriptor)`.
/// - **(d) typst compile** — only if phases (a)(c) produced **zero** errors
/// (otherwise the compile is noise on known-broken input). Compiles each
/// declared `lesson.targets` (defaulting to `"student"` if none), deduping
/// identical diagnostics across targets.
/// - **(e) render-coverage** — a `warning` (never an error) for each
/// `(kind, target)` the target's `covers` declaration does not include. Runs
/// regardless of (d) gating.
///
/// The engine is taken as a parameter so the caller owns it (and tests can
/// inject `Engine::with_render_dir`).
pub fn check(root: &Path, engine: &Engine) -> CheckReport {
let mut diags = Vec::new();
// (a) load.
let (lesson, load_diags) = cph_model::load(root);
diags.extend(load_diags);
let Some(lesson) = lesson else {
// Hard manifest failure: nothing else can run.
return CheckReport {
diagnostics: dedup(diags),
lesson_loaded: false,
};
};
let known = cph_schema::known_kinds();
// (b) structural + (c) schema.
run_structural_and_schema(&lesson, known, &mut diags);
// (d) typst compile — gated on zero errors from (a)(c).
let pre_compile_has_error = diags.iter().any(|d| d.severity == Severity::Error);
if !pre_compile_has_error {
for target in compile_targets(&lesson) {
diags.extend(engine.compile_check(&lesson, target));
}
}
// (e) render-coverage — always runs; only a warning. Skips parts already
// broken in (b) (missing folder / unknown kind).
diags.extend(render_coverage(&lesson, known));
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
}
}
/// Build a PDF for `target`.
///
/// Runs the check phases **(a)(c)** (load → structural → schema). If those
/// produce any `Error`, the build is refused: returns `(None, report)` with the
/// blocking diagnostics. Otherwise it compiles + exports the PDF for `target`
/// via `engine.build_pdf`; on a clean compile returns `(Some(bytes), report)`,
/// and on a typst error returns `(None, report)` with the compile diagnostics
/// folded into the report.
///
/// Note `build` does **not** run render-coverage (phase (e)): coverage warnings
/// describe export *loss*, which is informational for a `check`, not a gate on
/// producing a single target's PDF.
pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, CheckReport) {
let mut diags = Vec::new();
// (a) load.
let (lesson, load_diags) = cph_model::load(root);
diags.extend(load_diags);
let Some(lesson) = lesson else {
return (
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: false,
},
);
};
let known = cph_schema::known_kinds();
// (b) structural + (c) schema.
run_structural_and_schema(&lesson, known, &mut diags);
if diags.iter().any(|d| d.severity == Severity::Error) {
return (
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
);
}
// Phases passed: compile + export the requested target.
match engine.build_pdf(&lesson, 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,
},
)
}
}
}
/// One shell step's execution outcome (for [`run_shell_target`]).
#[derive(Debug, Clone, PartialEq)]
pub struct ShellStepOutcome {
/// The command line that was run (verbatim from the manifest `run` field).
pub run: String,
/// Process exit code (`None` if the process was killed by a signal).
pub status: Option<i32>,
/// Captured stdout.
pub stdout: String,
/// Captured stderr.
pub stderr: String,
}
impl ShellStepOutcome {
/// Whether the step exited successfully (status 0).
pub fn ok(&self) -> bool {
self.status == Some(0)
}
}
/// The result of [`run_shell_target`].
#[derive(Debug, Clone, PartialEq)]
pub struct ShellRunReport {
/// The check phases' report (load → structural → schema). Shell steps run
/// only when this has no errors.
pub check: CheckReport,
/// Each shell step's outcome, in declared order. Empty if the build was
/// refused (check errors, unknown target, or the target has no shell steps).
pub outcomes: Vec<ShellStepOutcome>,
/// `true` if the target was found, check passed, and every shell step exited 0.
pub ok: bool,
}
/// Execute the `Shell` steps of a `file-tree`/tool-generated target (ADR-0009
/// category (b); e.g. the KenKen interactives produced by the `kendoku` CLI).
///
/// This is the **escape-hatch executor**: unlike [`build`] (which compiles a
/// typst template to a PDF), a shell target hands off to an external tool that
/// writes files itself. cph only *runs the declared command* in the engineering
/// root and reports what happened — it assembles nothing.
///
/// Contract / safety:
/// - Runs the check phases first (load → structural → schema). Any error refuses
/// the run (`ok == false`, no command executed) — a shell build of a broken
/// lesson is not attempted.
/// - Each `Step::Shell { run }` is executed via the platform shell
/// (`sh -c <run>` / `cmd /C <run>`) with the **engineering root as the working
/// directory**, so a manifest can use root-relative paths.
/// - **Arbitrary command execution is opt-in by construction**: this function is
/// only reached from `cph build --target <name>`, never from `check`. Callers
/// (the CLI) surface the command before running it.
/// - `TypstCompile` steps inside the same target are skipped here (a shell target
/// is not a typst build); a target with no shell steps yields `ok == false`.
pub fn run_shell_target(root: &Path, engine: &Engine, target: &str) -> ShellRunReport {
let mut diags = Vec::new();
let (lesson, load_diags) = cph_model::load(root);
diags.extend(load_diags);
let Some(lesson) = lesson else {
return ShellRunReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: false,
},
outcomes: Vec::new(),
ok: false,
};
};
let known = cph_schema::known_kinds();
run_structural_and_schema(&lesson, known, &mut diags);
// Suppress the unused-parameter warning while keeping the signature uniform
// with `build`/`check` (the engine is not needed to run shell steps, but
// callers pass it so the API is consistent and future steps may use it).
let _ = engine;
let has_error = diags.iter().any(|d| d.severity == Severity::Error);
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{target}' not declared in manifest"),
)
.with_hint(format!(
"add a `[targets.{target}]` table, or run a declared target"
)),
);
return ShellRunReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
outcomes: Vec::new(),
ok: false,
};
};
let shell_steps: Vec<&str> = tc
.steps
.iter()
.filter_map(|s| match s {
cph_model::Step::Shell { run } => Some(run.as_str()),
cph_model::Step::TypstCompile { .. } => None,
cph_model::Step::AssembleMarkdown { .. } => None,
})
.collect();
if has_error || shell_steps.is_empty() {
return ShellRunReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
outcomes: Vec::new(),
ok: false,
};
}
// Run each shell step in the engineering root. First non-zero exit stops the
// sequence (a failed step's successors likely depend on it).
let mut outcomes = Vec::new();
let mut all_ok = true;
for run in shell_steps {
let outcome = run_one_shell(&lesson.root, run);
let ok = outcome.ok();
outcomes.push(outcome);
if !ok {
all_ok = false;
break;
}
}
ShellRunReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
outcomes,
ok: all_ok,
}
}
/// Whether the named target is a **shell-step** target (its steps contain at
/// least one `Step::Shell` and no `Step::TypstCompile`) — i.e. a tool-generated
/// asset bundle that [`run_shell_target`] executes rather than [`build`]
/// compiling to PDF.
///
/// Loads the lesson read-only (ignoring diagnostics); an unloadable lesson or an
/// unknown target returns `false`, so the caller falls through to the normal
/// (typst) build path, which then reports the real error.
pub fn target_is_shell(root: &Path, target: &str) -> bool {
let (Some(lesson), _) = cph_model::load(root) else {
return false;
};
lesson
.targets
.iter()
.find(|t| t.name == target)
.is_some_and(|t| {
t.steps.iter().any(|s| matches!(s, cph_model::Step::Shell { .. }))
&& !t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
})
}
/// Run one shell command line in `cwd` via the platform shell, capturing output.
fn run_one_shell(cwd: &Path, run: &str) -> ShellStepOutcome {
use std::process::Command;
let output = if cfg!(target_os = "windows") {
Command::new("cmd").arg("/C").arg(run).current_dir(cwd).output()
} else {
Command::new("sh").arg("-c").arg(run).current_dir(cwd).output()
};
match output {
Ok(out) => ShellStepOutcome {
run: run.to_string(),
status: out.status.code(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
},
Err(e) => ShellStepOutcome {
run: run.to_string(),
status: None,
stdout: String::new(),
stderr: format!("failed to spawn command: {e}"),
},
}
}
/// One assemble-markdown step's outcome (for [`run_markdown_assemble_target`]).
#[derive(Debug, Clone, PartialEq)]
pub struct MarkdownAssembleOutcome {
/// The field name that was assembled (verbatim from the step's `field`).
pub field: String,
/// How many elements' `<field>.md` files were read and concatenated.
pub parts_read: usize,
/// The assembled markdown body (empty if nothing was read).
pub body: String,
/// Where the assembled file was written (relative to the engineering root);
/// `None` if the write failed or was skipped.
pub written: Option<String>,
/// `None` on success; an error message if reading a required file or writing
/// the artifact failed.
pub error: Option<String>,
}
impl MarkdownAssembleOutcome {
/// Whether the step succeeded (wrote the artifact with no error).
pub fn ok(&self) -> bool {
self.error.is_none() && self.written.is_some()
}
}
/// The result of [`run_markdown_assemble_target`].
#[derive(Debug, Clone, PartialEq)]
pub struct MarkdownAssembleReport {
/// The check phases' report (load → structural → schema). Assembly runs only
/// when this has no errors.
pub check: CheckReport,
/// Each assemble-markdown step's outcome, in declared order. Empty if the
/// build was refused (check errors, unknown target, no assemble steps).
pub outcomes: Vec<MarkdownAssembleOutcome>,
/// `true` if the target was found, check passed, and every assemble step wrote
/// its artifact without error.
pub ok: bool,
}
/// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each
/// element's `<field>.md` markdown content file in `[[parts]]` order into the
/// target's single-file artifact. This is the **third typed step**: unlike
/// [`build`] (typst template → PDF) the framework owns the read/concatenate/write
/// itself (not a typst compile, not an external tool like [`run_shell_target`]).
///
/// Contract / safety (same three boundaries as the shell step, ADR-0013/0015):
/// - Runs the check phases first (load → structural → schema). Any error refuses
/// the run (`ok == false`, nothing written) — assembling a broken lesson is
/// not attempted.
/// - Walks `lesson.parts` **in declared order**, reading `<part.path>/<field>.md`
/// when it exists (skipping absent ones — these fields are optional), joining
/// them with a blank line, and writing to the artifact's single-file
/// `filepath` (relative to the engineering root). Each `slides.md`/etc.
/// authors its own heading; the assembler injects nothing (presentation in the
/// content, ADR-0011).
/// - **Opt-in by construction**: only reached from `cph build --target <name>`,
/// never from `check`. A non-typst target, so `check`'s compile phase skips it.
/// - A write error is a **build-process error**, not a lesson diagnostic — it is
/// reported in `MarkdownAssembleOutcome::error`, not the 6-class taxonomy.
pub fn run_markdown_assemble_target(
root: &Path,
engine: &Engine,
target: &str,
) -> MarkdownAssembleReport {
let mut diags = Vec::new();
let (lesson, load_diags) = cph_model::load(root);
diags.extend(load_diags);
let Some(lesson) = lesson else {
return MarkdownAssembleReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: false,
},
outcomes: Vec::new(),
ok: false,
};
};
let known = cph_schema::known_kinds();
run_structural_and_schema(&lesson, known, &mut diags);
// Signature uniform with `build`/`run_shell_target`; the engine is not used
// to assemble markdown (no typst compile), but callers pass it for API
// consistency.
let _ = engine;
let has_error = diags.iter().any(|d| d.severity == Severity::Error);
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{target}' not declared in manifest"),
)
.with_hint(format!(
"add a `[targets.{target}]` table, or run a declared target"
)),
);
return MarkdownAssembleReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
outcomes: Vec::new(),
ok: false,
};
};
let assemble_steps: Vec<&str> = tc
.steps
.iter()
.filter_map(|s| match s {
cph_model::Step::AssembleMarkdown { field } => Some(field.as_str()),
_ => None,
})
.collect();
if has_error || assemble_steps.is_empty() {
return MarkdownAssembleReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
outcomes: Vec::new(),
ok: false,
};
}
// Run each assemble-markdown step. First failure stops the sequence.
let mut outcomes = Vec::new();
let mut all_ok = true;
for field in assemble_steps {
let outcome = assemble_one(&lesson, field);
let ok = outcome.ok();
outcomes.push(outcome);
if !ok {
all_ok = false;
break;
}
}
MarkdownAssembleReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
outcomes,
ok: all_ok,
}
}
/// Assemble one markdown `field` across all parts in order, writing the
/// single-file artifact.
fn assemble_one(lesson: &cph_model::Lesson, field: &str) -> MarkdownAssembleOutcome {
let mut chunks: Vec<String> = Vec::new();
let mut parts_read = 0usize;
// Inject the course-level title as the document's h1. The title is course
// metadata (from `[info]` in the manifest), not per-element content, so the
// assembler prepends it; each element's `<field>.md` then contributes its
// `##` part heading and `###` 小节. (ADR-0011's "presentation lives in the
// content" is about per-element presentation; the single course title is a
// document-level fact the assembler owns.)
if !lesson.info.title.trim().is_empty() {
chunks.push(format!("# {}", lesson.info.title.trim()));
}
for part in &lesson.parts {
if !part.descriptor.dir.is_dir() {
continue;
}
let md = part.descriptor.dir.join(format!("{field}.md"));
if md.is_file() {
match std::fs::read_to_string(&md) {
Ok(body) => {
chunks.push(body);
parts_read += 1;
}
Err(e) => {
return MarkdownAssembleOutcome {
field: field.to_string(),
parts_read,
body: String::new(),
written: None,
error: Some(format!("failed to read '{}': {e}", md.display())),
};
}
}
}
// Absent `<field>.md` is fine — the field is optional (ADR-0015).
}
let body = chunks.join("\n\n");
// Resolve the target's single-file artifact path. (The assemble target is a
// single-file artifact by construction; a file-tree target has no single
// filepath to write to, which is a manifest error surfaced here.)
let tc = lesson.targets.iter().find(|t| {
t.steps
.iter()
.any(|s| matches!(s, cph_model::Step::AssembleMarkdown { field: f } if f == field))
});
let Some(tc) = tc else {
return MarkdownAssembleOutcome {
field: field.to_string(),
parts_read,
body,
written: None,
error: Some("no target carries this assemble-markdown step".into()),
};
};
let cph_model::Artifact::SingleFile { filepath } = &tc.artifact else {
return MarkdownAssembleOutcome {
field: field.to_string(),
parts_read,
body,
written: None,
error: Some(format!(
"target '{}' has a non-single-file artifact; assemble-markdown writes a single file",
tc.name
)),
};
};
let out_path = lesson.root.join(filepath);
if let Some(parent) = out_path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
return MarkdownAssembleOutcome {
field: field.to_string(),
parts_read,
body,
written: None,
error: Some(format!("failed to create '{}': {e}", parent.display())),
};
}
}
match std::fs::write(&out_path, &body) {
Ok(()) => {
// Collect referenced image assets so the build dir is self-contained.
// The assembled `body` is written under `build_root` (the artifact
// filepath's parent); its `![](rel)` image references resolve against
// that build root. So each referenced local image is copied from the
// engineering root (`lesson.root/<rel>`) into the build root
// (`build_root/<rel>`), preserving its relative path. A referenced
// image that is missing at the engineering root is a build-process
// error (a self-contained build with a dangling ref is broken) — it
// does not enter the 6-class diagnostic taxonomy (ADR-0015).
if let Some(build_root) = out_path.parent() {
if let Err(e) = collect_image_assets(&lesson.root, build_root, &body) {
return MarkdownAssembleOutcome {
field: field.to_string(),
parts_read,
body,
written: Some(filepath.display().to_string()),
error: Some(e),
};
}
}
MarkdownAssembleOutcome {
field: field.to_string(),
parts_read,
body,
written: Some(filepath.display().to_string()),
error: None,
}
}
Err(e) => MarkdownAssembleOutcome {
field: field.to_string(),
parts_read,
body,
written: None,
error: Some(format!("failed to write '{}': {e}", out_path.display())),
},
}
}
/// Copy each local markdown image reference in `body` from the engineering root
/// `eng_root` into the build root `build_root`, preserving the reference's
/// relative path. Returns `Err(message)` on the first referenced image that is
/// missing at `eng_root` (a build-process error, ADR-0015).
///
/// References are `![alt](target)` markdown images; `target` is taken up to the
/// first whitespace (a possible ` "title"` suffix is stripped). Absolute URLs
/// (`http://`, `https://`, `data:`) are left untouched — they are not local
/// assets to collect. Duplicate references copy once.
fn collect_image_assets(eng_root: &Path, build_root: &Path, body: &str) -> Result<(), String> {
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for target in image_refs(body) {
if target.is_empty()
|| target.starts_with("http://")
|| target.starts_with("https://")
|| target.starts_with("data:")
{
continue;
}
if !seen.insert(target.to_string()) {
continue;
}
let src = eng_root.join(target);
if !src.is_file() {
return Err(format!(
"slides references image '{}' which is missing from the engineering root",
src.display()
));
}
let dst = build_root.join(target);
if let Some(parent) = dst.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
return Err(format!("failed to create '{}': {e}", parent.display()));
}
}
if let Err(e) = std::fs::copy(&src, &dst) {
return Err(format!("failed to copy '{}' → '{}': {e}", src.display(), dst.display()));
}
}
Ok(())
}
/// Scan `body` for markdown image references `![alt](target)` and yield each
/// `target` (whitespace-trimmed of a trailing ` "title"`). Hand-rolled to avoid
/// pulling a regex dependency for a single, simple pattern.
fn image_refs(body: &str) -> Vec<&str> {
let bytes = body.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i + 1 < bytes.len() {
// Look for the `![` opener.
if bytes[i] == b'!' && bytes[i + 1] == b'[' {
// Find the matching `]`.
if let Some(bracket_close) = body[i + 2..].find(']') {
let after_bracket = i + 2 + bracket_close + 1;
if after_bracket < bytes.len() && bytes[after_bracket] == b'(' {
// Find the closing `)` of the link target.
if let Some(paren_close) = body[after_bracket + 1..].find(')') {
let target_start = after_bracket + 1;
let target_end = target_start + paren_close;
let target = &body[target_start..target_end];
// Trim a possible ` "title"` suffix: take up to first space.
let target = target.split_whitespace().next().unwrap_or("");
out.push(target);
i = target_end + 1;
continue;
}
}
}
}
i += 1;
}
out
}
/// Whether the named target is an **assemble-markdown** target (its steps
/// contain at least one `Step::AssembleMarkdown` and no `Step::TypstCompile`) —
/// i.e. a markdown deliverable that [`run_markdown_assemble_target`] builds
/// rather than [`build`] compiling to PDF.
///
/// Loads the lesson read-only (ignoring diagnostics); an unloadable lesson or an
/// unknown target returns `false`, so the caller falls through to the normal
/// (typst) build path, which then reports the real error.
pub fn target_is_markdown_assemble(root: &Path, target: &str) -> bool {
let (Some(lesson), _) = cph_model::load(root) else {
return false;
};
lesson
.targets
.iter()
.find(|t| t.name == target)
.is_some_and(|t| {
t.steps
.iter()
.any(|s| matches!(s, cph_model::Step::AssembleMarkdown { .. }))
&& !t.steps
.iter()
.any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
})
}
/// Whether a part's folder exists on disk. cph-model emits `PartPathMissing`
/// for parts whose folder is absent, so the orchestrator skips those parts in
/// the structural/schema/coverage phases to avoid piling diagnostics on the
/// same root cause.
fn part_exists(p: &cph_model::Part) -> bool {
p.descriptor.dir.is_dir()
}
/// Phases (b) + (c): the known-kind structural check and per-part schema
/// validation. Shared by [`check`] and [`build`] so their gating stays
/// identical.
fn run_structural_and_schema(
lesson: &cph_model::Lesson,
known: &[&str],
diags: &mut Vec<Diagnostic>,
) {
// (b) structural: known-kind check.
for part in &lesson.parts {
if !part_exists(part) {
continue;
}
if !known.contains(&part.kind.as_str()) {
diags.push(
Diagnostic::error(
DiagCode::UnknownKind,
format!(
"part '{}' has unknown kind '{}'",
part.path.display(),
part.kind
),
)
.with_hint(format!("valid kinds are: {}", known.join(", "))),
);
}
}
// (c) schema: validate each existing part with a known kind. Skipping
// unknown kinds avoids a second `UnknownKind` from `cph_schema::validate`
// (already reported in (b)).
for part in &lesson.parts {
if !part_exists(part) || !known.contains(&part.kind.as_str()) {
continue;
}
diags.extend(cph_schema::validate(&part.descriptor));
}
}
/// The targets to compile-check for [`check`]: every declared **typst-compile**
/// target, or `["student"]` if the lesson declares none.
///
/// A target whose steps are all `Shell` (e.g. a tool-generated asset bundle —
/// ADR-0005 category (b), like the KenKen interactives produced by `kendoku`) is
/// **not** typst-compiled, so it is excluded here: compile-checking it would
/// wrongly route a non-typst build through the typst engine. Such targets are
/// executed only on an explicit `cph build --target <name>` (see
/// [`run_shell_target`]); `check` validates the lesson structure, not the
/// outcome of running an external tool.
fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> {
if lesson.targets.is_empty() {
return vec![DEFAULT_TARGET];
}
lesson
.targets
.iter()
.filter(|t| t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. })))
.map(|t| t.name.as_str())
.collect()
}
/// Phase (e): for each `(part.kind, target)` over the lesson's declared targets,
/// emit a `RenderIgnored` **warning** when the target does not cover that kind.
///
/// Coverage is the per-target **declaration** ADR-0011 pinned (and the spec's
/// `RenderConfig.covers k t` in `Export/Render.lean`): a target's
/// `cph_model::TargetConfig::covers` lists the kinds it renders. A used kind not
/// in that list draws the warning. An **absent** `covers` (the loader's `None`)
/// is resolved here — the orchestrator owns the kind universe via `cph-schema` —
/// to "covers all known kinds", so an undeclared target is fully covering rather
/// than covering nothing. (An explicit `covers = []` stays empty: it covers
/// nothing on purpose.) Parts already broken in (b) (missing folder / unknown
/// kind) are skipped — they are reported there.
fn render_coverage(lesson: &cph_model::Lesson, known: &[&str]) -> Vec<Diagnostic> {
let mut out = Vec::new();
// De-dupe (kind, target) pairs so we emit one warning per pair, not per part.
let mut seen: Vec<(String, String)> = Vec::new();
for part in &lesson.parts {
// Skip parts already flagged structurally.
if !part_exists(part) || !known.contains(&part.kind.as_str()) {
continue;
}
for target in &lesson.targets {
// Resolve the coverage set: an absent declaration defaults to the
// full known-kind universe; an explicit list (possibly empty) is
// taken verbatim.
let covered = match &target.covers {
None => true,
Some(kinds) => kinds.iter().any(|k| k == &part.kind),
};
if covered {
continue;
}
let key = (part.kind.clone(), target.name.clone());
if seen.contains(&key) {
continue;
}
seen.push(key);
let mut d = Diagnostic::warning(
DiagCode::RenderIgnored,
format!(
"kind '{}' is not covered by target '{}'; it will be omitted from that export",
part.kind, target.name
),
)
.with_hint(format!(
"add '{}' to `covers` under target '{}', or remove the target",
part.kind, target.name
));
// Pin the severity via the named contract const. `Diagnostic::warning`
// already produces a warning, but assigning the const keeps the
// alignment to `renderIgnoredSeverity` load-bearing and greppable.
d.severity = RENDER_IGNORED_SEVERITY;
out.push(d);
}
}
out
}
/// Deduplicate diagnostics that are identical (same code+severity+message+span+
/// hint). The same typst error can surface once per compiled target, and the
/// same root cause can be reached by more than one phase; callers see each
/// distinct problem once.
fn dedup(diags: Vec<Diagnostic>) -> Vec<Diagnostic> {
let mut out: Vec<Diagnostic> = Vec::with_capacity(diags.len());
for d in diags {
if !out.contains(&d) {
out.push(d);
}
}
out
}
+672
View File
@@ -0,0 +1,672 @@
//! Integration tests for the `cph-check` orchestrator.
//!
//! These exercise phase gating and the public API against the shared `mini`
//! fixture (`crates/cph-typst/tests/fixtures/mini/`) and small throwaway
//! fixtures built in a temp dir. The typst Engine is pointed at the repo's real
//! `render/` package via `Engine::with_render_dir`.
use std::path::PathBuf;
use cph_diag::DiagCode;
use cph_typst::Engine;
/// `<this crate>/../cph-typst/tests/fixtures/mini` — the known-good lesson.
fn mini_fixture() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("cph-typst")
.join("tests")
.join("fixtures")
.join("mini")
}
/// `<this crate>/../../render` — the repo render package.
fn render_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("render")
}
fn engine() -> Engine {
Engine::with_render_dir(render_dir())
}
#[test]
fn good_fixture_has_no_errors() {
let report = cph_check::check(&mini_fixture(), &engine());
assert!(report.lesson_loaded, "mini fixture should load");
assert_eq!(
report.error_count(),
0,
"mini fixture should have zero errors, got: {:#?}",
report.diagnostics
);
assert!(!report.has_errors());
}
#[test]
fn unknown_kind_is_an_error() {
// Build a throwaway lesson whose part declares kind "frob".
let tmp = tempdir();
std::fs::write(
tmp.join("manifest.toml"),
r#"
[project]
id = "broken"
name = "broken"
[info]
title = "broken"
[[parts]]
kind = "frob"
path = "elements/widget"
"#,
)
.unwrap();
let part_dir = tmp.join("elements").join("widget");
std::fs::create_dir_all(&part_dir).unwrap();
std::fs::write(part_dir.join("element.toml"), "kind = \"frob\"\n").unwrap();
let report = cph_check::check(&tmp, &engine());
assert!(report.lesson_loaded);
assert!(report.has_errors());
assert!(
report
.diagnostics
.iter()
.any(|d| d.code == DiagCode::UnknownKind && d.message.contains("frob")),
"expected an UnknownKind error mentioning 'frob', got: {:#?}",
report.diagnostics
);
cleanup(&tmp);
}
#[test]
fn hard_manifest_failure_sets_lesson_not_loaded() {
let tmp = tempdir();
// No manifest.toml at all → cph_model::load returns None.
let report = cph_check::check(&tmp, &engine());
assert!(!report.lesson_loaded, "missing manifest is a hard failure");
assert!(report.has_errors());
cleanup(&tmp);
}
#[test]
fn build_returns_pdf_on_good_fixture() {
let (pdf, report) = cph_check::build(&mini_fixture(), &engine(), "student");
assert!(report.lesson_loaded);
assert_eq!(
report.error_count(),
0,
"no check errors expected, got: {:#?}",
report.diagnostics
);
let pdf = pdf.expect("expected PDF bytes for the good fixture");
assert!(pdf.starts_with(b"%PDF"), "output should be a PDF");
assert!(pdf.len() > 1000, "PDF should be non-trivial");
}
#[test]
fn build_returns_none_on_broken_fixture() {
// A part folder is missing → cph-model flags PartPathMissing (an error),
// so build refuses before ever compiling.
let tmp = tempdir();
std::fs::write(
tmp.join("manifest.toml"),
r#"
[project]
id = "broken"
name = "broken"
[info]
title = "broken"
[[parts]]
kind = "segment"
path = "segments/does-not-exist"
"#,
)
.unwrap();
let (pdf, report) = cph_check::build(&tmp, &engine(), "student");
assert!(pdf.is_none(), "broken lesson must not produce a PDF");
assert!(report.has_errors());
cleanup(&tmp);
}
/// Write a throwaway one-segment lesson into `tmp`, with the given `[targets.x]`
/// body spliced in. Returns nothing; the caller runs `check`.
fn write_segment_lesson_with_target(tmp: &PathBuf, target_body: &str) {
std::fs::write(
tmp.join("manifest.toml"),
format!(
r#"
[project]
id = "cov"
name = "cov"
[info]
title = "cov"
[[parts]]
kind = "segment"
path = "segments/intro"
{target_body}
"#
),
)
.unwrap();
let part_dir = tmp.join("segments").join("intro");
std::fs::create_dir_all(&part_dir).unwrap();
std::fs::write(part_dir.join("element.toml"), "kind = \"segment\"\n").unwrap();
std::fs::write(part_dir.join("textbook.typ"), "Hello.\n").unwrap();
}
#[test]
fn covers_omitting_used_kind_warns_render_ignored() {
// A target that declares `covers = ["example"]` does NOT cover the lesson's
// `segment` part → exactly one RenderIgnored warning (non-blocking).
let tmp = tempdir();
write_segment_lesson_with_target(
&tmp,
r#"[targets.handout]
artifact = { type = "single-file", filepath = "build/handout.pdf" }
covers = ["example"]
[[targets.handout.steps]]
type = "typst-compile"
template = "exports/handout.typ"
"#,
);
let report = cph_check::check(&tmp, &engine());
assert!(report.lesson_loaded);
let render_ignored: Vec<_> = report
.diagnostics
.iter()
.filter(|d| d.code == DiagCode::RenderIgnored)
.collect();
assert_eq!(
render_ignored.len(),
1,
"expected exactly one RenderIgnored warning, got: {:#?}",
report.diagnostics
);
// It is a non-blocking warning, so legality is unaffected by it.
assert_eq!(
report.warning_count(),
1,
"the coverage gap is a warning: {:#?}",
report.diagnostics
);
cleanup(&tmp);
}
#[test]
fn covers_including_used_kind_has_no_render_ignored() {
// A target that covers `segment` (the only used kind) → no RenderIgnored.
let tmp = tempdir();
write_segment_lesson_with_target(
&tmp,
r#"[targets.handout]
artifact = { type = "single-file", filepath = "build/handout.pdf" }
covers = ["segment"]
[[targets.handout.steps]]
type = "typst-compile"
template = "exports/handout.typ"
"#,
);
let report = cph_check::check(&tmp, &engine());
assert!(report.lesson_loaded);
assert!(
!report
.diagnostics
.iter()
.any(|d| d.code == DiagCode::RenderIgnored),
"a covering target should yield no RenderIgnored, got: {:#?}",
report.diagnostics
);
cleanup(&tmp);
}
#[test]
fn absent_covers_defaults_to_full_coverage() {
// No `covers` key at all → the orchestrator defaults to all known kinds, so
// even a non-"student"/"teacher" target name covers `segment`. (This is the
// behavior change: coverage now follows the declaration/default, not a
// hardcoded target-name allowlist.)
let tmp = tempdir();
write_segment_lesson_with_target(
&tmp,
r#"[targets.handout]
artifact = { type = "single-file", filepath = "build/handout.pdf" }
[[targets.handout.steps]]
type = "typst-compile"
template = "exports/handout.typ"
"#,
);
let report = cph_check::check(&tmp, &engine());
assert!(report.lesson_loaded);
assert!(
!report
.diagnostics
.iter()
.any(|d| d.code == DiagCode::RenderIgnored),
"an absent `covers` defaults to full coverage, got: {:#?}",
report.diagnostics
);
cleanup(&tmp);
}
/// Write a one-segment lesson with a `file-tree` shell target whose `run` is the
/// given command. Returns nothing; the caller runs `run_shell_target`.
fn write_shell_target_lesson(tmp: &PathBuf, run: &str) {
std::fs::write(
tmp.join("manifest.toml"),
format!(
r#"
[project]
id = "sh"
name = "sh"
[info]
title = "sh"
[[parts]]
kind = "segment"
path = "segments/intro"
[targets.assets]
artifact = {{ type = "file-tree", root = "build/assets", outputs = "**/*" }}
[[targets.assets.steps]]
type = "shell"
run = "{run}"
"#
),
)
.unwrap();
let part_dir = tmp.join("segments").join("intro");
std::fs::create_dir_all(&part_dir).unwrap();
std::fs::write(part_dir.join("element.toml"), "kind = \"segment\"\n").unwrap();
std::fs::write(part_dir.join("textbook.typ"), "Hi.\n").unwrap();
}
#[test]
fn target_is_shell_detects_shape() {
let tmp = tempdir();
write_shell_target_lesson(&tmp, "true");
assert!(cph_check::target_is_shell(&tmp, "assets"));
assert!(!cph_check::target_is_shell(&tmp, "nonexistent"));
cleanup(&tmp);
}
#[test]
fn run_shell_target_executes_in_engineering_root() {
// The command writes a file under the engineering root; cwd must be the root.
let tmp = tempdir();
write_shell_target_lesson(
&tmp,
"mkdir -p build/assets && printf ok > build/assets/marker.txt",
);
let report = cph_check::run_shell_target(&tmp, &engine(), "assets");
assert!(report.ok, "shell target should succeed: {:#?}", report);
assert_eq!(report.outcomes.len(), 1);
assert!(report.outcomes[0].ok());
// The side effect landed in the engineering root.
let marker = tmp.join("build").join("assets").join("marker.txt");
assert_eq!(std::fs::read_to_string(&marker).unwrap(), "ok");
cleanup(&tmp);
}
#[test]
fn run_shell_target_reports_nonzero_exit() {
let tmp = tempdir();
write_shell_target_lesson(&tmp, "exit 3");
let report = cph_check::run_shell_target(&tmp, &engine(), "assets");
assert!(!report.ok, "non-zero exit must fail the run");
assert_eq!(report.outcomes.len(), 1);
assert_eq!(report.outcomes[0].status, Some(3));
cleanup(&tmp);
}
#[test]
fn run_shell_target_refuses_broken_lesson() {
// A part path that doesn't exist → check error → shell steps never run.
let tmp = tempdir();
std::fs::write(
tmp.join("manifest.toml"),
r#"
[project]
id = "sh"
name = "sh"
[info]
title = "sh"
[[parts]]
kind = "segment"
path = "segments/missing"
[targets.assets]
artifact = { type = "file-tree", root = "build/assets", outputs = "**/*" }
[[targets.assets.steps]]
type = "shell"
run = "printf SHOULD_NOT_RUN > build/ran.txt"
"#,
)
.unwrap();
let report = cph_check::run_shell_target(&tmp, &engine(), "assets");
assert!(!report.ok);
assert!(report.outcomes.is_empty(), "no command should run on a broken lesson");
assert!(report.check.has_errors());
assert!(
!tmp.join("build").join("ran.txt").exists(),
"the command must not have executed"
);
cleanup(&tmp);
}
// --- assemble-markdown target (ADR-0015) ---------------------------------------
/// Write a two-segment lesson where each segment carries a `slides.md` markdown
/// content file, plus a `slides` target that assembles them. The `which` slice
/// controls which segments get a `slides.md` (so the "skip absent" path can be
/// exercised). Returns nothing; the caller runs `run_markdown_assemble_target`.
fn write_markdown_assemble_target_lesson(tmp: &PathBuf, slides: &[(&str, &str)]) {
let mut parts = String::new();
for (i, (name, _)) in slides.iter().enumerate() {
if i > 0 {
parts.push('\n');
}
parts.push_str(&format!(
"[[parts]]\nkind = \"segment\"\npath = \"segments/{name}\"\n"
));
}
std::fs::write(
tmp.join("manifest.toml"),
format!(
r#"
[project]
id = "md"
name = "md"
[info]
title = "md"
{parts}
[targets.slides]
artifact = {{ type = "single-file", filepath = "build/slides.md" }}
[[targets.slides.steps]]
type = "assemble-markdown"
field = "slides"
"#
),
)
.unwrap();
for (name, body) in slides {
let dir = tmp.join("segments").join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap();
std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap();
std::fs::write(dir.join("slides.md"), body).unwrap();
}
}
#[test]
fn target_is_markdown_assemble_detects_shape() {
let tmp = tempdir();
write_markdown_assemble_target_lesson(
&tmp,
&[("a", "# A"), ("b", "# B")],
);
assert!(cph_check::target_is_markdown_assemble(&tmp, "slides"));
// The student target doesn't exist here → falls through to typst path → false.
assert!(!cph_check::target_is_markdown_assemble(&tmp, "student"));
cleanup(&tmp);
}
#[test]
fn run_markdown_assemble_target_concatenates_in_parts_order() {
let tmp = tempdir();
write_markdown_assemble_target_lesson(
&tmp,
&[("intro", "# 规则一\n- 范围"), ("rule2", "# 规则二\n$8\\times$")],
);
let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides");
assert!(report.ok, "assembly should succeed: {:#?}", report);
assert_eq!(report.outcomes.len(), 1);
let o = &report.outcomes[0];
assert!(o.ok());
assert_eq!(o.parts_read, 2);
assert_eq!(o.field, "slides");
let out = tmp.join("build").join("slides.md");
let body = std::fs::read_to_string(&out).unwrap();
// Both parts present, in declared order, joined by a blank line.
let intro_idx = body.find("规则一").unwrap();
let rule2_idx = body.find("规则二").unwrap();
assert!(intro_idx < rule2_idx, "parts must keep manifest order");
assert!(body.contains("$8\\times$"), "KaTeX source survives as text");
cleanup(&tmp);
}
#[test]
fn run_markdown_assemble_target_skips_parts_without_the_field() {
// Only the first segment has a slides.md; the second is skipped (optional).
let tmp = tempdir();
let mut parts = String::new();
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n\n");
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/b\"\n");
std::fs::write(
tmp.join("manifest.toml"),
format!(
r#"
[project]
id = "md"
name = "md"
[info]
title = "md"
{parts}
[targets.slides]
artifact = {{ type = "single-file", filepath = "build/slides.md" }}
[[targets.slides.steps]]
type = "assemble-markdown"
field = "slides"
"#
),
)
.unwrap();
for name in ["a", "b"] {
let dir = tmp.join("segments").join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap();
std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap();
}
// Only `a` gets a slides.md.
std::fs::write(tmp.join("segments/a/slides.md"), "# A only\n").unwrap();
let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides");
assert!(report.ok, "{:#?}", report);
assert_eq!(report.outcomes[0].parts_read, 1);
let body = std::fs::read_to_string(tmp.join("build/slides.md")).unwrap();
// The assembler prepends the course title (info.title = "md") as an h1,
// then the single part's slides.md.
assert_eq!(body, "# md\n\n# A only\n");
cleanup(&tmp);
}
#[test]
fn run_markdown_assemble_target_copies_referenced_images() {
// A slides.md that references two local images; both must be copied from
// the engineering root into the build root (same relative path) so the
// build dir is self-contained (ADR-0015).
let tmp = tempdir();
std::fs::write(
tmp.join("manifest.toml"),
r#"
[project]
id = "md"
name = "md"
[info]
title = "md"
[[parts]]
kind = "segment"
path = "segments/a"
[targets.slides]
artifact = { type = "single-file", filepath = "build/slides.md" }
[[targets.slides.steps]]
type = "assemble-markdown"
field = "slides"
"#,
)
.unwrap();
let dir = tmp.join("segments").join("a");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap();
std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap();
std::fs::write(
dir.join("slides.md"),
"## A\n\n![one](assets/img/one.png)\n\n![two](assets/img/two.png)\n",
)
.unwrap();
// The two referenced images exist at the engineering root.
std::fs::create_dir_all(tmp.join("assets/img")).unwrap();
std::fs::write(tmp.join("assets/img/one.png"), b"PNG1").unwrap();
std::fs::write(tmp.join("assets/img/two.png"), b"PNG2").unwrap();
let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides");
assert!(report.ok, "{:#?}", report);
// Both images copied into the build root, preserving the assets/img/ path.
assert_eq!(
std::fs::read(tmp.join("build/assets/img/one.png")).unwrap(),
b"PNG1"
);
assert_eq!(
std::fs::read(tmp.join("build/assets/img/two.png")).unwrap(),
b"PNG2"
);
cleanup(&tmp);
}
#[test]
fn run_markdown_assemble_target_fails_on_missing_referenced_image() {
// A slides.md references an image that does NOT exist at the engineering
// root → a build-process error (self-contained build would be broken),
// not a lesson diagnostic.
let tmp = tempdir();
std::fs::write(
tmp.join("manifest.toml"),
r#"
[project]
id = "md"
name = "md"
[info]
title = "md"
[[parts]]
kind = "segment"
path = "segments/a"
[targets.slides]
artifact = { type = "single-file", filepath = "build/slides.md" }
[[targets.slides.steps]]
type = "assemble-markdown"
field = "slides"
"#,
)
.unwrap();
let dir = tmp.join("segments").join("a");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap();
std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap();
std::fs::write(
dir.join("slides.md"),
"## A\n\n![ghost](assets/img/ghost.png)\n",
)
.unwrap();
// No image created → reference is dangling.
let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides");
assert!(!report.ok, "a missing referenced image must fail the build");
let o = &report.outcomes[0];
assert!(o.error.as_deref().unwrap_or("").contains("ghost.png"));
// The markdown was still written, but the image was not (it doesn't exist).
assert!(tmp.join("build/slides.md").is_file());
cleanup(&tmp);
}
#[test]
fn run_markdown_assemble_target_refuses_broken_lesson() {
// A part path that doesn't exist → check error → assembly never runs.
let tmp = tempdir();
std::fs::write(
tmp.join("manifest.toml"),
r#"
[project]
id = "md"
name = "md"
[info]
title = "md"
[[parts]]
kind = "segment"
path = "segments/missing"
[targets.slides]
artifact = { type = "single-file", filepath = "build/slides.md" }
[[targets.slides.steps]]
type = "assemble-markdown"
field = "slides"
"#,
)
.unwrap();
let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides");
assert!(!report.ok);
assert!(report.outcomes.is_empty(), "no assembly on a broken lesson");
assert!(report.check.has_errors());
assert!(
!tmp.join("build").join("slides.md").exists(),
"nothing should have been written"
);
cleanup(&tmp);
}
// --- tiny temp-dir helpers (no external dev-dep) ------------------------------
fn tempdir() -> PathBuf {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
p.push(format!(
"cph-check-test-{nanos}-{:?}",
std::thread::current().id()
));
std::fs::create_dir_all(&p).unwrap();
p
}
fn cleanup(p: &PathBuf) {
let _ = std::fs::remove_dir_all(p);
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "cph-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "cph"
path = "src/main.rs"
[dependencies]
cph-check = { path = "../cph-check" }
cph-diag = { workspace = true }
cph-typst = { path = "../cph-typst" }
clap = { version = "4", features = ["derive"] }
+231
View File
@@ -0,0 +1,231 @@
//! `cph` — the command-line entrypoint for the checker.
//!
//! Owned by **WU-5**. Parses args, constructs the typst [`Engine`], runs the
//! `cph-check` pipeline against an engineering file, and prints diagnostics.
//!
//! Convention: **diagnostics go to stderr, results go to stdout.** `check`
//! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit
//! 0); `build` exits 1 when the PDF could not be produced.
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use cph_check::CheckReport;
use cph_typst::Engine;
/// The `cph` checker for curriculum engineering files.
#[derive(Debug, Parser)]
#[command(name = "cph", version, about, long_about = None)]
struct Cli {
/// Override the `cph-render` package directory (the folder containing
/// `lib.typ` / `typst.toml`). Defaults to the engine's own resolution
/// (`CPH_RENDER_DIR` env var, else the repo `render/`).
#[arg(long, global = true, value_name = "DIR")]
render_dir: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Run the full check pipeline and print diagnostics. Exits 1 on any error.
Check {
/// Path to the engineering-file root (the folder with `manifest.toml`).
path: PathBuf,
},
/// Build a PDF for a render target. Exits 1 if the build fails.
Build {
/// Path to the engineering-file root (the folder with `manifest.toml`).
path: PathBuf,
/// Render target to export.
#[arg(long, default_value = "student")]
target: String,
/// Output PDF path. Defaults to `<PATH>/build/<target>.pdf`.
#[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
let engine = match &cli.render_dir {
Some(dir) => Engine::with_render_dir(dir.clone()),
None => Engine::new(),
};
match cli.command {
Command::Check { path } => run_check(&path, &engine),
Command::Build { path, target, out } => run_build(&path, &engine, &target, out),
}
}
/// Print every diagnostic in `report` to stderr, followed by a summary line.
fn print_diagnostics(report: &CheckReport) {
for d in &report.diagnostics {
eprintln!("{d}");
}
eprintln!(
"{} error{}, {} warning{}",
report.error_count(),
plural(report.error_count()),
report.warning_count(),
plural(report.warning_count()),
);
}
fn plural(n: usize) -> &'static str {
if n == 1 {
""
} else {
"s"
}
}
fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode {
let report = cph_check::check(path, engine);
print_diagnostics(&report);
if report.has_errors() {
ExitCode::FAILURE
} else {
println!("check passed: {}", path.display());
ExitCode::SUCCESS
}
}
fn run_build(
path: &std::path::Path,
engine: &Engine,
target: &str,
out: Option<PathBuf>,
) -> ExitCode {
// A target whose steps are shell commands (a tool-generated asset bundle,
// ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by
// executing those commands, not by compiling a typst template. Detect that
// shape up front and route accordingly.
if cph_check::target_is_shell(path, target) {
return run_shell_build(path, engine, target);
}
// A target whose steps assemble markdown (ADR-0015: slides outline / 逐字稿
// transcript surfaces) is built by concatenating per-element `<field>.md`
// files in parts order, not by compiling a typst template.
if cph_check::target_is_markdown_assemble(path, target) {
return run_markdown_assemble_build(path, engine, target);
}
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
let (pdf, report) = cph_check::build(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 ExitCode::FAILURE;
}
}
if let Err(e) = std::fs::write(&out_path, &bytes) {
eprintln!("error: cannot write '{}': {e}", out_path.display());
return ExitCode::FAILURE;
}
println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
ExitCode::SUCCESS
}
None => {
eprintln!("build failed: {} errors", report.error_count());
ExitCode::FAILURE
}
}
}
/// Run a shell-step target: execute its declared commands in the engineering
/// root. Arbitrary command execution is **opt-in** — it only happens on an
/// explicit `cph build --target <name>`, and each command is printed before it
/// runs so the user sees exactly what is executed.
fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode {
eprintln!("running shell-step target '{target}' (commands execute in {})", path.display());
let report = cph_check::run_shell_target(path, engine, target);
print_diagnostics(&report.check);
if report.outcomes.is_empty() && !report.ok {
// Refused before running anything (check errors / unknown target / no
// shell steps): the diagnostics above explain why.
if report.check.has_errors() {
eprintln!("build refused: {} errors (fix the lesson first)", report.check.error_count());
} else {
eprintln!("build failed: target '{target}' has no shell steps to run");
}
return ExitCode::FAILURE;
}
for outcome in &report.outcomes {
eprintln!("$ {}", outcome.run);
if !outcome.stdout.trim().is_empty() {
print!("{}", outcome.stdout);
}
if !outcome.ok() {
eprint!("{}", outcome.stderr);
eprintln!(
"step failed (exit {})",
outcome.status.map(|c| c.to_string()).unwrap_or_else(|| "signal".into())
);
}
}
if report.ok {
println!("shell-step target '{target}' completed: {} step(s) ok", report.outcomes.len());
ExitCode::SUCCESS
} else {
eprintln!("shell-step target '{target}' failed");
ExitCode::FAILURE
}
}
/// Run an assemble-markdown target: concatenate each element's `<field>.md`
/// (in parts order) and write the single-file artifact (ADR-0015). Like the
/// shell path, this is opt-in — only on an explicit `cph build --target <name>`,
/// and never in `check`.
fn run_markdown_assemble_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode {
eprintln!("assembling markdown target '{target}' (reading parts under {})", path.display());
let report = cph_check::run_markdown_assemble_target(path, engine, target);
print_diagnostics(&report.check);
if report.outcomes.is_empty() && !report.ok {
if report.check.has_errors() {
eprintln!("build refused: {} errors (fix the lesson first)", report.check.error_count());
} else {
eprintln!("build failed: target '{target}' has no assemble-markdown steps to run");
}
return ExitCode::FAILURE;
}
for outcome in &report.outcomes {
eprintln!(
"assembled field '{}' from {} part(s)",
outcome.field, outcome.parts_read
);
if let Some(out_rel) = &outcome.written {
println!("wrote {} ({} bytes)", path.join(out_rel).display(), outcome.body.len());
}
if let Some(err) = &outcome.error {
eprintln!("assemble failed: {err}");
}
}
if report.ok {
println!("assemble-markdown target '{target}' completed: {} step(s) ok", report.outcomes.len());
ExitCode::SUCCESS
} else {
eprintln!("assemble-markdown target '{target}' failed");
ExitCode::FAILURE
}
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "cph-diag"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
+277
View File
@@ -0,0 +1,277 @@
//! `cph-diag` — the shared diagnostic vocabulary for the checker.
//!
//! Every other crate in the workspace depends on these types to report
//! problems. The vocabulary is intentionally small and stable: a [`Severity`]
//! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an
//! optional [`SourceSpan`] pointing back at the offending source, and a
//! [`Diagnostic`] tying them together with a human message and a fix hint.
//!
//! All public types derive `Serialize` (downstream prints diagnostics as JSON),
//! plus `Debug`, `Clone`, `PartialEq`, `Eq`.
use std::fmt;
use std::ops::Range;
use std::path::PathBuf;
use serde::Serialize;
/// Severity of a diagnostic.
///
/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
/// exactly:
///
/// ```text
/// inductive Severity where
/// | warning
/// | error
/// ```
///
/// This two-valued shape is a **contract decision**, not an accident: the Lean
/// module pins `Severity` to exactly `warning | error` and states the finer
/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore
/// do **not** add an info/note level here. `error` blocks (the artifact is
/// invalid); `warning` does not block (the artifact still exports, but with
/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning").
///
/// There is no CI gate enforcing this alignment (see the repo constitution);
/// it is maintained by review, which is why this correspondence is documented
/// here rather than only in the spec.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Severity {
/// Non-blocking: the artifact still exports, but is lossy / has an ignored
/// element. Mirrors Lean `Severity.warning`.
Warning,
/// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`.
Error,
}
/// A location in a source file within the engineering-file tree.
///
/// `file` is relative to the engineering-file root so spans are portable and
/// diffable. `line` / `col` are 1-based (as editors display them); `byte_range`
/// is a 0-based byte offset range into the file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SourceSpan {
/// Path to the file, relative to the engineering-file root.
pub file: PathBuf,
/// Byte offset range into the file (0-based).
pub byte_range: Range<usize>,
/// 1-based line number.
pub line: u32,
/// 1-based column number.
pub col: u32,
}
/// Stable, machine-readable diagnostic codes.
///
/// This set is **closed**: codes are part of the checker's public contract, so
/// downstream tooling can match on the string form (see [`DiagCode::as_str`]).
/// Do not invent codes outside this enum without a deliberate decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum DiagCode {
/// A `[[parts]]` entry references a folder/path that does not exist.
PartPathMissing,
/// An element declares a `kind` that is not a known kind.
UnknownKind,
/// A required `content` `.typ` file (per the kind schema) is missing.
MissingContentFile,
/// Instance data does not conform to its kind's JSON Schema.
SchemaViolation,
/// The assembled typst source failed to compile.
///
/// Includes **reference-resolution** failures — an unresolved cross-reference
/// (`@ref`) or a relative `import`/`include` that escapes the import boundary
/// or names a missing file. typst detects all of these at compile time, so
/// they surface here rather than as a separate diagnostic (ADR-0012 folded
/// the former `DanglingReference` class into this one).
TypstCompile,
/// An element is ignored under a render target (ADR-0005: warning).
RenderIgnored,
}
impl DiagCode {
/// The stable string form of this code, e.g. `"E-PART-PATH"`.
///
/// The `E-` / `W-` prefix reflects the *typical* severity of the code but
/// is not authoritative — the actual severity lives on the [`Diagnostic`].
pub fn as_str(&self) -> &'static str {
match self {
DiagCode::PartPathMissing => "E-PART-PATH",
DiagCode::UnknownKind => "E-UNKNOWN-KIND",
DiagCode::MissingContentFile => "E-MISSING-CONTENT",
DiagCode::SchemaViolation => "E-SCHEMA",
DiagCode::TypstCompile => "E-TYPST-COMPILE",
DiagCode::RenderIgnored => "W-RENDER-IGNORED",
}
}
}
impl fmt::Display for DiagCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// A single diagnostic emitted by the checker.
///
/// Construct via [`Diagnostic::error`] / [`Diagnostic::warning`] and attach an
/// optional [`SourceSpan`] / hint with the builder methods
/// [`Diagnostic::with_span`] / [`Diagnostic::with_hint`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Diagnostic {
/// How serious this diagnostic is.
pub severity: Severity,
/// The stable machine code.
pub code: DiagCode,
/// Human-readable message.
pub message: String,
/// Optional location this diagnostic points at.
pub span: Option<SourceSpan>,
/// Optional fix hint — the product's core value.
pub hint: Option<String>,
}
impl Diagnostic {
/// Create an `error`-severity diagnostic.
pub fn error(code: DiagCode, message: impl Into<String>) -> Self {
Diagnostic {
severity: Severity::Error,
code,
message: message.into(),
span: None,
hint: None,
}
}
/// Create a `warning`-severity diagnostic.
pub fn warning(code: DiagCode, message: impl Into<String>) -> Self {
Diagnostic {
severity: Severity::Warning,
code,
message: message.into(),
span: None,
hint: None,
}
}
/// Attach a source span (builder-style).
pub fn with_span(mut self, span: SourceSpan) -> Self {
self.span = Some(span);
self
}
/// Attach a fix hint (builder-style).
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Severity::Warning => "warning",
Severity::Error => "error",
})
}
}
impl fmt::Display for Diagnostic {
/// Renders one line `error[E-PART-PATH]: <msg>`, plus an indented
/// `--> file:line:col` line when a span is present, and a `hint: ...` line
/// when a hint is present.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}[{}]: {}", self.severity, self.code, self.message)?;
if let Some(span) = &self.span {
write!(
f,
"\n --> {}:{}:{}",
span.file.display(),
span.line,
span.col
)?;
}
if let Some(hint) = &self.hint {
write!(f, "\n hint: {hint}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn code_as_str_is_stable() {
assert_eq!(DiagCode::PartPathMissing.as_str(), "E-PART-PATH");
assert_eq!(DiagCode::RenderIgnored.as_str(), "W-RENDER-IGNORED");
assert_eq!(DiagCode::SchemaViolation.as_str(), "E-SCHEMA");
// Display matches as_str.
assert_eq!(DiagCode::UnknownKind.to_string(), "E-UNKNOWN-KIND");
}
#[test]
fn error_constructor() {
let d = Diagnostic::error(DiagCode::UnknownKind, "no such kind 'frob'");
assert_eq!(d.severity, Severity::Error);
assert_eq!(d.code, DiagCode::UnknownKind);
assert_eq!(d.message, "no such kind 'frob'");
assert!(d.span.is_none());
assert!(d.hint.is_none());
}
#[test]
fn warning_constructor() {
let d = Diagnostic::warning(DiagCode::RenderIgnored, "ignored under 'teacher'");
assert_eq!(d.severity, Severity::Warning);
assert_eq!(d.code, DiagCode::RenderIgnored);
}
#[test]
fn builders_attach_span_and_hint() {
let span = SourceSpan {
file: PathBuf::from("segments/intro/textbook.typ"),
byte_range: 10..20,
line: 3,
col: 5,
};
let d = Diagnostic::error(DiagCode::TypstCompile, "unclosed bracket")
.with_span(span.clone())
.with_hint("close the '[' opened on line 2");
assert_eq!(d.span, Some(span));
assert_eq!(d.hint.as_deref(), Some("close the '[' opened on line 2"));
}
#[test]
fn display_bare() {
let d = Diagnostic::error(DiagCode::PartPathMissing, "folder not found");
assert_eq!(d.to_string(), "error[E-PART-PATH]: folder not found");
}
#[test]
fn display_with_span_and_hint() {
let d = Diagnostic::error(DiagCode::PartPathMissing, "folder not found")
.with_span(SourceSpan {
file: PathBuf::from("manifest.toml"),
byte_range: 0..0,
line: 12,
col: 3,
})
.with_hint("create the folder 'segments/intro' or fix the path");
assert_eq!(
d.to_string(),
"error[E-PART-PATH]: folder not found\n --> manifest.toml:12:3\n hint: create the folder 'segments/intro' or fix the path"
);
}
#[test]
fn display_warning() {
let d = Diagnostic::warning(DiagCode::RenderIgnored, "ignored under 'teacher'");
assert_eq!(
d.to_string(),
"warning[W-RENDER-IGNORED]: ignored under 'teacher'"
);
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "cph-model"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
cph-diag = { workspace = true }
serde = { workspace = true }
# `preserve_order` keeps `[targets.*]` tables in TOML document order, which is
# the declared order this crate exposes via `Lesson.targets` (ADR-0009).
toml = { workspace = true, features = ["preserve_order"] }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
[project]
id = "fixture-bad-target-config"
name = "bad-target-config"
[info]
title = "测试课:非法导出目标配置"
# Invalid artifact `type` → SchemaViolation, falls back to default (single-file).
[targets.student]
artifact = { type = "pdf-thing", filepath = "build/student.pdf" }
# Invalid step `type` → SchemaViolation; the bad step is skipped and the build
# falls back to the default step.
[targets.teacher]
artifact = { type = "file-tree", root = "build/teacher", outputs = "**/*.html" }
[[targets.teacher.steps]]
type = "make-it-nice"
template = "exports/teacher.typ"
@@ -0,0 +1,12 @@
[project]
id = "fixture-kind-mismatch"
name = "kind-mismatch"
[info]
title = "kind 不一致测试"
[[parts]]
kind = "segment"
path = "segments/intro"
[targets.student]
@@ -0,0 +1 @@
kind = "lemma"
@@ -0,0 +1 @@
这是一段测试。
@@ -0,0 +1,3 @@
[project
id = "broken"
this is not valid toml = =
@@ -0,0 +1,16 @@
[project]
id = "fixture-missing-part"
name = "missing-part"
[info]
title = "缺部件测试"
[[parts]]
kind = "segment"
path = "segments/intro"
[[parts]]
kind = "lemma"
path = "lemmas/does-not-exist"
[targets.student]
@@ -0,0 +1 @@
kind = "segment"
@@ -0,0 +1 @@
这是一段测试。
@@ -0,0 +1,30 @@
[project]
id = "fixture-target-configs"
name = "target-configs"
[info]
title = "测试课:导出目标的结构化 build 配置"
# Array form: a lesson authored by several people.
author = ["张老师", "李老师"]
# Full v2 config: explicit single-file artifact + an ordered typst-compile step.
[targets.student]
artifact = { type = "single-file", filepath = "build/student.pdf" }
[[targets.student.steps]]
type = "typst-compile"
template = "exports/student.typ"
# file-tree artifact + multiple steps (typst-compile then a shell step),
# exercising both step types and order preservation.
[targets.archive]
artifact = { type = "file-tree", root = "build/archive", outputs = "**/*.{html,js,json}" }
[[targets.archive.steps]]
type = "typst-compile"
template = "exports/archive.typ"
[[targets.archive.steps]]
type = "shell"
run = "npm run build"
# Empty body → all defaults (single-file build/teacher.pdf, one typst-compile
# step exports/teacher.typ).
[targets.teacher]
@@ -0,0 +1,2 @@
kind = "lemma"
source = "测试引理"
@@ -0,0 +1 @@
这是一段测试。
@@ -0,0 +1 @@
这是一段测试。
+18
View File
@@ -0,0 +1,18 @@
[project]
id = "fixture-valid"
name = "valid-2-part"
[info]
title = "测试课:两个部件"
author = "范式教育教研组"
[[parts]]
kind = "segment"
path = "segments/intro"
[[parts]]
kind = "lemma"
path = "lemmas/young"
[targets.student]
[targets.teacher]
@@ -0,0 +1 @@
kind = "segment"
@@ -0,0 +1 @@
这是一段测试。
+274
View File
@@ -0,0 +1,274 @@
//! Integration tests for `cph_model::load`, driven by static fixtures under
//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0008
//! on-disk format.
use std::path::PathBuf;
use cph_diag::DiagCode;
use cph_model::load;
/// Absolute path to a fixture engineering-file root.
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
#[test]
fn valid_two_part_lesson_loads_in_order_with_no_errors() {
let (lesson, diags) = load(&fixture("valid"));
let lesson = lesson.expect("valid fixture must produce a Lesson");
assert!(
diags.is_empty(),
"valid fixture must have no diagnostics, got: {diags:?}"
);
assert_eq!(lesson.project.id, "fixture-valid");
assert_eq!(lesson.project.name, "valid-2-part");
assert_eq!(lesson.info.title, "测试课:两个部件");
// A single-string `author` loads as a one-element list.
assert_eq!(lesson.info.authors, vec!["范式教育教研组".to_string()]);
// Parts preserve declared order: segment first, lemma second.
assert_eq!(lesson.parts.len(), 2);
assert_eq!(lesson.parts[0].kind, "segment");
assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro"));
assert_eq!(lesson.parts[0].descriptor.kind, "segment");
assert_eq!(lesson.parts[1].kind, "lemma");
assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young"));
assert_eq!(lesson.parts[1].descriptor.kind, "lemma");
// `source` scalar survives on the lemma descriptor; `kind` is removed.
let scalars = &lesson.parts[1].descriptor.scalars;
assert_eq!(
scalars.get("source").and_then(|v| v.as_str()),
Some("测试引理")
);
assert!(scalars.get("kind").is_none());
// Targets are collected from [targets.*], in declared order.
assert_eq!(lesson.target_names(), vec!["student", "teacher"]);
// Empty `[targets.x]` bodies → all-default build config, no diagnostics:
// single-file artifact at build/<name>.pdf + one typst-compile step.
for target in &lesson.targets {
assert_eq!(
target.artifact,
cph_model::Artifact::SingleFile {
filepath: PathBuf::from(format!("build/{}.pdf", target.name)),
}
);
assert_eq!(
target.steps,
vec![cph_model::Step::TypstCompile {
template: PathBuf::from(format!("exports/{}.typ", target.name)),
}]
);
}
// Descriptor dir is absolute, anchored under the root.
assert!(lesson.parts[0].descriptor.dir.is_absolute());
assert_eq!(
lesson.parts[0].descriptor.dir,
lesson.root.join("segments/intro")
);
}
#[test]
fn missing_part_folder_yields_part_path_missing() {
let (lesson, diags) = load(&fixture("missing-part"));
// Still produces a Lesson (loader stays best-effort).
let lesson = lesson.expect("missing-part fixture must still produce a Lesson");
assert_eq!(lesson.parts.len(), 2, "both parts are recorded for order");
let missing: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::PartPathMissing)
.collect();
assert_eq!(
missing.len(),
1,
"exactly one PartPathMissing expected, got diags: {diags:?}"
);
assert_eq!(missing[0].severity, cph_diag::Severity::Error);
}
#[test]
fn kind_mismatch_yields_unknown_kind_diagnostic() {
let (lesson, diags) = load(&fixture("kind-mismatch"));
let lesson = lesson.expect("kind-mismatch fixture must still produce a Lesson");
assert_eq!(lesson.parts.len(), 1);
let mismatch: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::UnknownKind)
.collect();
assert_eq!(
mismatch.len(),
1,
"exactly one kind-mismatch diagnostic expected, got: {diags:?}"
);
assert!(
mismatch[0].message.contains("segment") && mismatch[0].message.contains("lemma"),
"message should name both kinds, got: {}",
mismatch[0].message
);
}
#[test]
fn malformed_manifest_is_a_hard_failure() {
let (lesson, diags) = load(&fixture("malformed-manifest"));
assert!(
lesson.is_none(),
"malformed manifest must be a hard failure (None)"
);
assert_eq!(diags.len(), 1, "one hard-failure diagnostic expected");
assert_eq!(diags[0].code, DiagCode::SchemaViolation);
assert_eq!(diags[0].severity, cph_diag::Severity::Error);
}
#[test]
fn missing_manifest_is_a_hard_failure() {
// A directory with no manifest.toml at all.
let (lesson, diags) = load(&fixture("does-not-exist-at-all"));
assert!(lesson.is_none());
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagCode::SchemaViolation);
}
#[test]
fn structured_target_configs_parse_into_typed_builds() {
use cph_model::{Artifact, Step};
let (lesson, diags) = load(&fixture("target-configs"));
let lesson = lesson.expect("target-configs fixture must produce a Lesson");
assert!(
diags.is_empty(),
"well-formed target configs must have no diagnostics, got: {diags:?}"
);
// An array `author` loads as the ordered author list.
assert_eq!(
lesson.info.authors,
vec!["张老师".to_string(), "李老师".to_string()]
);
// Declared order is preserved.
assert_eq!(lesson.target_names(), vec!["student", "archive", "teacher"]);
// student: explicit single-file artifact (with filepath) + one
// typst-compile step (with template).
let student = &lesson.targets[0];
assert_eq!(student.name, "student");
assert_eq!(
student.artifact,
Artifact::SingleFile {
filepath: PathBuf::from("build/student.pdf"),
}
);
assert_eq!(
student.steps,
vec![Step::TypstCompile {
template: PathBuf::from("exports/student.typ"),
}]
);
// archive: file-tree artifact (root + outputs glob) + two ordered steps,
// a typst-compile followed by a shell step (order preserved).
let archive = &lesson.targets[1];
assert_eq!(archive.name, "archive");
assert_eq!(
archive.artifact,
Artifact::FileTree {
root: PathBuf::from("build/archive"),
outputs: "**/*.{html,js,json}".to_string(),
}
);
assert_eq!(
archive.steps,
vec![
Step::TypstCompile {
template: PathBuf::from("exports/archive.typ"),
},
Step::Shell {
run: "npm run build".to_string(),
},
]
);
// teacher: empty body → all defaults.
let teacher = &lesson.targets[2];
assert_eq!(teacher.name, "teacher");
assert_eq!(
teacher.artifact,
Artifact::SingleFile {
filepath: PathBuf::from("build/teacher.pdf"),
}
);
assert_eq!(
teacher.steps,
vec![Step::TypstCompile {
template: PathBuf::from("exports/teacher.typ"),
}]
);
}
#[test]
fn malformed_target_config_is_non_fatal_with_schema_violations() {
use cph_model::{Artifact, Step};
let (lesson, diags) = load(&fixture("bad-target-config"));
let lesson = lesson.expect("bad-target-config must still produce a Lesson");
// Both targets survive despite their malformed fields.
assert_eq!(lesson.target_names(), vec!["student", "teacher"]);
// Bad `artifact.type` → SchemaViolation, target kept with default artifact.
let student = &lesson.targets[0];
assert_eq!(
student.artifact,
Artifact::SingleFile {
filepath: PathBuf::from("build/student.pdf"),
}
);
// Bad step `type` → the step is skipped; the file-tree artifact survives and
// the empty step list falls back to the default step.
let teacher = &lesson.targets[1];
assert_eq!(
teacher.artifact,
Artifact::FileTree {
root: PathBuf::from("build/teacher"),
outputs: "**/*.html".to_string(),
}
);
assert_eq!(
teacher.steps,
vec![Step::TypstCompile {
template: PathBuf::from("exports/teacher.typ"),
}]
);
let violations: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::SchemaViolation)
.collect();
assert_eq!(
violations.len(),
2,
"expected one SchemaViolation per malformed field, got: {diags:?}"
);
assert!(
violations.iter().any(|d| d.message.contains("pdf-thing")),
"a diagnostic should name the bad artifact type, got: {diags:?}"
);
assert!(
violations
.iter()
.any(|d| d.message.contains("make-it-nice")),
"a diagnostic should name the bad step type, got: {diags:?}"
);
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "cph-schema"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
cph-diag = { workspace = true }
cph-model = { workspace = true }
serde_json = "1"
toml = { workspace = true }
[dev-dependencies]
toml = { workspace = true }
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "cph:kind/example",
"title": "example",
"type": "object",
"properties": {
"problem": { "type": "string", "x-cph-content": true },
"solution": { "type": "string", "x-cph-content": true },
"slides": { "type": "string", "x-cph-content": "md" },
"transcript": { "type": "string", "x-cph-content": "md" },
"source": { "type": "string" }
},
"required": ["problem", "solution"],
"additionalProperties": false
}
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "cph:kind/lemma",
"title": "lemma",
"type": "object",
"properties": {
"stmt": { "type": "string", "x-cph-content": true },
"proof": { "type": "string", "x-cph-content": true },
"slides": { "type": "string", "x-cph-content": "md" },
"transcript": { "type": "string", "x-cph-content": "md" }
},
"required": ["stmt"],
"additionalProperties": false
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "cph:kind/segment",
"title": "segment",
"type": "object",
"properties": {
"textbook": { "type": "string", "x-cph-content": true },
"slides": { "type": "string", "x-cph-content": "md" },
"transcript": { "type": "string", "x-cph-content": "md" }
},
"required": ["textbook"],
"additionalProperties": false
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "cph:kind/sop",
"title": "sop",
"type": "object",
"properties": {
"sop": { "type": "string", "x-cph-content": true },
"slides": { "type": "string", "x-cph-content": "md" },
"transcript": { "type": "string", "x-cph-content": "md" }
},
"required": ["sop"],
"additionalProperties": false
}
+548
View File
@@ -0,0 +1,548 @@
//! `cph-schema` — kind JSON Schemas (ADR-0006) + structural validation.
//!
//! Owned by **WU-3**. The schema layer reads each kind's declarative JSON
//! Schema (built-in scalars plus the `content` extension type) and validates
//! element instance data against it, including which `content` `.typ` files
//! must exist.
//!
//! # The `content` extension convention
//!
//! ADR-0006 fixes that a kind's data schema is a declarative **JSON Schema**
//! (draft 2020-12 here) with one extension type — `content` — whose value is a
//! Typst source rather than inline data. JSON Schema has no native `content`
//! type, so this crate marks a content field with a **custom keyword**:
//!
//! ```json
//! { "type": "string", "x-cph-content": true }
//! ```
//!
//! The `x-cph-content: true` marker is the single convention this crate honors.
//! A field carrying it is treated as a **content field**: it does NOT appear in
//! `element.toml`; instead the validator checks that the sibling file
//! `<field>.typ` exists in the element's directory (ADR-0008). A required
//! content field whose `.typ` file is missing yields a
//! [`cph_diag::DiagCode::MissingContentFile`]; an *optional* content field
//! (one not in the schema's `required` list, e.g. a lemma's `proof`) may be
//! absent with no diagnostic. All other (non-`x-cph-content`) properties are
//! **scalar** fields that live in `element.toml` and are validated structurally.
//!
//! The four stdlib kind schemas live as real JSON artifacts under
//! `crates/cph-schema/schemas/{segment,example,lemma,sop}.json` and are
//! compiled in via `include_str!`, parsed once on first use.
//!
//! # Validation approach
//!
//! The MVP scalar surface is intentionally tiny (a single optional string on
//! `example`), so rather than pull in a full JSON Schema engine (and its
//! version churn), this crate uses `serde_json` to parse the schemas and a
//! small hand-rolled interpreter that understands exactly the keywords the
//! stdlib schemas use: `properties`, `required`, `type`, `enum`,
//! `additionalProperties`, plus the `x-cph-content` extension marker. This
//! keeps the schemas as declarative artifacts (ADR-0006 intent) while keeping
//! the validator dependency-light and fully under our control.
use std::collections::BTreeMap;
use std::sync::OnceLock;
use cph_diag::{DiagCode, Diagnostic};
use cph_model::ElementDescriptor;
use serde_json::Value as Json;
/// The custom JSON Schema keyword marking a field as a `content` (Typst-source)
/// field rather than a scalar. See the crate docs for the convention.
const CONTENT_MARKER: &str = "x-cph-content";
/// The known stdlib kinds, in a stable order.
const KNOWN_KINDS: &[&str] = &["segment", "example", "lemma", "sop"];
/// Raw schema JSON for each kind, embedded at compile time.
const SCHEMA_SOURCES: &[(&str, &str)] = &[
("segment", include_str!("../schemas/segment.json")),
("example", include_str!("../schemas/example.json")),
("lemma", include_str!("../schemas/lemma.json")),
("sop", include_str!("../schemas/sop.json")),
];
/// The scalar `type` a property declares (the subset the stdlib schemas use).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ScalarType {
String,
Number,
Integer,
Boolean,
}
impl ScalarType {
fn from_json(s: &str) -> Option<Self> {
match s {
"string" => Some(ScalarType::String),
"number" => Some(ScalarType::Number),
"integer" => Some(ScalarType::Integer),
"boolean" => Some(ScalarType::Boolean),
_ => None,
}
}
fn label(self) -> &'static str {
match self {
ScalarType::String => "string",
ScalarType::Number => "number",
ScalarType::Integer => "integer",
ScalarType::Boolean => "boolean",
}
}
/// Whether `value` (a scalar coming from `element.toml`, already converted
/// to JSON) satisfies this declared type.
fn matches(self, value: &Json) -> bool {
match self {
ScalarType::String => value.is_string(),
ScalarType::Number => value.is_number(),
ScalarType::Integer => value.is_i64() || value.is_u64(),
ScalarType::Boolean => value.is_boolean(),
}
}
}
/// One scalar property of a kind (a field that lives in `element.toml`).
#[derive(Debug, Clone)]
struct ScalarField {
name: String,
/// Declared `type`, if any. `None` means "unconstrained" (we still accept
/// the field but apply no type check).
ty: Option<ScalarType>,
/// Allowed values from a JSON Schema `enum`, as JSON values.
allowed: Option<Vec<Json>>,
required: bool,
}
/// One content property of a kind (a field whose value is a sibling content
/// file, marked with `x-cph-content`). The file extension is determined by the
/// marker's value: `true` (or `"typ"`) → `.typ`; any other string → that
/// extension (e.g. `"md"` → `.md`, ADR-0015 markdown content surfaces).
#[derive(Debug, Clone)]
struct ContentField {
name: String,
required: bool,
/// The content file's extension without the dot (e.g. `"typ"`, `"md"`).
ext: String,
}
/// A parsed kind schema: the content fields (which drive `.typ` existence
/// checks) and the scalar fields (validated against `element.toml`).
#[derive(Debug, Clone)]
pub struct KindSchema {
kind: String,
content_fields: Vec<ContentField>,
scalar_fields: Vec<ScalarField>,
/// Whether the schema forbids properties beyond those declared
/// (`additionalProperties: false`).
deny_additional: bool,
}
impl KindSchema {
/// The kind name this schema describes (e.g. `"example"`).
pub fn kind(&self) -> &str {
&self.kind
}
/// The names of the `content` fields (those requiring a `<field>.typ`
/// file), in schema order.
pub fn content_field_names(&self) -> Vec<&str> {
self.content_fields
.iter()
.map(|f| f.name.as_str())
.collect()
}
/// The names of the scalar fields (those living in `element.toml`), in
/// schema order.
pub fn scalar_field_names(&self) -> Vec<&str> {
self.scalar_fields.iter().map(|f| f.name.as_str()).collect()
}
/// Parse a kind schema from its raw JSON source. Panics on a malformed
/// schema — schemas are compile-time artifacts owned by this crate, so a
/// bad one is a build-time bug, not a runtime condition.
fn parse(kind: &str, src: &str) -> Self {
let root: Json = serde_json::from_str(src)
.unwrap_or_else(|e| panic!("kind schema for '{kind}' is not valid JSON: {e}"));
let deny_additional = root
.get("additionalProperties")
.and_then(Json::as_bool)
.map(|b| !b)
.unwrap_or(false);
let required: Vec<String> = root
.get("required")
.and_then(Json::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let is_required = |name: &str| required.iter().any(|r| r == name);
let mut content_fields = Vec::new();
let mut scalar_fields = Vec::new();
let props = root
.get("properties")
.and_then(Json::as_object)
.unwrap_or_else(|| panic!("kind schema for '{kind}' has no `properties` object"));
for (name, prop) in props {
// The `x-cph-content` marker selects a content field and fixes its
// file extension. `true` (the legacy form) and the string `"typ"`
// both mean a `.typ` file; any other string is taken as the
// extension (e.g. `"md"` → `.md` for markdown content surfaces,
// ADR-0015). Absent ⇒ a scalar field.
let ext = match prop.get(CONTENT_MARKER) {
Some(Json::Bool(true)) => Some("typ".to_string()),
Some(Json::String(s)) => Some(s.clone()),
_ => None,
};
if let Some(ext) = ext {
content_fields.push(ContentField {
name: name.clone(),
required: is_required(name),
ext,
});
} else {
let ty = prop
.get("type")
.and_then(Json::as_str)
.and_then(ScalarType::from_json);
let allowed = prop
.get("enum")
.and_then(Json::as_array)
.map(|a| a.to_vec());
scalar_fields.push(ScalarField {
name: name.clone(),
ty,
allowed,
required: is_required(name),
});
}
}
KindSchema {
kind: kind.to_string(),
content_fields,
scalar_fields,
deny_additional,
}
}
}
/// The lazily-built registry mapping kind name → parsed schema.
fn registry() -> &'static BTreeMap<&'static str, KindSchema> {
static REGISTRY: OnceLock<BTreeMap<&'static str, KindSchema>> = OnceLock::new();
REGISTRY.get_or_init(|| {
SCHEMA_SOURCES
.iter()
.map(|(kind, src)| (*kind, KindSchema::parse(kind, src)))
.collect()
})
}
/// Look up the parsed [`KindSchema`] for `kind`, or `None` if it is unknown.
pub fn schema_for(kind: &str) -> Option<&'static KindSchema> {
registry().get(kind)
}
/// The known stdlib kind names (`["segment", "example", "lemma", "sop"]`),
/// for use by the orchestrator (`cph-check`).
pub fn known_kinds() -> &'static [&'static str] {
KNOWN_KINDS
}
/// Validate an element instance against its kind's schema.
///
/// Self-contained: looks up the schema by `desc.kind` and, if the kind is
/// unknown, returns a single [`cph_diag::DiagCode::UnknownKind`] diagnostic.
/// Otherwise it checks, in order:
///
/// 1. **Scalar fields** (`desc.scalars`, a `toml::Table`) against the
/// non-content part of the schema — required-ness, declared `type`, and
/// `enum`. Violations are [`cph_diag::DiagCode::SchemaViolation`]. Under an
/// `additionalProperties: false` schema, an unexpected scalar key is also a
/// `SchemaViolation`.
/// 2. **Content fields**: for each required content field, that the sibling
/// file `desc.dir.join("<field>.typ")` exists; if not, a
/// [`cph_diag::DiagCode::MissingContentFile`]. Optional content fields may be
/// absent with no diagnostic.
///
/// Every diagnostic carries a fix `hint`. `MissingContentFile` names the
/// expected path in its message; `span` is left `None` (there is no line/col
/// for a file that does not exist).
pub fn validate(desc: &ElementDescriptor) -> Vec<Diagnostic> {
let Some(schema) = schema_for(&desc.kind) else {
return vec![Diagnostic::error(
DiagCode::UnknownKind,
format!("unknown element kind '{}'", desc.kind),
)
.with_hint(format!("valid kinds are: {}", KNOWN_KINDS.join(", ")))];
};
let mut diags = Vec::new();
validate_scalars(schema, desc, &mut diags);
validate_content_files(schema, desc, &mut diags);
diags
}
/// Validate the scalar fields in `desc.scalars` against the schema's scalar
/// part.
fn validate_scalars(schema: &KindSchema, desc: &ElementDescriptor, diags: &mut Vec<Diagnostic>) {
let scalars = &desc.scalars;
// Required scalar fields must be present.
for field in &schema.scalar_fields {
if field.required && !scalars.contains_key(&field.name) {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"element of kind '{}' is missing required field '{}'",
schema.kind, field.name
),
)
.with_hint(format!("add `{} = ...` to element.toml", field.name)),
);
}
}
// Each present scalar: type / enum checks, and (if known) reject unexpected
// keys when additionalProperties is false. Content fields must never appear
// in element.toml.
for (key, value) in scalars {
if let Some(field) = schema.content_fields.iter().find(|f| f.name == *key) {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"field '{}' of kind '{}' is a content field and must not appear in element.toml",
key, schema.kind
),
)
.with_hint(format!(
"remove '{key}' from element.toml; put its content in the sibling file '{key}.{}'",
field.ext
)),
);
continue;
}
let Some(field) = schema.scalar_fields.iter().find(|f| f.name == *key) else {
if schema.deny_additional {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("unexpected field '{}' for kind '{}'", key, schema.kind),
)
.with_hint(format!(
"remove '{key}' from element.toml (it is not part of the '{}' schema)",
schema.kind
)),
);
}
continue;
};
let json = toml_to_json(value);
if let Some(ty) = field.ty {
if !ty.matches(&json) {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"field '{}' of kind '{}' must be a {}, got {}",
key,
schema.kind,
ty.label(),
json_type_label(&json)
),
)
.with_hint(format!(
"set '{key}' to a {} value in element.toml",
ty.label()
)),
);
continue;
}
}
if let Some(allowed) = &field.allowed {
if !allowed.contains(&json) {
let choices: Vec<String> = allowed.iter().map(render_json_choice).collect();
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"field '{}' of kind '{}' has value {} which is not one of the allowed values",
key,
schema.kind,
render_json_choice(&json)
),
)
.with_hint(format!("'{key}' must be one of: {}", choices.join(", "))),
);
}
}
}
}
/// For each required content field, check that its `<field>.<ext>` file exists
/// in the element directory (`.typ` for typst content, `.md` for markdown
/// content surfaces — ADR-0015).
fn validate_content_files(
schema: &KindSchema,
desc: &ElementDescriptor,
diags: &mut Vec<Diagnostic>,
) {
for field in &schema.content_fields {
if !field.required {
// Optional content (e.g. lemma's `proof`): absence is fine.
continue;
}
let file_name = format!("{}.{}", field.name, field.ext);
let path = desc.dir.join(&file_name);
if !path.is_file() {
diags.push(
Diagnostic::error(
DiagCode::MissingContentFile,
format!(
"kind '{}' requires the content file '{}', which is missing",
schema.kind,
path.display()
),
)
.with_hint(format!(
"create '{file_name}' in the element folder with the {} source for '{}'",
ext_label(&field.ext),
field.name
)),
);
}
}
}
/// A human label for a content-file extension, used in diagnostic hints.
fn ext_label(ext: &str) -> &'static str {
match ext {
"md" => "markdown",
"typ" => "typst",
_ => "content",
}
}
/// Convert a `toml::Value` scalar into the equivalent `serde_json::Value` for
/// type checking. Composite TOML values (arrays / tables) are converted
/// structurally too, though the MVP stdlib schemas only use scalars.
fn toml_to_json(value: &toml::Value) -> Json {
match value {
toml::Value::String(s) => Json::String(s.clone()),
toml::Value::Integer(i) => Json::from(*i),
toml::Value::Float(f) => Json::from(*f),
toml::Value::Boolean(b) => Json::Bool(*b),
toml::Value::Datetime(dt) => Json::String(dt.to_string()),
toml::Value::Array(arr) => Json::Array(arr.iter().map(toml_to_json).collect()),
toml::Value::Table(tbl) => Json::Object(
tbl.iter()
.map(|(k, v)| (k.clone(), toml_to_json(v)))
.collect(),
),
}
}
/// A short label for a JSON value's runtime type, for diagnostic messages.
fn json_type_label(value: &Json) -> &'static str {
match value {
Json::Null => "null",
Json::Bool(_) => "boolean",
Json::Number(n) => {
if n.is_i64() || n.is_u64() {
"integer"
} else {
"number"
}
}
Json::String(_) => "string",
Json::Array(_) => "array",
Json::Object(_) => "object",
}
}
/// Render a JSON value for an enum-choice list (strings unquoted-ish, others as
/// JSON).
fn render_json_choice(value: &Json) -> String {
match value {
Json::String(s) => format!("\"{s}\""),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_has_all_known_kinds() {
for kind in known_kinds() {
assert!(schema_for(kind).is_some(), "missing schema for {kind}");
}
assert!(schema_for("frob").is_none());
}
#[test]
fn example_field_split() {
let s = schema_for("example").unwrap();
// `problem`/`solution` are typst content; `slides`/`transcript` are the
// optional markdown content surfaces (ADR-0015); `source` is a scalar.
// (serde_json's Map is a BTreeMap without the `preserve_order` feature,
// so content field names come back alphabetically sorted.)
assert_eq!(
s.content_field_names(),
vec!["problem", "slides", "solution", "transcript"]
);
assert_eq!(s.scalar_field_names(), vec!["source"]);
}
#[test]
fn lemma_proof_is_optional_content() {
let s = schema_for("lemma").unwrap();
let proof = s.content_fields.iter().find(|f| f.name == "proof").unwrap();
assert!(!proof.required);
let stmt = s.content_fields.iter().find(|f| f.name == "stmt").unwrap();
assert!(stmt.required);
}
#[test]
fn markdown_content_fields_carry_md_ext() {
// ADR-0015: `slides`/`transcript` are markdown content fields (`x-cph-content: "md"`),
// distinct from the `.typ` content fields.
let s = schema_for("segment").unwrap();
let slides = s.content_fields.iter().find(|f| f.name == "slides").unwrap();
assert_eq!(slides.ext, "md");
assert!(!slides.required);
let transcript = s
.content_fields
.iter()
.find(|f| f.name == "transcript")
.unwrap();
assert_eq!(transcript.ext, "md");
assert!(!transcript.required);
// Legacy typst content still resolves to `.typ`.
let textbook = s
.content_fields
.iter()
.find(|f| f.name == "textbook")
.unwrap();
assert_eq!(textbook.ext, "typ");
assert!(textbook.required);
}
}
@@ -0,0 +1,2 @@
= Problem
only the problem here
@@ -0,0 +1,2 @@
= Problem
What is 2+2?
@@ -0,0 +1,2 @@
= Solution
4.
@@ -0,0 +1,2 @@
= Statement
A lemma.
+76
View File
@@ -0,0 +1,76 @@
//! Integration tests for `cph-schema::validate`, driven by tiny fixtures under
//! `tests/fixtures/<case>/` and in-code `ElementDescriptor`s.
use std::path::{Path, PathBuf};
use cph_diag::DiagCode;
use cph_model::ElementDescriptor;
use cph_schema::validate;
/// Absolute path to a fixture directory.
fn fixture(case: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(case)
}
fn desc(kind: &str, dir: PathBuf, scalars: toml::Table) -> ElementDescriptor {
ElementDescriptor {
kind: kind.to_string(),
dir,
scalars,
}
}
#[test]
fn valid_example_no_diagnostics() {
let mut scalars = toml::Table::new();
scalars.insert("source".into(), toml::Value::String("Textbook §3".into()));
let d = desc("example", fixture("example_valid"), scalars);
let diags = validate(&d);
assert!(diags.is_empty(), "expected no diagnostics, got {diags:?}");
}
#[test]
fn example_missing_solution_typ_is_missing_content_file() {
let d = desc(
"example",
fixture("example_missing_solution"),
toml::Table::new(),
);
let diags = validate(&d);
assert_eq!(diags.len(), 1, "got {diags:?}");
assert_eq!(diags[0].code, DiagCode::MissingContentFile);
assert!(diags[0].message.contains("solution.typ"));
assert!(diags[0].hint.is_some());
}
#[test]
fn lemma_without_proof_typ_is_ok() {
// stmt.typ exists, proof.typ does not — proof is optional, so no diagnostic.
let d = desc("lemma", fixture("lemma_no_proof"), toml::Table::new());
let diags = validate(&d);
assert!(diags.is_empty(), "expected no diagnostics, got {diags:?}");
}
#[test]
fn unknown_kind_is_reported() {
let d = desc("frob", fixture("example_valid"), toml::Table::new());
let diags = validate(&d);
assert_eq!(diags.len(), 1, "got {diags:?}");
assert_eq!(diags[0].code, DiagCode::UnknownKind);
assert!(diags[0].message.contains("frob"));
}
#[test]
fn example_source_non_string_is_schema_violation() {
let mut scalars = toml::Table::new();
scalars.insert("source".into(), toml::Value::Integer(42));
let d = desc("example", fixture("example_valid"), scalars);
let diags = validate(&d);
assert_eq!(diags.len(), 1, "got {diags:?}");
assert_eq!(diags[0].code, DiagCode::SchemaViolation);
assert!(diags[0].message.contains("source"));
assert!(diags[0].message.contains("string"));
}
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "cph-typst"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
cph-diag = { workspace = true }
cph-model = { workspace = true }
cph-schema = { path = "../cph-schema" }
toml = { workspace = true }
# Embed the `render/` package (cph-render + vendored @preview/numbly) INTO the
# binary so an installed `cph` is self-contained — no reliance on a source
# checkout living at a compile-time path. `dirs` locates a per-user cache dir to
# extract the embedded tree to (typst's World reads render files from disk).
include_dir = "0.7"
dirs = "6"
typst = "=0.15.0"
typst-layout = "=0.15.0"
typst-pdf = "=0.15.0"
typst-syntax = "=0.15.0"
typst-library = "=0.15.0"
typst-kit = { version = "=0.15.0", features = [
"scan-fonts",
"embedded-fonts",
] }
[dev-dependencies]
cph-model = { workspace = true }
tempfile = "3"
+64
View File
@@ -0,0 +1,64 @@
//! Build script: stage a clean copy of the repo `render/` package into `OUT_DIR`
//! so `src/embedded.rs` can `include_dir!` it without tripping over the dev-only
//! self-referential symlink at `render/vendor/local-packages/`.
//!
//! Why this exists: `include_dir!` walks a directory at compile time and follows
//! symlinks. The repo keeps `render/vendor/local-packages/local/cph-render/0.1.0`
//! as a symlink pointing back at `render/` (a gitignored dev convenience for
//! typst's local-package resolution). Embedding `render/` directly would make
//! `include_dir!` recurse into that link forever. The runtime never needs that
//! link (the engine resolves `@local/cph-render` from the render dir directly,
//! and `@preview/*` from `vendor/typst-packages/`), so we copy everything EXCEPT
//! `vendor/local-packages/` into `OUT_DIR/render` and embed that staged tree.
use std::path::{Path, PathBuf};
fn main() {
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let render_src = manifest_dir.join("..").join("..").join("render");
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let staged = out_dir.join("render");
// Rebuild the staged tree from scratch each run (cheap — ~140 KiB).
if staged.exists() {
let _ = std::fs::remove_dir_all(&staged);
}
copy_tree(&render_src, &staged).expect("stage render/ for embedding");
// Re-run when the render package changes.
println!("cargo:rerun-if-changed={}", render_src.display());
// Expose the staged path to the crate via an env var include_dir! can read.
println!("cargo:rustc-env=CPH_STAGED_RENDER_DIR={}", staged.display());
}
/// Recursively copy `src` → `dst`, skipping the dev-only `vendor/local-packages`
/// subtree and never following symlinks into it.
fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let name = entry.file_name();
let from = entry.path();
let to = dst.join(&name);
// Skip the self-referential dev symlink tree.
if from.ends_with("vendor/local-packages") || name == *"local-packages" {
continue;
}
// Resolve symlinks/metadata without following into a cycle: use
// `symlink_metadata` so a symlinked dir is treated by its link, not its
// (recursive) target. We only copy real files and real directories.
let meta = std::fs::symlink_metadata(&from)?;
let ft = meta.file_type();
if ft.is_symlink() {
// Skip any symlink (the only one in the tree is the dev convenience).
continue;
} else if ft.is_dir() {
copy_tree(&from, &to)?;
} else if ft.is_file() {
std::fs::copy(&from, &to)?;
}
}
Ok(())
}
+120
View File
@@ -0,0 +1,120 @@
//! Map typst [`SourceDiagnostic`]s to [`cph_diag::Diagnostic`]s.
//!
//! ## Span mapping (typst 0.15 specifics)
//!
//! In 0.15 a diagnostic's `span` is a [`typst::syntax::DiagSpan`] (not the bare
//! `Span` of 0.14). To locate it we:
//! 1. `span.id() -> Option<FileId>` for the owning file (`None` ⇒ detached, so
//! no span is emitted);
//! 2. `WorldExt::range(span) -> Option<Range<usize>>` for the byte range — this
//! handles every `DiagSpanKind` variant internally (numbered source spans
//! *and* raw external byte ranges), so we never match the enum by hand;
//! 3. compute 1-based line/col from the file's [`typst::syntax::Source`] via
//! `lines().byte_to_line_column` (0-based ⇒ `+1`).
//!
//! The `main` file is now the real **template** (`exports/<target>.typ`), so a
//! span there resolves naturally to an authored file — no re-pointing needed. A
//! span landing in the **virtual augmented manifest** ([`crate::MANIFEST_VPATH`],
//! served in-memory, not on disk) is reported with its vpath plus a hint marking
//! it as the framework-synthesized manifest (a manifest-generation bug
//! indicator) rather than mis-attributed to a user file. Spans in the
//! `cph-render` package are reported with an `@local/cph-render:…` pseudo-path.
use std::path::PathBuf;
use cph_diag::{DiagCode, Diagnostic, Severity, SourceSpan};
use typst::diag::{Severity as TypstSeverity, SourceDiagnostic};
use typst::syntax::{FileId, VirtualRoot};
use typst::{World, WorldExt};
use crate::world::LessonWorld;
/// Convert one typst diagnostic into a `cph-diag` [`Diagnostic`], resolving its
/// span against `world`.
pub fn map_diagnostic(world: &LessonWorld, d: &SourceDiagnostic) -> Diagnostic {
let severity = match d.severity {
TypstSeverity::Error => Severity::Error,
TypstSeverity::Warning => Severity::Warning,
};
// Flatten the typst call trace into the message tail so call-site context
// isn't lost (the primary span points at the innermost node).
let mut message = d.message.to_string();
for tp in &d.trace {
message.push_str(&format!("\n in {}", tp.v));
}
let mut diag = Diagnostic {
severity,
code: DiagCode::TypstCompile,
message,
span: None,
hint: None,
};
// typst's own hints are the product's core value — carry them through.
let mut hints: Vec<String> = d.hints.iter().map(|h| h.v.to_string()).collect();
if let Some(id) = d.span.id() {
if let Some(file) = file_for(world, id) {
if is_virtual_manifest(id) {
hints.push(
"this span is inside the framework-synthesized manifest \
(/.cph/manifest.toml), not a lesson source file — likely a \
manifest-generation issue"
.to_string(),
);
}
// Byte range via WorldExt::range (DiagSpan is Copy).
let byte_range = world.range(d.span).unwrap_or(0..0);
let (line, col) = line_col(world, id, byte_range.start);
diag.span = Some(SourceSpan {
file,
byte_range,
line,
col,
});
}
}
if !hints.is_empty() {
diag.hint = Some(hints.join("; "));
}
diag
}
/// Whether `id` is the in-memory augmented-manifest virtual file
/// ([`crate::MANIFEST_VPATH`]) the engine injects (not a real lesson file).
fn is_virtual_manifest(id: FileId) -> bool {
matches!(id.root(), VirtualRoot::Project)
&& id.vpath().get_with_slash() == crate::MANIFEST_VPATH
}
/// 1-based (line, col) for a byte offset in file `id`, defaulting to `(1, 1)`
/// when the source can't be read or the offset is out of range.
fn line_col(world: &LessonWorld, id: FileId, byte: usize) -> (u32, u32) {
let Ok(source) = world.source(id) else {
return (1, 1);
};
match source.lines().byte_to_line_column(byte) {
// byte_to_line_column is 0-based; editors are 1-based.
Some((line, col)) => (line as u32 + 1, col as u32 + 1),
None => (1, 1),
}
}
/// File path for a `FileId`. Project files are relative to the lesson root (no
/// leading slash); package files use an `@namespace/name:version/path` form.
fn file_for(world: &LessonWorld, id: FileId) -> Option<PathBuf> {
let _ = world;
match id.root() {
VirtualRoot::Project => Some(PathBuf::from(id.vpath().get_without_slash())),
VirtualRoot::Package(spec) => Some(PathBuf::from(format!(
"@{}/{}:{}{}",
spec.namespace,
spec.name,
spec.version,
id.vpath().get_with_slash()
))),
}
}
+104
View File
@@ -0,0 +1,104 @@
//! Embedded `cph-render` package — makes an installed `cph` self-contained.
//!
//! The typst [`crate::world::LessonWorld`] reads the render package
//! (`@local/cph-render` + the vendored `@preview/numbly`) **from disk**, by
//! `render_dir.join(vpath.realize)`. In a source checkout that dir is the repo's
//! `render/`. But a `cargo install`-ed binary has no repo around it — the old
//! `env!("CARGO_MANIFEST_DIR")/../../render` path points into the build sandbox
//! and does not exist at runtime.
//!
//! So we **embed the whole `render/` tree into the binary** at compile time
//! (`include_dir!`) and, on first use, **extract it to a per-user cache dir**
//! keyed by the crate version. `default_render_dir` returns that extracted path.
//! The tree is tiny (~140 KiB, ~33 files), so embedding is cheap and extraction
//! is a one-time cost per version.
//!
//! `render/vendor/local-packages/` (a dev-only self-referential symlink,
//! gitignored) is excluded by the build script when it stages the tree for
//! embedding — see `build.rs`. The embedded copy never contains it, which is
//! correct: the World resolves `@local/cph-render` from `render_dir` directly,
//! and `@preview/*` from `vendor/typst-packages/`, neither via that symlink.
use std::path::{Path, PathBuf};
use include_dir::{include_dir, Dir};
/// The `render/` tree, embedded at compile time from the **staged** copy the
/// build script wrote to `OUT_DIR/render` (which omits the dev-only symlink that
/// would otherwise make `include_dir!` recurse forever). `CPH_STAGED_RENDER_DIR`
/// is set by `build.rs`.
static RENDER_DIR: Dir<'_> = include_dir!("$CPH_STAGED_RENDER_DIR");
/// Resolve the directory the typst World should read the render package from,
/// extracting the embedded copy to a per-user cache dir if needed.
///
/// Resolution order:
/// 1. `CPH_RENDER_DIR` env var — an explicit override (dev convenience: point
/// at the live repo `render/`).
/// 2. The extracted embedded copy under the user cache dir
/// (`<cache>/cph/render-<version>/`). Extracted once per crate version;
/// subsequent runs reuse it.
///
/// On any failure to locate a cache dir or extract, falls back to a temp-dir
/// location so the engine still works (just re-extracting per process).
pub fn resolve_render_dir() -> PathBuf {
if let Ok(dir) = std::env::var("CPH_RENDER_DIR") {
return PathBuf::from(dir);
}
ensure_extracted().unwrap_or_else(|_| {
// Last-resort: extract under the OS temp dir. Still correct, just not
// cached across processes.
let fallback = std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION")));
let _ = extract_to(&fallback);
fallback
})
}
/// The version-keyed cache location and a guarantee the embedded tree is present
/// there. Returns the directory the World should use.
fn ensure_extracted() -> std::io::Result<PathBuf> {
let base = dirs::cache_dir()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?;
let dest = base
.join("cph")
.join(format!("render-{}", env!("CARGO_PKG_VERSION")));
// A sentinel marks a complete extraction; if present, reuse as-is. (Keyed by
// version, so a new `cph` version re-extracts into a fresh dir.)
let sentinel = dest.join(".extracted");
if sentinel.is_file() {
return Ok(dest);
}
extract_to(&dest)?;
std::fs::write(&sentinel, env!("CARGO_PKG_VERSION"))?;
Ok(dest)
}
/// Write the embedded `render/` tree under `dest` (creating dirs as needed).
///
/// Each embedded entry's `.path()` is already relative to the embed root (e.g.
/// `src/elements/segment.typ`), so we join the **full** path under `dest` and
/// create parents — no per-level mirroring needed.
fn extract_to(dest: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dest)?;
write_dir(&RENDER_DIR, dest)
}
/// Recursively write an embedded [`Dir`]'s files under `dest`, preserving the
/// full relative path of each file.
fn write_dir(dir: &Dir<'_>, dest: &Path) -> std::io::Result<()> {
for entry in dir.entries() {
match entry {
include_dir::DirEntry::Dir(sub) => write_dir(sub, dest)?,
include_dir::DirEntry::File(file) => {
let file_dest = dest.join(file.path());
if let Some(parent) = file_dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&file_dest, file.contents())?;
}
}
}
Ok(())
}
+251
View File
@@ -0,0 +1,251 @@
//! `cph-typst` — typst `World`, augmented-manifest injection, compile, PDF,
//! span mapping.
//!
//! Owned by **WU-4**. Embeds the typst 0.15 compiler as a library to:
//! - **compile-check** a [`cph_model::Lesson`] for a target (collect typst
//! errors+warnings as [`cph_diag::Diagnostic`]s), and
//! - **export a PDF** for a render target.
//!
//! ## Model (ADR-0011): compile a template, inject a manifest
//!
//! There is **no framework-generated driver** any more. An export target is a
//! build with a typed [`cph_model::Step::TypstCompile`] naming a **template
//! file** (e.g. `exports/student.typ`) that lives in the engineering file. The
//! engine:
//!
//! 1. mounts the lesson's engineering-file tree (ADR-0007) as the typst project
//! root (`--root`);
//! 2. sets the World's `main` to that **real template file** under the root, so
//! diagnostic spans resolve to an authored file;
//! 3. builds an **augmented manifest** (the lesson's `info` + ordered `parts`,
//! each with a `fields` array of the content fields present on disk — see
//! [`manifest`]) and serves it as a virtual in-memory file at
//! [`world::MANIFEST_VPATH`], injecting `sys.inputs.manifest` to point there;
//! 4. lets the template `toml()`-read that manifest and `include` each part's
//! content via computed root-relative paths (legal per ADR-0011).
//!
//! The augmented manifest closes the OPEN optional-content point: typst has no
//! file-exists primitive, so the engine (which has filesystem access) tells the
//! template which optional content fields exist via the per-part `fields` array.
//!
//! ## typst version
//!
//! Pinned to the `typst` family `=0.15.0`. The 0.15 path/file API differs from
//! 0.14: `FileId::new(RootedPath)` where `RootedPath::new(VirtualRoot,
//! VirtualPath)`, and a diagnostic span is a `DiagSpan` (not a bare `Span`).
mod diag;
mod embedded;
mod manifest;
mod world;
use std::path::PathBuf;
use cph_diag::{DiagCode, Diagnostic};
use cph_model::{Artifact, Lesson, Step, TargetConfig};
use typst_kit::fonts::{self, FontStore};
use typst_layout::PagedDocument;
use typst_pdf::PdfOptions;
pub use manifest::build_augmented_manifest;
pub use world::{render_package_spec, LessonWorld, MANIFEST_VPATH};
/// The compile/PDF engine: holds the shared font store and the on-disk location
/// of the `cph-render` package.
///
/// Fonts are discovered once (system + embedded) and shared across every
/// compilation via an [`std::sync::Arc`] inside the per-compile [`LessonWorld`].
///
/// `render_dir` is the directory the World resolves `@local/cph-render` (and the
/// vendored `@preview/*`) from. It is **not** where the compiled template comes
/// from: the template is a real file under the lesson root (`<root>/<template>`),
/// found via the normal project-file mapping. `render_dir` only matters for
/// package resolution.
pub struct Engine {
fonts: std::sync::Arc<FontStore>,
render_dir: PathBuf,
}
impl Engine {
/// Construct an engine, discovering system + embedded fonts. The render
/// package directory is taken from the `CPH_RENDER_DIR` environment variable
/// when set, otherwise the **embedded** render package is extracted to a
/// per-user cache dir and used (see [`default_render_dir`]). This keeps an
/// installed `cph` self-contained — no source checkout required at runtime.
pub fn new() -> Self {
Self::with_render_dir(default_render_dir())
}
/// Construct an engine pointing at an explicit `cph-render` package
/// directory (the folder containing `lib.typ` / `typst.toml`, and the
/// vendored `@preview/*` tree under `vendor/`).
pub fn with_render_dir(render_dir: PathBuf) -> Self {
let mut store = FontStore::new();
// System fonts first (so the host's CJK fonts are preferred), then the
// embedded fallbacks. `scan-fonts` / `embedded-fonts` features gate
// these iterators.
store.extend(fonts::system());
store.extend(fonts::embedded());
Self {
fonts: std::sync::Arc::new(store),
render_dir,
}
}
/// The directory this engine resolves `@local/cph-render` from.
pub fn render_dir(&self) -> &std::path::Path {
&self.render_dir
}
/// Compile-check `lesson` for `target`, returning every typst diagnostic
/// (errors **and** warnings) mapped to [`Diagnostic`]s. An empty `Vec` means
/// a clean compile. Spans are resolved to files relative to `lesson.root`.
///
/// A request the engine cannot honor (unknown target, or an MVP-unsupported
/// artifact/step shape) short-circuits to a single blocking diagnostic; see
/// [`target_precheck`].
pub fn compile_check(&self, lesson: &Lesson, target: &str) -> Vec<Diagnostic> {
let template = match self.world_for(lesson, target) {
Ok(world) => world,
Err(blocking) => return blocking,
};
let world = template;
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 `lesson` / `target`. On a clean compile returns the PDF
/// bytes; on a blocking precheck failure or fatal compile errors returns the
/// mapped diagnostics. Compile warnings are not surfaced here (use
/// [`Engine::compile_check`] for those).
///
/// The bytes are returned, not written: for a [`Artifact::SingleFile`] the
/// artifact's `filepath` is the *default* output location, but the caller
/// (cph-check / the CLI) owns where the PDF lands (and may override with
/// `-o`).
pub fn build_pdf(&self, lesson: &Lesson, target: &str) -> Result<Vec<u8>, Vec<Diagnostic>> {
let world = self.world_for(lesson, 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 `(lesson, target)`, or `Err(blocking)` when
/// the request cannot be honored (see [`target_precheck`]).
fn world_for(&self, lesson: &Lesson, target: &str) -> Result<LessonWorld, Vec<Diagnostic>> {
let template = target_precheck(lesson, target)?;
let manifest_src = build_augmented_manifest(lesson);
Ok(LessonWorld::new(
lesson.root.clone(),
self.render_dir.clone(),
&template,
manifest_src,
self.fonts.clone(),
))
}
}
impl Default for Engine {
fn default() -> Self {
Self::new()
}
}
/// Validate a `(lesson, target)` request and resolve the template path to
/// compile. Returns `Ok(template_path)` (relative to the lesson root) when the
/// request is buildable, or `Err(blocking_diagnostics)` when it is not:
///
/// - **Unknown target** (the `--target` name isn't in `lesson.targets`, and the
/// lesson declares at least one target): a `SchemaViolation` error — a target
/// must be declared in the manifest to be built (ADR-0009).
/// - **No declared targets at all**: not an error — callers (e.g. `cph-check`)
/// may compile-check a defaulted `"student"` target the lesson never declared.
/// The stock template path `exports/<target>.typ` is used (the framework
/// default; ADR-0011 [`cph_model::Step::default_for`]).
/// - **MVP-unsupported shape**: per ADR-0011 only a [`Artifact::SingleFile`] with
/// a [`Step::TypstCompile`] step is implemented this round. A
/// [`Artifact::FileTree`] artifact, or a target whose (only) step is a
/// [`Step::Shell`], returns a clear "not yet implemented" `SchemaViolation`
/// rather than wrong output. The template is taken from the **first**
/// `TypstCompile` step (MVP: one step per target).
fn target_precheck(lesson: &Lesson, target: &str) -> Result<PathBuf, Vec<Diagnostic>> {
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else {
if lesson.targets.is_empty() {
// Lesson declares no targets; the orchestrator compiles a defaulted
// target. Use the stock template path (matches cph-model's default).
return Ok(PathBuf::from(format!("exports/{target}.typ")));
}
return Err(vec![Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{target}' not declared in manifest"),
)
.with_hint(format!(
"add a `[targets.{target}]` table to manifest.toml, or build a declared target"
))]);
};
// MVP supports a single-file artifact only.
if let Artifact::FileTree { .. } = tc.artifact {
return Err(vec![Diagnostic::error(
DiagCode::SchemaViolation,
"file-tree artifact not yet implemented (MVP supports single-file typst-compile)",
)
.with_hint(format!(
"set `[targets.{target}].artifact` to a single-file artifact for now"
))]);
}
template_step(tc).ok_or_else(|| {
vec![Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{target}' has no typst-compile step; only single-file \
typst-compile builds are implemented (MVP)"
),
)
.with_hint(format!(
"add a `typst-compile` step with `template = \"exports/{target}.typ\"`, \
or defer this target (shell/file-tree builds are not yet implemented)"
))]
})
}
/// The template of the first [`Step::TypstCompile`] in `tc`, if any (MVP: a
/// target has one step; a `Shell`-only or `AssembleMarkdown`-only target yields
/// `None`).
fn template_step(tc: &TargetConfig) -> Option<PathBuf> {
tc.steps.iter().find_map(|s| match s {
Step::TypstCompile { template } => Some(template.clone()),
Step::Shell { .. } => None,
Step::AssembleMarkdown { .. } => None,
})
}
/// Map a batch of typst diagnostics to `cph-diag` ones against `world`.
fn map_all(world: &LessonWorld, diags: &[typst::diag::SourceDiagnostic]) -> Vec<Diagnostic> {
diags
.iter()
.map(|d| diag::map_diagnostic(world, d))
.collect()
}
/// The render-package directory the engine resolves `@local/cph-render` (and the
/// vendored `@preview/*`) from when no explicit dir is given.
///
/// Honors `CPH_RENDER_DIR` first (dev override → live repo `render/`); otherwise
/// returns a per-user cache dir holding the **embedded** render tree, extracted
/// on demand (see [`embedded`]). This is what makes a `cargo install`-ed `cph`
/// self-contained: it no longer depends on a source checkout living at a
/// compile-time path.
fn default_render_dir() -> PathBuf {
embedded::resolve_render_dir()
}
+131
View File
@@ -0,0 +1,131 @@
//! Augmented-manifest construction (ADR-0011).
//!
//! The template (`exports/<target>.typ`) reads the manifest via
//! `toml(sys.inputs.manifest)`, then for each part `include`s its content fields
//! by a **computed** path and reads scalar fields from `<path>/element.toml`.
//! For *optional* content fields the template must know whether the file exists
//! on disk — typst has no file-exists primitive and a missing `include` is a
//! hard error (see the OPEN contract point in `render/templates/student.typ`).
//!
//! The ENGINE has filesystem access, so it closes that gap: it builds an
//! **augmented manifest** = the lesson's `[info]` + ordered `[[parts]]`, with a
//! per-part **`fields` array** listing the content fields whose `<field>.typ`
//! actually exists under the lesson root. The augmented manifest is served as an
//! in-memory virtual file in the [`crate::world::LessonWorld`] (it is **never**
//! written to the user's tree), and injected via `sys.inputs.manifest`.
//!
//! ## `fields` is computed from `cph-schema`
//!
//! `cph-schema` already encodes each kind's content fields
//! ([`cph_schema::KindSchema::content_field_names`]) — the same knowledge the
//! render package exposes as `part-fields`. We reuse it here rather than
//! re-deriving a kind→fields map, so the engine and the template agree on what a
//! kind's content fields are. For each part, a content field is listed in
//! `fields` iff `<root>/<part.path>/<field>.typ` is a real file.
use cph_model::Lesson;
/// Build the augmented-manifest TOML source for `lesson`.
///
/// The result is a self-contained TOML document the template's
/// `toml(sys.inputs.manifest)` reads. It carries `[info]` (title + optional
/// author) and the ordered `[[parts]]`, each with `kind`, `path`, and a
/// `fields = [...]` array of the content fields present on disk (per
/// [`present_fields`]). It does **not** reproduce `[project]` or `[targets.*]`
/// — the template only consumes `info` and `parts`.
pub fn build_augmented_manifest(lesson: &Lesson) -> String {
let mut doc = toml::Table::new();
// [info]
let mut info = toml::Table::new();
info.insert(
"title".to_string(),
toml::Value::String(lesson.info.title.clone()),
);
if !lesson.info.authors.is_empty() {
let authors = lesson
.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));
// [[parts]] — preserve declared order; attach the on-disk `fields` array.
let parts: Vec<toml::Value> = lesson
.parts
.iter()
.map(|part| {
let mut entry = toml::Table::new();
entry.insert("kind".to_string(), toml::Value::String(part.kind.clone()));
entry.insert(
"path".to_string(),
toml::Value::String(path_to_forward_slash(&part.path)),
);
let fields = present_fields(lesson, part)
.into_iter()
.map(toml::Value::String)
.collect();
entry.insert("fields".to_string(), toml::Value::Array(fields));
toml::Value::Table(entry)
})
.collect();
doc.insert("parts".to_string(), toml::Value::Array(parts));
toml::to_string(&doc).expect("augmented manifest serializes")
}
/// The content fields of `part`'s kind whose `<root>/<part.path>/<field>.typ`
/// file exists on disk, in schema order.
///
/// The kind→content-fields knowledge is reused from `cph-schema`
/// ([`cph_schema::schema_for`]); an unknown kind has no schema and yields an
/// empty list (the render package surfaces an unknown kind on its own). Both
/// required and optional content fields are probed — listing a *required* field
/// here is harmless (the template includes required fields unconditionally), and
/// it keeps `fields` a faithful "what exists on disk" record.
fn present_fields(lesson: &Lesson, part: &cph_model::Part) -> Vec<String> {
let Some(schema) = cph_schema::schema_for(&part.kind) else {
return Vec::new();
};
let part_dir = lesson.root.join(&part.path);
schema
.content_field_names()
.into_iter()
.filter(|field| part_dir.join(format!("{field}.typ")).is_file())
.map(str::to_string)
.collect()
}
/// Render a relative `PathBuf` as a forward-slash string, dropping any leading
/// `./` or `/` and ignoring `..` (already rejected by the cph-model loader).
/// Element folder names are UTF-8 (e.g. Chinese) and kept verbatim. The template
/// rebuilds an absolute root-relative include path as `"/" + path + "/" + field`,
/// so `path` must be a clean relative slash path.
fn path_to_forward_slash(path: &std::path::Path) -> String {
use std::path::Component;
let mut out = String::new();
for comp in path.components() {
if let Component::Normal(s) = comp {
if !out.is_empty() {
out.push('/');
}
out.push_str(&s.to_string_lossy());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn forward_slash_path_keeps_unicode() {
let p = std::path::Path::new("segments/开场对照导言");
assert_eq!(path_to_forward_slash(p), "segments/开场对照导言");
}
}
+236
View File
@@ -0,0 +1,236 @@
//! [`LessonWorld`] — a typst [`World`] over an engineering-file directory tree
//! plus an in-memory augmented manifest and a disk-mounted `cph-render` package.
//!
//! ## Model (ADR-0011)
//!
//! The `main` file is the target's **template** (e.g. `<root>/exports/student.typ`),
//! a *real* file under the lesson root — no framework-generated driver. The
//! template `toml()`-reads an injected manifest and `include`s each part's
//! content via computed root-relative paths. The engine injects the manifest as
//! a virtual file (see below) and sets `sys.inputs.manifest` to its vpath.
//!
//! ## File resolution
//!
//! - **The template** (`main()`): a `FileId` with `VirtualRoot::Project` whose
//! vpath is the template's path under the lesson root (e.g. `/exports/student.typ`).
//! It is read from disk like any other project file; its `include "/<part>/…"`
//! paths anchor at the lesson root (`--root`).
//! - **The augmented manifest**: served in-memory at `VirtualRoot::Project` +
//! vpath [`MANIFEST_VPATH`] (`/.cph/manifest.toml`). It is **never** written to
//! disk — the engine builds it (original manifest + a per-part `fields` array)
//! and the template reads it via `toml(sys.inputs.manifest)`.
//! - **Project files**: any other `FileId` with `VirtualRoot::Project` is read
//! from `root.join(vpath.realize)`. Used for content `.typ`, images,
//! `element.toml` `toml()`, etc.
//! - **`@local/cph-render`**: a `FileId` whose root is the render package spec is
//! read from `render_dir.join(vpath.realize)`, avoiding installing the package
//! into the typst local-package directory.
//! - **`@preview/<name>:<version>`**: resolved offline from the in-repo vendored
//! preview tree at `<render_dir>/vendor/typst-packages/preview/<name>/<version>/`.
//! The render package imports `@preview/numbly:0.1.0`; reading it from the
//! vendored dir keeps compilation network-free.
//! - Any *other* `@package` namespace yields a `NotFound` error.
//!
//! Sources are cached in a `Mutex<HashMap<FileId, Source>>` so repeated accesses
//! during one compilation are cheap, as the `World` contract expects.
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use typst::diag::{FileError, FileResult};
use typst::foundations::{Bytes, Datetime, Dict, Duration, Value};
use typst::syntax::package::{PackageSpec, PackageVersion};
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Library, LibraryExt, World};
use typst_kit::fonts::FontStore;
/// Root-relative vpath the augmented manifest is served at (in-memory only).
///
/// A **leading slash** is essential: the template lives under `exports/`, and
/// `toml(sys.inputs.manifest)` resolves a relative path against the template's
/// own directory — a bare name would miss. A root-relative absolute path anchors
/// at `--root` (the lesson root) regardless of where the template sits.
pub const MANIFEST_VPATH: &str = "/.cph/manifest.toml";
/// The package spec the template imports and the World mounts from `render_dir`.
pub fn render_package_spec() -> PackageSpec {
PackageSpec {
namespace: "local".into(),
name: "cph-render".into(),
version: PackageVersion {
major: 0,
minor: 1,
patch: 0,
},
}
}
/// A typst `World` for one lesson + target compilation.
pub struct LessonWorld {
/// Engineering-file root (the typst project root).
root: PathBuf,
/// On-disk location of the `cph-render` package.
render_dir: PathBuf,
/// The render package spec (`@local/cph-render:0.1.0`).
render_spec: PackageSpec,
/// FileId of the template entrypoint (a real file under `root`).
main: FileId,
/// FileId of the in-memory augmented manifest.
manifest_id: FileId,
/// The augmented-manifest source (in-memory; never on disk).
manifest_source: Source,
/// Standard library, with `sys.inputs.manifest` set.
library: LazyHash<Library>,
/// Shared font store (book + lazily-loaded fonts).
fonts: Arc<FontStore>,
/// Source cache for on-disk files.
sources: Mutex<HashMap<FileId, Source>>,
}
impl LessonWorld {
/// Build a world whose `main` is the template at `<root>/<template>` and
/// whose injected manifest is `manifest_src` (served virtually at
/// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there).
///
/// `template` is the lesson-root-relative template path (e.g.
/// `exports/student.typ`), taken from the target's `Step::TypstCompile`.
pub fn new(
root: PathBuf,
render_dir: PathBuf,
template: &Path,
manifest_src: String,
fonts: Arc<FontStore>,
) -> Self {
let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template)))
.expect("template vpath is a valid virtual path");
let main = FileId::new(RootedPath::new(VirtualRoot::Project, main_vpath));
let manifest_vpath =
VirtualPath::new(MANIFEST_VPATH).expect("manifest vpath is a valid virtual path");
let manifest_id = FileId::new(RootedPath::new(VirtualRoot::Project, manifest_vpath));
let manifest_source = Source::new(manifest_id, manifest_src);
// Inject `sys.inputs.manifest = "/.cph/manifest.toml"` so the template's
// `toml(sys.inputs.manifest)` reads the augmented manifest.
let mut inputs = Dict::new();
inputs.insert("manifest".into(), Value::Str(MANIFEST_VPATH.into()));
let library = Library::builder().with_inputs(inputs).build();
Self {
root,
render_dir,
render_spec: render_package_spec(),
main,
manifest_id,
manifest_source,
library: LazyHash::new(library),
fonts,
sources: Mutex::new(HashMap::new()),
}
}
/// Map a `FileId` to its on-disk path, honoring the project / render-package
/// / vendored-`@preview` roots. Returns a `FileError` for any other package
/// root.
fn resolve(&self, id: FileId) -> FileResult<PathBuf> {
let vpath = id.vpath();
match id.root() {
VirtualRoot::Project => vpath.realize(&self.root).map_err(Into::into),
VirtualRoot::Package(spec) if *spec == self.render_spec => {
vpath.realize(&self.render_dir).map_err(Into::into)
}
// `@preview/<name>:<version>` → vendored preview tree under the
// render dir. Offline, no download: mirrors typst's preview-cache
// layout so e.g. `@preview/numbly:0.1.0` (imported by the render
// package) reads from
// `<render_dir>/vendor/typst-packages/preview/numbly/0.1.0/`.
VirtualRoot::Package(spec) if spec.namespace == "preview" => {
let pkg_root = self
.render_dir
.join("vendor")
.join("typst-packages")
.join("preview")
.join(spec.name.as_str())
.join(spec.version.to_string());
vpath.realize(&pkg_root).map_err(Into::into)
}
VirtualRoot::Package(spec) => Err(FileError::Package(
typst::diag::PackageError::NotFound(spec.clone()),
)),
}
}
/// Read raw bytes for `id` from disk (virtual files excluded — handled by
/// callers).
fn read_bytes(&self, id: FileId) -> FileResult<Vec<u8>> {
let path = self.resolve(id)?;
std::fs::read(&path).map_err(|e| FileError::from_io(e, &path))
}
}
impl World for LessonWorld {
fn library(&self) -> &LazyHash<Library> {
&self.library
}
fn book(&self) -> &LazyHash<FontBook> {
self.fonts.book()
}
fn main(&self) -> FileId {
self.main
}
fn source(&self, id: FileId) -> FileResult<Source> {
if id == self.manifest_id {
return Ok(self.manifest_source.clone());
}
// Cache hit?
if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) {
return Ok(src.clone());
}
let bytes = self.read_bytes(id)?;
let text = String::from_utf8(bytes).map_err(|_| FileError::InvalidUtf8)?;
let source = Source::new(id, text);
self.sources
.lock()
.expect("sources mutex")
.insert(id, source.clone());
Ok(source)
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
if id == self.manifest_id {
return Ok(Bytes::from_string(self.manifest_source.text().to_string()));
}
let bytes = self.read_bytes(id)?;
Ok(Bytes::new(bytes))
}
fn font(&self, index: usize) -> Option<Font> {
self.fonts.font(index)
}
fn today(&self, _offset: Option<Duration>) -> Option<Datetime> {
// No clock dependence needed; lessons are reproducible.
None
}
}
/// Render a relative `Path` as a forward-slash string, dropping any leading
/// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim.
fn path_to_forward_slash(path: &Path) -> String {
let mut out = String::new();
for comp in path.components() {
if let Component::Normal(s) = comp {
if !out.is_empty() {
out.push('/');
}
out.push_str(&s.to_string_lossy());
}
}
out
}
+264
View File
@@ -0,0 +1,264 @@
//! 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.
//!
//! 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::{build_augmented_manifest, Engine};
fn fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mini")
}
fn real_render_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("render")
}
fn load_mini() -> cph_model::Lesson {
let (lesson, diags) = cph_model::load(&fixture_root());
let lesson = lesson.expect("mini fixture loads into a Lesson");
let errors: Vec<_> = diags
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(errors.is_empty(), "fixture has loader errors: {errors:?}");
lesson
}
/// PURE UNIT TEST (no fonts, no render package): the augmented manifest carries
/// `[info]`, the ordered `[[parts]]`, and a per-part `fields` array listing the
/// content fields present on disk.
#[test]
fn augmented_manifest_has_per_part_fields() {
let lesson = load_mini();
let src = build_augmented_manifest(&lesson);
// Info round-trips.
assert!(src.contains("迷你示例课时"), "info.title present:\n{src}");
assert!(src.contains("测试作者"), "info.author present:\n{src}");
// Parse it back to inspect the per-part fields precisely.
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}");
// `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()
};
// 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"]);
}
/// 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_through_template() {
let lesson = load_mini();
let engine = Engine::with_render_dir(real_render_dir());
let diags = engine.compile_check(&lesson, "student");
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 (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_through_template_offline() {
let lesson = load_mini();
let render_dir = real_render_dir();
assert!(
render_dir.join("lib.typ").is_file(),
"real render package missing at {}",
render_dir.display()
);
assert!(
render_dir
.join("vendor/typst-packages/preview/numbly/0.1.0/lib.typ")
.is_file(),
"vendored numbly missing under {}",
render_dir.display()
);
let engine = Engine::with_render_dir(render_dir);
for target in ["student", "teacher"] {
let pdf = engine
.build_pdf(&lesson, target)
.unwrap_or_else(|d| panic!("{target} PDF build failed: {d:#?}"));
assert!(pdf.starts_with(b"%PDF"), "{target} output is a PDF");
assert!(
pdf.len() > 1024,
"{target} PDF is non-trivial (got {} bytes)",
pdf.len()
);
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(),
authors: vec![],
},
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"),
}],
covers: None,
}],
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(),
authors: vec![],
},
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(),
}],
covers: None,
}],
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,2 @@
kind = "example"
source = "自拟"
@@ -0,0 +1 @@
一物体从静止自由下落,求下落时间 $t$ 后的速度 $v$ 与位移 $h$
@@ -0,0 +1 @@
由匀加速运动公式,$v = g t$$h = 1/2 g t^2$。代入 $g approx 9.8 "m/s"^2$ 即得数值结果。
@@ -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$ 有最大值与最小值。
@@ -0,0 +1 @@
kind = "lemma"
@@ -0,0 +1,3 @@
$T = l^a g^b$。量纲为 $[T] = "T"$$[l] = "L"$$[g] = "L T"^(-2)$
比较两端得 $a + b = 0$ $-2 b = 1$,解得 $a = 1 slash 2$$b = -1 slash 2$
$T prop sqrt(l slash g)$$qed$
@@ -0,0 +1,3 @@
设单摆周期 $T$ 仅依赖摆长 $l$ 与重力加速度 $g$,则由量纲分析必有
$ T = k sqrt(l / g) $
其中 $k$ 为无量纲常数。
+38
View File
@@ -0,0 +1,38 @@
[project]
id = "mini"
name = "迷你课时"
[info]
title = "迷你示例课时"
author = "测试作者"
[[parts]]
kind = "segment"
path = "segments/开场对照导言"
[[parts]]
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"
@@ -0,0 +1 @@
kind = "segment"
@@ -0,0 +1,2 @@
本节通过对照两种估计方法,引入量纲分析这一工具。我们先回顾自由落体,
其中能量与质量的关系可写作 $E = m c^2$(此处仅作记号示例)。
@@ -0,0 +1,131 @@
# ADR 0005: Lesson Is An Ordered Composition Of Typed Elements
## Status
Accepted — for the skeleton decisions below. Several sub-decisions are
deliberately left open; see *Open Questions / Deferred*.
This is the first ADR describing the **product core** (the curriculum
"engineering file"). ADR 00010004 cover only the platform side (Feishu group,
AgentRun, lock, permissions, on-demand context).
## Context
We are building a digitalization solution for curriculum production. The central
artifact is a structured "engineering file" for a course — edited with the help
of a coding agent, validated by a compiler-like rule-based checker that emits
helpful fix hints. The domain is new; we do not assume LLM pretraining priors
about its shape. This ADR fixes the shape of that engineering file so that the
developer and the agent stop making divergent assumptions about it.
What the file is *not*: it is not presentation. The same content drives multiple
export targets (student handout, teacher lesson plan, PPT, third-party platform
archive, an HTML interactive-courseware platform). The engineering file is the
target-agnostic content master; typst, markdown dialects, etc. are downstream.
## Decision
### Granularity: the engineering file is one lesson
One engineering file = one single lesson. A course / unit is *not* an
engineering file; it is an arrangement (编排) of lessons, modeled elsewhere (see
*Deferred*).
### A lesson is an ordered sequence of element instances
A lesson is an **ordered** sequence of elements. Order is significant; duration
is **not** modeled (the file is content-oriented, like a lecture note / handout
/ slide content flow, not a time-driven timeline). Content is formula-heavy.
### An element instance = a kind tag + data conforming to that kind's schema
Every element carries a **kind** tag plus **data** that conforms to that kind's
schema. Examples of kinds: worked example (例题), theorem/lemma (定理),
exposition (讲述/阐述).
### Element kinds form an open, extensible universe
Element kinds are **not** a closed enumeration. They form an open universe — like
a language standard library: the framework ships a good set of defaults (stdlib),
and (longer-term) third parties may contribute kinds (like crates). The model
must therefore never encode kinds as a fixed/closed type.
### A kind's minimal contract to the framework is its data schema only
The single thing a kind must declare to the framework is its **data schema**.
Rendering is explicitly **not** part of the kind contract.
### Export targets project/render the lesson
An export target (student handout, teacher plan, PPT, third-party archive, HTML
courseware platform, …) is a projection/rendering of the one lesson. One lesson
feeds many targets.
### Rendering is a per-(kind, target) configuration matrix that lives in the file
How a given kind appears in a given target is a **configuration matrix**
indexed by (kind, target). This matrix lives **inside the engineering file**;
the framework provides good **defaults**, and the file may **override** them.
(This corresponds to the prototype's `exports/{target}.typ`.)
When a lesson uses a kind for which the current target has **no** render rule,
the checker emits a **WARNING** (the element is ignored for that target) — **not
an error**. Missing rendering does not block.
### Target-specific information splits into two categories (a / b)
- **(a) semantic-but-target-specific** — information that genuinely belongs to an
element's meaning and only *surfaces* in some targets (e.g. a verbatim script
本质上是某 exposition 的口播版本, shown only in the teacher plan; key-point
highlighting shown only in the student handout). This **moves up onto the
element** as nullable fields.
- **(b) medium-only** — information that exists only because of a target's medium
(HTML interactive behavior, build script, npm dependencies). This does **not**
go on the element; it belongs to the target / external artifacts.
### Interactive courseware is an inline heavyweight element
A clickable "teaching apparatus" (教具) that a student plays with on the HTML
platform is an **inline element** in the content flow (bound to this lesson),
**not** a whole-lesson render target and **not** (for now) an externally
referenced artifact. In the model it carries only the apparatus's **problem /
configuration data**; the heavy implementation (e.g. an npm package such as a
KenKen-Sudoku renderer) lives **outside** this model, and reuse of such
implementations is out of scope for this model.
### Typst is a rendering backend, not the definition language
Typst is one rendering backend among several. It is **not** the language in
which kinds, schemas, or rules are defined (typst's typing is too weak for the
upper-layer design we want).
## Consequences
- Because kinds are an open universe, the formal model must be parameterized over
kinds — no closed `inductive`/enum of element kinds anywhere.
- Because missing rendering is a warning, not an error, a lesson stays valid and
exportable even when some (kind, target) pair has no rule yet.
- Because the content master is decoupled from presentation, the same lesson
projects to many targets; adding a target does not change the lesson.
- Because a kind's contract is "schema only", rendering and kind-definition can
evolve independently of the kind set.
- The checker (the thing that will "stand in Lean's position" at product runtime)
gets its first concrete rule from this ADR: *used-kind-with-no-render-rule ⇒
warning*.
## Open Questions / Deferred
Deliberately unspecified. Neither developer nor agent should assume a resolution;
surface them when they become relevant rather than picking one.
- **Schema representation** — how a kind's data schema is itself expressed (the
schema language).
- **Kind-definition mechanism** — the language/mechanism used to define an element
kind (typst scripting is rejected; the replacement is TBD).
- **Render-rule payload** — the concrete representation of a render rule.
- **Full lesson legality** — the complete definition of a "legal lesson"; this ADR
fixes only the one seed rule (missing render rule ⇒ warning).
- **Question bank (题库)** — its relationship to elements (reference vs inline) is
undecided; a pure-reference model may not be adequate.
- **Course = arrangement of lessons** — the concrete rules for composing lessons
into a course.
@@ -0,0 +1,116 @@
# ADR 0006: Element Schema Is Declarative, Rich Content Is Typst
## Status
Accepted — for the decisions below. Several sub-decisions are deliberately left
open; see *Open Questions / Deferred*.
Builds on ADR-0005, which fixed that an element instance = a kind tag + data
conforming to that kind's schema, and that a kind's minimal contract to the
framework is its **data schema only**. This ADR fixes **how that schema is
expressed** and **what the schema's "rich content" leaves actually are**.
## Context
ADR-0005 left the schema representation and the kind-definition mechanism as the
largest OPEN items — and they are the foundation the rule-based checker stands
on (the checker validates "does this data conform to its kind's schema?"). The
domain is formula-heavy and content-structured: a worked example has a problem
statement, options, a solution; a theorem has a statement, a proof, prerequisite
lemmas. These fields are not bare strings — they are *rich content*.
Two facts about Typst were verified against its source
(`/Users/sjfhsjfh/Typst/typst`) and drive the decisions below:
- A `.typ` file evaluates to a `Module` that carries **both** a `scope` of
`#let` exports **and** a body `Content` (the rendered top-level markup).
`Content` retains a `Span` that resolves back to a `FileId` + byte range, so
click-to-jump from rendered content to source is possible.
- `Content` has no `Deserialize`; and Typst's `Source::new` **requires** a
`FileId`. Source evaluated with a detached span loses its file identity:
`span.id()` becomes `None` (click-to-jump breaks) and a relative
`import "../x"` fails with *"cannot access file system from here"*. Relative
imports and click-to-jump only work when the source is a real file (with a
`VirtualPath`) in the compiler's `World`.
## Decision
### The data schema is declarative — a JSON Schema
A kind declares its data shape as a **declarative JSON Schema** (field names,
types, constraints), not as a program and not as a bespoke schema DSL. The
checker reads the schema and validates instance data structurally against it.
Whether stdlib kinds author their schema by hand-written JSON or via a host-
language builder is an implementation detail, not part of this contract.
### Field types: built-in scalars plus a `content` leaf type
Schema field types are the built-in scalars (string / number / boolean / enum /
list / object, as in ordinary JSON Schema) **plus one extension type:
`content`**. JSON Schema has no native notion of "typst content", so this is a
deliberate **extension** of JSON Schema (a custom type / `format`). Any
implementation must honor the same `content` extension rather than inventing its
own — this is the seam between the schema layer (A) and the rich-content layer.
### A `content` value is a Typst source; its meaning is the module's body content
The value of a `content` field is a **Typst source**. Its denotation is the
**body content** of that source evaluated as a module (Typst's `Module.content`)
— not its `#let` exports. The canonical stored form is the **source text**, not
a serialized `Content`: `Content` cannot be deserialized and its spans are only
meaningful with the source present, so content is re-evaluated from source when
needed, never round-tripped as data.
### Rich content is never free-floating — it has a virtual path
Because detached Typst source loses click-to-jump and cannot do relative
imports, a `content` value is **not** an anonymous text blob. It is a first-class
file in the compiler's `World`, carrying a **virtual path**. This is what makes
its spans resolve (click-to-jump) and its relative imports anchor. Concretely,
the engineering file constitutes a **virtual file system with internal path
structure**; each rich-content field sits at a path within it.
(This ADR commits only to *"there is a path structure"*. Its concrete on-disk
form — that the engineering file is a real directory tree — is fixed separately
in ADR-0007. ADR-0006 does not assume the path structure is a disk directory.)
### Import boundary: within the engineering file, plus packages
A relative `import` inside a rich-content source may resolve only to other paths
**within the same engineering file's path structure** (reusing definitions local
to this lesson), plus Typst **`@package`** ecosystem imports. It may **not**
reach into other engineering files. Cross-lesson reuse, if any, goes through a
separate mechanism (question bank / reuse — still OPEN, see ADR-0005 Deferred),
not through raw cross-file imports.
## Consequences
- The checker can introspect a rendered `Content` (its element type, fields,
downcasts) to validate rich content beyond "is it present", and can use spans
to point diagnostics back at the offending source location.
- Canonical state is source text; `Content` is a transient product of
evaluation. Storage, diffing, and agent edits all operate on source, not on
serialized content.
- Extending JSON Schema with `content` means conformance has a shared extension
point: implementations align on one `content` semantics instead of each
inventing a rich-text encoding.
- Rich content requiring a virtual path means the engineering file is inherently
a path-structured container, not a bag of anonymous snippets — which is what
ADR-0007 then grounds on disk.
- The import boundary keeps an engineering file self-contained: it depends only
on itself and on published packages, never on a sibling lesson's internals.
## Open Questions / Deferred
- **Content element constraints (Y)** — whether a `content` field may be further
constrained to admit only certain Typst element kinds (e.g. "inline only, no
figure", "exactly one final expression"). Deferred: we will know which
constraints are needed only once real stdlib kinds exist. Until then a
`content` field means "a block of content", inner element shape unconstrained.
- **stdlib kind set** — the first batch of framework-provided kinds and their
individual schemas (each is later, per-kind work).
- **JSON Schema dialect** — which draft, and exactly which keywords are
permitted in a kind schema (a subset/profile is likely, to keep the checker
tractable). Not pinned here.
- **On-disk form** — fixed in ADR-0007 (directory tree); layout conventions
(manifest, element ordering) deferred beyond that.
@@ -0,0 +1,73 @@
# ADR 0007: The Engineering File Is A Directory Tree On Disk
## Status
Accepted — for the on-disk decision below. The concrete layout conventions are
deliberately left open; see *Open Questions / Deferred*.
Grounds the abstract premise ADR-0006 relied on. ADR-0006 established that rich
content needs a **virtual path** and that the engineering file therefore
constitutes a path-structured virtual file system — but it deliberately did not
say what that path structure *is* physically. This ADR fixes that: it is a real
directory tree on a real file system.
## Context
ADR-0006 showed that a rich-content (`content`) value cannot be free-floating
Typst source: it must be a first-class file with a virtual path so that
click-to-jump (spans) and relative imports work. That leaves a choice for the
physical form of the engineering file: a packed/opaque archive, a database blob,
or a plain directory tree.
The decisive constraint is **who edits the engineering file**: a coding agent
(Claude) collaboratively with teachers (ADR-0005's premise), and ordinary tools.
An agent works best when it can read and modify files directly and use existing
tooling (`grep`, `find`, diff, version control) over plain files in plain
directories. A packed or database form would force a custom access layer for
every such operation.
The developer's earlier prototype already had this shape implicitly —
`segments/`, `examples/`, `lemmas/` directories with per-item files — which is
the directory tree in embryo, not a crude stand-in.
## Decision
The on-disk form of an engineering file is a **directory tree** on a real file
system.
- A `content` field's **virtual path** (ADR-0006) is a **real relative path**
within that directory tree. The compiler's `World` maps virtual paths to the
actual files in the tree; no synthetic/in-memory file system is needed.
- Relative imports inside rich content resolve relative to the importing file's
real position in the tree — which is exactly the import boundary ADR-0006
pinned ("within the engineering file, plus `@package`"). This ADR gives that
boundary its physical basis: "within the engineering file" = "within this
directory tree".
This keeps the engineering file legible to agents and to off-the-shelf tooling,
and makes the relationship between content fields and their sources directly
inspectable on disk.
## Consequences
- The engineering file is a directory of plain text/Typst files: directly
greppable, diffable, version-controllable, and editable by an agent without a
bespoke access layer.
- `World` is a thin mapping from the directory tree; virtual path = relative path
removes any need to invent or maintain a separate virtual file system.
- The engineering file remains self-contained on disk: its import closure is the
tree itself plus published packages (ADR-0006), so it can be moved, copied, or
archived as a directory without dangling references to sibling lessons.
- This is the **physical representation** of ADR-0005's abstract `Lesson` (an
ordered sequence of typed elements); how that ordered structure is encoded into
the directory is a separate question (below), not implied by "it's a tree".
## Open Questions / Deferred
- **Layout conventions** — the concrete directory layout is *not* fixed here.
Specifically deferred: the manifest file (name, location, what it holds); how
element instances are arranged in the tree and how their **order** (ADR-0005's
`Lesson` is an ordered sequence) is encoded; naming/organization of
rich-content `.typ` files. This is the on-disk encoding of ADR-0005's `Lesson`
and warrants its own ADR — kept separate so this one fixes only "it is a
directory tree", not "how the tree is arranged".
@@ -0,0 +1,145 @@
# ADR 0008: Engineering-File Layout — Declarative Manifest + Element Descriptors
## Status
Accepted — for the layout conventions below. Some sub-decisions are deferred;
see *Open Questions / Deferred*.
Discharges the layout question ADR-0007 left open. ADR-0007 fixed that the
engineering file is a real directory tree but explicitly deferred the concrete
layout: the manifest (name, location, contents), how element instances are
arranged and how their **order** (ADR-0005's `Lesson` is an ordered sequence) is
encoded, and how rich-content `.typ` files are named. This ADR fixes those.
## Context
Two real sample engineering files exist (TH-141, TH-144). They encode the lesson
as a typst file `main.typ` containing `#import`s of each element's `main.typ`
plus a `#let parts = (("segment", 模块), ("lemma", 模块), …)` ordered tuple list.
Each element is a folder under a per-kind directory (`segments/`, `examples/`,
`lemmas/`, `sop/`); its fields are typst source files (`textbook.typ`,
`problem.typ`/`solution.typ`, `stmt.typ`/`proof.typ`, `sop.typ`) wired up by the
element's own `main.typ` via `#let f = include "f.typ"`.
That embryo has one property in tension with the contract: **the ordering
manifest is itself a typst script**. ADR-0005 rejected typst as the
definition/scripting language (its typing is too weak), and ADR-0006 moved the
kind schema off typst onto declarative JSON Schema. A checker that had to *parse
typst* to recover the lesson's element order would re-introduce exactly the
dependency those ADRs removed: the order — a first-class part of the `Lesson`
contract — would be locked inside an evaluated typst program.
## Decision
### The lesson manifest is declarative, not a typst script
The lesson's order and membership live in a **declarative `manifest.toml`** at
the engineering-file root, read directly by the checker. TOML is chosen for
consistency with the samples' existing `project.toml` / `info.toml` /
`meta.toml`. It subsumes `project.toml` and `info.toml`. Shape:
```toml
[project]
id = "…" # stable project id
name = "…" # folder/display name
[info] # passed through to render targets verbatim
title = "…"
author = "…" # one author; or a list: author = ["…", "…"]
[[parts]] # ORDER OF THIS ARRAY IS THE LESSON ORDER (ADR-0005)
kind = "segment" # one of the known kinds (ADR-0006 / stdlib set)
path = "segments/开场对照导言" # element folder, relative to root
# … one [[parts]] block per element, in order …
[targets.student] # which export targets exist for this lesson
[targets.teacher]
```
The checker reads `parts` for order and membership. It **must not** parse typst
to recover ordering — the declarative array is the single source of truth. The
sample's `main.typ` `#let parts` is **replaced** by this; any typst entrypoint
that imports elements in order is a *generated build artifact* derived from the
manifest (see ADR-0007's note that the on-disk encoding of `Lesson` is separate
from "it's a tree"), never the canonical order.
### `[info].author` is a list; the on-disk single-string form is sugar
A lesson can be authored by several people (a teaching group), so the canonical
`author` is an **ordered list**, not a single value. For authoring convenience the
on-disk form accepts **either** a bare string (`author = "…"`, one author) **or** an
array (`author = ["…", "…"]`). That "string-or-array" union lives **only at the load
boundary**: the loader normalizes a bare string to a one-element list, and
everything downstream sees a list. Absent `author` ⇒ empty list. This raw-vs-
canonical split is pinned in the Lean master as `RawInfo` (the authoring surface)
vs `Info` (the canonical model whose `authors` is always a list) — see
`spec/Spec/Courseware/Model/Info.lean`.
### An element folder is self-describing via `element.toml`
Each element folder carries an **`element.toml`** declaring its kind and its
**scalar** fields (per the kind's JSON Schema, ADR-0006):
```toml
kind = "example"
source = "41 届物理竞赛复赛第三大题(1)" # a scalar field of the example kind
```
`kind` is explicit in the descriptor (not inferred from the parent directory
name) so a folder is self-describing and the `segments/`-vs-`lemmas/` directory
grouping is a human convenience, not load-bearing for the checker.
### `content` fields are convention-named `.typ` siblings
A kind's schema (ADR-0006) marks some fields as the `content` extension type.
For each such field `F`, its value is the typst source file **`F.typ`** in the
element folder. The schema — not `element.toml`, not a per-element `main.typ`
is the source of truth for *which* `.typ` files must exist. Concretely for the
MVP kind set: `segment``textbook.typ`; `example``problem.typ`,
`solution.typ`; `lemma``stmt.typ`, `proof.typ`; `sop``sop.typ`. This
**drops the samples' per-element `main.typ`** (`#let textbook = include …`):
that field-to-file wiring is now implied by the schema + naming convention and
materialized at build time by the export template, not hand-authored. (Originally
this said "the generated driver"; ADR-0011 supersedes that — there is no generated
driver, an export template reads the manifest and includes each part's content.)
A `content` field's denotation remains ADR-0006's: the **module body content**
of `F.typ`, not its `#let` exports. The file sits at a real relative path
(ADR-0007), so its spans resolve and its relative imports anchor within the tree
(ADR-0006 import boundary).
## Consequences
- The checker recovers the full ordered lesson from `manifest.toml` + each
`element.toml` without evaluating any typst — order and membership are plain
data, diffable and greppable.
- A per-target **export template** (a real, editable `.typ` file, e.g.
`exports/student.typ`) reads the manifest and renders the lesson; it is the
build entrypoint, not the canonical lesson. (This clause originally described a
*generated* driver with static `#import`s, on a since-corrected belief that
typst forbids runtime-computed include paths — see ADR-0011's *Correction*.
Include/import paths may be computed, so no driver is generated.)
- Element folders lose their per-element `main.typ`; `element.toml` + the schema's
content-file convention replace it. Migrating the samples is a mechanical
rewrite (the migration is part of the MVP).
- `path` being a real relative folder makes "the lesson references element X"
inspectable on disk; a dangling `path` is a structural error the checker can
point at.
## Open Questions / Deferred
- **Per-file render override.** ADR-0005 says the (kind × target) render matrix
lives *in* the engineering file with framework **defaults** the file may
**override**. This ADR's `[targets.*]` only *declares which targets exist*; the
MVP keeps the render rules entirely in the framework render layer with **no
per-file override**. Whether/how a lesson overrides a render rule (a block in
`manifest.toml`? a per-target file?) is deferred until a real override need
appears. This is a known, surfaced gap against ADR-0005, not an oversight.
- **Question bank (题库) on disk.** TH-144 has a `题目/` tree of problem/answer
pairs outside `parts`. Its layout and its element relationship (ADR-0005
Deferred: reference vs inline) stay open; out of MVP scope.
- **Manifest richness.** Per-part metadata, grouping/sectioning (TH-144's A/B/C
structure is only in folder names today), and target-specific options on a part
are not modeled here; add when needed rather than guessing now.
- **stdlib kind set & schema dialect** remain as ADR-0006 left them; this ADR
only fixes the on-disk *arrangement*, not the schemas themselves.
+149
View File
@@ -0,0 +1,149 @@
# ADR 0009: An Export Target Is A Build Producing A Typed Artifact
## Status
Accepted — for the decisions below. **Amends ADR-0005**: where ADR-0005 framed
rendering as a passive `(kind × target)` configuration matrix looked up per
element, this ADR reframes an export target as an active **build** that consumes
the lesson and produces a typed **artifact**. The seed rule ADR-0005 fixed
(missing render rule for a used kind ⇒ warning) survives, restated against the
new structure. Several sub-decisions are deferred; see *Open Questions*.
## Context
ADR-0005 said an export target "projects/renders the lesson", with a render
matrix that "lives in the engineering file; the framework provides defaults, the
file may override." The MVP implementation did not realize that override path:
it hardcoded one target's behavior as a `target == "student"` branch inside the
render package and baked presentation defaults (heading numbering) into the
package with no way for an engineering file to change them. That produced a
concrete bug — a single-symbol numbering pattern (`一、`) reused across heading
levels, so level-2 headings rendered as `二、一、…` — which is not a typo but the
**symptom of a missing layer**: targets were behavior welded into code, not
configuration a lesson can carry and override.
Looking at the prototype confirms the real shape of the problem. It had two
near-identical export entrypoints (`exports/学生版.typ`, `exports/教师版.typ`)
plus a `display-control.typ` switching on `sys.inputs("x-export-target")` with a
scatter of per-concern booleans (`show-answer`, `show-discussion-script`,
`show-eg-comment`, …). The two scripts are ~95% identical; the duplication —
load, assemble, compile, page setup — is the same across every target, and only
a thin policy delta differs. **That repetition is the thing to factor out.**
A target is therefore best understood as **a build script with the repeated
mechanism abstracted away** — closer in spirit to a GitHub Actions workflow
(declarative steps, plus a `run`-shell escape hatch) than to a Makefile.
## Decision
### A target is a build that produces a typed artifact
An export target is **a build**: it consumes the (ordered, typed) lesson and
produces one **artifact**. The artifact's *shape* is part of what the target
*is*, modeled as an ADT value the target carries:
- `SingleFile` — one bundled document (a 讲义 PDF, a teacher plan PDF).
- `FileTree` — many files plus an index/manifest (a third-party platform
archive).
"Single file vs. many files" is a genuine divergence point — it changes what
`cph build` emits and what a consumer expects — so the artifact type is part of
the contract, not an implementation afterthought. (Backend/format beyond this —
which concrete PDF engine, which markdown dialect — stays open.)
### A build is map + reduce; the reduce is determined by the artifact
A build has two phases, kept distinct:
- **map (per-kind).** For each element kind, how that kind becomes an
intermediate fragment. This is declarative and keyed by kind — the part a
GitHub-Actions-style schema expresses naturally. **Field visibility lives
here**: "student hides `example.solution` and `lemma.proof`; teacher shows
them" is simply *what this target's per-kind map does* — not a separate
concept and not a property of the element.
- **reduce / assemble.** The ordered fragments are folded into the artifact.
This fold is **determined by the artifact type**, not written per target:
- `SingleFile` ⇒ concatenate fragments in lesson order into one document, then
compile it. (This is *why* cross-references `@label`/`@ref` and the
例题1/例题2 counters work at all: they resolve only when the whole lesson is
one compiled document. The fold's statefulness is carried by the backend's
own counters at assemble-compile time, not re-implemented in the map.)
- `FileTree` ⇒ each part to its own file, plus a generated index.
The framework provides the assembler for each artifact shape; a build only
*chooses* its artifact type. This is the answer to "a per-kind rule can't express
aggregation": aggregation isn't crammed into the per-kind schema — the artifact
value picks a framework assembler, and the **shell escape hatch** is reserved for
the genuinely irregular case (a third-party archive that runs an npm build).
### The build form: declarative schema plus a `run`-shell hatch
A build is expressed declaratively (a schema — concretely a YAML-ish document,
consistent with the `manifest.toml`/`element.toml` lineage) with one **`run`
escape hatch** for steps that resist declaration. Explicitly **not** a Makefile
or a general scripting language: the declarative surface carries the common
cases (per-kind map, artifact choice, presentation knobs), and the shell hatch is
the bounded exception. This mirrors ADR-0006's stance on schemas: fix the *form*
here, keep the concrete payload an implementation detail.
### typst compilation is a hidden backend mechanism, not part of a target
Compiling typst to PDF is the *shared, repeated* mechanism every PDF-producing
target needs. It is therefore **backend mechanism, hidden below the target**, not
something a target re-specifies. A target says "I produce a `SingleFile` PDF";
*how* the assembled document becomes bytes is the framework's business. (This is
also where the checker's typst-compile diagnostic belongs — an external facility,
see ADR-0010.)
### Medium-only information rides the build's shell hatch
ADR-0005's category **(b)** — medium-only information that exists only because of
a target's medium (HTML interactive behavior, a build script, npm dependencies)
— finally has a home: it belongs to the **target's build** (its shell hatch /
artifact assembler), never to the element. This complements ADR-0005's
category-(a) decision (semantic-but-target-specific fields ride the element); (b)
rides the build.
### Defaults are seeded at project creation and overridable in the file
The framework ships **default builds** for its stock targets (e.g. student,
teacher), written into the engineering file when a project is created. The file
may then **override** pieces of them. The numbering bug is the worked example:
the fix is not patching the render package but **overriding the student/teacher
build's numbering** in the engineering file (e.g.
`numbly("{1:一}、", "{1:1}.{2:1}")` — level-1 `一、`, level-2 `1.1`). This realizes
ADR-0005's "matrix lives in the file, framework gives defaults, file overrides"
as an actual editable layer rather than a slogan.
## Consequences
- The two duplicated export scripts collapse into one shared build mechanism plus
two small target *configurations* (artifact type + per-kind map + presentation
overrides). Adding a target is adding a config value, not copying a script.
- `cph build --target T` is defined by T's artifact type: it writes one file for
`SingleFile`, a directory for `FileTree`.
- Presentation defaults (numbering, labels, styling) are file-resident and
overridable; a wrong default is a one-line edit in the engineering file, not a
package change.
- The contract gains structure (an `Artifact` ADT; a target = build with a
per-kind map + an artifact-determined assemble) without committing the concrete
build-schema fields — those stay implementation, per the depth ceiling
(constitution rule 5).
- ADR-0005's render-matrix language is superseded by this build framing; its
warning rule (missing render for a used kind ⇒ warning, non-blocking) is
preserved and restated in ADR-0010 against the new `covers`.
## Open Questions / Deferred
- **Concrete build-schema fields** — the exact declarative keys (per-kind map
syntax, where the shell `run` step sits, how an override is written) are an
implementation-level schema, fixed later, not in Lean.
- **Backend / format enumeration** — beyond `SingleFile`/`FileTree`: which PDF
engine, and the markdown-dialect / React-template export ADR-0005 mentioned for
third-party platforms. Open.
- **`FileTree` and the shell hatch implementation** — specified here but not
implemented in this round (no third-party target instance yet); the MVP
implements `SingleFile`/PDF only and marks the rest deferred.
- **Override granularity** — exactly which pieces of a build a file may override
(numbering only? arbitrary presentation? the per-kind map itself?) is fixed as
needed; this ADR commits only that an override layer exists and is file-resident.
@@ -0,0 +1,138 @@
# ADR 0010: Checker Diagnostics, Legal Lesson, And The Check Pipeline
## Status
Accepted — for the decisions below. **Backfills the "full lesson legality"** item
ADR-0005 explicitly deferred ("the complete definition of a legal lesson; this
ADR fixes only the one seed rule"). Builds on ADR-0005's seed (missing render ⇒
warning) and ADR-0009 (target = build). Some items deferred; see *Open Questions*.
> **Corrected by ADR-0012.** The taxonomy below lists *seven* classes including
> `DanglingReference`. ADR-0012 retires `DanglingReference` — its cases
> (unresolved `@ref`, out-of-boundary `import`) are all typst compile-time
> failures, so they fold into `TypstCompile` — leaving *six* classes, and removes
> the `Oracle.refsResolve` conjunct from `Legal`. Read the taxonomy and the
> `Legal` definition here through that correction.
## Context
The checker is the product's moat — the thing that "stands in Lean's position" at
runtime, validating an engineering file and emitting helpful fix hints. The MVP
implementation grew a **diagnostic taxonomy** (seven codes), a notion of when a
lesson is **legal** (publishable / buildable), and an ordered **check pipeline**
and then treated all three as "implementation detail." They are not. What each
error class *means*, whether it blocks, what "legal" *is*, and in what order
checks run with what gating are exactly the non-obvious domain decisions the
constitution (rule 5) says must be pinned: the developer and the agent would
otherwise each assume a different answer. This ADR lifts them into the contract.
A deliberate boundary runs through the taxonomy. Some checks the model can state
structurally (a manifest part points at a folder that exists; a declared kind is
known). Others are **external facilities** the semantic model cannot itself
decide — does this typst source compile? do its cross-references resolve? does
this data conform to its JSON Schema? Those are real, contractually-named
diagnostics, but their *verdict* comes from an implementation oracle (the typst
compiler, the schema validator), not from Lean. The contract names them and fixes
their severity; it does not pretend to decide them.
## Decision
### The diagnostic taxonomy (seven classes)
Each diagnostic carries a **code**, a **severity** (`error` | `warning`, per
ADR-0005 / `Spec.Courseware.Severity` — two-valued, no info/note), a message, an
optional source span, and a fix hint. The MVP classes:
| Code | Meaning | Severity | Kind |
|------|---------|----------|------|
| `PartPathMissing` | A manifest `[[parts]]` entry points at a folder that does not exist (or escapes the root via `..`). | error | structural |
| `UnknownKind` | A part declares a kind not in the known kind set (and: a part/`element.toml` kind mismatch). | error | structural |
| `MissingContentFile` | A `content` field required by the kind's schema has no corresponding `<field>.typ` on disk. | error | structural/schema |
| `SchemaViolation` | Instance data does not conform to its kind's JSON Schema (bad scalar type, missing required, stray key); also the fallback for a malformed `manifest.toml`/`element.toml`. | error | schema / external |
| `DanglingReference` | A reference does not resolve — a relative import outside the import boundary, or an unresolved cross-reference. | error | external |
| `TypstCompile` | The assembled typst source fails to compile (syntax error, unresolved `@ref`, etc.). | error | external |
| `RenderIgnored` | A used kind has no render rule for a declared target; the element is omitted from that export. | **warning** | semantic |
Six are `error` (blocking); **`RenderIgnored` alone is a `warning`** — the
ADR-0005 seed rule, restated. The split between *structural* (model decides),
*schema/external* (oracle decides), and *semantic* (the coverage rule) is the
boundary described above.
`RenderIgnored`'s severity is pinned to `warning` as a named contract fact (Lean:
`renderIgnoredSeverity`), so "it does not block export" is greppable and
alignable, not an inline literal.
### A legal lesson has no error-level diagnostic
A lesson is **legal** (valid, publishable) iff the check pipeline produces **no
`error`-severity diagnostic**. Warnings do not affect legality — a lesson with
`RenderIgnored` warnings is still legal and still exports (lossily). This is the
complete legality predicate ADR-0005 deferred: legality is defined *by the
diagnostic set*, not by a separate hand-written rulebook. New rules enter
legality by adding diagnostics, keeping one source of truth.
### External-facility diagnostics are oracle verdicts, not model judgments
`TypstCompile`, `DanglingReference`, and the schema-conformance face of
`SchemaViolation` assert facts the semantic model **cannot decide on its own**:
they depend on running the typst compiler and the schema validator. The contract
therefore models them as **abstract predicates** (e.g. "this source compiles",
"these references resolve", "this data conforms") **parameterized over an
implementation-provided oracle**. The Lean spec states *that* such a diagnostic
exists, what it means, and its severity — and explicitly marks that its truth
value is supplied by the implementation, not computed in Lean. This keeps the
contract honest (it does not embed a typst compiler) while still naming the
diagnostic as part of the legality definition.
### The check pipeline: ordered phases with compile gating
`check` runs five phases in a fixed order, collecting all diagnostics:
1. **load** — parse `manifest.toml` + each `element.toml`. A hard failure (no
parseable lesson) stops the pipeline; nothing downstream can run.
2. **structural** — every part path exists, no `..`, kind is known, part/element
kinds agree. Parts already flagged missing here are skipped downstream (no
piling diagnostics on one root cause).
3. **schema** — each present, known-kind part's data validated against its kind
schema (scalars + required content files).
4. **typst compile** — the external-facility phase. **Gated: it runs only if
phases 13 produced zero errors**, because compiling known-broken input yields
noise on top of the real cause. When it runs, it compiles each declared target
(ADR-0009), deduping identical diagnostics across targets.
5. **render-coverage** — the semantic warning rule; runs regardless of gating.
`build --target T` runs phases 13, refuses (no artifact) if any error, else
compiles + exports T's artifact (ADR-0009). It does **not** run coverage —
coverage describes export loss, informational for `check`, not a gate on one
target's build.
The **order and the gating are contract**, because they determine which
diagnostics a user sees (a syntax error behind a missing file is hidden until the
file exists — deliberate). The per-phase *algorithms* are not pinned in Lean
(depth ceiling, rule 5): the spec fixes the phases, their order, and the gate, not
how each check is computed.
## Consequences
- "Is this lesson done / publishable?" has one precise answer: no error-level
diagnostic. The CLI's exit code (1 on any error) is this predicate.
- Adding a checker rule = adding a diagnostic with a severity; legality updates
automatically, no separate legality rulebook to keep in sync.
- The contract can name compile/reference/schema diagnostics as part of legality
without embedding the oracles that decide them — the spec stays a semantic
master, not a re-implementation.
- The implementation's `DiagCode` enum, `CheckReport`, and phase sequence now have
a contract to align to (by review, per the constitution — no CI gate); the
alignment is documented at both ends.
## Open Questions / Deferred
- **Additional codes.** A dedicated `ManifestMalformed` (today folded into
`SchemaViolation`) and finer schema/reference codes may be added; the taxonomy
is open to growth, each addition a deliberate decision.
- **Cross-lesson diagnostics.** Anything about a *course* (arrangement of lessons)
or the question bank is out of scope — those models are still OPEN (ADR-0005).
- **Severity of future classes.** Each new diagnostic's blocking/non-blocking
status is decided when introduced; only the seven above are fixed here.
- **Warning sub-structure.** Whether warnings ever need ordering/grouping or a
third "info" level remains deferred (ADR-0005 kept `Severity` two-valued).
@@ -0,0 +1,142 @@
# ADR 0011: Export Builds Are Ordered Typed Steps Over Template Files
## Status
Accepted. **Refines ADR-0009** (which established target = a build producing a
typed artifact) by fixing the *shape* of a build and of an artifact. **Corrects
ADR-0008** on one point of fact (see *Correction* below). Some items deferred;
see *Open Questions*.
## Context
ADR-0009 said an export target is a build producing a typed `Artifact`, with a
map+reduce structure and a "declarative schema + shell hatch" form. The first
implementation of that idea (and its initial Lean encoding) got three things at
the wrong level of abstraction, surfaced in review:
1. **The `Artifact` type was a bare enum** (`singleFile | fileTree`) with no
fields. But "what the product *is*" — a single file at some path, vs. a tree
of files under some root — is exactly the non-obvious semantics that must be
pinned. A bare enum erases it.
2. **A target was modeled as a loose config bag** (an artifact plus a per-kind
render map) with presentation knobs (heading numbering) stuffed into
`manifest.toml`. But a build is naturally **an ordered list of steps**, each a
typed operation; and presentation belongs in a **template file**, not the
manifest.
3. **A false premise about typst drove the design.** The build was going to have
the framework *generate* a typst entrypoint with **static** `#include`s of
every element, on the stated belief that "typst forbids a runtime-computed
include/import path." That belief is **wrong** (verified below). It is the
reason ADR-0008 mentions "the generated driver" and "static `#import`s" — that
wording is a residue of the false premise and is corrected here.
## Correction: include/import paths may be runtime-computed
Verified against the typst source (`/Users/sjfhsjfh/Typst/typst`, 0.15):
- `#include` and `#import` parse an **arbitrary code expression**, not a string
literal (`typst-syntax` parser uses `code_expr` for both).
- The source operand is **evaluated at runtime**
(`typst-eval/src/import.rs:22` for import, `:174` for include); a
`Value::Str` is then resolved to a module via runtime path resolution
(`span.resolve_path(...)`). Path resolution is a **runtime** operation, so a
missing file is a runtime error, not a parse error.
- The test suite confirms it: `tests/suite/scripting/include.typ` contains
`#let chap2 = include "modu" + "les/chap" + "2.typ"` — a computed path.
- The **only** restriction: a *bare* `import expr` (no `as name`) rejects a
dynamic string source ("dynamic import requires an explicit name");
`import expr as name` and `include expr` both accept dynamic paths.
**Consequence:** the framework does **not** need to generate a static-include
driver. A template can `toml()`-read the manifest and `include` each part's
content via a **computed** path. This removes an entire generated-artifact layer.
## Decision
### `Artifact` is an ADT with fields
```
SingleFile { filepath } -- the product is one file, written at filepath
FileTree { root, outputs:glob } -- the product is the files under root matching outputs
```
`SingleFile.filepath` says where the one product lands (a 讲义/教案 PDF).
`FileTree.root` is the output directory; `FileTree.outputs` is a **glob**
describing which files this build produces (e.g. `**/*.{html,js,json}`) — lighter
than an explicit manifest of outputs, while still letting a consumer/checker know
what to expect and verify. These fields are the semantics; they go in the Lean
ADT with doc, not erased behind a bare tag.
### A target is an artifact plus ordered, typed steps
A target's build is `{ artifact, steps }`, where `steps` is an **ordered** list
and each `Step` is a **typed operation** (extensible):
- `typstCompile { template }` — compile a **template file** (e.g.
`exports/student.typ`) to the artifact. It is *typed*, not a raw shell line,
precisely because the framework must **wire the manifest into it** — a bare
`typst compile` shell string cannot express that injection.
- `shell { run }` — an escape hatch for steps that resist declaration (this is
where ADR-0005's medium-only category (b): HTML interactive builds, npm, lands).
MVP has exactly one step per target (a single `typstCompile`), but the structure
is a list because a `FileTree`/third-party build will need several.
### Presentation lives in the template file, not the manifest
The `typstCompile.template` (e.g. `exports/student.typ`) is a **real, editable
file** in the engineering file. Presentation — heading numbering (numbly), styling
— lives **there**, not in `manifest.toml`. The manifest's earlier
`[targets.*.numbering]` block is **removed**. (Framework defaults for these
templates are written into the engineering file at project-creation time; the
creation flow is out of this round's scope — the sample's templates are authored
by hand and noted as "should be seeded".)
### The manifest is injected, not statically inlined
A `typstCompile` step compiles its template **as the main file**, passing the
manifest in via `--input manifest=<path>` (a path relative to the typst `--root`,
which is the engineering-file root). The template does
`toml(sys.inputs.manifest)` to read metadata (course title, author) and the
ordered `parts`, and `include`s each part's content via the part's path (a
**computed** path — legal per the *Correction*). No framework-generated driver.
### Render coverage is a declaration, not a payload
ADR-0009's per-target render *payload* (`RenderRule`) is **retired**: the "how" of
rendering now lives in the template/steps, not in a contract-level rule object.
What the contract keeps is a **coverage declaration** — *which kinds a target
renders* — used only by the seed diagnostic. ADR-0005's rule survives unchanged:
a used kind a target does **not** cover ⇒ a `renderIgnored` **warning** (non-
blocking). `Primitives.RenderRule` is removed from the model.
## Consequences
- The product type is self-describing: `SingleFile`'s path and `FileTree`'s
root+glob are in the contract; `cph build` and any consumer know exactly what a
target emits and where.
- A build is a list of typed steps — adding a non-typst step (npm, packaging) is
adding a `shell` step, not bending the typst path.
- Presentation is editable in one obvious place (the template file); the manifest
carries structure (parts, targets, artifacts, steps), not styling.
- Removing the generated-driver layer simplifies the engine: it compiles a real
template file as main (spans resolve to a real authored file, not a synthetic
one) and sets one `sys.inputs` value.
- ADR-0008's "generated driver / static `#import`s" wording is superseded by the
template-injection model here.
## Open Questions / Deferred
- **`FileTree` and `shell` step implementation** — specified here, not built this
round (no third-party target instance yet). MVP implements `SingleFile` + a
single `typstCompile` step; the rest is deferred with explicit diagnostics.
- **Project-creation seeding** — that default templates are written into a new
engineering file at creation time is asserted but not implemented here; the
sample's templates are hand-authored.
- **Template ↔ framework calling convention** — how exactly the template pulls
part content (a `cph-render` helper that takes a loader closure, vs. the
template doing its own `for`+`include`) is an implementation detail settled in
the render package, not pinned in Lean.
- **Multiple steps / step dependencies** — ordering is fixed as a list; whether
steps ever need richer dependency structure is deferred until a multi-step
target exists.
@@ -0,0 +1,90 @@
# ADR 0012: Reference Resolution Folds Into `TypstCompile` (Taxonomy 7 → 6)
## Status
Accepted. **Corrects ADR-0010**, which fixed the diagnostic taxonomy at *seven*
classes including a separate `DanglingReference`. This ADR retires
`DanglingReference`, folding reference-resolution failures into `TypstCompile`, so
the taxonomy is now *six* classes. ADR-0010 stays the record of the original
decision; this one records the correction.
## Context
ADR-0010 named two distinct external-facility diagnostics for reference problems:
- `DanglingReference` — "a relative import outside the import boundary, or an
unresolved cross-reference."
- `TypstCompile` — "the assembled typst source fails to compile (syntax error,
**unresolved `@ref`**, etc.)."
The unresolved cross-reference (`@ref`) was listed under **both** — a genuine
contradiction in the contract. And on inspection, the *other* `DanglingReference`
case (an out-of-boundary relative import) is **also** a typst compile-time error:
typst resolves `import`/`include` paths at runtime (ADR-0011's *Correction*), so a
path escaping the import boundary fails as `"cannot access file system from
here"` — a `TypstCompile` diagnostic. Both of `DanglingReference`'s cases are
therefore detected by, and surface as, the typst compiler's own output.
The implementation reflected this from the start: `cph-typst` maps **every** typst
`SourceDiagnostic` to `DiagCode::TypstCompile` (`crates/cph-typst/src/diag.rs`), and
**no** code path ever emits `DanglingReference`. The Lean `Oracle` likewise had a
`refsResolve` field separate from `compiles`, but `compiles t` already entails that
target `t`'s references resolve (the source would not compile otherwise) — so
`refsResolve` was redundant in the `Legal` conjunction.
`DanglingReference` was thus a contract class with no distinct verdict, no distinct
emitter, and a definition overlapping `TypstCompile`. It diluted the taxonomy
rather than carving a real boundary.
## Decision
### `TypstCompile` owns reference resolution
`TypstCompile` is the single external-facility diagnostic for the assembled typst
source failing to compile — **including** an unresolved cross-reference (`@ref`)
and a relative `import`/`include` that escapes the import boundary or names a
missing file. There is no separate reference-resolution diagnostic.
### `DanglingReference` is retired; the taxonomy is six classes
The diagnostic taxonomy is now:
| Code | Meaning | Severity | Kind |
|------|---------|----------|------|
| `PartPathMissing` | A manifest `[[parts]]` entry points at a non-existent folder (or escapes the root via `..`). | error | structural |
| `UnknownKind` | A part declares a kind not in the known set (or a part/`element.toml` kind mismatch). | error | structural |
| `MissingContentFile` | A `content` field required by the kind schema has no `<field>.typ`. | error | structural/schema |
| `SchemaViolation` | Instance data violates its kind's JSON Schema; also the malformed-`manifest`/`element.toml` fallback. | error | schema / external |
| `TypstCompile` | The assembled typst source fails to compile — syntax error, unresolved `@ref`, out-of-boundary or missing `import`/`include`. | error | external |
| `RenderIgnored` | A used kind a target does not cover; the element is omitted from that export. | **warning** | semantic |
`RenderIgnored` alone is a `warning`; the other five are `error`. The
structural / external / semantic boundary from ADR-0010 is unchanged.
### `Oracle.refsResolve` is removed
The `Legal`-predicate oracle drops its `refsResolve` field. Reference resolution is
subsumed by `compiles` (per target): `compiles t` already requires `t`'s assembled
source to compile, which requires its references to resolve. `Legal` becomes
`dataConforms ∧ contentFilesPresent ∧ (∀ declared t, compiles t)`.
## Consequences
- The contract no longer contradicts itself on where an unresolved `@ref` is
reported: it is a `TypstCompile` diagnostic, full stop.
- The implementation needs no behavioral change — it already routed all reference
failures to `TypstCompile` and never emitted `DanglingReference`. The
correction *removes* the unused `DiagCode::DanglingReference` variant, closing
the spec↔impl gap (a named-but-unemitted code) rather than leaving it as a
permanent MVP-deferred stub.
- The `Oracle` / `Legal` definitions in the Lean master shrink by one redundant
conjunct, with no change to which lessons are legal.
## Open Questions / Deferred
- **Finer reference diagnostics.** Should a future checker want to distinguish an
out-of-boundary import from a missing file from an unresolved `@ref` — with
reference-specific fix hints better than typst's generic message — that is a
*new* diagnostic class, introduced deliberately when the need is real (ADR-0010's
"taxonomy is open to growth" still holds). This ADR removes the redundant class;
it does not forbid a genuinely distinct one later.
@@ -0,0 +1,106 @@
# ADR 0013: Shell-Step Execution For Tool-Generated Assets
## Status
Accepted — and implemented. The `Step::Shell` escape hatch (introduced
structurally in ADR-0011 but left unimplemented) now has an executor in
`cph-check`, wired into `cph build --target <shell-target>`. First real use: the
KenKen interactive boards of the 恒一小奥 lesson, generated by the external
`kendoku` CLI.
Builds on **ADR-0005** (category (b): medium-only information — HTML interactive
behavior, build scripts, npm deps — belongs to the target's build, not the
element) and **ADR-0009/0011** (a target is an `Artifact` produced by an ordered
list of typed `Step`s; `Shell` is the escape hatch for steps that resist
declaration). It does not amend them; it fixes the **execution semantics** of
`Shell`, which those ADRs left open ("MVP 不实现,先建结构").
## Context
Authoring the 恒一小奥 KenKen lesson as a native engineering file surfaced a
concrete category-(b) need. The lesson's interactive teaching apparatus — a
clickable 4×4 KenKen board per problem — is produced by a **separate tool**
(`kendoku`, a TypeScript CLI living in the playground: `kendoku export
<puzzle.json> -o <out.html>`). The structured source of truth is the puzzle JSON
(cages / solution / cell-to-cage); the self-contained interactive HTML is a
**generated build artifact**, exactly ADR-0005's category (b) and ADR-0009's
"third-party archive that runs an external build".
The contract already named this (`Step::Shell`, `Artifact::FileTree`), and
`cph-model` already parsed both, but the engine refused them: there was **no step
executor**, and `cph build` only knew how to compile a typst template to a single
PDF. So the lesson's apparatus could be *described* but not *produced* through the
same framework.
Three questions about *how* a shell step runs were genuinely undecided, and
divergent answers would matter (safety, what `check` means, where failures go).
## Decision
### A shell step executes the command, in the engineering root, on explicit build
`cph build --target <name>` for a target whose steps are `Shell` runs each `run`
string through the platform shell (`sh -c` / `cmd /C`) with the **engineering
root as the working directory**, so manifests use root-relative paths. cph
**executes** the declared command and reports the outcome; it does **not**
assemble or post-process the tool's output — the external tool writes the files
(this is the difference from a `TypstCompile` step, where the framework owns the
compile).
### Arbitrary command execution is opt-in by construction
A shell step is arbitrary code execution, so it must never be a surprise:
- It runs **only** on an explicit `cph build --target <shell-target>`.
- `cph check` **never** runs shell steps — `check` validates lesson *structure*
(is the lesson legal?), not the outcome of running an external tool. A
shell/file-tree target is also skipped by the check pipeline's typst-compile
phase (it is not a typst build).
- The CLI prints each command before running it, and streams the tool's output.
### A shell step failure is a build-process error, not a lesson diagnostic
A non-zero exit is a failure of the *build process*, categorically distinct from
the six lesson-legality diagnostics (`PartPathMissing`, `UnknownKind`,
`MissingContentFile`, `SchemaViolation`, `TypstCompile`, `RenderIgnored`). Those
six describe defects *in the lesson*; a tool that fails to run says nothing about
the lesson's legality.
**We deliberately do NOT add a `ShellStep` diagnostic code.** The closed,
spec-pinned taxonomy (`Check/Diagnostic.lean`, ADR-0010/0012) stays at six. Shell
outcomes are reported by the build-execution layer (exit status + captured
stdout/stderr surfaced by the CLI), keeping the diagnostic vocabulary about
lesson legality only. This is the lighter, constitution-respecting choice
(taxonomy is a pinned divergence point; not every error category belongs in it).
### `kendoku` reachability is left open
How the external tool is invoked (a relative `node …/dist/cli.js` path, `npx
kendoku` after `npm link`, or an installed binary on PATH) is **not** fixed by
the contract — it is a property of a given engineering file's `run` string and
its host. The 恒一小奥 manifest uses a relative `node` path as an MVP and its
README documents the prerequisite; a portable convention is left open.
## Consequences
- The same framework now *produces* the apparatus it could previously only
describe: `cph build --target interactives` runs `kendoku` and writes the 10
interactive boards under the target's `FileTree` root. The lesson's PDFs
(`student`/`teacher`) and its interactives all come from one engineering file
through one CLI.
- `Step::Shell` is no longer a placeholder; its execution semantics are pinned in
`Export/Render.lean`.
- The diagnostic taxonomy is unchanged (still six). Build-process failures live
outside it.
- `TypstCompile` targets are unaffected: the PDF path is exactly as before.
## Open Questions / Deferred
- **Portable tool reachability.** A cross-machine convention for locating an
external generator (`npx`/PATH/vendored) rather than a host-specific path.
- **`FileTree` output validation.** Whether the checker should verify a shell
target actually produced the files its `Artifact::FileTree` `outputs` glob
declares (currently the tool's exit status is the only signal).
- **Step animation (GIF) pipeline.** `kendoku steps` → headless-Chrome
screenshots → ffmpeg is a multi-tool chain; this lesson uses pre-generated
static step PNGs instead. Wiring that chain as shell steps is deferred.
@@ -0,0 +1,107 @@
# ADR 0014: Direction For A Markdown / HTML Export Backend
## Status
Proposed — **design-only**. This ADR fixes the *direction* and the key open
decision for a future markdown/HTML export target; it does **not** implement a
backend. No code or schema changes accompany it. (The 恒一小奥 lesson ships with
`student`/`teacher` PDF targets and a `kendoku`-generated `interactives` target;
a markdown/HTML analog is future work gated on the decision below.)
Builds on **ADR-0009** (export targets are builds producing typed artifacts;
"which markdown dialect … stays open") and **ADR-0011** (presentation lives in
the template; the manifest is injected). Relates to **ADR-0013** (a non-typst
backend could ride a shell step or earn a typed step — still open).
## Context
The third-party 恒一小奥 deliverable was a slide-deck markdown bundle
(`lesson_slides.md`). A recurring goal is to export an analog of such
markdown/HTML from the same engineering file. The hard part is **math**: a
curriculum is formula-heavy, and a markdown/HTML export that turns formulas into
images (losing selectability, accessibility, and editability) is a regression.
Three facts were established (web research + reading the pinned deps), and a
fourth is the user's framing:
1. **typlite (tinymist's markdown export)** renders math **as images** and loses
cross-references and styles — it goes through HTML as an intermediary. This is
exactly the failure mode to avoid for a math-rich curriculum.
2. **typst 0.15 native HTML export** (the version this repo pins; `typst-html
0.15` is already in `Cargo.lock`) emits equations as **MathML**. MathML is
*accessibility-oriented*: selectable and screen-readable, but **not** trivially
convertible to KaTeX/LaTeX, and rendering fidelity varies by browser.
3. **`mitex`** converts **LaTeX → typst** (an authoring-time import direction),
**not** typst → LaTeX. So it is not, by itself, the typst-source → markdown
lever; the user's instinct that "keep the formula source span and convert it"
is right about *what* we want, but `mitex`'s direction is the reverse of what a
typst→markdown path needs.
4. **The user's framing**: the content a markdown/HTML target needs and the
content the PDF 讲义/教案 need **do not fully overlap** — so whether a
markdown/HTML export must reuse the *same* `typst-content` source, or instead
draw on target-specific content, is **genuinely open** and is the pivotal
design decision here.
## Decision (direction, not implementation)
### The pivotal open decision: shared source vs. target-specific content
Before any backend is built, decide: **does the markdown/HTML target render the
same per-element `typst-content` the PDF targets render, or does it consume
target-specific content?** This ADR does **not** pre-pick it; it names it as the
gating decision and frames the two routes so the next round can choose
deliberately (constitution rule 2 — surface, do not assume):
- **(R1) Shared typst-content.** One source of truth; markdown/HTML is another
projection of the same `.typ`. Requires a real typst-source → markdown/HTML
converter that preserves math as editable markup. Highest reuse, hardest math
story.
- **(R2) Target-specific content.** The element carries (or the target supplies)
content authored for the web medium. Decouples the hard typst→markdown math
problem from the PDF path, at the cost of a second content surface to maintain.
### Preferred math direction (whichever route is chosen)
Math must survive as **markup, not images**. The favored approach is to obtain
each equation's **source span** from the typst AST and convert that span's source
to LaTeX/KaTeX, rather than accept typlite's image rendering or 0.15's
accessibility-MathML as the final form. Concretely, the next round should
evaluate:
- harvesting equation source spans via `typst-syntax` and emitting
`$…$`/`$$…$$` (KaTeX-compatible) from the typst math source, and
- studying tinymist's own conversion crate (typlite / `cmarker`) for reusable
pieces — adopting what helps, but **not** its math-as-image fallback.
A small **proof-of-concept** scoped to *this* lesson (whose math is trivial —
`8×`, `2-`, `12×`) is the right first experiment: it exercises the pipeline
end-to-end while sidestepping the hardest formula cases, and shows whether
equations come out as text/KaTeX rather than images.
### Backend mechanics, once the route is chosen
- typst 0.15 HTML export is reachable today (`typst-html 0.15` is a transitive
dep; promotable to a direct dep) — usable for R1's HTML side.
- A markdown (rather than HTML) artifact may post-process that HTML, or emit
markdown directly from the AST — open, decided with the route.
- Per ADR-0013, the backend may run as a typed step or a shell step; whether the
non-typst backend earns its own typed `Step` constructor is still open.
## Consequences
- The export-backend work has an explicit gating decision (R1 vs. R2) and a
pinned math principle (markup, not images), so the next round starts from a
decision rather than a blank page.
- No implementation, schema, or Lean change lands now — the contract is
unchanged; this is a recorded design direction.
- The PDF and interactives paths are unaffected and remain the lesson's shipping
targets.
## Open Questions / Deferred
- **R1 vs. R2** — the pivotal decision above; unresolved by design.
- **Concrete math conversion** — span-harvest → KaTeX vs. reusing a tinymist
crate; to be settled by the PoC.
- **Markdown dialect** — which dialect/fenced-div conventions the markdown
artifact targets (ADR-0009 left this open; still open).
- **Typed non-typst step** — whether the backend rides `Shell` or earns a typed
`Step` (ADR-0013 open question).
@@ -0,0 +1,177 @@
# ADR 0015: Slides & Transcript As Per-Element Markdown Content Surfaces
## Status
Accepted — and to be implemented. Adds two new **markdown** content surfaces
(`slides`, `transcript`) as per-element optional content fields, plus a typed
`Step.assembleMarkdown` that concatenates them in parts order into a single-file
markdown deliverable.
Builds on **ADR-0005** ((a)-class fields like 逐字稿 live inside a kind's
`ElementData`; (b)-class medium lives in the target build), **ADR-0006**
(`content` leaves in a kind schema), **ADR-0011** (a target is an artifact +
ordered typed steps; presentation lives in the template/content not the
manifest), **ADR-0013** (non-typst targets ride a build step; failures are
build-process errors outside the 6-class taxonomy), and **ADR-0014** (the
markdown/HTML export direction — R1 shared typst-content vs. R2 target-specific
content; math must survive as markup not images).
## Context
The KenKen lesson (and curriculum generally) needs a **slides** deliverable —
an **outline-grade** projection (titles + bullet points; detail belongs in the
讲义/教案 or the 逐字稿), aimed at a third-party platform whose interchange
format is a **specific markdown dialect**. It also needs a **逐字稿**
(transcript) — the spoken text, read aloud, no typography needed.
Two prior facts made the design:
1. **slides content ≠ 讲义 content.** ADR-0014 already recorded that a
markdown/HTML target's content does not fully overlap the PDF 讲义/教案.
Forcing slides to be a projection of the same typst source (ADR-0014's R1)
drags in the hard **typst→markdown math conversion** (typlite renders math
as images; 0.15 MathML is accessibility-oriented and hard to turn into KaTeX;
`mitex` is LaTeX→typst, the reverse direction — ADR-0014 §Context).
2. **slides/逐字稿 can be authored directly in markdown.** If `slides` and
`transcript` are authored as **markdown + KaTeX from the start**, there is
*no* typst→markdown conversion at all — the math is KaTeX source (`$…$`) on
the day it is written. The math principle ("markup, not images", ADR-0014)
is satisfied for free, not by building a fragile converter.
So for the slides/transcript surfaces this ADR picks **ADR-0014's R2**
(target-specific content) deliberately: it **sidesteps** the typst→md converter
entirely. The converter (R1) remains an open problem only for the *讲义* surface,
which is independent and out of scope here.
The spec already pins where (a)-class fields live: `Element.lean` says
"逐字稿、重点圈划 落在具体 kind 的 `ElementData` 内". So `slides`/`transcript`
are modeled as **per-kind optional `content` leaves** (declared in each kind's
JSON schema), consistent with the existing `textbook`/`problem`/`solution`
content fields — except their **format is markdown, not typst**. This is a real
extension to the `content` concept: a content leaf now carries a *format*
(typst | markdown). `RichContent.lean` gains a `ContentFormat` and the content
ref gains a `format` field.
## Decision
### Two new markdown content fields on every kind
Each stdlib kind schema (`segment`, `example`, `lemma`, `sop`) gains two
**optional** `content` fields — `slides` and `transcript` — whose format is
**markdown** (`x-cph-content: "md"`, the new extension-bearing form of the
marker; the old `x-cph-content: true` stays `.typ` for backward compat).
Optional ⇒ a kind instance without them is legal (like a lemma's `proof`).
Convention: `slides.md` is **outline-grade** (title + bullets; no worked
detail — that lives in 讲义/逐字稿); `transcript.md` is the **spoken** text.
### A new typed step `Step.assembleMarkdown field`
A target like `[targets.slides]` carries `[[…steps]] type = "assemble-markdown"
field = "slides"` over a `single-file` artifact. The framework **owns** the
assembly (not a `cat`): it walks the manifest `[[parts]]` **in order**, reads
each element's `<field>.md` if present (skips if absent), joins them with a
blank-line separator, and writes the single file to the artifact's `filepath`
(relative to the engineering root). It does **not** inject headings — each
`slides.md`/`transcript.md` authors its own `# …` (presentation lives in the
content, ADR-0011).
This is **typed**, not `shell`, because ordered-read + skip-missing +
write-to-artifact-path is cleaner owned by the framework than expressed as a
shell pipeline, and it stays deterministic/testable.
### Self-contained build dir: the assembler collects referenced images
The assembled `slides.md` is written under the build root (the artifact
`filepath`'s parent, e.g. `build/`). For that directory to be a **standalone
deliverable** — uploadable to the platform on its own — its `![](rel)` image
references must resolve *within* it, not back into the engineering root. So the
assembler, after writing the markdown, scans it for local `![](rel)` references
and copies each referenced image from the engineering root (`<eng-root>/<rel>`)
into the build root (`<build-root>/<rel>`), preserving the relative path. This
is part of the `assembleMarkdown` step's owned responsibility (one step, not a
separate copy step), keeping a slides target a single typed step.
Conventions that fall out of this:
- **Image references are authored relative to the engineering root** (`assets/images/…`),
which is *also* their form relative to the build root after the copy — so the
same path string is correct both at authoring time (the source image exists at
`<eng-root>/assets/images/…`) and in the assembled output.
- **Widget references are build-relative** (`interactives/ex1.html`): widgets are
*build artifacts* produced by a separate target (the `interactives` shell
target writes `build/interactives/`), not engineering-root sources, so they are
referenced but **not** copied by the assembler. A fully self-contained `build/`
therefore requires running the `interactives` target first — a documented
prerequisite, like `kendoku` reachability (ADR-0013).
- **External URLs** (`http://`/`https://`/`data:`) are left untouched.
- A referenced image **missing** at the engineering root is a build-process error
(the build dir would be broken), reported by the build layer — it does **not**
enter the 6-class taxonomy.
### Course-level h1 is injected
The assembler prepends the course title (from manifest `[info] title`) as the
document's `#` h1. This is course metadata, not per-element content; each
element's `<field>.md` then contributes its `##` part and `###` 小节. This is
the one piece of presentation the assembler owns at the document level (ADR-0011's
"presentation lives in the content" governs *per-element* presentation).
### Same three boundaries as the shell step (ADR-0013)
`assembleMarkdown` is a **non-typst target**, identical in boundary to the
shell step:
1. **opt-in by construction** — runs only on an explicit
`cph build --target <name>`, never in `check`. `check` validates lesson
*structure*, not the outcome of assembling markdown.
2. **failure is a build-process error, not a lesson diagnostic** — an assembly
failure (e.g. write error) does **not** enter the 6-class diagnostic
taxonomy; it is reported by the build layer. We do **not** add an
`AssembleMarkdown` diagnostic code (taxonomy stays 6, ADR-0010/0012/0013).
3. **non-typst targets skip typst compile**`check`'s compile phase already
filters to `TypstCompile`-bearing targets, so a pure `assembleMarkdown`
target is naturally excluded.
### Single-file artifact now; structured FileTree deferred
The artifact is a **single-file** markdown. The user reasoned that until parts
are organized as an **ordered tree** (rather than the current flat ordered
list), single-file is unavoidable — a loose FileTree of per-element `.md`
files would lose the ordering/structure the manifest encodes. So: single-file
now; a structured multi-file FileTree artifact is deferred until the parts
tree-restructure lands (it is out of scope here and recorded as OPEN).
## Consequences
- The framework gains two orthogonal content surfaces (outline slides, spoken
transcript) authored directly in markdown+KaTeX, with **no** typst→md math
conversion in their path. The math problem (ADR-0014) is *not* solved
generally — it is *avoided* for these surfaces by choosing R2.
- A third typed `Step` variant (`assembleMarkdown`) joins `typstCompile` and
`shell`; the step universe is extensible by design (ADR-0011).
- The `content` leaf concept broadens: it now carries a *format*. The
`x-cph-content` marker accepts an extension (`"md"`), backward-compatible
with the boolean form (`.typ`). `RichContent.lean` pins `ContentFormat`.
- The 6-class diagnostic taxonomy is unchanged.
- The 恒一小奥 KenKen lesson gains `slides`/`transcript` targets alongside its
existing `student`/`teacher`/`interactives` targets — all from one
engineering file through one CLI.
## Open Questions / Deferred
- **Third-party markdown dialect.** Baseline CommonMark + KaTeX (`$…$`/`$$…$$`)
ships now; the platform's specific dialect (page-break markers, callout /
fenced-div conventions, heading-depth rules, math-delimiter quirks) is a
transform layer to be added once the lesson's output is fed to the platform
and the real requirements are observed.
- **Structured FileTree artifact.** A multi-file markdown deliverable organized
by the parts tree — deferred until the parts tree-restructure (itself a
separate OPEN / future ADR; touches the manifest schema, render assembly,
and all examples).
- **讲义 (typst) → markdown.** Still ADR-0014's open R1/converter problem,
*unaffected* by this ADR. Whether the 讲义 surface ever needs a markdown
export is an independent decision.
- **Universal vs. per-kind markdown surfaces.** `slides`/`transcript` are
declared per-kind (4 schemas, duplicated) to match ADR-0005's "(a)-class
lives in kind `ElementData`". Whether they should become a cross-cutting
universal content surface is reconsidered alongside the parts tree-restructure.
-126
View File
@@ -1,126 +0,0 @@
model {
teacher = actor 'Teacher' {
description '教研老师。可以同时参与多个项目群,在群里讨论并在需要时 @Claude。'
}
admin = actor 'Admin' {
description '管理员。管理项目、权限、异常锁释放和审计。'
}
feishu = externalSystem 'Feishu / Lark' {
description '项目群、消息、卡片交互和历史消息 API。'
}
claude = externalSystem 'Claude / Claude Code' {
description '执行课程资料生成、修改和问答的 AI agent。'
}
workspace = externalSystem 'Project Workspace' {
description '每个课程项目的文件、素材、课件、教案和生成产物所在工作区。'
}
hub = system 'Curriculum Project Hub' {
description '教研项目工作台。把项目、飞书群、Claude run、锁、上下文和产物管理成一个明确的协作系统。'
web = app 'Project Console' {
description '后台/工作台 UI,用于项目管理、权限、run 审计、异常取消和强制释放锁。'
}
api = service 'Hub API' {
description '对 UI、飞书回调、内部 agent runtime 暴露业务 API。'
}
project = service 'Project Service' {
description '管理 Project、成员、项目群绑定、项目状态和产物引用。'
}
permission = service 'Permission Service' {
description '飞书云文档式权限层。按 resource + principal + role 授权,并用 permission settings 控制分享、评论、下载、协作者管理和 Claude 调用。'
}
im = adapter 'IM Bridge Adapter' {
description '飞书/Lark 适配层。负责事件去重、卡片、消息投递、按钮回调和限流;优先评估复用 Claude-to-IM 的 adapter/delivery 能力。'
}
run = service 'Run Orchestrator' {
description '在 @Claude 时创建 AgentRun,申请项目锁,调度 Claude runtime,处理取消、失败、等待用户和完成。'
}
locks = service 'Project Lock Service' {
description '统一项目级锁。scope = project_idowner = run_idlifetime = 一次 Claude run。'
}
context = service 'Context Service' {
description '提供当前触发消息、回复链、项目记忆、产物上下文和按需历史读取。第一版不长期缓存飞书全文。'
}
mcp = service 'MCP Gateway' {
description 'Claude 可调用的内部工具入口,例如读取当前项目群历史、触发消息、附件和产物上下文。'
}
runtime = runtime 'Claude Runtime' {
description '调用 Claude Code / SDK / wrapper,维护 provider session resume,并执行具体任务。'
}
db = store 'Application Database' {
description '项目、群绑定、AgentSession、AgentRun、锁、审计和轻量索引。'
projectEntity = entity 'Project'
groupEntity = entity 'ProjectGroupChat'
sessionEntity = entity 'AgentSession'
runEntity = entity 'AgentRun'
lockEntity = entity 'ProjectAgentLock'
permissionEntity = entity 'PermissionGrant'
settingsEntity = entity 'PermissionSettings'
memoryEntity = entity 'ProjectMemory / Decisions'
auditEntity = entity 'AuditLog'
}
web -> api 'manage projects, runs and locks'
api -> project 'project commands'
api -> permission 'authorize user actions'
api -> run 'trigger/cancel/admin actions'
api -> db 'read/write application state'
im -> api 'Feishu events and card callbacks'
im -> feishu 'send messages, cards, updates'
im -> project 'bind chat to project'
im -> permission 'check card/action permissions'
im -> run 'create/cancel run on @Claude or card action'
project -> db 'persist projects and group bindings'
permission -> db 'persist grants and settings'
run -> locks 'acquire/release project lock'
run -> permission 'authorize trigger/cancel/force release'
locks -> db 'persist lock owner = run_id'
run -> context 'build seed context'
run -> runtime 'execute Claude turn'
run -> db 'persist AgentRun state'
runtime -> claude 'resume/call Claude'
runtime -> workspace 'read/write project files'
runtime -> mcp 'request project context'
mcp -> context 'resolve run-scoped context'
context -> feishu 'read messages by chat/thread/message id'
context -> db 'read project memory and anchors'
projectEntity -> groupEntity 'has project group'
projectEntity -> permissionEntity 'has collaborators'
projectEntity -> settingsEntity 'has operation settings'
projectEntity -> sessionEntity 'has long-lived Claude session'
projectEntity -> runEntity 'has many runs'
projectEntity -> lockEntity 'has current lock'
permissionEntity -> settingsEntity 'is constrained by'
runEntity -> lockEntity 'owns while running'
sessionEntity -> runEntity 'is reused by runs'
projectEntity -> memoryEntity 'stores summary and decisions'
runEntity -> auditEntity 'records lifecycle events'
}
teacher -> feishu 'discusses in project groups'
teacher -> hub.im '@Claude and card actions'
teacher -> hub.web 'reviews projects and runs'
admin -> hub.web 'administers locks, permissions and audit'
feishu -> hub.im 'message events and callbacks'
}
-80
View File
@@ -1,80 +0,0 @@
specification {
element actor {
style {
shape person
color indigo
}
}
element system {
style {
color primary
}
}
element externalSystem {
style {
color gray
}
}
element app {
style {
shape browser
color green
}
}
element service {
style {
shape component
color primary
}
}
element adapter {
style {
shape component
color amber
}
}
element runtime {
style {
shape component
color secondary
}
}
element store {
style {
shape storage
color muted
}
}
element entity {
style {
shape storage
color muted
}
}
relationship sync {
line solid
}
relationship async {
line dotted
}
relationship reads {
line dashed
color gray
}
relationship writes {
line solid
color gray
}
}
-103
View File
@@ -1,103 +0,0 @@
views {
view index {
title 'System Context'
description '''
Curriculum Project Hub treats a Feishu group as the long-lived project collaboration space.
Claude joins only when teachers @Claude, and the Hub creates one AgentRun for that work.
'''
include teacher, admin, hub, feishu, claude, workspace
}
view containers of hub {
title 'Target Containers'
description 'Main runtime containers and external integrations for the target architecture.'
include *
include teacher, admin, feishu, claude, workspace
}
view permissions of hub {
title 'Permission Model'
description '''
Permissions follow the Feishu Docs pattern: collaborator grants are separate from operation settings.
Grants are resource + principal + role; settings decide who may share, comment, copy/download, manage collaborators and invoke Claude.
'''
include permission, project, im, run, db
include db.projectEntity, db.groupEntity, db.permissionEntity, db.settingsEntity, db.auditEntity
include teacher, admin, feishu
}
view domain_model of hub.db {
title 'Core Domain Model'
description '''
The main correction from the old demo model: project groups and Claude sessions are long-lived,
but project locks are owned by AgentRun, not by teacher, chat or session.
'''
include *
}
dynamic view run_lifecycle {
title '@Claude Run Lifecycle'
description 'A teacher @Claude message creates one AgentRun, which owns the project lock until completion, cancellation or timeout.'
teacher -> feishu 'send @Claude in project group'
feishu -> hub.im 'message event'
hub.im -> hub.project 'resolve chat binding'
hub.im -> hub.permission 'check trigger permission'
hub.im -> hub.run 'create AgentRun'
hub.run -> hub.locks 'acquire project lock with run_id'
hub.run -> hub.context 'build seed context'
hub.run -> hub.runtime 'execute Claude turn'
hub.runtime -> claude 'resume/call Claude'
hub.runtime -> workspace 'read/write project files'
hub.runtime -> hub.mcp 'request extra context if needed'
hub.mcp -> hub.context 'resolve run-scoped tools'
hub.context -> feishu 'read history by message/thread/chat'
hub.runtime -> hub.run 'return result'
hub.run -> hub.im 'publish status/result card'
hub.im -> feishu 'send/update card'
hub.run -> hub.locks 'release run-owned lock'
include teacher, feishu, claude, workspace
}
dynamic view cancel_lifecycle {
title 'Cancel Current Processing'
description 'A permitted teacher can cancel the current active run from card action or @Claude cancel command.'
teacher -> feishu 'click cancel or @Claude cancel'
feishu -> hub.im 'callback/event'
hub.im -> hub.project 'resolve project group binding'
hub.im -> hub.permission 'check cancel permission'
hub.im -> hub.run 'cancel active run'
hub.run -> hub.runtime 'abort controller if local process is alive'
hub.run -> hub.locks 'release lock by run_id'
hub.run -> hub.db 'mark AgentRun canceled and audit actor'
hub.run -> hub.im 'update status card'
hub.im -> feishu 'processing canceled'
include teacher, feishu
}
dynamic view context_access {
title 'On-demand Context Access'
description '''
The Hub stores anchors and memory, not full Feishu history. Claude can call MCP tools
to fetch the current trigger message, reply chain, thread history and recent group messages.
'''
hub.runtime -> hub.mcp 'call get_current_message / get_thread_context'
hub.mcp -> hub.context 'authorize by run_id and project_id'
hub.context -> hub.db 'read anchors, memory and decisions'
hub.context -> feishu 'fetch message/thread/chat history'
feishu -> hub.context 'history page'
hub.context -> hub.mcp 'filtered context'
hub.mcp -> hub.runtime 'tool result'
hub.runtime -> claude 'continue with retrieved context'
include feishu, claude
}
}
@@ -0,0 +1,2 @@
kind = "example"
source = "41 届物理竞赛复赛第三大题(1)"
@@ -0,0 +1,7 @@
某油井内未抽出的石油温度 $100 thin "℃"$,密度为水的 $80%$、比热容为水的 $60%$,记此为参考态。混合物表面张力系数
$ sigma \/ (10^(-3) thin "N/m") = 60 + 0.065 thin T \/ "K" - 24.0 thin p \/ "bar" + 3.15 thin (p \/ "bar")^2 . $
参考态下 $p$ $T$ 成正比,比例系数为等容压强系数 $beta = 7.28 times 10^(-3) thin "bar/K"$
某区域原本充满参考态石油,抽出一半的同时等体积注入水,混合后总体积等于两者之和,抽注过程绝热。问为使混合物 $sigma$ 最小,注入水的温度应为多少 ℃。
@@ -0,0 +1,27 @@
参考态满足 $p = beta T$。混合过程总体积不变,仍属等容过程,故混合后亦满足 $p = beta T$。即整个过程始终有 $p = beta T$
代入 $sigma(T, p)$,令 $tau equiv T \/ "K"$,得 $sigma$ 退化为 $tau$ 的单变量函数
$ sigma(tau) \/ (10^(-3) "N/m") = 60 + 0.065 tau - 24.0 thin (beta tau) + 3.15 thin (beta tau)^2 . $
代入 $beta = 7.28 times 10^(-3)$,注意 $24.0 times 7.28 times 10^(-3) = 0.1747$$3.15 times (7.28 times 10^(-3))^2 = 1.670 times 10^(-4)$
$ sigma(tau) \/ (10^(-3) "N/m") = 60 - 0.1097 tau + 1.670 times 10^(-4) tau^2 . $
$tau$ 求导取零:
$ tau_("mix") = 0.1097 / (2 times 1.670 times 10^(-4)) approx 328.4 , $
$T_("mix") approx 328.4 thin "K" approx 55.3 thin "℃"$
混合的热平衡。设参考态石油温度 $T_0 = 373.15 thin "K"$、密度 $rho_0 = 0.8 rho_w$、比热容 $c_0 = 0.6 c_w$;注入水温度 $T_w$、密度 $rho_w$、比热容 $c_w$。等体积混合且绝热给出
$ rho_0 c_0 (T_0 - T_("mix")) = rho_w c_w (T_("mix") - T_w) , $
代入 $rho_0 c_0 = 0.48 thin rho_w c_w$
$ 0.48 (T_0 - T_("mix")) = T_("mix") - T_w , $
解出
$ T_w = T_("mix") - 0.48 (T_0 - T_("mix")) approx 306.9 thin "K" approx 33.7 thin "℃" . $
@@ -0,0 +1,2 @@
kind = "example"
source = "41 届物理竞赛复赛第三大题(2)"
@@ -0,0 +1 @@
假设液体表面张力的存在可以全部归结为表面层内与液体内部每个粒子邻近粒子数目的不同,且表面层内粒子间距与液体内部相同。设表面层每个粒子邻近粒子数是液体内部的 $zeta$ 倍($0 < zeta < 1$)。已知液体摩尔质量 $mu$、摩尔汽化热 $L_m$、质量密度 $rho$、阿伏伽德罗常量 $N_A$,导出液气界面张力系数 $sigma$ 的表达式。
@@ -0,0 +1,3 @@
完整推导见 @缺键一般式。要点为:设体相分子最近邻数 $Z$、单键能 $epsilon$,由共享键计数得 $epsilon = 2 L_m \/ (N_A Z)$;表面分子缺 $(1 - zeta) Z$ 根键,按半键计赔账得亏损能 $Delta U = (1 - zeta) L_m \/ N_A$;分子占体积 $d^3 = mu \/ (rho N_A)$ 给出面密度 $n_s = (rho N_A \/ mu)^(2\/3)$。代入 @骨架公式
$ sigma = Delta U dot n_s = (1 - zeta) thin L_m thin rho^(2\/3) / (mu^(2\/3) thin N_A^(1\/3)) . $
+79
View File
@@ -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,
)
+62
View File
@@ -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,5 @@
剥离单位面积固液界面前后的界面能账目变化为:消失一份固液界面贡献 $-gamma_(S L)$,新生成一份固气面与一份液气面贡献 $+(gamma_(S G) + gamma_(L G))$。剥离所做的可逆功 $W_(S L)$ 等于界面能的净增量
$ W_(S L) = gamma_(S G) + gamma_(L G) - gamma_(S L) , $
整理即得 @Dupré关系。
@@ -0,0 +1,5 @@
设把单位面积固液界面剥离成各自暴露的固气面与液气面所需的可逆功为粘附功 $W_(S L)$,则
$ gamma_(S L) = gamma_(S G) + gamma_(L G) - W_(S L) . $ <Dupré关系>
$W_(S L)$ 越大,固液之间越"粘"得紧,$gamma_(S L)$ 越小。
@@ -0,0 +1 @@
kind = "lemma"
@@ -0,0 +1,5 @@
设液体的摩尔体积为 $V_m$、临界温度为 $T_c$,则其表面张力随温度的标度律为
$ sigma thin V_m^(2\/3) = k (T_c - T) , $ <Eötvös规则>
其中 $k approx 2.1 times 10^(-7) thin "J" dot "K"^(-1) dot "mol"^(-2\/3)$ 是一个对所有液体共用的普适常数,称为 Eötvös 常数。
@@ -0,0 +1 @@
kind = "lemma"
@@ -0,0 +1,13 @@
@GG公式 给出的 $gamma_(S L) = (sqrt(gamma_(S G)) - sqrt(gamma_(L G)))^2$ 代入 @Young方程:
$ cos theta = (gamma_(S G) - (sqrt(gamma_(S G)) - sqrt(gamma_(L G)))^2) / gamma_(L G) . $
展开右端分子:
$ gamma_(S G) - (gamma_(S G) - 2 sqrt(gamma_(S G) gamma_(L G)) + gamma_(L G)) = 2 sqrt(gamma_(S G) gamma_(L G)) - gamma_(L G) , $
代回得
$ cos theta = 2 sqrt(gamma_(S G) / gamma_(L G)) - 1 , $
@GGZ判据。
@@ -0,0 +1,5 @@
Berthelot 估计 @GG公式 代回 Young 方程 @Young方程,得到 GirifalcoGoodZisman 浸润判据
$ cos theta = 2 sqrt(gamma_(S G) / gamma_(L G)) - 1 . $ <GGZ判据>
接触角完全由比值 $gamma_(S G) \/ gamma_(L G)$ 一个无量纲数决定。
@@ -0,0 +1 @@
kind = "lemma"

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