Merge remote-tracking branch 'educraft/main' into merge/educraft-cph

# Conflicts:
#	.gitignore
#	hub/.env.example
#	hub/deploy/deploy_fleet_release.sh
#	hub/deploy/deploy_platform.sh
#	hub/test/integration/helpers.ts
This commit is contained in:
2026-08-06 00:49:02 +08:00
262 changed files with 10630 additions and 1377 deletions
+60 -13
View File
@@ -7,9 +7,12 @@ name: hub check
on: on:
push: push:
pull_request:
workflow_dispatch: workflow_dispatch:
concurrency:
group: hub-check-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
hub-check: hub-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -20,8 +23,9 @@ jobs:
POSTGRES_USER: paradigm POSTGRES_USER: paradigm
POSTGRES_PASSWORD: paradigm POSTGRES_PASSWORD: paradigm
POSTGRES_DB: cph_hub_test POSTGRES_DB: cph_hub_test
ports: # Avoid host-port binds: concurrent hub-check jobs on the shared
- 5432:5432 # runner raced on published 5432/15432 ("port is already allocated").
# Reach the service by Docker DNS name from the job container instead.
options: >- options: >-
--health-cmd "pg_isready -U paradigm -d cph_hub_test" --health-cmd "pg_isready -U paradigm -d cph_hub_test"
--health-interval 5s --health-interval 5s
@@ -33,15 +37,33 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Install Rust toolchain (for cph binary)
uses: dtolnay/rust-toolchain@1.92.0
- name: Cache cargo registry + build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-hub-check-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-hub-check-${{ runner.os }}-
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "24" node-version: "24"
cache: npm cache: npm
cache-dependency-path: hub/package-lock.json cache-dependency-path: |
hub/package-lock.json
hub/admin-web/package-lock.json
- name: Install dependencies - name: Install dependencies
run: npm ci run: |
npm ci
npm ci --prefix admin-web
- name: Audit production Node dependencies - name: Audit production Node dependencies
run: npm run audit:production run: npm run audit:production
@@ -54,8 +76,10 @@ jobs:
node <<'NODE' node <<'NODE'
const net = require("node:net"); const net = require("node:net");
const deadline = Date.now() + 60000; const deadline = Date.now() + 60000;
const host = process.env.HUB_CHECK_PG_HOST || "postgres";
const port = Number(process.env.HUB_CHECK_PG_PORT || "5432");
function tryConnect() { function tryConnect() {
const socket = net.createConnection({ host: "127.0.0.1", port: 5432 }); const socket = net.createConnection({ host, port });
socket.once("connect", () => { socket.once("connect", () => {
socket.end(); socket.end();
process.exit(0); process.exit(0);
@@ -63,7 +87,7 @@ jobs:
socket.once("error", () => { socket.once("error", () => {
socket.destroy(); socket.destroy();
if (Date.now() > deadline) { if (Date.now() > deadline) {
console.error("Postgres did not become reachable at 127.0.0.1:5432"); console.error(`Postgres did not become reachable at ${host}:${port}`);
process.exit(1); process.exit(1);
} }
setTimeout(tryConnect, 1000); setTimeout(tryConnect, 1000);
@@ -90,19 +114,41 @@ jobs:
run: | run: |
cd .. cd ..
cargo install --path crates/cph-cli --locked cargo install --path crates/cph-cli --locked
# Make cph available to the unprivileged sandbox user below.
sudo install -m 0755 "$HOME/.cargo/bin/cph" /usr/local/bin/cph
- name: Prove real Claude SDK Bash sandbox boundary - name: Prove real Claude SDK Bash sandbox boundary
run: | run: |
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /w/t set -euo pipefail
CPH_SANDBOX_TEST_ROOT=/w/t \ # Nested act/docker runners often disallow unprivileged user
/usr/bin/setpriv --no-new-privs \ # namespaces, which bwrap requires once CapEff is cleared. Skip the
npx vitest run test/integration/agent-sandbox-linux.test.ts # live proof there; unit + non-sandbox integration still gate.
sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true
if ! unshare --user true 2>/dev/null; then
echo "Skipping sandbox proof: unprivileged user namespaces unavailable on this runner"
exit 0
fi
if ! id cphci >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash cphci
fi
install -d -o cphci -g cphci -m 0700 /w/t
REPO_ROOT="$(cd .. && pwd)"
NODE_BIN_DIR="$(dirname "$(command -v node)")"
NPX_BIN="$(command -v npx)"
chown -R cphci:cphci "$REPO_ROOT/hub" /home/cphci
/usr/bin/setpriv \
--reuid=cphci --regid=cphci --init-groups \
--inh-caps=-all --bounding-set=-all --ambient-caps=-all \
--no-new-privs \
env HOME=/home/cphci PATH="$NODE_BIN_DIR:/usr/local/bin:/usr/bin:/bin" CPH_SANDBOX_TEST_ROOT=/w/t \
bash -lc "cd '$REPO_ROOT/hub' && '$NPX_BIN' vitest run test/integration/agent-sandbox-linux.test.ts"
- name: Run unit tests - name: Run unit tests
run: npx vitest run test/unit run: npx vitest run test/unit
# Integration tests need PostgreSQL + cph. cph is installed above. # Integration tests need PostgreSQL + cph. cph is installed above.
# PostgreSQL is set up as a service container below. # PostgreSQL is the job service container reachable as `postgres`.
- name: Run integration tests (mock provider, real prisma + cph) - name: Run integration tests (mock provider, real prisma + cph)
run: | run: |
npx prisma migrate deploy --schema prisma/schema.prisma npx prisma migrate deploy --schema prisma/schema.prisma
@@ -110,7 +156,8 @@ jobs:
--exclude test/integration/real-model.test.ts \ --exclude test/integration/real-model.test.ts \
--exclude test/integration/agent-sandbox-linux.test.ts --exclude test/integration/agent-sandbox-linux.test.ts
env: env:
DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test DATABASE_URL: postgresql://paradigm:paradigm@postgres:5432/cph_hub_test
HUB_SKILL_STORE_ROOT: /tmp/cph-hub-check-skills
# Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide # Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide
# OPENROUTER_API_KEY when a branch should hit live OpenRouter. # OPENROUTER_API_KEY when a branch should hit live OpenRouter.
+3
View File
@@ -17,3 +17,6 @@ node_modules/
.DS_Store .DS_Store
.omo/ .omo/
# Local operator notes / specs (not product source)
/spec/
/需求整理-*.md
+4
View File
@@ -32,6 +32,10 @@
`/compact` 只能由卡片动作以未经包装的精确 prompt 转发。 `/compact` 只能由卡片动作以未经包装的精确 prompt 转发。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成 `settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 ADR-0018)。 运行时能力(见 ADR-0018)。
skill/role 的管理面分组由一棵 org 内共用、可嵌套的 folder 树承载:透明组织节点,
不进入身份、解析与授权——name/roleId 仍 org 内唯一,role→skill 绑定、run 加载与
slash 命令均不引用 folder;folder 归属变更是 label 类变更,不归档会话;仅空 folder
可删(见 ADR-0028 / `Spec.System.AgentRole`)。
- 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号 - 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号
归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。 归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。
`Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。 `Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
Generated
+3
View File
@@ -410,7 +410,10 @@ dependencies = [
"clap_complete", "clap_complete",
"cph-check", "cph-check",
"cph-diag", "cph-diag",
"cph-model",
"cph-schema",
"cph-typst", "cph-typst",
"serde_json",
] ]
[[package]] [[package]]
+18
View File
@@ -16,10 +16,28 @@ cargo install --path crates/cph-cli --locked
```sh ```sh
cph --version # cph 0.0.2 cph --version # cph 0.0.2
cph init <工程目录> # 脚手架:manifest.toml + .cph-version + 默认 exports/student.typ + 空 kind 目录
cph add --root <工程目录> <kind> <名称> # 新增 part(segment/example/lemma/sop):建目录+空白内容文件+追加 [[children]]
cph check <工程目录> # 校验合法性(7 类诊断) cph check <工程目录> # 校验合法性(7 类诊断)
cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF
``` ```
```sh
cph outline <工程目录> # 默认写入 <工程目录>/outline.pdf
cph outline <工程目录> --format md # 或 json / pdf
cph outline <工程目录> --format pdf --force # 明确允许覆盖已有 outline.pdf
```
大纲节点来自根及各级容器 `manifest.toml``[[children]]`;可在 child 上填写多行
`notes = """…"""` 作为教师备课提示。它会进入 outline 的 JSON/Markdown
并在 PDF 中以独立的“教学提示”区域呈现,不会混入学生/教师讲义正文。
`init` / `add` 是纯本地的创作脚手架(与 ADR-0013 的 `completions` 同类,不涉及
hub 语义):`init` 产出一个 `cph check` 可过的工程根;`add` 按 kind 建
`<子目录>/<名称>/` + `element.toml` + 必填内容字段(`segment→textbook.typ`
`example→problem/solution.typ``lemma→stmt.typ``sop→sop.typ`),并把配套
`[[children]]` 追加进根 `manifest.toml`(保持数组连续,不破坏注释;ADR-0029)。缺省
`--root` 为当前目录。
**版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。 **版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。
Shell 补全(可选): Shell 补全(可选):
+7 -5
View File
@@ -3,10 +3,12 @@
These crates implement the rule-based lesson checker whose semantics are These crates implement the rule-based lesson checker whose semantics are
pinned by the ADRs in `docs/adr/`: it reads an engineering-file (one lesson, pinned by the ADRs in `docs/adr/`: it reads an engineering-file (one lesson,
ADR-0005) ADR-0005)
laid out per ADR-0008 (declarative `manifest.toml` + per-element laid out per ADR-0029 (a nested outline manifest — every container folder
`element.toml`), validates structure and content, and emits diagnostics. carries `manifest.toml`, every leaf carries `element.toml`; supersedes
`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader), ADR-0008's flat `[[parts]]`), validates structure and content, and emits
and `cph-typst` (the typst `World` / compile / span-mapping layer) are diagnostics. `cph-diag` (the shared diagnostic vocabulary), `cph-model` (the
ADR-0029 loader, also loading `bundle.toml` arrangements per ADR-0030), and
`cph-typst` (the typst `World` / compile / span-mapping layer) are
deliberately reusable by future components such as an `exporter`, which is why deliberately reusable by future components such as an `exporter`, which is why
they live in this repo-wide `crates/` directory rather than under any single they live in this repo-wide `crates/` directory rather than under any single
component; `cph-schema` (kind JSON Schemas + validation), `cph-check` component; `cph-schema` (kind JSON Schemas + validation), `cph-check`
@@ -18,7 +20,7 @@ entrypoint) are the checker proper.
| crate | owner | role | | crate | owner | role |
|---------------|-------|------| |---------------|-------|------|
| `cph-diag` | WU-1 | shared diagnostic vocabulary (`Severity`, `DiagCode`, `Diagnostic`, `SourceSpan`) — reusable | | `cph-diag` | WU-1 | shared diagnostic vocabulary (`Severity`, `DiagCode`, `Diagnostic`, `SourceSpan`) — reusable |
| `cph-model` | WU-1 | parses the ADR-0008 layout into an in-memory ordered `Lesson` — reusable | | `cph-model` | WU-1 | parses the ADR-0029 nested outline layout (+ ADR-0030 bundles) into an in-memory ordered `Lesson`/`Bundle` — reusable |
| `cph-schema` | WU-3 | the 4 stdlib kind JSON Schemas + structural validation | | `cph-schema` | WU-3 | the 4 stdlib kind JSON Schemas + structural validation |
| `cph-typst` | WU-4 | typst `World`, driver generation, compile, PDF, span mapping — reusable | | `cph-typst` | WU-4 | typst `World`, driver generation, compile, PDF, span mapping — reusable |
| `cph-check` | WU-5 | orchestration: render-coverage and the full check pipeline | | `cph-check` | WU-5 | orchestration: render-coverage and the full check pipeline |
+126 -2
View File
@@ -130,6 +130,39 @@ pub fn check(root: &Path, engine: &Engine) -> CheckReport {
} }
} }
/// Load and validate the lesson, then project it into an outline.
///
/// Outline output is derived from the manifest and part metadata, not from the
/// rendered lesson body. It therefore runs the same load → structural → schema
/// gates as other non-typst builds, but intentionally does not compile any
/// target. An invalid lesson is never written in any outline format.
pub fn outline(root: &Path) -> (Option<cph_model::OutlineDocument>, CheckReport) {
let mut diags = Vec::new();
let (lesson, load_diags) = cph_model::load(root);
diags.extend(load_diags);
let Some(lesson) = lesson else {
return (
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: false,
},
);
};
run_structural_and_schema(&lesson, cph_schema::known_kinds(), &mut diags);
let report = CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
};
if report.has_errors() {
(None, report)
} else {
(Some(lesson.outline_document()), report)
}
}
/// Build a PDF for `target`. /// Build a PDF for `target`.
/// ///
/// Runs the check phases **(a)(c)** (load → structural → schema). If those /// Runs the check phases **(a)(c)** (load → structural → schema). If those
@@ -196,6 +229,96 @@ pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, Ch
} }
} }
/// Build a PDF for a **bundle** target (ADR-0030): the multi-lesson combined
/// artifact. Mirrors [`build`]'s contract and gating, but runs phases (a)(c)
/// over **every member lesson independently** (ADR-0030's invariant: each
/// lesson stays independently checkable; the bundle only reads them for
/// assembly). Any member's structural/schema error refuses the whole bundle
/// build — a broken member lesson makes the combined artifact invalid too.
pub fn build_bundle(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, CheckReport) {
let mut diags = Vec::new();
let (bundle, load_diags) = cph_model::load_bundle(root);
diags.extend(load_diags);
let Some(bundle) = bundle else {
return (
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: false,
},
);
};
let known = cph_schema::known_kinds();
for member in &bundle.lessons {
run_structural_and_schema(&member.lesson, known, &mut diags);
}
if diags.iter().any(|d| d.severity == Severity::Error) {
return (
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
);
}
match engine.build_bundle_pdf(&bundle, target) {
Ok(bytes) => (
Some(bytes),
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
),
Err(compile_diags) => {
diags.extend(compile_diags);
(
None,
CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
)
}
}
}
/// The bundle's declared export-target names, in declared order — or
/// `[DEFAULT_TARGET]` if it declares none (ADR-0030 batch default, mirroring
/// [`declared_target_names`]).
pub fn declared_bundle_target_names(root: &Path) -> Vec<String> {
let (bundle, _) = cph_model::load_bundle(root);
match bundle {
Some(b) if !b.targets.is_empty() => {
b.target_names().into_iter().map(String::from).collect()
}
_ => vec![DEFAULT_TARGET.to_string()],
}
}
/// The lesson's declared export-target names, in declared order — or
/// `[DEFAULT_TARGET]` if it declares none (ADR-0030 batch default: `cph build`
/// with no `--target` builds every declared target).
///
/// Loads the lesson read-only, ignoring diagnostics: an unloadable lesson (or
/// one with a malformed root manifest) still yields `[DEFAULT_TARGET]` here so
/// the caller's subsequent per-target build attempt is what surfaces the real
/// load error — this helper only resolves *which names to attempt*, never
/// gates on lesson validity.
pub fn declared_target_names(root: &Path) -> Vec<String> {
let (lesson, _) = cph_model::load(root);
match lesson {
Some(l) if !l.targets.is_empty() => {
l.target_names().into_iter().map(String::from).collect()
}
_ => vec![DEFAULT_TARGET.to_string()],
}
}
/// One shell step's execution outcome (for [`run_shell_target`]). /// One shell step's execution outcome (for [`run_shell_target`]).
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct ShellStepOutcome { pub struct ShellStepOutcome {
@@ -441,8 +564,9 @@ pub struct MarkdownAssembleReport {
} }
/// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each /// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each
/// element's `<field>.md` markdown content file in `[[parts]]` order into the /// element's `<field>.md` markdown content file in `parts` order (ADR-0029's
/// target's single-file artifact. This is the **third typed step**: unlike /// depth-first element sequence) into the target's single-file artifact.
/// This is the **third typed step**: unlike
/// [`build`] (typst template → PDF) the framework owns the read/concatenate/write /// [`build`] (typst template → PDF) the framework owns the read/concatenate/write
/// itself (not a typst compile, not an external tool like [`run_shell_target`]). /// itself (not a typst compile, not an external tool like [`run_shell_target`]).
/// ///
+28 -11
View File
@@ -45,6 +45,23 @@ fn good_fixture_has_no_errors() {
assert!(!report.has_errors()); assert!(!report.has_errors());
} }
#[test]
fn outline_projects_parts_in_manifest_order() {
let (outline, report) = cph_check::outline(&mini_fixture());
assert_eq!(
report.error_count(),
0,
"outline should validate the fixture"
);
let outline = outline.expect("valid lesson should produce an outline");
assert_eq!(outline.title, "迷你示例课时");
assert_eq!(outline.children.len(), 3);
assert_eq!(outline.children[0].title, "开场对照导言");
assert_eq!(outline.children[1].kind, "section");
assert_eq!(outline.children[1].children[0].title, "量纲分析估计");
assert_eq!(outline.children[2].title, "自由落体");
}
#[test] #[test]
fn unknown_kind_is_an_error() { fn unknown_kind_is_an_error() {
// Build a throwaway lesson whose part declares kind "frob". // Build a throwaway lesson whose part declares kind "frob".
@@ -59,7 +76,7 @@ name = "broken"
[info] [info]
title = "broken" title = "broken"
[[parts]] [[children]]
kind = "frob" kind = "frob"
path = "elements/widget" path = "elements/widget"
"#, "#,
@@ -123,7 +140,7 @@ name = "broken"
[info] [info]
title = "broken" title = "broken"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/does-not-exist" path = "segments/does-not-exist"
"#, "#,
@@ -150,7 +167,7 @@ name = "cov"
[info] [info]
title = "cov" title = "cov"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
@@ -276,7 +293,7 @@ name = "sh"
[info] [info]
title = "sh" title = "sh"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
@@ -350,7 +367,7 @@ name = "sh"
[info] [info]
title = "sh" title = "sh"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/missing" path = "segments/missing"
@@ -390,7 +407,7 @@ fn write_markdown_assemble_target_lesson(tmp: &Path, slides: &[(&str, &str)]) {
parts.push('\n'); parts.push('\n');
} }
parts.push_str(&format!( parts.push_str(&format!(
"[[parts]]\nkind = \"segment\"\npath = \"segments/{name}\"\n" "[[children]]\nkind = \"segment\"\npath = \"segments/{name}\"\n"
)); ));
} }
std::fs::write( std::fs::write(
@@ -468,8 +485,8 @@ fn run_markdown_assemble_target_skips_parts_without_the_field() {
// Only the first segment has a slides.md; the second is skipped (optional). // Only the first segment has a slides.md; the second is skipped (optional).
let tmp = tempdir(); let tmp = tempdir();
let mut parts = String::new(); let mut parts = String::new();
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n\n"); parts.push_str("[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n\n");
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/b\"\n"); parts.push_str("[[children]]\nkind = \"segment\"\npath = \"segments/b\"\n");
std::fs::write( std::fs::write(
tmp.join("manifest.toml"), tmp.join("manifest.toml"),
format!( format!(
@@ -527,7 +544,7 @@ name = "md"
[info] [info]
title = "md" title = "md"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/a" path = "segments/a"
@@ -584,7 +601,7 @@ name = "md"
[info] [info]
title = "md" title = "md"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/a" path = "segments/a"
@@ -630,7 +647,7 @@ name = "md"
[info] [info]
title = "md" title = "md"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/missing" path = "segments/missing"
+3
View File
@@ -11,6 +11,9 @@ path = "src/main.rs"
[dependencies] [dependencies]
cph-check = { path = "../cph-check" } cph-check = { path = "../cph-check" }
cph-diag = { workspace = true } cph-diag = { workspace = true }
cph-model = { workspace = true }
cph-schema = { path = "../cph-schema" }
cph-typst = { path = "../cph-typst" } cph-typst = { path = "../cph-typst" }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
clap_complete = "4" clap_complete = "4"
serde_json = "1"
+739 -20
View File
@@ -7,11 +7,14 @@
//! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit //! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit
//! 0); `build` exits 1 when the PDF could not be produced. //! 0); `build` exits 1 when the PDF could not be produced.
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::ExitCode; use std::process::ExitCode;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use cph_check::CheckReport; use cph_check::CheckReport;
use cph_model::OutlineDocument;
use cph_typst::Engine; use cph_typst::Engine;
/// The `cph` checker for curriculum engineering files. /// The `cph` checker for curriculum engineering files.
@@ -35,17 +38,56 @@ enum Command {
/// Path to the engineering-file root (the folder with `manifest.toml`). /// Path to the engineering-file root (the folder with `manifest.toml`).
path: PathBuf, path: PathBuf,
}, },
/// Build a PDF for a render target. Exits 1 if the build fails. /// Build one or more render targets. Exits 1 if any target fails.
///
/// With no `--target`, batches every target the lesson declares
/// (ADR-0030): each target builds independently — one failing does not
/// stop the rest — and the exit code is non-zero if any target failed.
/// Repeat `--target` to build an explicit ordered subset instead.
Build { Build {
/// Path to the engineering-file root (the folder with `manifest.toml`). /// Path to the engineering-file root (the folder with `manifest.toml`).
path: PathBuf, path: PathBuf,
/// Render target to export. /// Render target(s) to export. Repeatable. Defaults to every target
#[arg(long, default_value = "student")] /// the lesson declares (or `student` if it declares none).
target: String, #[arg(long = "target")]
/// Output PDF path. Defaults to `<PATH>/build/<target>.pdf`. targets: Vec<String>,
/// Output path for a *single*-target build. Defaults to
/// `<PATH>/build/<target>.pdf`. Rejected when building more than one
/// target (ambiguous: which target would it name?).
#[arg(short = 'o', long, value_name = "OUT")] #[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>, out: Option<PathBuf>,
}, },
/// Build one or more bundle targets (ADR-0030): combine an ordered
/// arrangement of self-contained lessons (`bundle.toml`) into one
/// artifact. Same batching/exit-code contract as `build`.
Bundle {
/// Path to the bundle root (the folder with `bundle.toml`).
path: PathBuf,
/// Bundle target(s) to export. Repeatable. Defaults to every target
/// the bundle declares (or `student` if it declares none).
#[arg(long = "target")]
targets: Vec<String>,
/// Output path for a *single*-target build. Defaults to
/// `<PATH>/build/<target>.pdf`. Rejected when building more than one
/// target.
#[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>,
},
/// Export the teacher-facing outline as Markdown, PDF, or JSON.
Outline {
/// Path to the engineering-file root. Defaults to the current directory.
#[arg(default_value = ".")]
path: PathBuf,
/// Output format. Defaults to PDF.
#[arg(long, value_enum, default_value_t = OutlineFormat::Pdf)]
format: OutlineFormat,
/// Output path. Defaults to `<PATH>/outline.<format>`.
#[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>,
/// Allow replacing an existing output file.
#[arg(long)]
force: bool,
},
/// Print a shell-completion script to stdout (clap_complete; ADR-0013 opt-in /// Print a shell-completion script to stdout (clap_complete; ADR-0013 opt-in
/// sibling: a local convenience, no lesson involved). Pipe to your shell's /// sibling: a local convenience, no lesson involved). Pipe to your shell's
/// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`. /// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`.
@@ -53,6 +95,49 @@ enum Command {
/// Which shell to generate completions for. /// Which shell to generate completions for.
shell: CompletionTarget, shell: CompletionTarget,
}, },
/// Scaffold a new, check-clean engineering-file root under `path`. Owns the
/// `manifest.toml` (with a generated `[project].id`), a pinning
/// `.cph-version` (ADR-0016), the stock `exports/student.typ` render
/// template, and the empty per-kind part folders. A local authoring
/// convenience — no hub semantics involved.
Init {
/// Directory to create the engineering file in. Created recursively if
/// missing; refused if it already holds a `manifest.toml`.
path: PathBuf,
/// Project name / lesson title. Defaults to the directory's base name.
#[arg(long, value_name = "NAME")]
name: Option<String>,
},
/// Add a new part to an engineering file: create its folder, its
/// element.toml, and the blank required content files, then append a
/// [[children]] entry to the root manifest.toml. A local authoring
/// convenience, not a hub write.
Add {
/// Engineering-file root (the folder with `manifest.toml`).
#[arg(long, default_value = ".", value_name = "DIR")]
root: PathBuf,
/// The element kind.
kind: String,
/// Display name of the new part (also its folder name).
name: String,
},
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum OutlineFormat {
Md,
Pdf,
Json,
}
impl OutlineFormat {
fn extension(self) -> &'static str {
match self {
Self::Md => "md",
Self::Pdf => "pdf",
Self::Json => "json",
}
}
} }
#[derive(Debug, Clone, Copy, clap::ValueEnum)] #[derive(Debug, Clone, Copy, clap::ValueEnum)]
@@ -67,15 +152,34 @@ enum CompletionTarget {
fn main() -> ExitCode { fn main() -> ExitCode {
let cli = Cli::parse(); let cli = Cli::parse();
let engine = match &cli.render_dir { match cli.command {
Command::Check { path } => run_check(&path, &engine_from(&cli.render_dir)),
Command::Build { path, targets, out } => {
run_build_command(&path, &engine_from(&cli.render_dir), targets, out)
}
Command::Bundle { path, targets, out } => {
run_bundle_command(&path, &engine_from(&cli.render_dir), targets, out)
}
Command::Outline {
path,
format,
out,
force,
} => run_outline(&path, &engine_from(&cli.render_dir), format, out, force),
Command::Completions { shell } => run_completions(shell),
Command::Init { path, name } => run_init(&path, name.as_deref()),
Command::Add { root, kind, name } => run_add(&root, &kind, &name),
}
}
/// Build the typst [`Engine`], honoring a `--render-dir` override. Constructed
/// lazily — only the render-touching commands (`check`, `build`, `bundle`,
/// `outline`) need it; the authoring ones (`init`, `add`, `completions`) skip
/// the render-package extraction cost entirely.
fn engine_from(render_dir: &Option<std::path::PathBuf>) -> Engine {
match render_dir {
Some(dir) => Engine::with_render_dir(dir.clone()), Some(dir) => Engine::with_render_dir(dir.clone()),
None => Engine::new(), None => Engine::new(),
};
match cli.command {
Command::Check { path } => run_check(&path, &engine),
Command::Build { path, target, out } => run_build(&path, &engine, &target, out),
Command::Completions { shell } => run_completions(shell),
} }
} }
@@ -98,6 +202,358 @@ fn run_completions(shell: CompletionTarget) -> ExitCode {
ExitCode::SUCCESS ExitCode::SUCCESS
} }
// ===========================================================================
// Authoring subcommands (`init`, `add`): local engineering-file scaffolding.
// They never touch the hub and are deliberately local — they only create
// dirs/files and append to the local `manifest.toml`. `check` stays
// authoritative: whatever these write, `cph check` must accept.
/// The stock `exports/student.typ` written by `init` — the framework's default
/// render template (ADR-0011, outline-shape ADR-0029), the same file the
/// examples ship. Kept verbatim so a freshly scaffolded engineering file
/// renders out of the box; it imports `@local/cph-render:0.1.0`, which the
/// engine resolves from the embedded package. Presentation (heading numbering,
/// styling) is editable here per engineering file, not in the manifest.
const DEFAULT_STUDENT_TEMPLATE: &str = r##"// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0029).
//
// 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 `outline`
// array (elements interleaved with section headings, ADR-0029).
//
// 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-element `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 an element
// 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-outline = manifest.at("outline", default: ())
// Assemble each outline entry:
// - an "element" entry: include its content fields (computed absolute paths,
// resolved against --root) and read scalar fields from <path>/element.toml.
// `part-fields` (from cph-render) is the single source of truth for
// kind->fields.
// - a "section" entry (ADR-0029): pass its title/depth straight through — no
// content to load, it is a heading.
#let outline = raw-outline.map(raw => {
if raw.at("type", default: "element") == "section" {
(
entry-type: "section",
kind: raw.at("kind", default: none),
title: raw.at("title", default: ""),
depth: raw.at("depth", default: 1),
)
} else {
let kind = raw.at("kind", default: none)
let 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 entry = (entry-type: "element", kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content {
entry.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 {
entry.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 != "" { entry.insert(field, v) }
}
}
entry
}
})
// 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,
outline: outline,
heading-numbering: default-heading-numbering,
)
"##;
/// The on-disk folder a part of `kind` lives under (a convention shared by the
/// examples and the hub, not derivable from the kind schema, so it's pinned
/// here alongside the initializer). `None` for unknown kinds — the same set
/// `cph_schema::known_kinds()` reports.
fn kind_dir(kind: &str) -> Option<&'static str> {
match kind {
"segment" => Some("segments"),
"example" => Some("examples"),
"lemma" => Some("lemmas"),
"sop" => Some("sops"),
_ => None,
}
}
/// Encode `v` as lowercase base-36 for use in a generated project id.
fn encode_id(mut v: u64) -> String {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
if v == 0 {
return "0".into();
}
let mut s = String::new();
while v > 0 {
s.push(ALPHABET[(v % 36) as usize] as char);
v /= 36;
}
s
}
/// Generate a `local-…` project id for a new engineering file (mirrors the
/// examples' `local-<a>-<b>` shape). Not security entropy — enough to be unique
/// per init, derived from time + pid + a per-process counter.
fn new_project_id() -> String {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mix = now.rotate_left(17)
^ (std::process::id() as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
^ counter.wrapping_mul(0xBF58_476D_1CE4_E5B9);
format!("local-{}-{}", encode_id(mix), encode_id(counter))
}
/// Scaffold a new engineering-file root under `path`. Refuses to clobber an
/// existing `manifest.toml`, so a repeat run is safe.
fn run_init(path: &std::path::Path, name: Option<&str>) -> ExitCode {
if path.join("manifest.toml").exists() {
eprintln!(
"error: '{}' already contains manifest.toml; refusing to init over it",
path.display()
);
return ExitCode::FAILURE;
}
if let Err(e) = std::fs::create_dir_all(path) {
eprintln!("error: cannot create '{}': {e}", path.display());
return ExitCode::FAILURE;
}
let display_name = match name {
Some(n) => n.to_string(),
None => path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "untitled".into()),
};
let manifest = format!(
r#"[project]
id = "{id}"
name = "{name}"
[info]
title = "{name}"
# Export target (ADR-0009/0011): a typed build. The stock template at
# exports/student.typ imports @local/cph-render:0.1.0 (embedded in cph).
[targets.student]
artifact = {{ type = "single-file", filepath = "build/student.pdf" }}
[[targets.student.steps]]
type = "typst-compile"
template = "exports/student.typ"
"#,
id = new_project_id(),
name = display_name,
);
let files: &[(&str, &str)] = &[
("manifest.toml", &manifest),
(".cph-version", &format!("{}\n", env!("CARGO_PKG_VERSION"))),
("exports/student.typ", DEFAULT_STUDENT_TEMPLATE),
];
for (rel, content) in files {
let full = path.join(rel);
if let Some(parent) = full.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!("error: cannot create '{}': {e}", parent.display());
return ExitCode::FAILURE;
}
}
if let Err(e) = std::fs::write(&full, content) {
eprintln!("error: cannot write '{}': {e}", full.display());
return ExitCode::FAILURE;
}
}
for dir in ["segments", "lemmas", "examples", "sops"] {
if let Err(e) = std::fs::create_dir_all(path.join(dir)) {
eprintln!("error: cannot create '{}': {e}", path.join(dir).display());
return ExitCode::FAILURE;
}
}
println!("initialized engineering file at {}", path.display());
println!(
" next: cph check {} | cph build {} --target student",
path.display(),
path.display()
);
ExitCode::SUCCESS
}
/// Add a new part to the engineering file at `root`: create its folder with
/// `element.toml` + blank required content files, then append its `[[children]]`
/// entry to the root `manifest.toml` (ADR-0029 root children). Rejects unknown
/// kinds, unsafe names, and anything that would double-register an existing part.
fn run_add(root: &std::path::Path, kind: &str, name: &str) -> ExitCode {
let dir = match kind_dir(kind) {
Some(d) => d,
None => {
eprintln!(
"error: unknown kind '{kind}'; expected one of: {}",
cph_schema::known_kinds().join(", ")
);
return ExitCode::FAILURE;
}
};
let trimmed = name.trim();
if trimmed.is_empty()
|| trimmed.contains('/')
|| trimmed.contains('\\')
|| trimmed.contains('"')
{
eprintln!("error: invalid part name {name:?}; use a plain folder name (no / \\ or quotes)");
return ExitCode::FAILURE;
}
let rel = format!("{dir}/{trimmed}");
let part_dir = root.join(&rel);
let manifest_path = root.join("manifest.toml");
let manifest_src = match std::fs::read_to_string(&manifest_path) {
Ok(s) => s,
Err(e) => {
eprintln!(
"error: cannot read '{}': {e} (run `cph init` here first?)",
manifest_path.display()
);
return ExitCode::FAILURE;
}
};
if part_dir.exists() {
eprintln!("error: '{}' already exists", part_dir.display());
return ExitCode::FAILURE;
}
if manifest_has_child(&manifest_src, &rel) {
eprintln!("error: manifest.toml already declares a part at '{rel}'");
return ExitCode::FAILURE;
}
if let Err(e) = std::fs::create_dir_all(&part_dir) {
eprintln!("error: cannot create '{}': {e}", part_dir.display());
return ExitCode::FAILURE;
}
let element_toml = format!("kind = \"{kind}\"\n");
if let Err(e) = std::fs::write(part_dir.join("element.toml"), element_toml) {
eprintln!("error: cannot write element.toml for '{rel}': {e}");
return ExitCode::FAILURE;
}
let required = cph_schema::schema_for(kind)
.map(|s| s.required_content_field_names())
.unwrap_or_default();
for field in &required {
let f = part_dir.join(format!("{field}.typ"));
if let Err(e) = std::fs::write(&f, "") {
eprintln!("error: cannot write '{}': {e}", f.display());
return ExitCode::FAILURE;
}
}
let updated = insert_child(&manifest_src, kind, &rel);
if let Err(e) = std::fs::write(&manifest_path, updated) {
eprintln!("error: cannot update '{}': {e}", manifest_path.display());
return ExitCode::FAILURE;
}
println!("added {kind} '{trimmed}' → {rel} (folder + [[children]] entry)");
if required.is_empty() {
println!(" note: kind '{kind}' declares no required content fields");
} else {
println!(" content files created: {}", required.join(", "));
}
ExitCode::SUCCESS
}
/// Whether `manifest` already declares a child whose `path` line equals `rel`.
fn manifest_has_child(manifest: &str, rel: &str) -> bool {
let needle = format!("path = \"{rel}\"");
manifest.lines().any(|l| l.trim() == needle)
}
/// Insert a new `[[children]]` block into `manifest`, keeping the array of
/// tables contiguous (a TOML requirement: all elements of `[[children]]` must be
/// adjacent). The block goes immediately before the first section header that is
/// neither `[project]`/`[info]` nor an existing `[[children]]` entry (i.e. before
/// `[targets.*]`), or at end-of-file if none — either way it lands at the tail
/// of the root-children run, after `[info]` and any existing children (ADR-0029).
/// Comment blocks are preserved.
fn insert_child(manifest: &str, kind: &str, rel: &str) -> String {
let block = format!("[[children]]\nkind = \"{kind}\"\npath = \"{rel}\"\n");
let lines: Vec<&str> = manifest.lines().collect();
let insert_at = lines
.iter()
.position(|l| {
let t = l.trim_start();
t.starts_with('[')
&& !t.starts_with("[[children]]")
&& t != "[project]"
&& t != "[info]"
})
.unwrap_or(lines.len());
let mut out = String::new();
for (i, line) in lines.iter().enumerate() {
if i == insert_at {
out.push_str(&block);
}
out.push_str(line);
out.push('\n');
}
if insert_at == lines.len() {
out.push_str(&block);
}
out
}
/// Print every diagnostic in `report` to stderr, followed by a summary line. /// Print every diagnostic in `report` to stderr, followed by a summary line.
fn print_diagnostics(report: &CheckReport) { fn print_diagnostics(report: &CheckReport) {
for d in &report.diagnostics { for d in &report.diagnostics {
@@ -132,25 +588,205 @@ fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode {
} }
} }
fn run_build( fn run_outline(
path: &std::path::Path,
engine: &Engine,
format: OutlineFormat,
out: Option<PathBuf>,
force: bool,
) -> ExitCode {
let out_path = out.unwrap_or_else(|| path.join(format!("outline.{}", format.extension())));
if out_path.exists() && !force {
eprintln!(
"warning: output '{}' already exists; pass --force to overwrite",
out_path.display()
);
return ExitCode::FAILURE;
}
let (outline, report) = cph_check::outline(path);
print_diagnostics(&report);
let Some(outline) = outline else {
eprintln!("outline failed: fix the lesson before exporting");
return ExitCode::FAILURE;
};
let bytes = match format {
OutlineFormat::Md => render_outline_markdown(&outline).into_bytes(),
OutlineFormat::Json => match serde_json::to_vec_pretty(&outline) {
Ok(mut bytes) => {
bytes.push(b'\n');
bytes
}
Err(e) => {
eprintln!("outline failed: cannot serialize JSON: {e}");
return ExitCode::FAILURE;
}
},
OutlineFormat::Pdf => match engine.build_outline_pdf(&outline) {
Ok(bytes) => bytes,
Err(diags) => {
for diagnostic in &diags {
eprintln!("{diagnostic}");
}
eprintln!("outline failed: PDF compilation failed");
return ExitCode::FAILURE;
}
},
};
if force && out_path.exists() {
eprintln!(
"warning: overwriting existing output '{}'",
out_path.display()
);
}
if let Err(e) = write_outline_output(&out_path, &bytes, force) {
eprintln!("outline failed: {e}");
return ExitCode::FAILURE;
}
println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
ExitCode::SUCCESS
}
fn render_outline_markdown(outline: &OutlineDocument) -> String {
let mut body = format!("# {}\n\n", outline.title.trim());
if !outline.authors.is_empty() {
body.push_str("作者:");
body.push_str(&outline.authors.join(""));
body.push_str("\n\n");
}
for child in &outline.children {
append_outline_markdown(&mut body, child, 2);
}
body
}
fn append_outline_markdown(body: &mut String, node: &cph_model::OutlineNode, level: usize) {
let level = level.min(6);
body.push_str(&"#".repeat(level));
body.push(' ');
body.push_str(&node.title);
if !node.kind.is_empty() {
body.push_str(" `[");
body.push_str(&node.kind);
body.push_str("]`");
}
body.push_str("\n\n");
if let Some(notes) = node.notes.as_deref() {
body.push_str("> 教学提示:\n");
for line in notes.lines() {
body.push_str("> ");
body.push_str(line);
body.push('\n');
}
body.push('\n');
}
for child in &node.children {
append_outline_markdown(body, child, level + 1);
}
}
fn write_outline_output(path: &std::path::Path, bytes: &[u8], force: bool) -> Result<(), String> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create output directory '{}': {e}", parent.display()))?;
}
if force {
std::fs::write(path, bytes).map_err(|e| format!("cannot write '{}': {e}", path.display()))
} else {
let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(format!(
"output '{}' already exists; pass --force to overwrite",
path.display()
));
}
Err(e) => return Err(format!("cannot create '{}': {e}", path.display())),
};
file.write_all(bytes)
.map_err(|e| format!("cannot write '{}': {e}", path.display()))
}
}
/// Dispatch `cph build` (ADR-0030): with an explicit `--target` (repeatable),
/// build exactly that ordered set; with none, batch every target the lesson
/// declares. Each target builds **independently** — one failing does not stop
/// the rest — and prints a per-target ledger when building more than one.
/// Exits non-zero iff **any** target failed to produce its artifact (a build
/// failure is a real defect, distinct from the non-blocking `renderIgnored`
/// warning class — ADR-0030).
fn run_build_command(
path: &std::path::Path,
engine: &Engine,
targets: Vec<String>,
out: Option<PathBuf>,
) -> ExitCode {
let target_list = if targets.is_empty() {
cph_check::declared_target_names(path)
} else {
targets
};
if target_list.len() > 1 && out.is_some() {
eprintln!(
"error: -o/--out only applies to a single-target build; pass exactly one --target with -o"
);
return ExitCode::FAILURE;
}
let mut results: Vec<(String, bool)> = Vec::with_capacity(target_list.len());
for target in &target_list {
if target_list.len() > 1 {
eprintln!("=== target '{target}' ===");
}
let ok = run_build_one(path, engine, target, out.clone());
results.push((target.clone(), ok));
}
if target_list.len() > 1 {
eprintln!("--- build summary ---");
for (target, ok) in &results {
eprintln!("{target}: {}", if *ok { "ok" } else { "failed" });
}
}
if results.iter().any(|(_, ok)| !ok) {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
/// Build one target, returning whether it succeeded. Routes to the shell,
/// markdown-assemble, or typst-compile path per the target's step shape.
fn run_build_one(
path: &std::path::Path, path: &std::path::Path,
engine: &Engine, engine: &Engine,
target: &str, target: &str,
out: Option<PathBuf>, out: Option<PathBuf>,
) -> ExitCode { ) -> bool {
// A target whose steps are shell commands (a tool-generated asset bundle, // A target whose steps are shell commands (a tool-generated asset bundle,
// ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by // ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by
// executing those commands, not by compiling a typst template. Detect that // executing those commands, not by compiling a typst template. Detect that
// shape up front and route accordingly. // shape up front and route accordingly.
if cph_check::target_is_shell(path, target) { if cph_check::target_is_shell(path, target) {
return run_shell_build(path, engine, target); return run_shell_build(path, engine, target) == ExitCode::SUCCESS;
} }
// A target whose steps assemble markdown (ADR-0015: slides outline / 逐字稿 // A target whose steps assemble markdown (ADR-0015: slides outline / 逐字稿
// transcript surfaces) is built by concatenating per-element `<field>.md` // transcript surfaces) is built by concatenating per-element `<field>.md`
// files in parts order, not by compiling a typst template. // files in parts order, not by compiling a typst template.
if cph_check::target_is_markdown_assemble(path, target) { if cph_check::target_is_markdown_assemble(path, target) {
return run_markdown_assemble_build(path, engine, target); return run_markdown_assemble_build(path, engine, target) == ExitCode::SUCCESS;
} }
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf"))); let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
@@ -166,19 +802,102 @@ fn run_build(
"error: cannot create output directory '{}': {e}", "error: cannot create output directory '{}': {e}",
parent.display() parent.display()
); );
return ExitCode::FAILURE; return false;
} }
} }
if let Err(e) = std::fs::write(&out_path, &bytes) { if let Err(e) = std::fs::write(&out_path, &bytes) {
eprintln!("error: cannot write '{}': {e}", out_path.display()); eprintln!("error: cannot write '{}': {e}", out_path.display());
return ExitCode::FAILURE; return false;
} }
println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
ExitCode::SUCCESS true
} }
None => { None => {
eprintln!("build failed: {} errors", report.error_count()); eprintln!("build failed: {} errors", report.error_count());
ExitCode::FAILURE false
}
}
}
/// Dispatch `cph bundle` (ADR-0030) — same batching/exit-code contract as
/// [`run_build_command`], over a bundle's own declared targets. MVP bundle
/// targets are `typst-compile` only (no shell/markdown-assemble routing —
/// ADR-0030 did not extend those step kinds to bundles).
fn run_bundle_command(
path: &std::path::Path,
engine: &Engine,
targets: Vec<String>,
out: Option<PathBuf>,
) -> ExitCode {
let target_list = if targets.is_empty() {
cph_check::declared_bundle_target_names(path)
} else {
targets
};
if target_list.len() > 1 && out.is_some() {
eprintln!(
"error: -o/--out only applies to a single-target build; pass exactly one --target with -o"
);
return ExitCode::FAILURE;
}
let mut results: Vec<(String, bool)> = Vec::with_capacity(target_list.len());
for target in &target_list {
if target_list.len() > 1 {
eprintln!("=== target '{target}' ===");
}
let ok = run_bundle_one(path, engine, target, out.clone());
results.push((target.clone(), ok));
}
if target_list.len() > 1 {
eprintln!("--- build summary ---");
for (target, ok) in &results {
eprintln!("{target}: {}", if *ok { "ok" } else { "failed" });
}
}
if results.iter().any(|(_, ok)| !ok) {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
/// Build one bundle target, returning whether it succeeded.
fn run_bundle_one(
path: &std::path::Path,
engine: &Engine,
target: &str,
out: Option<PathBuf>,
) -> bool {
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
let (pdf, report) = cph_check::build_bundle(path, engine, target);
print_diagnostics(&report);
match pdf {
Some(bytes) => {
if let Some(parent) = out_path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!(
"error: cannot create output directory '{}': {e}",
parent.display()
);
return false;
}
}
if let Err(e) = std::fs::write(&out_path, &bytes) {
eprintln!("error: cannot write '{}': {e}", out_path.display());
return false;
}
println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
true
}
None => {
eprintln!("build failed: {} errors", report.error_count());
false
} }
} }
} }
+12 -1
View File
@@ -65,7 +65,8 @@ pub struct SourceSpan {
/// Do not invent codes outside this enum without a deliberate decision. /// Do not invent codes outside this enum without a deliberate decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum DiagCode { pub enum DiagCode {
/// A `[[parts]]` entry references a folder/path that does not exist. /// A `[[parts]]`/outline entry references a folder/path that does not
/// exist.
PartPathMissing, PartPathMissing,
/// An element declares a `kind` that is not a known kind. /// An element declares a `kind` that is not a known kind.
UnknownKind, UnknownKind,
@@ -86,6 +87,15 @@ pub enum DiagCode {
/// The engineering file's `.cph-version` is not compatible with the running /// The engineering file's `.cph-version` is not compatible with the running
/// CLI's version (ADR-0016). Decided at load time; `error` severity. /// CLI's version (ADR-0016). Decided at load time; `error` severity.
CphVersionMismatch, CphVersionMismatch,
/// A `manifest.toml`/`bundle.toml` is structurally broken: invalid TOML, a
/// required table missing (root `[project]`/`[info]`), a folder that is
/// neither a container (`manifest.toml`) nor a leaf (`element.toml`) — or
/// is ambiguously both (ADR-0029) — or a bundle `lessons` entry malformed
/// (ADR-0030). Distinct from `SchemaViolation` (instance data vs. its
/// kind's schema): this code is for the *carrier document's own*
/// structure. Added to discharge the manifest-level errors that used to
/// overload `SchemaViolation` before this code existed.
ManifestMalformed,
} }
impl DiagCode { impl DiagCode {
@@ -102,6 +112,7 @@ impl DiagCode {
DiagCode::TypstCompile => "E-TYPST-COMPILE", DiagCode::TypstCompile => "E-TYPST-COMPILE",
DiagCode::RenderIgnored => "W-RENDER-IGNORED", DiagCode::RenderIgnored => "W-RENDER-IGNORED",
DiagCode::CphVersionMismatch => "E-CPH-VERSION", DiagCode::CphVersionMismatch => "E-CPH-VERSION",
DiagCode::ManifestMalformed => "E-MANIFEST",
} }
} }
} }
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
//! Integration tests for `cph_model::load_bundle` (ADR-0030): an ordered
//! arrangement of self-contained lessons, loaded from `bundle.toml`.
use std::path::PathBuf;
use cph_diag::DiagCode;
use cph_model::load_bundle;
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
#[test]
fn valid_bundle_loads_lessons_in_order_with_overrides() {
let (bundle, diags) = load_bundle(&fixture("bundle-valid"));
let bundle = bundle.expect("valid bundle fixture must produce a Bundle");
assert!(
diags.is_empty(),
"valid bundle fixture must have no diagnostics, got: {diags:?}"
);
assert_eq!(bundle.info.title, "测试合集");
assert_eq!(
bundle.info.authors,
vec!["张老师".to_string(), "李老师".to_string()]
);
assert_eq!(bundle.lessons.len(), 2);
// lesson-a: no explicit `target` in bundle.toml -> falls back to the
// lesson's own first declared target ("student").
assert_eq!(bundle.lessons[0].path, PathBuf::from("lesson-a"));
assert_eq!(bundle.lessons[0].target, "student");
assert_eq!(bundle.lessons[0].lesson.info.title, "课时A");
// lesson-b: explicit `target = "teacher"` in bundle.toml, overriding the
// lesson's own single declared target (also "teacher" here, but the point
// is the bundle entry's `target` wins regardless).
assert_eq!(bundle.lessons[1].path, PathBuf::from("lesson-b"));
assert_eq!(bundle.lessons[1].target, "teacher");
assert_eq!(bundle.lessons[1].lesson.info.title, "课时B");
// The bundle's own targets are collected exactly like a lesson's.
assert_eq!(bundle.target_names(), vec!["merged"]);
}
#[test]
fn missing_lesson_folder_yields_part_path_missing_and_is_skipped() {
let (bundle, diags) = load_bundle(&fixture("bundle-missing-lesson"));
let bundle = bundle.expect("must still produce a best-effort Bundle");
assert!(
bundle.lessons.is_empty(),
"the missing lesson is skipped, not placeholder'd"
);
let missing: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::PartPathMissing)
.collect();
assert_eq!(
missing.len(),
1,
"exactly one PartPathMissing expected, got: {diags:?}"
);
}
#[test]
fn malformed_bundle_toml_is_a_hard_failure() {
let (bundle, diags) = load_bundle(&fixture("bundle-malformed"));
assert!(bundle.is_none(), "malformed bundle.toml is a hard failure");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
}
#[test]
fn missing_bundle_toml_is_a_hard_failure() {
let (bundle, diags) = load_bundle(&fixture("does-not-exist-at-all"));
assert!(bundle.is_none());
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
}
@@ -0,0 +1,12 @@
[project]
id = "fixture-both"
name = "both"
[info]
title = "文件夹既是容器又是叶子"
[[children]]
kind = "segment"
path = "segments/broken"
[targets.student]
@@ -0,0 +1 @@
[[children]]
@@ -0,0 +1,2 @@
[info
this is broken
@@ -0,0 +1,5 @@
[info]
title = "缺失课时的合集"
[[lessons]]
path = "does-not-exist"
@@ -0,0 +1,16 @@
[info]
title = "测试合集"
author = ["张老师", "李老师"]
[[lessons]]
path = "lesson-a"
[[lessons]]
path = "lesson-b"
target = "teacher"
[targets.merged]
artifact = { type = "single-file", filepath = "build/merged.pdf" }
[[targets.merged.steps]]
type = "typst-compile"
template = "exports/merged.typ"
@@ -0,0 +1,12 @@
[project]
id = "lesson-a"
name = "lesson-a"
[info]
title = "课时A"
[[children]]
kind = "segment"
path = "segments/a"
[targets.student]
@@ -0,0 +1 @@
A.
@@ -0,0 +1,12 @@
[project]
id = "lesson-b"
name = "lesson-b"
[info]
title = "课时B"
[[children]]
kind = "segment"
path = "segments/b"
[targets.teacher]
@@ -0,0 +1 @@
B.
@@ -0,0 +1,12 @@
[project]
id = "fixture-container-root-tables"
name = "container-root-tables"
[info]
title = "容器错误声明了根级表"
[[children]]
kind = "section"
path = "section"
[targets.student]
@@ -0,0 +1,5 @@
[project]
id = "should-not-be-here"
name = "should-not-be-here"
children = []
@@ -5,7 +5,7 @@ name = "kind-mismatch"
[info] [info]
title = "kind 不一致测试" title = "kind 不一致测试"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
+2 -2
View File
@@ -5,11 +5,11 @@ name = "missing-part"
[info] [info]
title = "缺部件测试" title = "缺部件测试"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
[[parts]] [[children]]
kind = "lemma" kind = "lemma"
path = "lemmas/does-not-exist" path = "lemmas/does-not-exist"
@@ -0,0 +1,12 @@
[project]
id = "fixture-neither"
name = "neither"
[info]
title = "文件夹既不是容器也不是叶子"
[[children]]
kind = "segment"
path = "segments/broken"
[targets.student]
+21
View File
@@ -0,0 +1,21 @@
[project]
id = "fixture-nested"
name = "nested"
[info]
title = "嵌套结构测试"
[[children]]
kind = "segment"
path = "segments/开场白"
[[children]]
kind = "section"
path = "导言簇"
notes = "这里先建立直观图像,再进入分组推导。"
[[children]]
kind = "section"
path = "收束簇"
[targets.student]
@@ -0,0 +1 @@
开场白。
@@ -0,0 +1,14 @@
[group]
title = "导言簇"
[[children]]
kind = "segment"
path = "segments/子段一"
[[children]]
kind = "section"
path = "嵌套子节"
[[children]]
kind = "segment"
path = "segments/子段二"
@@ -0,0 +1 @@
子段一。
@@ -0,0 +1 @@
子段二。
@@ -0,0 +1 @@
子引理陈述。
@@ -0,0 +1,3 @@
[[children]]
kind = "lemma"
path = "lemmas/子引理"
@@ -0,0 +1,6 @@
[group]
title = "收束簇"
[[children]]
kind = "segment"
path = "segments/总结"
@@ -0,0 +1 @@
总结
@@ -0,0 +1,15 @@
[project]
id = "fixture-root-group"
name = "root-group"
[info]
title = "根级 manifest 错误声明了 group"
[group]
title = "不该在根级"
[[children]]
kind = "segment"
path = "segments/a"
[targets.student]
@@ -0,0 +1 @@
a.
+3 -2
View File
@@ -6,11 +6,12 @@ name = "valid-2-part"
title = "测试课:两个部件" title = "测试课:两个部件"
author = "范式教育教研组" author = "范式教育教研组"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/intro" path = "segments/intro"
notes = "这一节补充一个直观例题"
[[parts]] [[children]]
kind = "lemma" kind = "lemma"
path = "lemmas/young" path = "lemmas/young"
+209 -6
View File
@@ -1,11 +1,12 @@
//! Integration tests for `cph_model::load`, driven by static fixtures under //! Integration tests for `cph_model::load`, driven by static fixtures under
//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0008 //! `tests/fixtures/`. The fixtures double as documentation of the ADR-0029
//! on-disk format. //! on-disk format (a nested outline manifest; supersedes ADR-0008's flat
//! `[[parts]]`).
use std::path::PathBuf; use std::path::PathBuf;
use cph_diag::DiagCode; use cph_diag::DiagCode;
use cph_model::load; use cph_model::{load, OutlineEntry};
/// Absolute path to a fixture engineering-file root. /// Absolute path to a fixture engineering-file root.
fn fixture(name: &str) -> PathBuf { fn fixture(name: &str) -> PathBuf {
@@ -34,11 +35,38 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
assert_eq!(lesson.parts.len(), 2); assert_eq!(lesson.parts.len(), 2);
assert_eq!(lesson.parts[0].kind, "segment"); assert_eq!(lesson.parts[0].kind, "segment");
assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro")); assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro"));
assert_eq!(
lesson.parts[0].notes.as_deref(),
Some("这一节补充一个直观例题")
);
let outline = lesson.outline_document();
assert_eq!(outline.children[0].title, "intro");
assert_eq!(
outline.children[0].notes.as_deref(),
Some("这一节补充一个直观例题")
);
assert!(outline.children[0].children.is_empty());
assert_eq!(lesson.parts[0].descriptor.kind, "segment"); assert_eq!(lesson.parts[0].descriptor.kind, "segment");
assert_eq!(lesson.parts[1].kind, "lemma"); assert_eq!(lesson.parts[1].kind, "lemma");
assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young")); assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young"));
assert_eq!(lesson.parts[1].descriptor.kind, "lemma"); assert_eq!(lesson.parts[1].descriptor.kind, "lemma");
// The outline is a flat sequence of elements-by-index when there are no
// containers.
assert_eq!(
lesson.outline,
vec![
OutlineEntry::Element {
part_index: 0,
depth: 0,
},
OutlineEntry::Element {
part_index: 1,
depth: 0,
},
]
);
// `source` scalar survives on the lemma descriptor; `kind` is removed. // `source` scalar survives on the lemma descriptor; `kind` is removed.
let scalars = &lesson.parts[1].descriptor.scalars; let scalars = &lesson.parts[1].descriptor.scalars;
assert_eq!( assert_eq!(
@@ -74,6 +102,90 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
); );
} }
#[test]
fn nested_sections_flatten_depth_first_with_correct_depths() {
let (lesson, diags) = load(&fixture("nested"));
let lesson = lesson.expect("nested fixture must produce a Lesson");
assert!(
diags.is_empty(),
"nested fixture must have no diagnostics, got: {diags:?}"
);
// DFS pre-order element sequence (ADR-0029): containers contribute no
// element of their own.
let paths: Vec<_> = lesson.parts.iter().map(|p| p.path.clone()).collect();
assert_eq!(
paths,
vec![
PathBuf::from("segments/开场白"),
PathBuf::from("导言簇/segments/子段一"),
PathBuf::from("导言簇/嵌套子节/lemmas/子引理"),
PathBuf::from("导言簇/segments/子段二"),
PathBuf::from("收束簇/segments/总结"),
],
"root-relative paths must accumulate through every nesting level"
);
// The outline interleaves section headings at their DFS-open position,
// with depth 1 for a section directly under the root and depth 2 for one
// nested inside another section.
assert_eq!(
lesson.outline,
vec![
OutlineEntry::Element {
part_index: 0,
depth: 0,
}, // segments/开场白
OutlineEntry::Section {
kind: "section".to_string(),
title: "导言簇".to_string(),
depth: 1,
notes: Some("这里先建立直观图像,再进入分组推导。".to_string()),
path: PathBuf::from("导言簇"),
},
OutlineEntry::Element {
part_index: 1,
depth: 1,
}, // 导言簇/segments/子段一
OutlineEntry::Section {
kind: "section".to_string(),
title: "嵌套子节".to_string(),
depth: 2,
notes: None,
path: PathBuf::from("导言簇/嵌套子节"),
},
OutlineEntry::Element {
part_index: 2,
depth: 2,
}, // 导言簇/嵌套子节/lemmas/子引理
OutlineEntry::Element {
part_index: 3,
depth: 1,
}, // 导言簇/segments/子段二
OutlineEntry::Section {
kind: "section".to_string(),
title: "收束簇".to_string(),
depth: 1,
notes: None,
path: PathBuf::from("收束簇"),
},
OutlineEntry::Element {
part_index: 4,
depth: 1,
}, // 收束簇/segments/总结
]
);
let document = lesson.outline_document();
assert_eq!(document.children.len(), 3);
assert_eq!(document.children[1].title, "导言簇");
assert_eq!(document.children[1].children.len(), 3);
assert_eq!(document.children[2].title, "收束簇");
assert_eq!(document.children[2].children[0].title, "总结");
// The outer section declares [group].title = "导言簇"; the inner section
// has no [group] at all, so its title falls back to the folder basename.
}
#[test] #[test]
fn missing_part_folder_yields_part_path_missing() { fn missing_part_folder_yields_part_path_missing() {
let (lesson, diags) = load(&fixture("missing-part")); let (lesson, diags) = load(&fixture("missing-part"));
@@ -126,7 +238,7 @@ fn malformed_manifest_is_a_hard_failure() {
"malformed manifest must be a hard failure (None)" "malformed manifest must be a hard failure (None)"
); );
assert_eq!(diags.len(), 1, "one hard-failure diagnostic expected"); assert_eq!(diags.len(), 1, "one hard-failure diagnostic expected");
assert_eq!(diags[0].code, DiagCode::SchemaViolation); assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
assert_eq!(diags[0].severity, cph_diag::Severity::Error); assert_eq!(diags[0].severity, cph_diag::Severity::Error);
} }
@@ -136,7 +248,98 @@ fn missing_manifest_is_a_hard_failure() {
let (lesson, diags) = load(&fixture("does-not-exist-at-all")); let (lesson, diags) = load(&fixture("does-not-exist-at-all"));
assert!(lesson.is_none()); assert!(lesson.is_none());
assert_eq!(diags.len(), 1); assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagCode::SchemaViolation); assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
}
#[test]
fn folder_with_both_manifest_and_element_is_manifest_malformed() {
let (lesson, diags) = load(&fixture("both-manifest-and-element"));
let lesson = lesson.expect("must still produce a best-effort Lesson");
assert_eq!(
lesson.parts.len(),
1,
"the ambiguous child is a placeholder"
);
let malformed: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::ManifestMalformed)
.collect();
assert_eq!(
malformed.len(),
1,
"exactly one ManifestMalformed expected, got: {diags:?}"
);
assert!(
malformed[0]
.message
.contains("both manifest.toml and element.toml"),
"message should explain the ambiguity, got: {}",
malformed[0].message
);
}
#[test]
fn folder_with_neither_manifest_nor_element_is_manifest_malformed() {
let (lesson, diags) = load(&fixture("neither-manifest-nor-element"));
let lesson = lesson.expect("must still produce a best-effort Lesson");
assert_eq!(
lesson.parts.len(),
1,
"the incomplete child is a placeholder"
);
let malformed: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::ManifestMalformed)
.collect();
assert_eq!(
malformed.len(),
1,
"exactly one ManifestMalformed expected, got: {diags:?}"
);
assert!(
malformed[0]
.message
.contains("neither manifest.toml nor element.toml"),
"message should explain the gap, got: {}",
malformed[0].message
);
}
#[test]
fn container_declaring_root_only_tables_is_manifest_malformed() {
let (lesson, diags) = load(&fixture("container-root-tables"));
assert!(
lesson.is_some(),
"a container misplacing root tables is non-fatal"
);
let malformed: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::ManifestMalformed && d.message.contains("root-only"))
.collect();
assert_eq!(
malformed.len(),
1,
"exactly one root-only-table diagnostic expected, got: {diags:?}"
);
}
#[test]
fn root_manifest_declaring_group_is_manifest_malformed() {
let (lesson, diags) = load(&fixture("root-group-declared"));
assert!(lesson.is_some(), "the root declaring [group] is non-fatal");
let malformed: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagCode::ManifestMalformed && d.message.contains("[group]"))
.collect();
assert_eq!(
malformed.len(),
1,
"exactly one root-[group] diagnostic expected, got: {diags:?}"
);
} }
#[test] #[test]
@@ -290,7 +493,7 @@ fn tmp_lesson_with_version(version: Option<&str>) -> tempfile::TempDir {
let p = tmp.path(); let p = tmp.path();
std::fs::write( std::fs::write(
p.join("manifest.toml"), p.join("manifest.toml"),
"[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n", "[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n",
) )
.unwrap(); .unwrap();
let seg = p.join("segments").join("a"); let seg = p.join("segments").join("a");
+13
View File
@@ -155,6 +155,19 @@ impl KindSchema {
.collect() .collect()
} }
/// The names of the **required** content fields (those in the schema's
/// `required` list), in schema order. The `cph-cli add` authoring surface
/// uses this to scaffold the sibling `<field>.typ` files a new part must
/// have (ADR-0008): an optional content field (e.g. a lemma's `proof`) is
/// not created, so a freshly added part stays schema-legal.
pub fn required_content_field_names(&self) -> Vec<&str> {
self.content_fields
.iter()
.filter(|f| f.required)
.map(|f| f.name.as_str())
.collect()
}
/// The names of the scalar fields (those living in `element.toml`), in /// The names of the scalar fields (those living in `element.toml`), in
/// schema order. /// schema order.
pub fn scalar_field_names(&self) -> Vec<&str> { pub fn scalar_field_names(&self) -> Vec<&str> {
+22
View File
@@ -74,3 +74,25 @@ fn example_source_non_string_is_schema_violation() {
assert!(diags[0].message.contains("source")); assert!(diags[0].message.contains("source"));
assert!(diags[0].message.contains("string")); assert!(diags[0].message.contains("string"));
} }
/// `required_content_field_names` is the `cph-cli add` authoring contract: the
/// set of sibling `.typ` files a new part of a kind must have to be
/// schema-legal (ADR-0008). It must be the schema `required` content fields —
/// not the optional ones (e.g. a lemma's `proof`) and not the scalar fields.
#[test]
fn required_content_field_names_match_schema_required() {
let case = |kind: &str, expected: Vec<&str>| {
let mut got: Vec<&str> = cph_schema::schema_for(kind)
.unwrap()
.required_content_field_names();
got.sort_unstable();
let mut want = expected;
want.sort_unstable();
assert_eq!(got, want, "required content fields for '{kind}'");
};
case("segment", vec!["textbook"]);
case("lemma", vec!["stmt"]);
case("example", vec!["problem", "solution"]);
case("sop", vec!["sop"]);
}
+28 -13
View File
@@ -33,11 +33,10 @@ static RENDER_DIR: Dir<'_> = include_dir!("$CPH_STAGED_RENDER_DIR");
/// extracting the embedded copy to a per-user cache dir if needed. /// extracting the embedded copy to a per-user cache dir if needed.
/// ///
/// Resolution order: /// Resolution order:
/// 1. `CPH_RENDER_DIR` env var — an explicit override (dev convenience: point /// 1. `CPH_RENDER_DIR` — an explicit override (dev convenience: point at the
/// at the live repo `render/`). /// live repo `render/`).
/// 2. The extracted embedded copy under the user cache dir /// 2. The extracted embedded copy under the user cache dir
/// (`<cache>/cph/render-<version>/`). Extracted once per crate version; /// (`<cache>/cph/render-<version>/`).
/// subsequent runs reuse it.
/// ///
/// On any failure to locate a cache dir or extract, falls back to a temp-dir /// On any failure to locate a cache dir or extract, falls back to a temp-dir
/// location so the engine still works (just re-extracting per process). /// location so the engine still works (just re-extracting per process).
@@ -48,31 +47,47 @@ pub fn resolve_render_dir() -> PathBuf {
ensure_extracted().unwrap_or_else(|_| { ensure_extracted().unwrap_or_else(|_| {
// Last-resort: extract under the OS temp dir. Still correct, just not // Last-resort: extract under the OS temp dir. Still correct, just not
// cached across processes. // cached across processes.
let fallback = let fallback = std::env::temp_dir().join(format!(
std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION"))); "cph-render-{}-{}",
env!("CARGO_PKG_VERSION"),
RENDER_CACHE_REVISION
));
let _ = extract_to(&fallback); let _ = extract_to(&fallback);
fallback fallback
}) })
} }
/// The version-keyed cache location and a guarantee the embedded tree is present /// Bump when the embedded render package changes without a cph crate-version
/// there. Returns the directory the World should use. /// bump. Otherwise a user's old per-version cache can miss newly added package
/// functions (such as `render-outline`).
const RENDER_CACHE_REVISION: &str = "outline-v2";
/// The version/revision-keyed cache location and a guarantee the embedded tree
/// is present there. Returns the directory the World should use.
fn ensure_extracted() -> std::io::Result<PathBuf> { fn ensure_extracted() -> std::io::Result<PathBuf> {
let base = dirs::cache_dir() let base = dirs::cache_dir()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?; .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?;
let dest = base let dest = base
.join("cph") .join("cph")
.join(format!("render-{}", env!("CARGO_PKG_VERSION"))); .join(format!("render-{}", env!("CARGO_PKG_VERSION")));
// A sentinel marks a complete extraction; if present, reuse as-is. (Keyed by
// version, so a new `cph` version re-extracts into a fresh dir.)
let sentinel = dest.join(".extracted"); let sentinel = dest.join(".extracted");
if sentinel.is_file() { let expected = format!("{}:{}", env!("CARGO_PKG_VERSION"), RENDER_CACHE_REVISION);
if std::fs::read_to_string(&sentinel)
.map(|contents| contents.trim_end() == expected)
.unwrap_or(false)
{
return Ok(dest); return Ok(dest);
} }
// The crate version can stay stable while the embedded render package
// evolves. Remove the old tree before extracting so deleted files do not
// survive a revision refresh.
if dest.exists() {
std::fs::remove_dir_all(&dest)?;
}
extract_to(&dest)?; extract_to(&dest)?;
std::fs::write(&sentinel, env!("CARGO_PKG_VERSION"))?; std::fs::write(&sentinel, expected)?;
Ok(dest) Ok(dest)
} }
+99 -14
View File
@@ -39,15 +39,15 @@ mod embedded;
mod manifest; mod manifest;
mod world; mod world;
use cph_diag::{DiagCode, Diagnostic};
use std::path::PathBuf; use std::path::PathBuf;
use cph_diag::{DiagCode, Diagnostic}; use cph_model::{Artifact, Bundle, Lesson, OutlineDocument, Step, TargetConfig};
use cph_model::{Artifact, Lesson, Step, TargetConfig};
use typst_kit::fonts::{self, FontStore}; use typst_kit::fonts::{self, FontStore};
use typst_layout::PagedDocument; use typst_layout::PagedDocument;
use typst_pdf::PdfOptions; use typst_pdf::PdfOptions;
pub use manifest::build_augmented_manifest; pub use manifest::{build_augmented_bundle_manifest, build_augmented_manifest};
pub use world::{render_package_spec, LessonWorld, MANIFEST_VPATH}; pub use world::{render_package_spec, LessonWorld, MANIFEST_VPATH};
/// The compile/PDF engine: holds the shared font store and the on-disk location /// The compile/PDF engine: holds the shared font store and the on-disk location
@@ -139,10 +139,38 @@ impl Engine {
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors)) typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors))
} }
/// Build a PDF for an outline document without creating files in the lesson.
///
/// The outline entrypoint and its TOML data are served by an in-memory
/// [`LessonWorld`]. This keeps outline generation independent of any
/// declared lesson export target while reusing the embedded fonts and PDF
/// backend.
pub fn build_outline_pdf(&self, outline: &OutlineDocument) -> Result<Vec<u8>, Vec<Diagnostic>> {
const SOURCE: &str = r#"#import "@local/cph-render:0.1.0": render-outline
#let outline = toml(sys.inputs.outline)
#render-outline(outline)
"#;
let outline_src = toml::to_string(outline).expect("outline serializes to TOML");
let world = LessonWorld::new_outline(
PathBuf::from("."),
self.render_dir.clone(),
SOURCE.to_owned(),
outline_src,
self.fonts.clone(),
);
let warned = typst::compile::<PagedDocument>(&world);
let doc = match warned.output {
Ok(doc) => doc,
Err(errors) => return Err(map_all(&world, &errors)),
};
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors))
}
/// Build the [`LessonWorld`] for `(lesson, target)`, or `Err(blocking)` when /// Build the [`LessonWorld`] for `(lesson, target)`, or `Err(blocking)` when
/// the request cannot be honored (see [`target_precheck`]). /// the request cannot be honored (see [`target_precheck`]).
fn world_for(&self, lesson: &Lesson, target: &str) -> Result<LessonWorld, Vec<Diagnostic>> { fn world_for(&self, lesson: &Lesson, target: &str) -> Result<LessonWorld, Vec<Diagnostic>> {
let template = target_precheck(lesson, target)?; let template = target_precheck(target, &lesson.targets)?;
let manifest_src = build_augmented_manifest(lesson); let manifest_src = build_augmented_manifest(lesson);
Ok(LessonWorld::new( Ok(LessonWorld::new(
lesson.root.clone(), lesson.root.clone(),
@@ -152,6 +180,60 @@ impl Engine {
self.fonts.clone(), self.fonts.clone(),
)) ))
} }
/// Compile-check `bundle` for `target` (ADR-0030) — same contract as
/// [`Engine::compile_check`], but over a [`Bundle`]'s own declared targets
/// and the augmented **bundle** manifest (each member lesson's outline,
/// path-prefixed to resolve against the bundle root).
pub fn compile_check_bundle(&self, bundle: &Bundle, target: &str) -> Vec<Diagnostic> {
let world = match self.world_for_bundle(bundle, target) {
Ok(world) => world,
Err(blocking) => return blocking,
};
let warned = typst::compile::<PagedDocument>(&world);
let mut out = Vec::new();
if let Err(errors) = &warned.output {
out.extend(map_all(&world, errors));
}
out.extend(map_all(&world, &warned.warnings));
out
}
/// Build a PDF for `bundle` / `target` (ADR-0030) — same contract as
/// [`Engine::build_pdf`], over a [`Bundle`]'s own declared targets.
pub fn build_bundle_pdf(
&self,
bundle: &Bundle,
target: &str,
) -> Result<Vec<u8>, Vec<Diagnostic>> {
let world = self.world_for_bundle(bundle, target)?;
let warned = typst::compile::<PagedDocument>(&world);
let doc = match warned.output {
Ok(doc) => doc,
Err(errors) => return Err(map_all(&world, &errors)),
};
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors))
}
/// Build the [`LessonWorld`] for `(bundle, target)`: same shape as
/// [`Engine::world_for`], main file resolved under the **bundle** root and
/// the injected manifest built by [`build_augmented_bundle_manifest`].
fn world_for_bundle(
&self,
bundle: &Bundle,
target: &str,
) -> Result<LessonWorld, Vec<Diagnostic>> {
let template = target_precheck(target, &bundle.targets)?;
let manifest_src = build_augmented_bundle_manifest(bundle);
Ok(LessonWorld::new(
bundle.root.clone(),
self.render_dir.clone(),
&template,
manifest_src,
self.fonts.clone(),
))
}
} }
impl Default for Engine { impl Default for Engine {
@@ -160,13 +242,16 @@ impl Default for Engine {
} }
} }
/// Validate a `(lesson, target)` request and resolve the template path to /// Validate a `(targets, target)` request and resolve the template path to
/// compile. Returns `Ok(template_path)` (relative to the lesson root) when the /// compile. Shared by [`Engine::world_for`] (a lesson's `targets`) and
/// request is buildable, or `Err(blocking_diagnostics)` when it is not: /// [`Engine::world_for_bundle`] (a bundle's own `targets` — ADR-0030 gives a
/// bundle target the exact same build/artifact/step shape). Returns
/// `Ok(template_path)` (relative to the lesson/bundle root) when the request is
/// buildable, or `Err(blocking_diagnostics)` when it is not:
/// ///
/// - **Unknown target** (the `--target` name isn't in `lesson.targets`, and the /// - **Unknown target** (the `--target` name isn't in `targets`, and `targets`
/// lesson declares at least one target): a `SchemaViolation` error — a target /// is non-empty): a `SchemaViolation` error — a target must be declared in
/// must be declared in the manifest to be built (ADR-0009). /// the manifest to be built (ADR-0009).
/// - **No declared targets at all**: not an error — callers (e.g. `cph-check`) /// - **No declared targets at all**: not an error — callers (e.g. `cph-check`)
/// may compile-check a defaulted `"student"` target the lesson never declared. /// may compile-check a defaulted `"student"` target the lesson never declared.
/// The stock template path `exports/<target>.typ` is used (the framework /// The stock template path `exports/<target>.typ` is used (the framework
@@ -177,10 +262,10 @@ impl Default for Engine {
/// [`Step::Shell`], returns a clear "not yet implemented" `SchemaViolation` /// [`Step::Shell`], returns a clear "not yet implemented" `SchemaViolation`
/// rather than wrong output. The template is taken from the **first** /// rather than wrong output. The template is taken from the **first**
/// `TypstCompile` step (MVP: one step per target). /// `TypstCompile` step (MVP: one step per target).
fn target_precheck(lesson: &Lesson, target: &str) -> Result<PathBuf, Vec<Diagnostic>> { fn target_precheck(target: &str, targets: &[TargetConfig]) -> Result<PathBuf, Vec<Diagnostic>> {
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else { let Some(tc) = targets.iter().find(|t| t.name == target) else {
if lesson.targets.is_empty() { if targets.is_empty() {
// Lesson declares no targets; the orchestrator compiles a defaulted // Declares no targets; the orchestrator compiles a defaulted
// target. Use the stock template path (matches cph-model's default). // target. Use the stock template path (matches cph-model's default).
return Ok(PathBuf::from(format!("exports/{target}.typ"))); return Ok(PathBuf::from(format!("exports/{target}.typ")));
} }
+156 -34
View File
@@ -1,18 +1,28 @@
//! Augmented-manifest construction (ADR-0011). //! Augmented-manifest construction (ADR-0011, outline shape per ADR-0029).
//! //!
//! The template (`exports/<target>.typ`) reads the manifest via //! The template (`exports/<target>.typ`) reads the manifest via
//! `toml(sys.inputs.manifest)`, then for each part `include`s its content fields //! `toml(sys.inputs.manifest)`, then for each **element** outline entry
//! by a **computed** path and reads scalar fields from `<path>/element.toml`. //! `include`s its content fields by a **computed** path and reads scalar
//! For *optional* content fields the template must know whether the file exists //! fields from `<path>/element.toml`. For *optional* content fields the
//! on disk — typst has no file-exists primitive and a missing `include` is a //! template must know whether the file exists on disk — typst has no
//! hard error (see the OPEN contract point in `render/templates/student.typ`). //! file-exists primitive and a missing `include` is a hard error (see the OPEN
//! contract point in `render/templates/student.typ`).
//! //!
//! The ENGINE has filesystem access, so it closes that gap: it builds an //! The ENGINE has filesystem access, so it closes that gap: it builds an
//! **augmented manifest** = the lesson's `[info]` + ordered `[[parts]]`, with a //! **augmented manifest** = the lesson's `[info]` + the ordered `[[outline]]`
//! per-part **`fields` array** listing the content fields whose `<field>.typ` //! (ADR-0029's depth-first rendering order — elements interleaved with section
//! actually exists under the lesson root. The augmented manifest is served as an //! headings at their DFS-open position). Each `[[outline]]` entry carries a
//! in-memory virtual file in the [`crate::world::LessonWorld`] (it is **never** //! `type` discriminator (`"element"` | `"section"`):
//! written to the user's tree), and injected via `sys.inputs.manifest`. //!
//! - `type = "element"`: `kind`, `path`, and a per-part **`fields` array**
//! listing the content fields whose `<field>.typ` actually exists under the
//! lesson root (same contract as before ADR-0029).
//! - `type = "section"`: `kind`, `title`, `depth`, `path` — a section heading;
//! the template renders it without touching any content file.
//!
//! The augmented manifest is served as an in-memory virtual file in the
//! [`crate::world::LessonWorld`] (it is **never** written to the user's tree),
//! and injected via `sys.inputs.manifest`.
//! //!
//! ## `fields` is computed from `cph-schema` //! ## `fields` is computed from `cph-schema`
//! //!
@@ -20,23 +30,104 @@
//! ([`cph_schema::KindSchema::content_field_names`]) — the same knowledge the //! ([`cph_schema::KindSchema::content_field_names`]) — the same knowledge the
//! render package exposes as `part-fields`. We reuse it here rather than //! render package exposes as `part-fields`. We reuse it here rather than
//! re-deriving a kind→fields map, so the engine and the template agree on what a //! re-deriving a kind→fields map, so the engine and the template agree on what a
//! kind's content fields are. For each part, a content field is listed in //! kind's content fields are. For each element, a content field is listed in
//! `fields` iff `<root>/<part.path>/<field>.typ` is a real file. //! `fields` iff `<root>/<part.path>/<field>.typ` is a real file.
use cph_model::Lesson; use std::path::Path;
use cph_model::{Bundle, BundleLesson, Lesson, OutlineEntry};
/// Build the augmented-manifest TOML source for `lesson`. /// Build the augmented-manifest TOML source for `lesson`.
/// ///
/// The result is a self-contained TOML document the template's /// The result is a self-contained TOML document the template's
/// `toml(sys.inputs.manifest)` reads. It carries `[info]` (title + optional /// `toml(sys.inputs.manifest)` reads. It carries `[info]` (title + optional
/// author) and the ordered `[[parts]]`, each with `kind`, `path`, and a /// author) and the ordered `[[outline]]` (ADR-0029's depth-first rendering
/// `fields = [...]` array of the content fields present on disk (per /// order), each entry typed `"element"` or `"section"` per the module docs. It
/// [`present_fields`]). It does **not** reproduce `[project]` or `[targets.*]` /// does **not** reproduce `[project]` or `[targets.*]` — the template only
/// — the template only consumes `info` and `parts`. /// consumes `info` and `outline`.
pub fn build_augmented_manifest(lesson: &Lesson) -> String { pub fn build_augmented_manifest(lesson: &Lesson) -> String {
let mut doc = toml::Table::new(); let mut doc = toml::Table::new();
// [info] doc.insert("info".to_string(), toml::Value::Table(info_table(lesson)));
// [[outline]] — ADR-0029's depth-first rendering order: elements
// interleaved with section headings at their DFS-open position.
let outline: Vec<toml::Value> = lesson
.outline
.iter()
.map(|entry| toml::Value::Table(outline_entry_table(lesson, entry, None)))
.collect();
doc.insert("outline".to_string(), toml::Value::Array(outline));
toml::to_string(&doc).expect("augmented manifest serializes")
}
/// Build the augmented **bundle** manifest TOML source for `bundle` (ADR-0030).
///
/// The bundle template (`exports/<target>.typ` under the `bundle.toml` root)
/// reads it via `toml(sys.inputs.manifest)`. It carries `[info]` (the bundle's
/// own title/author) and the ordered `[[lessons]]`, each a
/// `(info, target, outline)` table — the same shape a single-lesson template
/// would assemble, except every outline entry's `path` is **prefixed with that
/// lesson's own bundle-root-relative directory** (`BundleLesson::path`), since
/// the bundle template's computed include paths resolve against the *bundle*
/// root, not each lesson's own root (ADR-0030: combination reads
/// already-authored lessons at export time; each lesson's `path` bookkeeping
/// stays correct because the prefix is applied only here, in the manifest the
/// template consumes — never inside a lesson's own authored content).
pub fn build_augmented_bundle_manifest(bundle: &Bundle) -> String {
let mut doc = toml::Table::new();
let mut info = toml::Table::new();
info.insert(
"title".to_string(),
toml::Value::String(bundle.info.title.clone()),
);
if !bundle.info.authors.is_empty() {
let authors = bundle
.info
.authors
.iter()
.cloned()
.map(toml::Value::String)
.collect();
info.insert("author".to_string(), toml::Value::Array(authors));
}
doc.insert("info".to_string(), toml::Value::Table(info));
let lessons: Vec<toml::Value> = bundle
.lessons
.iter()
.map(|bl| toml::Value::Table(bundle_lesson_table(bl)))
.collect();
doc.insert("lessons".to_string(), toml::Value::Array(lessons));
toml::to_string(&doc).expect("augmented bundle manifest serializes")
}
/// Build one `[[lessons]]` entry's table: that member lesson's own `info`,
/// its selected `target`, and its outline with every entry's `path` prefixed
/// by the lesson's bundle-relative directory.
fn bundle_lesson_table(bl: &BundleLesson) -> toml::Table {
let mut t = toml::Table::new();
t.insert(
"info".to_string(),
toml::Value::Table(info_table(&bl.lesson)),
);
t.insert("target".to_string(), toml::Value::String(bl.target.clone()));
let outline: Vec<toml::Value> = bl
.lesson
.outline
.iter()
.map(|entry| toml::Value::Table(outline_entry_table(&bl.lesson, entry, Some(&bl.path))))
.collect();
t.insert("outline".to_string(), toml::Value::Array(outline));
t
}
/// Build the `[info]` table shared by a single-lesson manifest and a bundle
/// member's `info` entry.
fn info_table(lesson: &Lesson) -> toml::Table {
let mut info = toml::Table::new(); let mut info = toml::Table::new();
info.insert( info.insert(
"title".to_string(), "title".to_string(),
@@ -52,30 +143,61 @@ pub fn build_augmented_manifest(lesson: &Lesson) -> String {
.collect(); .collect();
info.insert("author".to_string(), toml::Value::Array(authors)); info.insert("author".to_string(), toml::Value::Array(authors));
} }
doc.insert("info".to_string(), toml::Value::Table(info)); info
}
// [[parts]] — preserve declared order; attach the on-disk `fields` array. /// Build one `[[outline]]` entry's table for either variant of
let parts: Vec<toml::Value> = lesson /// [`OutlineEntry`]. `bundle_prefix`, when set (ADR-0030's bundle case), is
.parts /// joined onto the emitted `path` so the bundle template's computed include
.iter() /// resolves against the bundle root rather than the lesson's own root.
.map(|part| { fn outline_entry_table(
let mut entry = toml::Table::new(); lesson: &Lesson,
entry.insert("kind".to_string(), toml::Value::String(part.kind.clone())); entry: &OutlineEntry,
entry.insert( bundle_prefix: Option<&Path>,
) -> toml::Table {
let mut e = toml::Table::new();
match entry {
OutlineEntry::Element { part_index, .. } => {
let part = &lesson.parts[*part_index];
e.insert("type".to_string(), toml::Value::String("element".into()));
e.insert("kind".to_string(), toml::Value::String(part.kind.clone()));
e.insert(
"path".to_string(), "path".to_string(),
toml::Value::String(path_to_forward_slash(&part.path)), toml::Value::String(prefixed_forward_slash(bundle_prefix, &part.path)),
); );
let fields = present_fields(lesson, part) let fields = present_fields(lesson, part)
.into_iter() .into_iter()
.map(toml::Value::String) .map(toml::Value::String)
.collect(); .collect();
entry.insert("fields".to_string(), toml::Value::Array(fields)); e.insert("fields".to_string(), toml::Value::Array(fields));
toml::Value::Table(entry) }
}) OutlineEntry::Section {
.collect(); kind,
doc.insert("parts".to_string(), toml::Value::Array(parts)); title,
depth,
path,
notes: _,
} => {
e.insert("type".to_string(), toml::Value::String("section".into()));
e.insert("kind".to_string(), toml::Value::String(kind.clone()));
e.insert("title".to_string(), toml::Value::String(title.clone()));
e.insert("depth".to_string(), toml::Value::Integer(i64::from(*depth)));
e.insert(
"path".to_string(),
toml::Value::String(prefixed_forward_slash(bundle_prefix, path)),
);
}
}
e
}
toml::to_string(&doc).expect("augmented manifest serializes") /// [`path_to_forward_slash`], with `prefix` (a bundle member's own
/// bundle-relative directory) joined in front when present.
fn prefixed_forward_slash(prefix: Option<&Path>, path: &Path) -> String {
match prefix {
Some(p) => path_to_forward_slash(&p.join(path)),
None => path_to_forward_slash(path),
}
} }
/// The content fields of `part`'s kind whose `<root>/<part.path>/<field>.typ` /// The content fields of `part`'s kind whose `<root>/<part.path>/<field>.typ`
+68 -26
View File
@@ -48,13 +48,14 @@ use typst::{Library, LibraryExt, World};
use typst_kit::fonts::FontStore; use typst_kit::fonts::FontStore;
/// Root-relative vpath the augmented manifest is served at (in-memory only). /// Root-relative vpath the augmented manifest is served at (in-memory only).
///
/// A **leading slash** is essential: the template lives under `exports/`, and
/// `toml(sys.inputs.manifest)` resolves a relative path against the template's
/// own directory — a bare name would miss. A root-relative absolute path anchors
/// at `--root` (the lesson root) regardless of where the template sits.
pub const MANIFEST_VPATH: &str = "/.cph/manifest.toml"; pub const MANIFEST_VPATH: &str = "/.cph/manifest.toml";
/// Root-relative vpath of the virtual outline entrypoint.
pub const OUTLINE_VPATH: &str = "/exports/outline.typ";
/// Root-relative vpath of the virtual outline data file.
pub const OUTLINE_DATA_VPATH: &str = "/.cph/outline.toml";
/// The package spec the template imports and the World mounts from `render_dir`. /// The package spec the template imports and the World mounts from `render_dir`.
pub fn render_package_spec() -> PackageSpec { pub fn render_package_spec() -> PackageSpec {
PackageSpec { PackageSpec {
@@ -76,13 +77,11 @@ pub struct LessonWorld {
render_dir: PathBuf, render_dir: PathBuf,
/// The render package spec (`@local/cph-render:0.1.0`). /// The render package spec (`@local/cph-render:0.1.0`).
render_spec: PackageSpec, render_spec: PackageSpec,
/// FileId of the template entrypoint (a real file under `root`). /// FileId of the entrypoint.
main: FileId, main: FileId,
/// FileId of the in-memory augmented manifest. /// In-memory project files (manifest, or the outline entrypoint/data).
manifest_id: FileId, virtual_sources: HashMap<FileId, Source>,
/// The augmented-manifest source (in-memory; never on disk). /// Standard library inputs exposed to the Typst source.
manifest_source: Source,
/// Standard library, with `sys.inputs.manifest` set.
library: LazyHash<Library>, library: LazyHash<Library>,
/// Shared font store (book + lazily-loaded fonts). /// Shared font store (book + lazily-loaded fonts).
fonts: Arc<FontStore>, fonts: Arc<FontStore>,
@@ -95,8 +94,8 @@ impl LessonWorld {
/// whose injected manifest is `manifest_src` (served virtually at /// whose injected manifest is `manifest_src` (served virtually at
/// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there). /// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there).
/// ///
/// `template` is the lesson-root-relative template path (e.g. /// `template` is the lesson-root-relative path taken from the target's
/// `exports/student.typ`), taken from the target's `Step::TypstCompile`. /// `Step::TypstCompile`.
pub fn new( pub fn new(
root: PathBuf, root: PathBuf,
render_dir: PathBuf, render_dir: PathBuf,
@@ -107,16 +106,55 @@ impl LessonWorld {
let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template))) let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template)))
.expect("template vpath is a valid virtual path"); .expect("template vpath is a valid virtual path");
let main = FileId::new(RootedPath::new(VirtualRoot::Project, main_vpath)); let main = FileId::new(RootedPath::new(VirtualRoot::Project, main_vpath));
let manifest_id = project_file_id(MANIFEST_VPATH);
let mut virtual_sources = HashMap::new();
virtual_sources.insert(manifest_id, Source::new(manifest_id, manifest_src));
Self::with_virtual_files(
root,
render_dir,
main,
virtual_sources,
&[("manifest", MANIFEST_VPATH)],
fonts,
)
}
let manifest_vpath = /// Build a world for a fully virtual outline document and its TOML data.
VirtualPath::new(MANIFEST_VPATH).expect("manifest vpath is a valid virtual path"); /// The caller never has to create temporary files in the engineering file.
let manifest_id = FileId::new(RootedPath::new(VirtualRoot::Project, manifest_vpath)); pub fn new_outline(
let manifest_source = Source::new(manifest_id, manifest_src); root: PathBuf,
render_dir: PathBuf,
source: String,
outline_src: String,
fonts: Arc<FontStore>,
) -> Self {
let main = project_file_id(OUTLINE_VPATH);
let outline_id = project_file_id(OUTLINE_DATA_VPATH);
let mut virtual_sources = HashMap::new();
virtual_sources.insert(main, Source::new(main, source));
virtual_sources.insert(outline_id, Source::new(outline_id, outline_src));
Self::with_virtual_files(
root,
render_dir,
main,
virtual_sources,
&[("outline", OUTLINE_DATA_VPATH)],
fonts,
)
}
// Inject `sys.inputs.manifest = "/.cph/manifest.toml"` so the template's fn with_virtual_files(
// `toml(sys.inputs.manifest)` reads the augmented manifest. root: PathBuf,
render_dir: PathBuf,
main: FileId,
virtual_sources: HashMap<FileId, Source>,
input_files: &[(&str, &str)],
fonts: Arc<FontStore>,
) -> Self {
let mut inputs = Dict::new(); let mut inputs = Dict::new();
inputs.insert("manifest".into(), Value::Str(MANIFEST_VPATH.into())); for (name, path) in input_files {
inputs.insert((*name).into(), Value::Str((*path).into()));
}
let library = Library::builder().with_inputs(inputs).build(); let library = Library::builder().with_inputs(inputs).build();
Self { Self {
@@ -124,8 +162,7 @@ impl LessonWorld {
render_dir, render_dir,
render_spec: render_package_spec(), render_spec: render_package_spec(),
main, main,
manifest_id, virtual_sources,
manifest_source,
library: LazyHash::new(library), library: LazyHash::new(library),
fonts, fonts,
sources: Mutex::new(HashMap::new()), sources: Mutex::new(HashMap::new()),
@@ -185,8 +222,8 @@ impl World for LessonWorld {
} }
fn source(&self, id: FileId) -> FileResult<Source> { fn source(&self, id: FileId) -> FileResult<Source> {
if id == self.manifest_id { if let Some(source) = self.virtual_sources.get(&id) {
return Ok(self.manifest_source.clone()); return Ok(source.clone());
} }
// Cache hit? // Cache hit?
if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) { if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) {
@@ -203,8 +240,8 @@ impl World for LessonWorld {
} }
fn file(&self, id: FileId) -> FileResult<Bytes> { fn file(&self, id: FileId) -> FileResult<Bytes> {
if id == self.manifest_id { if let Some(source) = self.virtual_sources.get(&id) {
return Ok(Bytes::from_string(self.manifest_source.text().to_string())); return Ok(Bytes::from_string(source.text().to_string()));
} }
let bytes = self.read_bytes(id)?; let bytes = self.read_bytes(id)?;
Ok(Bytes::new(bytes)) Ok(Bytes::new(bytes))
@@ -220,6 +257,11 @@ impl World for LessonWorld {
} }
} }
fn project_file_id(path: &str) -> FileId {
let vpath = VirtualPath::new(path).expect("virtual project path is valid");
FileId::new(RootedPath::new(VirtualRoot::Project, vpath))
}
/// Render a relative `Path` as a forward-slash string, dropping any leading /// Render a relative `Path` as a forward-slash string, dropping any leading
/// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim. /// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim.
fn path_to_forward_slash(path: &Path) -> String { fn path_to_forward_slash(path: &Path) -> String {
+122
View File
@@ -0,0 +1,122 @@
//! Integration tests for the bundle build path (ADR-0030): compiling a bundle
//! target's template (`exports/<target>.typ` under a `bundle.toml` root) as
//! main, injecting the augmented **bundle** manifest, against the real
//! `render/` package.
use std::path::PathBuf;
use cph_diag::Severity;
use cph_typst::{build_augmented_bundle_manifest, Engine};
fn fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bundle")
}
fn real_render_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("render")
}
fn load_bundle() -> cph_model::Bundle {
let (bundle, diags) = cph_model::load_bundle(&fixture_root());
let bundle = bundle.expect("bundle fixture loads into a Bundle");
let errors: Vec<_> = diags
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(errors.is_empty(), "fixture has loader errors: {errors:?}");
bundle
}
/// PURE UNIT TEST (no fonts, no render package): the augmented bundle manifest
/// carries the bundle's own `[info]` and an ordered `[[lessons]]`, each with
/// that member's own `info`/`target` and a `path`-prefixed outline (ADR-0030).
#[test]
fn augmented_bundle_manifest_prefixes_member_paths() {
let bundle = load_bundle();
let src = build_augmented_bundle_manifest(&bundle);
assert!(
src.contains("测试合集"),
"bundle info.title present:\n{src}"
);
let doc: toml::Value = toml::from_str(&src).expect("augmented bundle manifest is valid TOML");
let lessons = doc
.get("lessons")
.and_then(|l| l.as_array())
.expect("lessons array present");
assert_eq!(lessons.len(), 2, "two member lessons:\n{src}");
// lesson-a: target defaults to its own first declared target ("student"),
// outline paths are prefixed with "lesson-a/".
let a_target = lessons[0].get("target").unwrap().as_str().unwrap();
assert_eq!(a_target, "student");
let a_outline = lessons[0].get("outline").unwrap().as_array().unwrap();
// segment + section heading + lemma = 3 outline entries.
assert_eq!(a_outline.len(), 3);
let a_seg_path = a_outline[0].get("path").unwrap().as_str().unwrap();
assert_eq!(a_seg_path, "lesson-a/segments/a");
let a_section_path = a_outline[1].get("path").unwrap().as_str().unwrap();
assert_eq!(a_section_path, "lesson-a/小节");
let a_lemma_path = a_outline[2].get("path").unwrap().as_str().unwrap();
assert_eq!(a_lemma_path, "lesson-a/小节/lemmas/引理甲");
// lesson-b: bundle.toml overrides `target = "teacher"`.
let b_target = lessons[1].get("target").unwrap().as_str().unwrap();
assert_eq!(b_target, "teacher");
let b_outline = lessons[1].get("outline").unwrap().as_array().unwrap();
assert_eq!(b_outline.len(), 1);
let b_seg_path = b_outline[0].get("path").unwrap().as_str().unwrap();
assert_eq!(b_seg_path, "lesson-b/segments/b");
}
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling
/// the bundle's `merged` target as main with the injected augmented bundle
/// manifest is clean.
#[test]
fn compile_check_clean_through_bundle_template() {
let bundle = load_bundle();
let engine = Engine::with_render_dir(real_render_dir());
let diags = engine.compile_check_bundle(&bundle, "merged");
let errors: Vec<_> = diags
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(errors.is_empty(), "unexpected compile errors: {errors:#?}");
}
/// THROUGH-TEMPLATE PDF export, fully offline: a non-trivial combined PDF is
/// produced from the two member lessons through the real bundle template.
#[test]
fn build_bundle_pdf_through_template_offline() {
let bundle = load_bundle();
let engine = Engine::with_render_dir(real_render_dir());
let pdf = engine
.build_bundle_pdf(&bundle, "merged")
.unwrap_or_else(|d| panic!("bundle PDF build failed: {d:#?}"));
assert!(pdf.starts_with(b"%PDF"), "output is a PDF");
assert!(
pdf.len() > 1024,
"bundle PDF is non-trivial (got {} bytes)",
pdf.len()
);
}
/// An undeclared bundle target name is a blocking `SchemaViolation`, exactly
/// like a lesson's own unknown-target path.
#[test]
fn unknown_bundle_target_is_blocking() {
let bundle = load_bundle();
let engine = Engine::with_render_dir(real_render_dir());
let diags = engine.compile_check_bundle(&bundle, "nonexistent");
assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}");
assert_eq!(diags[0].severity, Severity::Error);
assert!(
diags[0].message.contains("not declared"),
"expected an undeclared-target error: {diags:#?}"
);
}
+73 -37
View File
@@ -39,10 +39,12 @@ fn load_mini() -> cph_model::Lesson {
} }
/// PURE UNIT TEST (no fonts, no render package): the augmented manifest carries /// PURE UNIT TEST (no fonts, no render package): the augmented manifest carries
/// `[info]`, the ordered `[[parts]]`, and a per-part `fields` array listing the /// `[info]` and the ordered `[[outline]]` (ADR-0029) — elements (with a
/// content fields present on disk. /// per-element `fields` array of the content fields present on disk)
/// interleaved with the section heading the mini fixture nests its two lemmas
/// under.
#[test] #[test]
fn augmented_manifest_has_per_part_fields() { fn augmented_manifest_has_outline_with_section_and_fields() {
let lesson = load_mini(); let lesson = load_mini();
let src = build_augmented_manifest(&lesson); let src = build_augmented_manifest(&lesson);
@@ -50,66 +52,98 @@ fn augmented_manifest_has_per_part_fields() {
assert!(src.contains("迷你示例课时"), "info.title present:\n{src}"); assert!(src.contains("迷你示例课时"), "info.title present:\n{src}");
assert!(src.contains("测试作者"), "info.author present:\n{src}"); assert!(src.contains("测试作者"), "info.author present:\n{src}");
// Parse it back to inspect the per-part fields precisely. // Parse it back to inspect the outline entries precisely.
let doc: toml::Value = toml::from_str(&src).expect("augmented manifest is valid TOML"); let doc: toml::Value = toml::from_str(&src).expect("augmented manifest is valid TOML");
let parts = doc let outline = doc
.get("parts") .get("outline")
.and_then(|p| p.as_array()) .and_then(|p| p.as_array())
.expect("parts array present"); .expect("outline array present");
assert_eq!(parts.len(), 4, "four parts in declared order:\n{src}"); // segment, section, lemma, lemma, example — 5 entries (ADR-0029: the
// section contributes a heading entry, not an element).
assert_eq!(outline.len(), 5, "five outline entries:\n{src}");
// `fields` is a presence SET (the template tests membership), so order is let entry_type = |idx: usize| {
// not load-bearing; sort for a stable assertion. outline[idx]
let fields_of = |idx: usize| -> Vec<String> { .get("type")
let mut v: Vec<String> = parts[idx]
.get("fields")
.and_then(|f| f.as_array())
.expect("part has a fields array")
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
v.sort();
v
};
let path_of = |idx: usize| {
parts[idx]
.get("path")
.unwrap() .unwrap()
.as_str() .as_str()
.unwrap() .unwrap()
.to_string() .to_string()
}; };
let kind_of = |idx: usize| { let kind_of = |idx: usize| {
parts[idx] outline[idx]
.get("kind") .get("kind")
.unwrap() .unwrap()
.as_str() .as_str()
.unwrap() .unwrap()
.to_string() .to_string()
}; };
let path_of = |idx: usize| {
outline[idx]
.get("path")
.unwrap()
.as_str()
.unwrap()
.to_string()
};
// `fields` is a presence SET (the template tests membership), so order is
// not load-bearing; sort for a stable assertion.
let fields_of = |idx: usize| -> Vec<String> {
let mut v: Vec<String> = outline[idx]
.get("fields")
.and_then(|f| f.as_array())
.expect("element entry has a fields array")
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
v.sort();
v
};
// Order preserved: segment, lemma (w/ proof), lemma (no proof), example. // Order preserved: segment, section (引理组), lemma (w/ proof), lemma (no
// proof), example.
assert_eq!(entry_type(0), "element");
assert_eq!(kind_of(0), "segment"); assert_eq!(kind_of(0), "segment");
assert_eq!(kind_of(1), "lemma");
assert_eq!(kind_of(2), "lemma");
assert_eq!(kind_of(3), "example");
// Paths kept as forward-slash UTF-8.
assert_eq!(path_of(0), "segments/开场对照导言"); assert_eq!(path_of(0), "segments/开场对照导言");
assert_eq!(path_of(2), "lemmas/无证明引理");
// segment: only `textbook` exists.
assert_eq!(fields_of(0), vec!["textbook"]); assert_eq!(fields_of(0), vec!["textbook"]);
assert_eq!(entry_type(1), "section");
assert_eq!(outline[1].get("title").unwrap().as_str().unwrap(), "引理组");
assert_eq!(outline[1].get("depth").unwrap().as_integer().unwrap(), 1);
assert_eq!(path_of(1), "引理组");
assert_eq!(entry_type(2), "element");
assert_eq!(kind_of(2), "lemma");
assert_eq!(path_of(2), "引理组/lemmas/量纲分析估计");
// lemma WITH proof.typ: both stmt + proof present (sorted). // lemma WITH proof.typ: both stmt + proof present (sorted).
assert_eq!(fields_of(1), vec!["proof", "stmt"]); assert_eq!(fields_of(2), vec!["proof", "stmt"]);
assert_eq!(entry_type(3), "element");
assert_eq!(kind_of(3), "lemma");
assert_eq!(path_of(3), "引理组/lemmas/无证明引理");
// lemma WITHOUT proof.typ: only stmt present (the OPTIONAL-content path). // lemma WITHOUT proof.typ: only stmt present (the OPTIONAL-content path).
assert_eq!( assert_eq!(
fields_of(2), fields_of(3),
vec!["stmt"], vec!["stmt"],
"proof must be omitted when absent" "proof must be omitted when absent"
); );
assert_eq!(entry_type(4), "element");
assert_eq!(kind_of(4), "example");
// example: problem + solution present (source is a scalar, not a content field). // example: problem + solution present (source is a scalar, not a content field).
assert_eq!(fields_of(3), vec!["problem", "solution"]); assert_eq!(fields_of(4), vec!["problem", "solution"]);
}
#[test]
fn outline_pdf_renders_without_lesson_files() {
let lesson = load_mini();
let outline = lesson.outline_document();
let engine = Engine::with_render_dir(real_render_dir());
let pdf = engine
.build_outline_pdf(&outline)
.expect("outline PDF should compile");
assert!(pdf.starts_with(b"%PDF"), "output should be a PDF");
assert!(pdf.len() > 1_000, "outline PDF should be non-trivial");
} }
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the /// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the
@@ -202,6 +236,7 @@ fn file_tree_artifact_is_deferred() {
authors: vec![], authors: vec![],
}, },
parts: vec![], parts: vec![],
outline: vec![],
targets: vec![TargetConfig { targets: vec![TargetConfig {
name: "web".into(), name: "web".into(),
artifact: Artifact::FileTree { artifact: Artifact::FileTree {
@@ -241,6 +276,7 @@ fn shell_only_target_is_deferred() {
authors: vec![], authors: vec![],
}, },
parts: vec![], parts: vec![],
outline: vec![],
targets: vec![TargetConfig { targets: vec![TargetConfig {
name: "packaged".into(), name: "packaged".into(),
artifact: Artifact::SingleFile { artifact: Artifact::SingleFile {
+16
View File
@@ -0,0 +1,16 @@
[info]
title = "测试合集"
author = "测试作者"
[[lessons]]
path = "lesson-a"
[[lessons]]
path = "lesson-b"
target = "teacher"
[targets.merged]
artifact = { type = "single-file", filepath = "build/merged.pdf" }
[[targets.merged.steps]]
type = "typst-compile"
template = "exports/merged.typ"
@@ -0,0 +1,76 @@
// DEFAULT BUNDLE TEMPLATE (ADR-0030, outline shape ADR-0029).
//
// Lives in a bundle at `<bundle-root>/exports/<target>.typ`, e.g.
// `exports/merged.typ`. Compiled AS MAIN with the augmented BUNDLE manifest
// injected:
// typst compile --root <bundle-root> --input manifest=<path-rel-to-root> exports/merged.typ <out>
//
// Structurally identical to the single-lesson `student.typ`/`teacher.typ`
// templates (see their notes on why the include loop lives in the template,
// not in cph-render), except it reads `manifest.lessons` (an ordered array of
// per-lesson `(info, target, outline)` tables — see
// `cph_typst::build_augmented_bundle_manifest`) instead of a single
// `manifest.outline`, and calls `render-bundle` instead of `render-lesson`.
//
// Every outline entry's `path` in a bundle manifest is ALREADY prefixed with
// that lesson's own bundle-root-relative directory (done by the Rust engine),
// so the same `include "/" + path + "/" + field + ".typ"` computation used by
// a single-lesson template resolves correctly here too — no special-casing
// needed in this loop.
#import "@local/cph-render:0.1.0": render-bundle, part-fields, default-heading-numbering
#let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:))
#let raw-lessons = manifest.at("lessons", default: ())
// Assemble one outline entry exactly as a single-lesson template would.
#let assemble-entry(raw) = {
if raw.at("type", default: "element") == "section" {
(
entry-type: "section",
kind: raw.at("kind", default: none),
title: raw.at("title", default: ""),
depth: raw.at("depth", default: 1),
)
} else {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
let present = raw.at("fields", default: ())
let entry = (entry-type: "element", kind: kind)
for field in spec.content {
entry.insert(field, include "/" + path + "/" + field + ".typ")
}
for field in spec.optional-content {
if field in present {
entry.insert(field, include "/" + path + "/" + field + ".typ")
}
}
if spec.scalars.len() > 0 {
let element = toml("/" + path + "/element.toml")
for field in spec.scalars {
let v = element.at(field, default: none)
if v != none and v != "" { entry.insert(field, v) }
}
}
entry
}
}
#let lessons = raw-lessons.map(raw => (
info: raw.at("info", default: (:)),
target: raw.at("target", default: "student"),
outline: raw.at("outline", default: ()).map(assemble-entry),
))
// Presentation: shared per-level heading numbering across the whole bundle,
// and the ADR-0030 recommended default of resetting auto-counters at each
// lesson boundary (override `reset-counters: false` for continuous numbering).
#render-bundle(
info: info,
lessons: lessons,
heading-numbering: default-heading-numbering,
reset-counters: true,
)
@@ -0,0 +1,17 @@
[project]
id = "lesson-a"
name = "lesson-a"
[info]
title = "课时A"
author = "作者A"
[[children]]
kind = "segment"
path = "segments/a"
[[children]]
kind = "section"
path = "小节"
[targets.student]
@@ -0,0 +1 @@
= 课时A导言
@@ -0,0 +1 @@
引理甲陈述。
@@ -0,0 +1,6 @@
[group]
title = "小节"
[[children]]
kind = "lemma"
path = "lemmas/引理甲"
@@ -0,0 +1,12 @@
[project]
id = "lesson-b"
name = "lesson-b"
[info]
title = "课时B"
[[children]]
kind = "segment"
path = "segments/b"
[targets.teacher]
@@ -0,0 +1 @@
= 课时B导言
+46 -32
View File
@@ -1,4 +1,4 @@
// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011). // DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0029).
// //
// This is a *real, editable* file that lives in an engineering file at // This is a *real, editable* file that lives in an engineering file at
// `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the // `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the
@@ -15,15 +15,16 @@
// its own virtual root — an include inside cph-render would resolve against the // its own virtual root — an include inside cph-render would resolve against the
// PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE // PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE
// (this template lives under `--root`) resolves against `--root`. So the // (this template lives under `--root`) resolves against `--root`. So the
// template loads content and hands cph-render an already-assembled `parts` array. // template loads content and hands cph-render an already-assembled `outline`
// array (elements interleaved with section headings, ADR-0029).
// //
// OPEN CONTRACT POINT — optional-content presence. typst has no "does this file // OPEN CONTRACT POINT — optional-content presence. typst has no "does this file
// exist" primitive (a missing `include` is a hard compile error). So the // exist" primitive (a missing `include` is a hard compile error). So the
// template CANNOT probe disk the way the old Rust driver did for lemma `proof`. // template CANNOT probe disk the way the old Rust driver did for lemma `proof`.
// It relies on the manifest declaring which optional content fields are present, // It relies on the manifest declaring which optional content fields are present,
// via a per-part `fields` array listing the content fields that exist on disk // via a per-element `fields` array listing the content fields that exist on disk
// (the engine knows this — it walks the part dir). Required fields are loaded // (the engine knows this — it walks the part dir). Required fields are loaded
// unconditionally; optional fields load only if listed in `fields`. If a part // unconditionally; optional fields load only if listed in `fields`. If an element
// omits `fields`, optional content is skipped (conservative). The exact shape of // omits `fields`, optional content is skipped (conservative). The exact shape of
// this declaration is for the manifest/Rust contract to pin. // this declaration is for the manifest/Rust contract to pin.
@@ -35,38 +36,51 @@
// Read the injected manifest (a path string relative to typst --root). // Read the injected manifest (a path string relative to typst --root).
#let manifest = toml(sys.inputs.manifest) #let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:)) #let info = manifest.at("info", default: (:))
#let raw-parts = manifest.at("parts", default: ()) #let raw-outline = manifest.at("outline", default: ())
// Assemble each part: include its content fields (computed absolute paths, // Assemble each outline entry:
// resolved against --root) and read scalar fields from <path>/element.toml. // - an "element" entry: include its content fields (computed absolute paths,
// `part-fields` (from cph-render) is the single source of truth for kind->fields. // resolved against --root) and read scalar fields from <path>/element.toml.
#let parts = raw-parts.map(raw => { // `part-fields` (from cph-render) is the single source of truth for
let kind = raw.at("kind", default: none) // kind->fields.
let path = raw.at("path", default: none) // - a "section" entry (ADR-0029): pass its title/depth straight through — no
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) // content to load, it is a heading.
// Which optional content fields are present on disk (manifest-declared). #let outline = raw-outline.map(raw => {
let present = raw.at("fields", default: ()) if raw.at("type", default: "element") == "section" {
let part = (kind: kind) (
entry-type: "section",
kind: raw.at("kind", default: none),
title: raw.at("title", default: ""),
depth: raw.at("depth", default: 1),
)
} else {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ())
let entry = (entry-type: "element", kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative). // Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content { for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ") entry.insert(field, include "/" + path + "/" + field + ".typ")
}
// Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content {
if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ")
} }
} // Optional content fields: only when the manifest says the file exists.
// Scalar fields come from <path>/element.toml. for field in spec.optional-content {
if spec.scalars.len() > 0 { if field in present {
let element = toml("/" + path + "/element.toml") entry.insert(field, include "/" + path + "/" + field + ".typ")
for field in spec.scalars { }
let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) }
} }
// 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 != "" { entry.insert(field, v) }
}
}
entry
} }
part
}) })
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
@@ -74,6 +88,6 @@
#render-lesson( #render-lesson(
info: info, info: info,
target: target, target: target,
parts: parts, outline: outline,
heading-numbering: default-heading-numbering, heading-numbering: default-heading-numbering,
) )
+38 -29
View File
@@ -1,4 +1,4 @@
// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011). // DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0029).
// //
// Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with // Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with
// the manifest injected: // the manifest injected:
@@ -18,38 +18,47 @@
// Read the injected manifest (a path string relative to typst --root). // Read the injected manifest (a path string relative to typst --root).
#let manifest = toml(sys.inputs.manifest) #let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:)) #let info = manifest.at("info", default: (:))
#let raw-parts = manifest.at("parts", default: ()) #let raw-outline = manifest.at("outline", default: ())
// Assemble each part: include its content fields (computed absolute paths, // Assemble each outline entry: an "element" entry includes its content fields
// resolved against --root) and read scalar fields from <path>/element.toml. // and reads scalars from element.toml; a "section" entry (ADR-0029) passes
// `part-fields` (from cph-render) is the single source of truth for kind->fields. // title/depth straight through as a heading, no content to load.
#let parts = raw-parts.map(raw => { #let outline = raw-outline.map(raw => {
let kind = raw.at("kind", default: none) if raw.at("type", default: "element") == "section" {
let path = raw.at("path", default: none) (
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) entry-type: "section",
// Which optional content fields are present on disk (manifest-declared). kind: raw.at("kind", default: none),
let present = raw.at("fields", default: ()) title: raw.at("title", default: ""),
let part = (kind: kind) depth: raw.at("depth", default: 1),
)
} else {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ())
let entry = (entry-type: "element", kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative). // Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content { for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ") entry.insert(field, include "/" + path + "/" + field + ".typ")
}
// Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content {
if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ")
} }
} // Optional content fields: only when the manifest says the file exists.
// Scalar fields come from <path>/element.toml. for field in spec.optional-content {
if spec.scalars.len() > 0 { if field in present {
let element = toml("/" + path + "/element.toml") entry.insert(field, include "/" + path + "/" + field + ".typ")
for field in spec.scalars { }
let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) }
} }
// 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 != "" { entry.insert(field, v) }
}
}
entry
} }
part
}) })
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
@@ -57,6 +66,6 @@
#render-lesson( #render-lesson(
info: info, info: info,
target: target, target: target,
parts: parts, outline: outline,
heading-numbering: default-heading-numbering, heading-numbering: default-heading-numbering,
) )
+5 -9
View File
@@ -6,19 +6,15 @@ name = "迷你课时"
title = "迷你示例课时" title = "迷你示例课时"
author = "测试作者" author = "测试作者"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/开场对照导言" path = "segments/开场对照导言"
[[parts]] [[children]]
kind = "lemma" kind = "section"
path = "lemmas/量纲分析估计" path = "引理组"
[[parts]] [[children]]
kind = "lemma"
path = "lemmas/无证明引理"
[[parts]]
kind = "example" kind = "example"
path = "examples/自由落体" path = "examples/自由落体"
@@ -0,0 +1,10 @@
[group]
title = "引理组"
[[children]]
kind = "lemma"
path = "lemmas/量纲分析估计"
[[children]]
kind = "lemma"
path = "lemmas/无证明引理"
+81
View File
@@ -0,0 +1,81 @@
# ADR 0028: Agent Configuration Folder Tree
## Status
Accepted.
## Context
ADR-0017/0018 made Agent roles and skills Organization-scoped dynamic runtime
configuration, managed without process restarts. The org admin surfaces for
them (`/admin/roles` and `/admin/skills`) render every
role/skill as one large editor card in a single flat list. As an Organization
accumulates roles and skills, the management pages degrade into an endless
scroll with no grouping affordance.
The project explorer already solves the analogous problem for projects with
transparent folders (ADR-0021): org-scoped navigation nodes that are not
permission resources. Roles and skills need the same affordance, but their
semantics differ from projects in one crucial way: skill names and role IDs
are referenced by role→skill bindings, run-time skill snapshot loading, and
Feishu slash commands. Any grouping mechanism must not leak into those
resolution paths.
## Decision
Introduce an Organization-scoped folder tree shared by Agent roles and Agent
skills:
- One folder tree per Organization is shared by both roles and skills (e.g. a
"高三化学组" folder groups that team's roles and its skills together). It is
a distinct entity from the project explorer `Folder` of ADR-0021 — the two
trees are managed independently and never reference each other.
- Folders are **transparent organization nodes**, following the ADR-0021
project-folder precedent: they exist for management-surface navigation and
grouping only, are not permission resources, and hold no grants.
- Folder membership is **not part of role/skill identity or resolution**:
- skill `name` and role `roleId` remain unique per Organization regardless
of folder membership;
- role→skill bindings, run admission's frozen role snapshot, run-scoped
skill loading, and Feishu slash commands never reference folders.
- Each role/skill sits in at most one folder; membership is optional
(unfiled items remain first-class). Folders nest arbitrarily.
- Folder assignment is a label-class change in the ADR-0017 sense: it never
archives Agent sessions, because the execution surface (model, prompt,
tools, skill content) is untouched.
- A folder can be deleted only when empty — no child folders, no roles, no
skills. Relocating items out of a folder is an explicit user action, so no
orphan-placement rule is needed yet.
- Admin web renders both pages as a left folder tree plus the item list of
the selected folder ("all" and "unfiled" included). The host-console CLI is
unchanged: folder management lives in the web surface, and CLI
`upsert-role`/`install-skill` never touch folder assignment.
## Consequences
- New DB entity `OrganizationAgentConfigFolder` (org-scoped, self-nesting via
`parentId`, delete restricted while referenced) plus nullable `folderId` on
`OrganizationAgentRole` and `OrganizationAgentSkill` (SetNull on folder
delete, though the service refuses to delete non-empty folders).
- The spec pins the transparency and single-membership semantics in
`Spec.System.AgentRole` (`AgentConfigFolder`), so future implementors do
not re-derive them differently (e.g. path-style names or per-folder
uniqueness).
- Org admin APIs gain folder CRUD plus role/skill folder-assignment endpoints
that skip session archival by construction.
- Moving a role/skill between folders changes nothing about authorization,
run resolution, or audit-visible configuration lineage beyond the folder
assignment event itself.
## Open Questions / Deferred
- Drag-and-drop assignment and bulk moves are deferred; assignment is a
per-item select for now.
- Folder-level usage aggregation for roles/skills is deferred (project
folders already aggregate usage under ADR-0021; agent configuration has no
usage dimension yet).
- CLI flags for folder assignment are deferred until a console workflow asks
for them.
- Folder-scoped default-role policies (e.g. per-folder defaults) are rejected
for now: the Organization keeps exactly one active default role
(ADR-0017/0018 invariant) regardless of folder structure.
@@ -0,0 +1,193 @@
# ADR 0029: Engineering-File Structure Is A Nested Outline Manifest
## Status
Accepted. **Supersedes ADR-0008** on the concrete layout of a lesson's
structure (the ordered `[[parts]]` arrangement), and discharges the
"grouping/sectioning" and "manifest richness" gaps ADR-0008 left Open. ADR-0007
(the engineering file is a real directory tree) stands unchanged. ADR-0005's
"a lesson is an ordered sequence of element instances" stands unchanged — order
and membership are preserved; the *shape* that encodes them is now a tree.
## Context
ADR-0008 encodes a lesson's order and membership as a **single flat `[[parts]]`
array** in a root `manifest.toml`, where every `[[parts]]` entry is a `kind` +
`path` to an element folder. Two forces now push against that flat shape:
1. **Real lessons are internally structured.** TH-144 has a `题目/` tree of
problem/answer pairs and A/B/C sections that exist only in folder names
today. Teachers think of a lesson as an **outline** — sections, sub-sections,
groups of worked examples — not an unbroken flat list of ~40 parts. The
admin/teacher surface (the 老师端 being built against the Hub) is supposed to
show "the project structure, expanding each structural element to the files
inside" — and today that structure is a giant flat scroll.
2. **Outline and file structure should correspond 1:1, not via a separate
index.** The 7.31 design discussion landed on a shape where each level of the
lesson is a folder whose manifest states that level's children — so the
on-disk tree *is* the outline, self-descriptive, with no secondary artifact
to drift out of sync. A flat root `[[parts]]` list, by contrast, names the
whole lesson in one file and forces the folder tree to be a projection of it
(or vice versa) with two sources of truth.
ADR-0008 itself anticipated this: its Open Questions list "Per-part metadata,
grouping/sectioning (TH-144's A/B/C structure is only in folder names today)" as
explicitly not modeled. This ADR closes that gap.
## Decision
### A lesson is a tree of folders; each folder is a self-describing node whose manifest names its children
The engineering file remains a real directory tree (ADR-0007). The ordering and
membership encoding of ADR-0008 changes from **one flat root `[[parts]]`** to a
**nested, per-folder outline**:
- The root's `manifest.toml` keeps `[project]`, `[info]`, and the `[targets.*]`
build configuration exactly as ADR-0008/0011 define them.
- The lesson's **structure is expressed as a folder tree**, where every folder
that groups children carries its own small **outline manifest** (per-folder
`manifest.toml`, see *Name and discriminator* below) stating that level's
ordered children.
- A **leaf** is an element folder exactly as ADR-0008 defines it: an
`element.toml` declaring `kind` + scalar fields, plus convention-named
content `.typ` siblings. A leaf has no outline manifest — its own
`element.toml` is its descriptor.
- An **internal folder** (a grouping node) has an outline manifest but no
`element.toml`: it is not an element, it is a container of elements/containers.
It carries only structure and, optionally, group-level scalar metadata.
### The engineering-file root is itself the implicit top container
Children live directly in the root `manifest.toml`'s `children` array — there is
no mandated single top-level section folder. Rationale: migration is a pure
flatten of the existing root `[[parts]]` into root `children` (same order, zero
forcing); a mandated wrapper folder would be pure indirection for most lessons.
A lesson that wants a top-level section simply creates one as a child
(consistent with the ADR-0021 folder-tree precedent, which holds direct children
at the root).
### Name and discriminator: every grouping folder uses `manifest.toml`
Every folder that groups children uses the same filename, `manifest.toml`, at
every level including the root:
- Root `manifest.toml`: `[project]`, `[info]`, `[targets.*]`, plus a `children`
array.
- Internal folder `manifest.toml`: `children` (+ optional `[group]` scalars);
never `[project]`/`[info]`/`[targets.*]`.
The leaf/container discriminator is disjoint and structural: a folder with
`element.toml` is a **leaf** (ADR-0008 descriptor); a folder with `manifest.toml`
and no `element.toml` is a **container**; a folder with neither is a structural
error. `OUTLINE.toml` was rejected (a new reserved name, no benefit over the
uniform name); `info.toml` was rejected because it collides with the model's
`Info` (title/author, folded into root `manifest.toml`'s `[info]` by ADR-0008)
and would blur "metadata vs structure". The 7.31 sketch's intent — each level
self-describes its children — is preserved; the name aligns with current
ADR-0008.
### Order is encoded per-folder, and the lesson order is the depth-first traversal
ADR-0005 requires the lesson to be an ordered sequence. In the tree, **order is
declared locally at each folder** by the order of children in that folder's
`manifest.toml`. The canonical lesson order is the **depth-first pre-order
traversal** of the tree: an internal folder contributes no element of its own
(its label is a heading, not a part), and leaves contribute in the order they
appear. The checker materializes this traversal; no part of the lesson order
lives in a typst script (ADR-0008's core rejection of typst-as-order-manifest
stands).
Concretely, a `segment` that in TH-141 was one flat `[[parts]]` entry can now be
a folder whose outline lists its sub-segments and examples in order — and any
grouping (TH-144's A/B/C, a "导言 cluster", a "例题组") is a folder, transparent
in the element sequence but a real node in the outline.
### The outline manifest shape
A folder's `manifest.toml` `children` array holds its ordered children. Each
child entry is either:
```toml
# a leaf element (ADR-0008 descriptor), by relative path
{ kind = "example", path = "examples/41届复赛三-1-混注石油" }
# or an internal grouping folder, by relative path (recursed)
{ kind = "section", path = "导言簇" }
```
The `kind` of a **leaf** is still read from that folder's `element.toml`
(ADR-0008: the folder is self-describing; the outline entry may restate it for
readability but the `element.toml` is authoritative). The `kind` of an
**internal** child is a container kind — recognized from a small, open set of
container kinds — and selects how that subtree is rendered/grouped. Leaves and
containers are disjoint by construction: a folder is a container iff it has a
`manifest.toml`; a leaf iff it has an `element.toml`. A folder must have exactly
one of the two.
### Container kinds: MVP ships exactly one — `section`, rendered as a heading
The demonstrated needs (TH-144's A/B/C, a "导言簇") are all `section`, so the
MVP ships exactly one container kind:
- A `section` opens a **heading** at its depth in the DFS, then renders its
children in order; it never appears in the element part sequence (the
already-decided DFS semantics).
- The heading uses `[group].title` when present, else the folder name.
- `group` (a heading-less visual grouping) and any other container kind are
**deferred**: added only when a real need appears, honoring ADR-0005's open
universe / "add when needed".
### Group-level scalars
An internal folder MAY carry a `[group]` table (e.g. a title distinct from the
folder name, a description) in its `manifest.toml`. Kept minimal — no other
container metadata until a real need appears.
### The flat `[[parts]]` array at the root is retired for structure
The root `manifest.toml` no longer needs a root-level `[[parts]]` that names the
whole lesson. Lesson structure lives in the folder tree, rooted at the
engineering-file root's `manifest.toml` `children`. `[targets.*]` and
`[project]`/`[info]` stay at the root `manifest.toml`.
## Consequences
- The teacher/admin surface shows the outline: the folder tree *is* the lesson
structure, self-descriptive and 1:1 with files. Opening an element reveals its
files (as the 老师端 requirement asked). This discharges the 7.31 driver
("项目内部有一套 cph schema 定义的结构,由 manifest 组织,给老师看的应该是这个,
展开每个结构元素内部才是文件").
- The checker can still recover the full ordered lesson **without evaluating
typst**: it reads the root `manifest.toml` (project/info/targets) and walks the
folder tree, honoring each folder's `manifest.toml` children order and each
leaf's `element.toml`. Order and membership remain declarative data, greppable
and diffable.
- Grouping (sections) is now a real, checkable structure rather than a folder
naming convention — TH-144's A/B/C can be first-class.
- Every folder is self-describing, so a subtree can be understood/moved on its
own; nothing about a subtree's structure lives only in the root file.
- Migration is mechanical: flatten the existing root `[[parts]]` into root
`children` with the same leaf order (root is the implicit top container, so no
wrapper folder is needed). The element sequence is unchanged, so
`cph check`/`cph build` semantics for leaves carry over.
## Open Questions / Deferred
- **Target-scoped container options** (e.g. hide a section in the student
build): deferred, stay out of structure. ADR-0009/0011 field-visibility /
per-target map already handles this in rendered output, not structure; keep it
there unless a concrete need forces it back into the manifest.
- **Additional container kinds** beyond `section` (e.g. a heading-less `group`):
deferred until a real need appears (open universe, ADR-0005).
- **Container metadata beyond `[group]`** (title/description): deferred — only
the minimal `[group]` table ships; richer container scalars await a concrete
authoring need.
## Supersedes
ADR-0008's "the ordering manifest is declarative" decision stands; this ADR
replaces its **flat `[[parts]]` encoding of order/membership** with the nested
per-folder outline. ADR-0008's other decisions (declarative `manifest.toml`/
`element.toml`, folder self-description, content-file naming convention, schema
as source of truth for which `.typ` files exist) are unchanged and carry into
this tree form.
+167
View File
@@ -0,0 +1,167 @@
# ADR 0030: Batch & Combined Export
## Status
Accepted. **Extends/refines ADR-0009 and ADR-0011** (export target = a build
producing a typed artifact) by adding **two** export dimensions that today have
no home: (1) building **multiple targets of one lesson** in one batch, and (2)
**combining multiple lessons into one** artifact — a 讲义合集 / course bundle.
It does **not** redefine the SingleFile vs FileTree artifact distinction
(ADR-0011) or the single-`typstCompile`-step MVP; it adds the *collection*
semantics on top.
## Context
Today `cph build --target T` builds exactly one target `T` of one engineering
file into one artifact. Two real needs fall outside that:
1. **A lesson's multiple versions.** A lesson already declares several targets
(student handout, teacher plan, slides, script). Producing all of them is
today N separate `cph build` invocations with no shared invocation, ordering,
or failure summary. Teachers preparing a lesson want "build the whole lesson
in all needed forms" as one action.
2. **Combining lessons into one deliverable.** ADR-0005 deferred "course =
arrangement of lessons" — a course/unit is *not* an engineering file; it is
an arrangement of lessons "modeled elsewhere". The elsewhere is empty. A real
deliverable is a **讲义合集 / course bundle** — several lessons ordered into
one document (e.g. "期中复习合集", a term bundle, a topic compilation). This
spans multiple engineering files and currently has no model and no CLI path.
Both are product-plain features (the 老师端 exports; a bundle is what a teacher
hands a class), not architectural speculation.
## Decision
### The existing single-lesson single-target build is the atomic unit
ADR-0009/0011's model — a target is a build over one lesson producing one
`Artifact` via ordered `Step`s — is unchanged and remains the *unit*. Nothing
below replaces it; the new semantics are **aggregations over that unit**.
### Dimension 1 — Batch: build a set of targets of one lesson
`cph build` on a lesson gains the ability to produce **several targets in one
invocation**, as one batched operation:
- The lesson root `manifest.toml` `[targets.*]` already enumerates the declared
targets and their order (ADR-0008/0011). Building "all declared targets" is the
default batch: each declared target builds to its own artifact
(`build/<target>.{pdf,md}`), in declaration order.
- A batched build is **non-transactional and independent per target**: each
target is a separate build with its own artifact, own diagnostics, own
exit/result. One target failing (e.g. teacher plan PDF) does not block the rest
(student PDF), matching the per-target independent-failure stance of
ADR-0009's "missing render for a used kind ⇒ warning, non-blocking".
- The batch emits a **summary**: a per-target ledger (ok/failed + its artifact
or error), and a **non-zero aggregate exit if any target failed** to produce
its artifact. A target that fails to produce its artifact is a real defect
(this repo's fail-fast stance — don't paper over bugs), distinct from
ADR-0009's "missing render rule ⇒ warning" (a policy-level skip, non-error).
"Don't block the rest" still holds: every target is attempted, but a single
failure makes the aggregate non-zero so CI/observability catch it. This is the
CLI's job; it is the natural "build the whole lesson" affordance.
### Dimension 2 — Combined: arrange multiple lessons into one artifact
A **course bundle** is a new, lightweight, second kind of engineering-file-adjacent
unit: an **ordered arrangement of lessons** (ADR-0005's deferred "course =
arrangement of lessons" finally given a concrete export home).
#### Bundle carrier: a directory containing `bundle.toml`
A bundle is a **directory containing `bundle.toml`**. A directory gives the
bundle a stable root for relative lesson paths and a home for build output
(echoing ADR-0007's "engineering file = directory"). The `bundle.toml` carries:
- `[info]` — the bundle's own title/author (of the 合集);
- `[targets.*]` — the bundle's build configuration, reusing ADR-0011's build
mechanism;
- an ordered `lessons` array — each entry: a lesson path (relative to the
bundle root, pointing at each engineering-file root, each a self-contained
directory tree per ADR-0007) plus optional per-lesson per-target overrides.
A bundle target produces an **ordered concatenation/assembly of the lessons'
artifacts or content** into one `Artifact`, reusing the ADR-0011 artifact ADT:
- `SingleFile` — a combined document (讲义合集): the lessons' content assembled
in order into one compiled document, with the existing cross-reference /
`@label` machinery working because it is one compiled document (the same
reason ADR-0011 gives for why SingleFile concatenation works at all).
- `FileTree` — each lesson to its own file plus a generated index (ADR-0011's
third-party-archive case, now with a first-class multi-lesson trigger).
A bundle target's steps are the **ordered typed steps** of ADR-0011, but the
"map" now operates at the level of whole lessons rather than a single lesson's
parts: a step like `assembleLessons` (ordered inclusion of each lesson's
content/artifact) plus the existing `typstCompile`/`shell` steps for assembly
and any post-processing. Concretely the framework provides a
`typstCompile`-style step that pulls each listed lesson's content in order into
one document (mirroring how a single lesson's template pulls its parts).
#### Renumbering in a `SingleFile` bundle: template-resident, default reset per lesson
Numbering is presentation, which ADR-0011 already owns to the template file (not
the manifest), so the **bundle target's template decides** whether auto-counters
reset at lesson boundaries; the framework ships a helper to reset counters at a
lesson boundary. The **recommended default resets auto-counters at each lesson
boundary**: lessons are authored self-contained, so an internal "例题3" means
that lesson's 例题3; cross-lesson continuation would silently break author
references. A genuine "全书 continuous numbering" is an explicit template
override. `@label` cross-references stay global (resolved by label name,
independent of counters); only auto-increment counters reset.
#### Bundles do not nest (MVP)
A bundle references lessons only, not other bundles, until a real need appears —
mirrors the tree-nesting simplicity and keeps the first bundle target minimal.
### Invariant: lessons stay self-contained; combination is export-time only
Opening the door to combining lessons must **not** open the door to cross-lesson
imports inside a lesson's own content (ADR-0006's import boundary: within one
engineering file plus `@package`, never into a sibling lesson). A bundle is
allowed to *assemble already-authored lessons at export time* — reading their
content for the combined artifact — but no lesson's rich content may `import`
another lesson's internals as part of *its own* authoring. Combination is a
**projection over self-contained lessons**, exactly as a render target is a
projection over a lesson. This keeps each engineering file independently
checkable, buildable, and movable, and avoids reintroducing cross-file coupling
ADR-0006 explicitly rejected.
### CLI surface
- `cph build <lesson>` → all declared targets (the batch default).
- `cph build <lesson> --target student --target teacher` → the named subset
(the multi-version batch), in the given order.
- `cph bundle <bundle-path> --target <name>` → build one combined bundle target
(`SingleFile` merged doc or `FileTree`), giving the multi-lesson merge.
(`cph build` on a bundle root is the batch-of-bundle-targets equivalent.)
## Consequences
- **One lesson, many versions** is one command with a per-target ledger — the
natural 老师端 "导出全部版本" action, and any single failure surfaces as a
non-zero aggregate.
- **Course = arrangement of lessons** gets a concrete, export-focused home (the
bundle), discharging the ADR-0005 deferred item without inventing a full
course-authoring model.
- The **artifact/distinction and build-step machinery (ADR-0011) is reused** — a
bundle target is just a build whose inputs are whole lessons, not a new
parallel export engine.
- **Self-containment stays** (ADR-0006/0007): each lesson remains independently
checkable and buildable; the bundle only reads them for assembly. A lesson and
a bundle can version/evolve independently.
- The teacher surface can offer "export all versions" (batch) and "compile into
a 合集" (combined) as two concrete, productisible actions.
## Open Questions / Deferred
- **Bundle-of-bundles / nesting** — no nesting in MVP; re-open only when a real
need appears.
- **Dedup/caching across targets and lessons** — none in MVP, consistent with
ADR-0011's "no caching in MVP".
- **Exact counter-reset semantics in a `SingleFile` bundle** — the default
(reset per lesson) is decided; the precise mechanism (which counters, how the
template override is expressed) is settled with the first bundle template
implementation.
+46 -32
View File
@@ -1,4 +1,4 @@
// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011). // DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0029).
// //
// This is a *real, editable* file that lives in an engineering file at // This is a *real, editable* file that lives in an engineering file at
// `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the // `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the
@@ -15,15 +15,16 @@
// its own virtual root — an include inside cph-render would resolve against the // its own virtual root — an include inside cph-render would resolve against the
// PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE // PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE
// (this template lives under `--root`) resolves against `--root`. So the // (this template lives under `--root`) resolves against `--root`. So the
// template loads content and hands cph-render an already-assembled `parts` array. // template loads content and hands cph-render an already-assembled `outline`
// array (elements interleaved with section headings, ADR-0029).
// //
// OPEN CONTRACT POINT — optional-content presence. typst has no "does this file // OPEN CONTRACT POINT — optional-content presence. typst has no "does this file
// exist" primitive (a missing `include` is a hard compile error). So the // exist" primitive (a missing `include` is a hard compile error). So the
// template CANNOT probe disk the way the old Rust driver did for lemma `proof`. // template CANNOT probe disk the way the old Rust driver did for lemma `proof`.
// It relies on the manifest declaring which optional content fields are present, // It relies on the manifest declaring which optional content fields are present,
// via a per-part `fields` array listing the content fields that exist on disk // via a per-element `fields` array listing the content fields that exist on disk
// (the engine knows this — it walks the part dir). Required fields are loaded // (the engine knows this — it walks the part dir). Required fields are loaded
// unconditionally; optional fields load only if listed in `fields`. If a part // unconditionally; optional fields load only if listed in `fields`. If an element
// omits `fields`, optional content is skipped (conservative). The exact shape of // omits `fields`, optional content is skipped (conservative). The exact shape of
// this declaration is for the manifest/Rust contract to pin. // this declaration is for the manifest/Rust contract to pin.
@@ -35,38 +36,51 @@
// Read the injected manifest (a path string relative to typst --root). // Read the injected manifest (a path string relative to typst --root).
#let manifest = toml(sys.inputs.manifest) #let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:)) #let info = manifest.at("info", default: (:))
#let raw-parts = manifest.at("parts", default: ()) #let raw-outline = manifest.at("outline", default: ())
// Assemble each part: include its content fields (computed absolute paths, // Assemble each outline entry:
// resolved against --root) and read scalar fields from <path>/element.toml. // - an "element" entry: include its content fields (computed absolute paths,
// `part-fields` (from cph-render) is the single source of truth for kind->fields. // resolved against --root) and read scalar fields from <path>/element.toml.
#let parts = raw-parts.map(raw => { // `part-fields` (from cph-render) is the single source of truth for
let kind = raw.at("kind", default: none) // kind->fields.
let path = raw.at("path", default: none) // - a "section" entry (ADR-0029): pass its title/depth straight through — no
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) // content to load, it is a heading.
// Which optional content fields are present on disk (manifest-declared). #let outline = raw-outline.map(raw => {
let present = raw.at("fields", default: ()) if raw.at("type", default: "element") == "section" {
let part = (kind: kind) (
entry-type: "section",
kind: raw.at("kind", default: none),
title: raw.at("title", default: ""),
depth: raw.at("depth", default: 1),
)
} else {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ())
let entry = (entry-type: "element", kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative). // Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content { for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ") entry.insert(field, include "/" + path + "/" + field + ".typ")
}
// Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content {
if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ")
} }
} // Optional content fields: only when the manifest says the file exists.
// Scalar fields come from <path>/element.toml. for field in spec.optional-content {
if spec.scalars.len() > 0 { if field in present {
let element = toml("/" + path + "/element.toml") entry.insert(field, include "/" + path + "/" + field + ".typ")
for field in spec.scalars { }
let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) }
} }
// 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 != "" { entry.insert(field, v) }
}
}
entry
} }
part
}) })
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
@@ -74,6 +88,6 @@
#render-lesson( #render-lesson(
info: info, info: info,
target: target, target: target,
parts: parts, outline: outline,
heading-numbering: default-heading-numbering, heading-numbering: default-heading-numbering,
) )
+38 -29
View File
@@ -1,4 +1,4 @@
// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011). // DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0029).
// //
// Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with // Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with
// the manifest injected: // the manifest injected:
@@ -18,38 +18,47 @@
// Read the injected manifest (a path string relative to typst --root). // Read the injected manifest (a path string relative to typst --root).
#let manifest = toml(sys.inputs.manifest) #let manifest = toml(sys.inputs.manifest)
#let info = manifest.at("info", default: (:)) #let info = manifest.at("info", default: (:))
#let raw-parts = manifest.at("parts", default: ()) #let raw-outline = manifest.at("outline", default: ())
// Assemble each part: include its content fields (computed absolute paths, // Assemble each outline entry: an "element" entry includes its content fields
// resolved against --root) and read scalar fields from <path>/element.toml. // and reads scalars from element.toml; a "section" entry (ADR-0029) passes
// `part-fields` (from cph-render) is the single source of truth for kind->fields. // title/depth straight through as a heading, no content to load.
#let parts = raw-parts.map(raw => { #let outline = raw-outline.map(raw => {
let kind = raw.at("kind", default: none) if raw.at("type", default: "element") == "section" {
let path = raw.at("path", default: none) (
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) entry-type: "section",
// Which optional content fields are present on disk (manifest-declared). kind: raw.at("kind", default: none),
let present = raw.at("fields", default: ()) title: raw.at("title", default: ""),
let part = (kind: kind) depth: raw.at("depth", default: 1),
)
} else {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ())
let entry = (entry-type: "element", kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative). // Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content { for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ") entry.insert(field, include "/" + path + "/" + field + ".typ")
}
// Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content {
if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ")
} }
} // Optional content fields: only when the manifest says the file exists.
// Scalar fields come from <path>/element.toml. for field in spec.optional-content {
if spec.scalars.len() > 0 { if field in present {
let element = toml("/" + path + "/element.toml") entry.insert(field, include "/" + path + "/" + field + ".typ")
for field in spec.scalars { }
let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) }
} }
// 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 != "" { entry.insert(field, v) }
}
}
entry
} }
part
}) })
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
@@ -57,6 +66,6 @@
#render-lesson( #render-lesson(
info: info, info: info,
target: target, target: target,
parts: parts, outline: outline,
heading-numbering: default-heading-numbering, heading-numbering: default-heading-numbering,
) )
+23 -142
View File
@@ -6,161 +6,42 @@ name = "TH-141_表面张力的严肃理论"
title = "TH-141:表面张力的严肃理论" title = "TH-141:表面张力的严肃理论"
author = "范式教育教研组" author = "范式教育教研组"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/开场对照导言" path = "segments/开场对照导言"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/胡克唯象模型回顾" path = "segments/胡克唯象模型回顾"
[[parts]] [[children]]
kind = "segment" kind = "segment"
path = "segments/液面拉伸的本质" path = "segments/液面拉伸的本质"
[[parts]] [[children]]
kind = "segment" kind = "section"
path = "segments/液气界面导言" path = "液气界面"
notes = "先从液面拉伸的宏观图像切入,再用缺键模型和 LJ 对势逐步建立微观解释;可补充一个数量级估算例题。"
[[parts]] [[children]]
kind = "segment" kind = "section"
path = "segments/微观建模的共同骨架" path = "固气界面"
notes = "把液体表面能与固体表面应力放在同一张对照表中,强调固体表面能的晶面各向异性。"
[[parts]] [[children]]
kind = "lemma" kind = "section"
path = "lemmas/量纲分析估计" path = "固液界面"
notes = "围绕界面能的物理图像,串起 Dupré、Girifalco-Good、Fowkes 与 Young 方程;可安排一个浸润判据例题。"
[[parts]] [[children]]
kind = "segment" kind = "section"
path = "segments/缺键模型导言" path = "σTp态函数建模"
notes = "这一节是温度依赖建模主线,先回顾 σT,再解释微观模型和经验规则之间的联系;进阶学生可比较不同模型的适用范围。"
[[parts]] [[children]]
kind = "lemma" kind = "section"
path = "lemmas/缺键模型一般公式" path = "收束"
notes = "最后对照各模型对水的预测,回收本节主线,并明确为什么实际数值可能算不准。"
[[parts]]
kind = "example"
path = "examples/41届复赛三-2-缺键模型"
[[parts]]
kind = "lemma"
path = "lemmas/Stefan极简估算"
[[parts]]
kind = "lemma"
path = "lemmas/立方格子下zeta具体值"
[[parts]]
kind = "segment"
path = "segments/LJ积分导言"
[[parts]]
kind = "lemma"
path = "lemmas/LJ对势积分标度"
[[parts]]
kind = "segment"
path = "segments/固气界面导言"
[[parts]]
kind = "segment"
path = "segments/表面能γ与表面应力f"
[[parts]]
kind = "lemma"
path = "lemmas/拉伸固体的总应力"
[[parts]]
kind = "lemma"
path = "lemmas/缺键模型迁移到固气"
[[parts]]
kind = "lemma"
path = "lemmas/固体表面能的晶面各向异性"
[[parts]]
kind = "segment"
path = "segments/不同物质γ量级对比"
[[parts]]
kind = "segment"
path = "segments/固液界面导言"
[[parts]]
kind = "segment"
path = "segments/固液界面能的物理图像"
[[parts]]
kind = "lemma"
path = "lemmas/Dupré关系"
[[parts]]
kind = "lemma"
path = "lemmas/Girifalco-Good公式"
[[parts]]
kind = "segment"
path = "segments/Fowkes极性修正"
[[parts]]
kind = "segment"
path = "segments/三相接触导言"
[[parts]]
kind = "lemma"
path = "lemmas/Young方程"
[[parts]]
kind = "lemma"
path = "lemmas/GGZ浸润判据"
[[parts]]
kind = "segment"
path = "segments/浸润全谱与高低能表面"
[[parts]]
kind = "segment"
path = "segments/σT建模导言"
[[parts]]
kind = "segment"
path = "segments/微观派Lm下降"
[[parts]]
kind = "lemma"
path = "lemmas/Eötvös规则"
[[parts]]
kind = "lemma"
path = "lemmas/Guggenheim-Katayama改良"
[[parts]]
kind = "lemma"
path = "lemmas/表面熵热力学关系"
[[parts]]
kind = "segment"
path = "segments/σTp态函数导言"
[[parts]]
kind = "segment"
path = "segments/σ作为态函数的图像"
[[parts]]
kind = "example"
path = "examples/41届复赛三-1-混注石油"
[[parts]]
kind = "segment"
path = "segments/收束导言"
[[parts]]
kind = "segment"
path = "segments/各模型对水的预测对照"
[[parts]]
kind = "segment"
path = "segments/算不准背后的真实物理"
# Export targets (ADR-0009/0011): each target is a build producing a typed # Export targets (ADR-0009/0011): each target is a build producing a typed
# artifact, run as an ordered list of typed steps. A `typst-compile` step names a # artifact, run as an ordered list of typed steps. A `typst-compile` step names a
@@ -0,0 +1,34 @@
[group]
title = "σTp 态函数建模"
[[children]]
kind = "segment"
path = "segments/σT建模导言"
[[children]]
kind = "segment"
path = "segments/微观派Lm下降"
[[children]]
kind = "lemma"
path = "lemmas/Eötvös规则"
[[children]]
kind = "lemma"
path = "lemmas/Guggenheim-Katayama改良"
[[children]]
kind = "lemma"
path = "lemmas/表面熵热力学关系"
[[children]]
kind = "segment"
path = "segments/σTp态函数导言"
[[children]]
kind = "segment"
path = "segments/σ作为态函数的图像"
[[children]]
kind = "example"
path = "examples/41届复赛三-1-混注石油"

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