Compare commits

..

1 Commits

Author SHA1 Message Date
hongjr03 7bc9e4f449 feat(hub): switch capability provider from MinerU to Aliyun Doc Mind (ADR-0027)
Replace the MinerU client with Alibaba Cloud Document Mind (docmind) as the
backing service for pdf_to_md_bundle. Aliyun docmind covers both PDF→MD
(with LaTeX formula enhancement) and audio/video→text in one provider,
unlike MinerU which only does documents.

Changes:
- Install @alicloud/docmind-api20220711 + @alicloud/credentials + tea-util
- Delete mineruClient.ts; add docmindClient.ts with AliyunDocmindClient
  using the official SDK (SubmitDocParserJobAdvance → poll → GetDocParserResult)
- CapabilitySecretPayload: baseUrl+apiToken → accessKeyId+accessKeySecret+endpoint
- pdfToMdBundle adapter: provider aliyun_docmind, DocmindClientError
- Tests updated: 7/7 green, mock client matches new interface

Pricing (aliyun docmind, 2025-07):
- PDF 增强链路 (含公式 LaTeX): 0.04元/页 ≈ $0.0056/页
- 视频: 0.002元/秒
- 音频: 0.00035元/秒
Cost is derived from page count × unit price (COST_PER_PAGE_USD) until the
service reports an actual cost field; costSource=provider_reported.

The aliyun CLI is NOT used at runtime — Hub calls the SDK directly. The CLI
remains available for operators to manage AccessKeys and test connectivity.
2026-07-18 16:41:14 +08:00
448 changed files with 4126 additions and 29175 deletions
+5 -5
View File
@@ -1,12 +1,12 @@
name: checker check
# Builds and lints the Rust implementation crates under crates/ (the rule-based
# lesson checker).
# checker that "stands in Lean's position" at product runtime).
#
# This is an INTERNAL gate on the implementation's own health
# (does it build, pass its tests, satisfy clippy + rustfmt?). There is no
# decision-to-implementation conformance gate — implementations align to the
# ADRs by human review, not by CI. See the repo README.
# Like spec-check, this is an INTERNAL gate on the implementation's own health
# (does it build, pass its tests, satisfy clippy + rustfmt?). It is NOT a
# spec-to-implementation conformance gate — implementations align to the Lean
# contract by human review, not by CI. See the repo README.
on:
push:
+15 -62
View File
@@ -1,18 +1,15 @@
name: hub check
# Builds, type-checks, and tests the Hub TS package under hub/.
# The Hub is the Feishu-group collaboration + agent runtime half.
# This is an INTERNAL gate on the Hub's own
# The Hub is the Feishu-group collaboration + agent runtime half
# (spec/System implementation). This is an INTERNAL gate on the Hub's own
# health, like checker-check is for the Rust half.
on:
push:
pull_request:
workflow_dispatch:
concurrency:
group: hub-check-${{ github.ref }}
cancel-in-progress: true
jobs:
hub-check:
runs-on: ubuntu-latest
@@ -23,9 +20,8 @@ jobs:
POSTGRES_USER: paradigm
POSTGRES_PASSWORD: paradigm
POSTGRES_DB: cph_hub_test
# Avoid host-port binds: concurrent hub-check jobs on the shared
# runner raced on published 5432/15432 ("port is already allocated").
# Reach the service by Docker DNS name from the job container instead.
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U paradigm -d cph_hub_test"
--health-interval 5s
@@ -37,33 +33,15 @@ jobs:
steps:
- 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
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: |
hub/package-lock.json
hub/admin-web/package-lock.json
cache-dependency-path: hub/package-lock.json
- name: Install dependencies
run: |
npm ci
npm ci --prefix admin-web
run: npm ci
- name: Audit production Node dependencies
run: npm run audit:production
@@ -76,10 +54,8 @@ jobs:
node <<'NODE'
const net = require("node:net");
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() {
const socket = net.createConnection({ host, port });
const socket = net.createConnection({ host: "127.0.0.1", port: 5432 });
socket.once("connect", () => {
socket.end();
process.exit(0);
@@ -87,7 +63,7 @@ jobs:
socket.once("error", () => {
socket.destroy();
if (Date.now() > deadline) {
console.error(`Postgres did not become reachable at ${host}:${port}`);
console.error("Postgres did not become reachable at 127.0.0.1:5432");
process.exit(1);
}
setTimeout(tryConnect, 1000);
@@ -114,41 +90,19 @@ jobs:
run: |
cd ..
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
run: |
set -euo pipefail
# Nested act/docker runners often disallow unprivileged user
# namespaces, which bwrap requires once CapEff is cleared. Skip the
# 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"
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /w/t
CPH_SANDBOX_TEST_ROOT=/w/t \
/usr/bin/setpriv --no-new-privs \
npx vitest run test/integration/agent-sandbox-linux.test.ts
- name: Run unit tests
run: npx vitest run test/unit
# Integration tests need PostgreSQL + cph. cph is installed above.
# PostgreSQL is the job service container reachable as `postgres`.
# PostgreSQL is set up as a service container below.
- name: Run integration tests (mock provider, real prisma + cph)
run: |
npx prisma migrate deploy --schema prisma/schema.prisma
@@ -156,8 +110,7 @@ jobs:
--exclude test/integration/real-model.test.ts \
--exclude test/integration/agent-sandbox-linux.test.ts
env:
DATABASE_URL: postgresql://paradigm:paradigm@postgres:5432/cph_hub_test
HUB_SKILL_STORE_ROOT: /tmp/cph-hub-check-skills
DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test
# Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide
# OPENROUTER_API_KEY when a branch should hit live OpenRouter.
+20
View File
@@ -0,0 +1,20 @@
name: spec check
# Builds the Lean semantic master spec under spec/.
# This is an INTERNAL well-formedness gate (does the contract type-check?),
# NOT a spec-to-implementation conformance gate — implementations align to the
# contract by human review, not by CI. See repo README.
on:
push:
pull_request:
workflow_dispatch:
jobs:
spec-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: leanprover/lean-action@v1
with:
lake-package-directory: spec
+4 -5
View File
@@ -1,3 +1,7 @@
# Lean / Lake build artifacts (spec/ has its own .gitignore too)
.lake/
**/.lake/
# Rust / Cargo build artifacts (repo-wide cargo workspace at root)
/target
**/*.pdf
@@ -15,8 +19,3 @@ node_modules/
# OS / editor
.DS_Store
.omo/
# Local operator notes / specs (not product source)
/spec/
/需求整理-*.md
@@ -29,7 +29,7 @@ workload brakes.
The full current-state inventory, accepted behavior, and release evidence are
recorded in [Initial abuse and capacity controls](../assets/initial-abuse-capacity-controls.md),
with the durable decision in ADR-0022. Numerical
with the durable decision in ADR-0022 and `Spec.System.Capacity`. Numerical
ceilings remain open until production-like calibration.
The implementation frontier is:
@@ -7,6 +7,6 @@ Blocked by: 01, 02, 03, 04, 05, 06, 07, 09, 10, 11, 12, 13, 14, 15, 16, 17, 18,
## Question
After the readiness investigations and resulting fixes are resolved, can one
repeatable release procedure prove build/test health, deploy a clean
repeatable release procedure prove build/test/spec health, deploy a clean
production-like environment, exercise critical tenant and agent journeys,
verify observability and recovery, and either roll forward or roll back safely?
@@ -8,7 +8,7 @@ Blocked by: 04
Separate or unify run-bound audit entries, pre-run security/permission events,
structured messages, and operational recovery events without weakening
the pinned AuditEntry-to-run relation. Decide durability,
`Spec.System.Audit`'s pinned AuditEntry-to-run relation. Decide durability,
failure, retention, and query semantics; then enforce referential integrity and
observable/recoverable writes instead of silently swallowing lost evidence.
Do not merge these customer Project/Run records with ADR-0023's already-decided
@@ -40,7 +40,9 @@ an off-host recovery key, an incident and reason, and issues only an expiring
Emergency Platform Grant.
The complete accepted decision and implementation divergences are in
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md),
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md).
The pinned semantic invariants are in
[`Spec.System.PlatformAdministration`](../../../spec/Spec/System/PlatformAdministration.lean),
and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md).
Exact numeric session/invitation/step-up limits and browser mechanics remain
+16 -16
View File
@@ -1,25 +1,29 @@
# AGENTS.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
- 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021)。
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021 /
`Spec.System.Organization`)。
- Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd
credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退
process-global credential;Agent child 只接收 run-scoped loopback proxy capability,
不接收 org provider credential(见 ADR-0024)。
不接收 org provider credential(见 ADR-0024 / `Spec.System.Organization`)。
- 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022)。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022 /
`Spec.System.Capacity`)。
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
双因子的离线恢复流程(见 ADR-0023)。
双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。
- 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
@@ -32,10 +36,6 @@
`/compact` 只能由卡片动作以未经包装的精确 prompt 转发。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 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` 搜索派生文档、项目编号
归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。
`Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
@@ -44,12 +44,12 @@
## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+10 -8
View File
@@ -1,23 +1,25 @@
# CLAUDE.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
-4
View File
@@ -99,7 +99,3 @@ _Avoid_: Cost budget, unlimited run
**Emergency Workload Brake**:
An audited Platform Administrator control that prevents new agent work for one Organization or the whole platform and may explicitly stop active work during an incident.
_Avoid_: Organization deletion, service restart
**Member Group**:
A global, unlimited-depth, nestable authorization principal managed by the website administrator; a file-library grant on a group applies to that group and its whole descendant subtree, and a user's effective permission collects every group they belong to plus those groups' ancestors (ADR-0028). It stores no folder/project permission itself — only the user→group membership. Global: not owned by any Organization.
_Avoid_: Team (the org-scoped flat grouping), Feishu department
Generated
-3
View File
@@ -410,10 +410,7 @@ dependencies = [
"clap_complete",
"cph-check",
"cph-diag",
"cph-model",
"cph-schema",
"cph-typst",
"serde_json",
]
[[package]]
+23 -34
View File
@@ -2,7 +2,7 @@
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`docs/adr/` 是系统级决策的唯一权威来源,代码注释把关键不变量锚到 ADR 编号,可 grep。**
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。**
## 安装 `cph` 命令行
@@ -16,28 +16,10 @@ cargo install --path crates/cph-cli --locked
```sh
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 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-0036)。缺省
`--root` 为当前目录。
**版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。
Shell 补全(可选):
@@ -51,40 +33,47 @@ cph completions zsh > ~/.zfunc/_cph # 或 bash/fish/powershell/elvish
```
README.md ← 本文件:总览 + 宪法(下面 5 条)
CLAUDE.md ← 全局 agent 操作手册(管整个 repo)
docs/adr/ ← 系统级架构决策记录(跨部件,决策的唯一权威来源)
CONTEXT.md平台语言词汇表(术语与禁用说法)
docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用)
spec/ Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md
Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate)
crates/ ← 实现:rule-based checker(语义由 ADR 锚定)。见 crates/README.md
crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md
cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎)
cph-check / cph-cli ← checker 本体 + `cph` 命令行
render/ ← typst 渲染包 cph-render(checker 的渲染后端,ADR-0005)
render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
(exporter/ …) ← 将来的其他部件,平级于 crates/
(exporter/ …) ← 将来的其他部件,平级于 spec/
```
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
未来部件(如 exporter)复用,而非各自重造。
## 宪法
4 条是本仓库的协作约定,是一切工作的前提。
5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。
1. **角色 —— ADR 是决策真相**
跨部件的语义决策只记录在 `docs/adr/`,一份决策一份 ADR,编号顺延、正文不改写历史。代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。没有第二份权威文档
1. **角色 —— Lean 是研发侧的上游参照**
`spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来
2. **对齐机制 —— 人肉承载,无机器兜底**
CI 只验各部件自身良构(build / test / clippy),**没有**决策↔实现的一致性 gate。实现对齐 ADR,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。发现漂移,报告它,不要默默让其中一边将就另一边。
2. **对齐机制 —— Lean 只做上游参照**
不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
3. **形态 —— 自包含**
凡 ADR 未明文规定的,开发者与 agent 双方都不该假设;遇到没覆盖的地方,**显式 surface** 出来让开发者决定
3. **资产性 —— 由 review 纪律承载,无机器兜底**
这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档
4. **深度判据 —— 只收录分歧点**
一条语义该不该写进 ADR,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进 ADR;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)
深度上限是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
4. **形态 —— 它是人机共识的契约**
契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞
5. **深度判据 —— 只收录分歧点。**
一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
## CI
`.gitea/workflows/spec-check.yml` 在每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。
Rust checker 的本地与 CI 工具链由根 `rust-toolchain.toml` 固定;`.gitea/workflows/checker-check.yml`
必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace
全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。
+7 -10
View File
@@ -1,14 +1,11 @@
# crates/
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,
ADR-0005)
laid out per ADR-0036 (a nested outline manifest — every container folder
carries `manifest.toml`, every leaf carries `element.toml`; supersedes
ADR-0008's flat `[[parts]]`), validates structure and content, and emits
diagnostics. `cph-diag` (the shared diagnostic vocabulary), `cph-model` (the
ADR-0036 loader, also loading `bundle.toml` arrangements per ADR-0037), and
`cph-typst` (the typst `World` / compile / span-mapping layer) are
These crates implement the rule-based lesson checker that aligns to the
semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005)
laid out per ADR-0008 (declarative `manifest.toml` + per-element
`element.toml`), validates structure and content, and emits diagnostics.
`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader),
and `cph-typst` (the typst `World` / compile / span-mapping layer) are
deliberately reusable by future components such as an `exporter`, which is why
they live in this repo-wide `crates/` directory rather than under any single
component; `cph-schema` (kind JSON Schemas + validation), `cph-check`
@@ -20,7 +17,7 @@ entrypoint) are the checker proper.
| crate | owner | role |
|---------------|-------|------|
| `cph-diag` | WU-1 | shared diagnostic vocabulary (`Severity`, `DiagCode`, `Diagnostic`, `SourceSpan`) — reusable |
| `cph-model` | WU-1 | parses the ADR-0036 nested outline layout (+ ADR-0037 bundles) into an in-memory ordered `Lesson`/`Bundle` — reusable |
| `cph-model` | WU-1 | parses the ADR-0008 layout into an in-memory ordered `Lesson` — reusable |
| `cph-schema` | WU-3 | the 4 stdlib kind JSON Schemas + structural validation |
| `cph-typst` | WU-4 | typst `World`, driver generation, compile, PDF, span mapping — reusable |
| `cph-check` | WU-5 | orchestration: render-coverage and the full check pipeline |
+13 -135
View File
@@ -19,11 +19,13 @@ const DEFAULT_TARGET: &str = "student";
/// Severity of the render-coverage ("element ignored under a target") diagnostic.
///
/// **PINNED to `warning` by ADR-0005:** when a
/// **PINNED to `warning` by the contract.** Mirrors the Lean master's
/// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning`
/// (`spec/Spec/Courseware/Check/Diagnostic.lean`), itself citing ADR-0005: when a
/// `(kind, target)` pair has no render rule the checker reports that the element
/// is ignored under that target and **does not block the export**. Naming the
/// severity as a const makes "it is a warning, not an error" a greppable
/// fact rather than an inline literal.
/// severity as a const makes "it is a warning, not an error" a greppable,
/// alignable fact rather than an inline literal.
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
/// The result of running [`check`] (or the check phases of [`build`]).
@@ -55,12 +57,12 @@ impl CheckReport {
/// Whether any collected diagnostic is `Error`-severity.
///
/// **Legality decision (ADR-0010).** `!has_errors()` decides lesson
/// legality: a lesson is *legal* iff its diagnostics contain no error-level
/// diagnostic (warnings are non-blocking — see `Severity`). There is no CI
/// gate enforcing ADR↔implementation alignment (repo constitution); it is
/// kept greppable here so a reviewer can tie the orchestrator's gate to
/// the ADR.
/// **Legality decision (spec alignment).** `!has_errors()` is the
/// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`):
/// a lesson is *legal* iff its diagnostics contain no error-level diagnostic
/// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI
/// gate enforcing this alignment (repo constitution); it is kept greppable
/// here so a reviewer can tie the orchestrator's gate to the Lean master.
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
@@ -130,39 +132,6 @@ 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`.
///
/// Runs the check phases **(a)(c)** (load → structural → schema). If those
@@ -229,96 +198,6 @@ pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, Ch
}
}
/// Build a PDF for a **bundle** target (ADR-0037): the multi-lesson combined
/// artifact. Mirrors [`build`]'s contract and gating, but runs phases (a)(c)
/// over **every member lesson independently** (ADR-0037'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-0037 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-0037 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`]).
#[derive(Debug, Clone, PartialEq)]
pub struct ShellStepOutcome {
@@ -564,9 +443,8 @@ pub struct MarkdownAssembleReport {
}
/// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each
/// element's `<field>.md` markdown content file in `parts` order (ADR-0036's
/// depth-first element sequence) into the target's single-file artifact.
/// This is the **third typed step**: unlike
/// element's `<field>.md` markdown content file in `[[parts]]` order into the
/// target's single-file artifact. This is the **third typed step**: unlike
/// [`build`] (typst template → PDF) the framework owns the read/concatenate/write
/// itself (not a typst compile, not an external tool like [`run_shell_target`]).
///
+11 -28
View File
@@ -45,23 +45,6 @@ fn good_fixture_has_no_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]
fn unknown_kind_is_an_error() {
// Build a throwaway lesson whose part declares kind "frob".
@@ -76,7 +59,7 @@ name = "broken"
[info]
title = "broken"
[[children]]
[[parts]]
kind = "frob"
path = "elements/widget"
"#,
@@ -140,7 +123,7 @@ name = "broken"
[info]
title = "broken"
[[children]]
[[parts]]
kind = "segment"
path = "segments/does-not-exist"
"#,
@@ -167,7 +150,7 @@ name = "cov"
[info]
title = "cov"
[[children]]
[[parts]]
kind = "segment"
path = "segments/intro"
@@ -293,7 +276,7 @@ name = "sh"
[info]
title = "sh"
[[children]]
[[parts]]
kind = "segment"
path = "segments/intro"
@@ -367,7 +350,7 @@ name = "sh"
[info]
title = "sh"
[[children]]
[[parts]]
kind = "segment"
path = "segments/missing"
@@ -407,7 +390,7 @@ fn write_markdown_assemble_target_lesson(tmp: &Path, slides: &[(&str, &str)]) {
parts.push('\n');
}
parts.push_str(&format!(
"[[children]]\nkind = \"segment\"\npath = \"segments/{name}\"\n"
"[[parts]]\nkind = \"segment\"\npath = \"segments/{name}\"\n"
));
}
std::fs::write(
@@ -485,8 +468,8 @@ fn run_markdown_assemble_target_skips_parts_without_the_field() {
// Only the first segment has a slides.md; the second is skipped (optional).
let tmp = tempdir();
let mut parts = String::new();
parts.push_str("[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n\n");
parts.push_str("[[children]]\nkind = \"segment\"\npath = \"segments/b\"\n");
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n\n");
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/b\"\n");
std::fs::write(
tmp.join("manifest.toml"),
format!(
@@ -544,7 +527,7 @@ name = "md"
[info]
title = "md"
[[children]]
[[parts]]
kind = "segment"
path = "segments/a"
@@ -601,7 +584,7 @@ name = "md"
[info]
title = "md"
[[children]]
[[parts]]
kind = "segment"
path = "segments/a"
@@ -647,7 +630,7 @@ name = "md"
[info]
title = "md"
[[children]]
[[parts]]
kind = "segment"
path = "segments/missing"
-3
View File
@@ -11,9 +11,6 @@ path = "src/main.rs"
[dependencies]
cph-check = { path = "../cph-check" }
cph-diag = { workspace = true }
cph-model = { workspace = true }
cph-schema = { path = "../cph-schema" }
cph-typst = { path = "../cph-typst" }
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
serde_json = "1"
+20 -739
View File
@@ -7,14 +7,11 @@
//! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit
//! 0); `build` exits 1 when the PDF could not be produced.
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use cph_check::CheckReport;
use cph_model::OutlineDocument;
use cph_typst::Engine;
/// The `cph` checker for curriculum engineering files.
@@ -38,56 +35,17 @@ enum Command {
/// Path to the engineering-file root (the folder with `manifest.toml`).
path: PathBuf,
},
/// Build one or more render targets. Exits 1 if any target fails.
///
/// With no `--target`, batches every target the lesson declares
/// (ADR-0037): 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 a PDF for a render target. Exits 1 if the build fails.
Build {
/// Path to the engineering-file root (the folder with `manifest.toml`).
path: PathBuf,
/// Render target(s) to export. Repeatable. Defaults to every target
/// the lesson 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 (ambiguous: which target would it name?).
/// Render target to export.
#[arg(long, default_value = "student")]
target: String,
/// Output PDF path. Defaults to `<PATH>/build/<target>.pdf`.
#[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>,
},
/// Build one or more bundle targets (ADR-0037): 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
/// sibling: a local convenience, no lesson involved). Pipe to your shell's
/// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`.
@@ -95,49 +53,6 @@ enum Command {
/// Which shell to generate completions for.
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)]
@@ -152,34 +67,15 @@ enum CompletionTarget {
fn main() -> ExitCode {
let cli = Cli::parse();
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 {
let engine = match &cli.render_dir {
Some(dir) => Engine::with_render_dir(dir.clone()),
None => Engine::new(),
};
match cli.command {
Command::Check { path } => run_check(&path, &engine),
Command::Build { path, target, out } => run_build(&path, &engine, &target, out),
Command::Completions { shell } => run_completions(shell),
}
}
@@ -202,358 +98,6 @@ fn run_completions(shell: CompletionTarget) -> ExitCode {
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-0036), 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-0036).
//
// 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-0036).
//
// 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-0036): 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-0036 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-0036).
/// 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.
fn print_diagnostics(report: &CheckReport) {
for d in &report.diagnostics {
@@ -588,205 +132,25 @@ fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode {
}
}
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-0037): 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-0037).
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(
fn run_build(
path: &std::path::Path,
engine: &Engine,
target: &str,
out: Option<PathBuf>,
) -> bool {
) -> ExitCode {
// A target whose steps are shell commands (a tool-generated asset bundle,
// ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by
// executing those commands, not by compiling a typst template. Detect that
// shape up front and route accordingly.
if cph_check::target_is_shell(path, target) {
return run_shell_build(path, engine, target) == ExitCode::SUCCESS;
return run_shell_build(path, engine, target);
}
// A target whose steps assemble markdown (ADR-0015: slides outline / 逐字稿
// transcript surfaces) is built by concatenating per-element `<field>.md`
// files in parts order, not by compiling a typst template.
if cph_check::target_is_markdown_assemble(path, target) {
return run_markdown_assemble_build(path, engine, target) == ExitCode::SUCCESS;
return run_markdown_assemble_build(path, engine, target);
}
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
@@ -802,102 +166,19 @@ fn run_build_one(
"error: cannot create output directory '{}': {e}",
parent.display()
);
return false;
return ExitCode::FAILURE;
}
}
if let Err(e) = std::fs::write(&out_path, &bytes) {
eprintln!("error: cannot write '{}': {e}", out_path.display());
return false;
return ExitCode::FAILURE;
}
println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
true
ExitCode::SUCCESS
}
None => {
eprintln!("build failed: {} errors", report.error_count());
false
}
}
}
/// Dispatch `cph bundle` (ADR-0037) — 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-0037 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
ExitCode::FAILURE
}
}
}
+16 -22
View File
@@ -2,7 +2,7 @@
//!
//! Every other crate in the workspace depends on these types to report
//! problems. The vocabulary is intentionally small and stable: a [`Severity`]
//! (two-valued, ADR-0010), a closed set of machine-stable [`DiagCode`]s, an
//! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an
//! optional [`SourceSpan`] pointing back at the offending source, and a
//! [`Diagnostic`] tying them together with a human message and a fix hint.
//!
@@ -17,27 +17,32 @@ use serde::Serialize;
/// Severity of a diagnostic.
///
/// **Pinned by ADR-0005 / ADR-0010: exactly two values.**
/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
/// exactly:
///
/// ```text
/// warning | error
/// inductive Severity where
/// | warning
/// | error
/// ```
///
/// This two-valued shape is a **contract decision**, not an accident: the
/// finer levels (`info` / `hint` / `note`) are deliberately undecided, so we
/// This two-valued shape is a **contract decision**, not an accident: the Lean
/// module pins `Severity` to exactly `warning | error` and states the finer
/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore
/// do **not** add an info/note level here. `error` blocks (the artifact is
/// invalid); `warning` does not block (the artifact still exports, but with
/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning").
///
/// There is no CI gate enforcing ADR↔implementation alignment (see the repo
/// constitution); it is maintained by review, which is why the decision is
/// documented here rather than only in the ADR.
/// There is no CI gate enforcing this alignment (see the repo constitution);
/// it is maintained by review, which is why this correspondence is documented
/// here rather than only in the spec.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Severity {
/// Non-blocking: the artifact still exports, but is lossy / has an ignored
/// element. ADR-0010 `warning`.
/// element. Mirrors Lean `Severity.warning`.
Warning,
/// Blocking: the artifact is invalid. ADR-0010 `error`.
/// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`.
Error,
}
@@ -65,8 +70,7 @@ pub struct SourceSpan {
/// Do not invent codes outside this enum without a deliberate decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum DiagCode {
/// A `[[parts]]`/outline entry references a folder/path that does not
/// exist.
/// A `[[parts]]` entry references a folder/path that does not exist.
PartPathMissing,
/// An element declares a `kind` that is not a known kind.
UnknownKind,
@@ -87,15 +91,6 @@ pub enum DiagCode {
/// The engineering file's `.cph-version` is not compatible with the running
/// CLI's version (ADR-0016). Decided at load time; `error` severity.
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-0036) — or a bundle `lessons` entry malformed
/// (ADR-0037). 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 {
@@ -112,7 +107,6 @@ impl DiagCode {
DiagCode::TypstCompile => "E-TYPST-COMPILE",
DiagCode::RenderIgnored => "W-RENDER-IGNORED",
DiagCode::CphVersionMismatch => "E-CPH-VERSION",
DiagCode::ManifestMalformed => "E-MANIFEST",
}
}
}
File diff suppressed because it is too large Load Diff
-83
View File
@@ -1,83 +0,0 @@
//! Integration tests for `cph_model::load_bundle` (ADR-0037): 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);
}
@@ -1,12 +0,0 @@
[project]
id = "fixture-both"
name = "both"
[info]
title = "文件夹既是容器又是叶子"
[[children]]
kind = "segment"
path = "segments/broken"
[targets.student]
@@ -1 +0,0 @@
[[children]]
@@ -1,2 +0,0 @@
[info
this is broken
@@ -1,5 +0,0 @@
[info]
title = "缺失课时的合集"
[[lessons]]
path = "does-not-exist"
@@ -1,16 +0,0 @@
[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"
@@ -1,12 +0,0 @@
[project]
id = "lesson-a"
name = "lesson-a"
[info]
title = "课时A"
[[children]]
kind = "segment"
path = "segments/a"
[targets.student]
@@ -1 +0,0 @@
A.
@@ -1,12 +0,0 @@
[project]
id = "lesson-b"
name = "lesson-b"
[info]
title = "课时B"
[[children]]
kind = "segment"
path = "segments/b"
[targets.teacher]
@@ -1 +0,0 @@
B.
@@ -1,12 +0,0 @@
[project]
id = "fixture-container-root-tables"
name = "container-root-tables"
[info]
title = "容器错误声明了根级表"
[[children]]
kind = "section"
path = "section"
[targets.student]
@@ -1,5 +0,0 @@
[project]
id = "should-not-be-here"
name = "should-not-be-here"
children = []
@@ -5,7 +5,7 @@ name = "kind-mismatch"
[info]
title = "kind 不一致测试"
[[children]]
[[parts]]
kind = "segment"
path = "segments/intro"
+2 -2
View File
@@ -5,11 +5,11 @@ name = "missing-part"
[info]
title = "缺部件测试"
[[children]]
[[parts]]
kind = "segment"
path = "segments/intro"
[[children]]
[[parts]]
kind = "lemma"
path = "lemmas/does-not-exist"
@@ -1,12 +0,0 @@
[project]
id = "fixture-neither"
name = "neither"
[info]
title = "文件夹既不是容器也不是叶子"
[[children]]
kind = "segment"
path = "segments/broken"
[targets.student]
-21
View File
@@ -1,21 +0,0 @@
[project]
id = "fixture-nested"
name = "nested"
[info]
title = "嵌套结构测试"
[[children]]
kind = "segment"
path = "segments/开场白"
[[children]]
kind = "section"
path = "导言簇"
notes = "这里先建立直观图像,再进入分组推导。"
[[children]]
kind = "section"
path = "收束簇"
[targets.student]
@@ -1 +0,0 @@
开场白。
@@ -1,14 +0,0 @@
[group]
title = "导言簇"
[[children]]
kind = "segment"
path = "segments/子段一"
[[children]]
kind = "section"
path = "嵌套子节"
[[children]]
kind = "segment"
path = "segments/子段二"
@@ -1 +0,0 @@
子段一。
@@ -1 +0,0 @@
子段二。
@@ -1 +0,0 @@
子引理陈述。
@@ -1,3 +0,0 @@
[[children]]
kind = "lemma"
path = "lemmas/子引理"
@@ -1,6 +0,0 @@
[group]
title = "收束簇"
[[children]]
kind = "segment"
path = "segments/总结"
@@ -1 +0,0 @@
总结
@@ -1,15 +0,0 @@
[project]
id = "fixture-root-group"
name = "root-group"
[info]
title = "根级 manifest 错误声明了 group"
[group]
title = "不该在根级"
[[children]]
kind = "segment"
path = "segments/a"
[targets.student]
@@ -1 +0,0 @@
a.
+2 -3
View File
@@ -6,12 +6,11 @@ name = "valid-2-part"
title = "测试课:两个部件"
author = "范式教育教研组"
[[children]]
[[parts]]
kind = "segment"
path = "segments/intro"
notes = "这一节补充一个直观例题"
[[children]]
[[parts]]
kind = "lemma"
path = "lemmas/young"
+6 -209
View File
@@ -1,12 +1,11 @@
//! Integration tests for `cph_model::load`, driven by static fixtures under
//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0036
//! on-disk format (a nested outline manifest; supersedes ADR-0008's flat
//! `[[parts]]`).
//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0008
//! on-disk format.
use std::path::PathBuf;
use cph_diag::DiagCode;
use cph_model::{load, OutlineEntry};
use cph_model::load;
/// Absolute path to a fixture engineering-file root.
fn fixture(name: &str) -> PathBuf {
@@ -35,38 +34,11 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
assert_eq!(lesson.parts.len(), 2);
assert_eq!(lesson.parts[0].kind, "segment");
assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro"));
assert_eq!(
lesson.parts[0].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[1].kind, "lemma");
assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young"));
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.
let scalars = &lesson.parts[1].descriptor.scalars;
assert_eq!(
@@ -102,90 +74,6 @@ 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-0036): 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]
fn missing_part_folder_yields_part_path_missing() {
let (lesson, diags) = load(&fixture("missing-part"));
@@ -238,7 +126,7 @@ fn malformed_manifest_is_a_hard_failure() {
"malformed manifest must be a hard failure (None)"
);
assert_eq!(diags.len(), 1, "one hard-failure diagnostic expected");
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
assert_eq!(diags[0].code, DiagCode::SchemaViolation);
assert_eq!(diags[0].severity, cph_diag::Severity::Error);
}
@@ -248,98 +136,7 @@ fn missing_manifest_is_a_hard_failure() {
let (lesson, diags) = load(&fixture("does-not-exist-at-all"));
assert!(lesson.is_none());
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagCode::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:?}"
);
assert_eq!(diags[0].code, DiagCode::SchemaViolation);
}
#[test]
@@ -493,7 +290,7 @@ fn tmp_lesson_with_version(version: Option<&str>) -> tempfile::TempDir {
let p = tmp.path();
std::fs::write(
p.join("manifest.toml"),
"[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n",
"[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n",
)
.unwrap();
let seg = p.join("segments").join("a");
-13
View File
@@ -155,19 +155,6 @@ impl KindSchema {
.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
/// schema order.
pub fn scalar_field_names(&self) -> Vec<&str> {
-22
View File
@@ -74,25 +74,3 @@ fn example_source_non_string_is_schema_violation() {
assert!(diags[0].message.contains("source"));
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"]);
}
+13 -28
View File
@@ -33,10 +33,11 @@ static RENDER_DIR: Dir<'_> = include_dir!("$CPH_STAGED_RENDER_DIR");
/// extracting the embedded copy to a per-user cache dir if needed.
///
/// Resolution order:
/// 1. `CPH_RENDER_DIR` — an explicit override (dev convenience: point at the
/// live repo `render/`).
/// 1. `CPH_RENDER_DIR` env var — an explicit override (dev convenience: point
/// at the live repo `render/`).
/// 2. The extracted embedded copy under the user cache dir
/// (`<cache>/cph/render-<version>/`).
/// (`<cache>/cph/render-<version>/`). Extracted once per crate version;
/// subsequent runs reuse it.
///
/// On any failure to locate a cache dir or extract, falls back to a temp-dir
/// location so the engine still works (just re-extracting per process).
@@ -47,47 +48,31 @@ pub fn resolve_render_dir() -> PathBuf {
ensure_extracted().unwrap_or_else(|_| {
// Last-resort: extract under the OS temp dir. Still correct, just not
// cached across processes.
let fallback = std::env::temp_dir().join(format!(
"cph-render-{}-{}",
env!("CARGO_PKG_VERSION"),
RENDER_CACHE_REVISION
));
let fallback =
std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION")));
let _ = extract_to(&fallback);
fallback
})
}
/// Bump when the embedded render package changes without a cph crate-version
/// 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.
/// The version-keyed cache location and a guarantee the embedded tree is present
/// there. Returns the directory the World should use.
fn ensure_extracted() -> std::io::Result<PathBuf> {
let base = dirs::cache_dir()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?;
let dest = base
.join("cph")
.join(format!("render-{}", env!("CARGO_PKG_VERSION")));
let sentinel = dest.join(".extracted");
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)
{
// A sentinel marks a complete extraction; if present, reuse as-is. (Keyed by
// version, so a new `cph` version re-extracts into a fresh dir.)
let sentinel = dest.join(".extracted");
if sentinel.is_file() {
return Ok(dest);
}
// 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)?;
std::fs::write(&sentinel, expected)?;
std::fs::write(&sentinel, env!("CARGO_PKG_VERSION"))?;
Ok(dest)
}
+14 -99
View File
@@ -39,15 +39,15 @@ mod embedded;
mod manifest;
mod world;
use cph_diag::{DiagCode, Diagnostic};
use std::path::PathBuf;
use cph_model::{Artifact, Bundle, Lesson, OutlineDocument, Step, TargetConfig};
use cph_diag::{DiagCode, Diagnostic};
use cph_model::{Artifact, Lesson, Step, TargetConfig};
use typst_kit::fonts::{self, FontStore};
use typst_layout::PagedDocument;
use typst_pdf::PdfOptions;
pub use manifest::{build_augmented_bundle_manifest, build_augmented_manifest};
pub use manifest::build_augmented_manifest;
pub use world::{render_package_spec, LessonWorld, MANIFEST_VPATH};
/// The compile/PDF engine: holds the shared font store and the on-disk location
@@ -139,38 +139,10 @@ impl Engine {
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
/// the request cannot be honored (see [`target_precheck`]).
fn world_for(&self, lesson: &Lesson, target: &str) -> Result<LessonWorld, Vec<Diagnostic>> {
let template = target_precheck(target, &lesson.targets)?;
let template = target_precheck(lesson, target)?;
let manifest_src = build_augmented_manifest(lesson);
Ok(LessonWorld::new(
lesson.root.clone(),
@@ -180,60 +152,6 @@ impl Engine {
self.fonts.clone(),
))
}
/// Compile-check `bundle` for `target` (ADR-0037) — 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-0037) — 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 {
@@ -242,16 +160,13 @@ impl Default for Engine {
}
}
/// Validate a `(targets, target)` request and resolve the template path to
/// compile. Shared by [`Engine::world_for`] (a lesson's `targets`) and
/// [`Engine::world_for_bundle`] (a bundle's own `targets` — ADR-0037 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:
/// Validate a `(lesson, target)` request and resolve the template path to
/// compile. Returns `Ok(template_path)` (relative to the lesson root) when the
/// request is buildable, or `Err(blocking_diagnostics)` when it is not:
///
/// - **Unknown target** (the `--target` name isn't in `targets`, and `targets`
/// is non-empty): a `SchemaViolation` error — a target must be declared in
/// the manifest to be built (ADR-0009).
/// - **Unknown target** (the `--target` name isn't in `lesson.targets`, and the
/// lesson declares at least one target): a `SchemaViolation` error — a target
/// must be declared in the manifest to be built (ADR-0009).
/// - **No declared targets at all**: not an error — callers (e.g. `cph-check`)
/// may compile-check a defaulted `"student"` target the lesson never declared.
/// The stock template path `exports/<target>.typ` is used (the framework
@@ -262,10 +177,10 @@ impl Default for Engine {
/// [`Step::Shell`], returns a clear "not yet implemented" `SchemaViolation`
/// rather than wrong output. The template is taken from the **first**
/// `TypstCompile` step (MVP: one step per target).
fn target_precheck(target: &str, targets: &[TargetConfig]) -> Result<PathBuf, Vec<Diagnostic>> {
let Some(tc) = targets.iter().find(|t| t.name == target) else {
if targets.is_empty() {
// Declares no targets; the orchestrator compiles a defaulted
fn target_precheck(lesson: &Lesson, target: &str) -> Result<PathBuf, Vec<Diagnostic>> {
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else {
if lesson.targets.is_empty() {
// Lesson declares no targets; the orchestrator compiles a defaulted
// target. Use the stock template path (matches cph-model's default).
return Ok(PathBuf::from(format!("exports/{target}.typ")));
}
+34 -156
View File
@@ -1,28 +1,18 @@
//! Augmented-manifest construction (ADR-0011, outline shape per ADR-0036).
//! Augmented-manifest construction (ADR-0011).
//!
//! The template (`exports/<target>.typ`) reads the manifest via
//! `toml(sys.inputs.manifest)`, then for each **element** outline entry
//! `include`s its content fields by a **computed** path and reads scalar
//! fields from `<path>/element.toml`. For *optional* content fields the
//! template must know whether the file exists on disk — typst has no
//! file-exists primitive and a missing `include` is a hard error (see the OPEN
//! contract point in `render/templates/student.typ`).
//! `toml(sys.inputs.manifest)`, then for each part `include`s its content fields
//! by a **computed** path and reads scalar fields from `<path>/element.toml`.
//! For *optional* content fields the template must know whether the file exists
//! on disk — typst has no file-exists primitive and a missing `include` is a
//! hard error (see the OPEN contract point in `render/templates/student.typ`).
//!
//! The ENGINE has filesystem access, so it closes that gap: it builds an
//! **augmented manifest** = the lesson's `[info]` + the ordered `[[outline]]`
//! (ADR-0036's depth-first rendering order — elements interleaved with section
//! headings at their DFS-open position). Each `[[outline]]` entry carries a
//! `type` discriminator (`"element"` | `"section"`):
//!
//! - `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-0036).
//! - `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`.
//! **augmented manifest** = the lesson's `[info]` + ordered `[[parts]]`, with a
//! per-part **`fields` array** listing the content fields whose `<field>.typ`
//! actually exists under the lesson root. The augmented manifest is served as an
//! in-memory virtual file in the [`crate::world::LessonWorld`] (it is **never**
//! written to the user's tree), and injected via `sys.inputs.manifest`.
//!
//! ## `fields` is computed from `cph-schema`
//!
@@ -30,104 +20,23 @@
//! ([`cph_schema::KindSchema::content_field_names`]) — the same knowledge the
//! render package exposes as `part-fields`. We reuse it here rather than
//! re-deriving a kind→fields map, so the engine and the template agree on what a
//! kind's content fields are. For each element, a content field is listed in
//! kind's content fields are. For each part, a content field is listed in
//! `fields` iff `<root>/<part.path>/<field>.typ` is a real file.
use std::path::Path;
use cph_model::{Bundle, BundleLesson, Lesson, OutlineEntry};
use cph_model::Lesson;
/// Build the augmented-manifest TOML source for `lesson`.
///
/// The result is a self-contained TOML document the template's
/// `toml(sys.inputs.manifest)` reads. It carries `[info]` (title + optional
/// author) and the ordered `[[outline]]` (ADR-0036's depth-first rendering
/// order), each entry typed `"element"` or `"section"` per the module docs. It
/// does **not** reproduce `[project]` or `[targets.*]` — the template only
/// consumes `info` and `outline`.
/// author) and the ordered `[[parts]]`, each with `kind`, `path`, and a
/// `fields = [...]` array of the content fields present on disk (per
/// [`present_fields`]). It does **not** reproduce `[project]` or `[targets.*]`
/// — the template only consumes `info` and `parts`.
pub fn build_augmented_manifest(lesson: &Lesson) -> String {
let mut doc = toml::Table::new();
doc.insert("info".to_string(), toml::Value::Table(info_table(lesson)));
// [[outline]] — ADR-0036'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-0037).
///
/// 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-0037: 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 {
// [info]
let mut info = toml::Table::new();
info.insert(
"title".to_string(),
@@ -143,61 +52,30 @@ fn info_table(lesson: &Lesson) -> toml::Table {
.collect();
info.insert("author".to_string(), toml::Value::Array(authors));
}
info
}
doc.insert("info".to_string(), toml::Value::Table(info));
/// Build one `[[outline]]` entry's table for either variant of
/// [`OutlineEntry`]. `bundle_prefix`, when set (ADR-0037's bundle case), is
/// joined onto the emitted `path` so the bundle template's computed include
/// resolves against the bundle root rather than the lesson's own root.
fn outline_entry_table(
lesson: &Lesson,
entry: &OutlineEntry,
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(
// [[parts]] — preserve declared order; attach the on-disk `fields` array.
let parts: Vec<toml::Value> = lesson
.parts
.iter()
.map(|part| {
let mut entry = toml::Table::new();
entry.insert("kind".to_string(), toml::Value::String(part.kind.clone()));
entry.insert(
"path".to_string(),
toml::Value::String(prefixed_forward_slash(bundle_prefix, &part.path)),
toml::Value::String(path_to_forward_slash(&part.path)),
);
let fields = present_fields(lesson, part)
.into_iter()
.map(toml::Value::String)
.collect();
e.insert("fields".to_string(), toml::Value::Array(fields));
}
OutlineEntry::Section {
kind,
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
}
entry.insert("fields".to_string(), toml::Value::Array(fields));
toml::Value::Table(entry)
})
.collect();
doc.insert("parts".to_string(), toml::Value::Array(parts));
/// [`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),
}
toml::to_string(&doc).expect("augmented manifest serializes")
}
/// The content fields of `part`'s kind whose `<root>/<part.path>/<field>.typ`
+26 -68
View File
@@ -48,14 +48,13 @@ use typst::{Library, LibraryExt, World};
use typst_kit::fonts::FontStore;
/// Root-relative vpath the augmented manifest is served at (in-memory only).
///
/// A **leading slash** is essential: the template lives under `exports/`, and
/// `toml(sys.inputs.manifest)` resolves a relative path against the template's
/// own directory — a bare name would miss. A root-relative absolute path anchors
/// at `--root` (the lesson root) regardless of where the template sits.
pub const MANIFEST_VPATH: &str = "/.cph/manifest.toml";
/// 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`.
pub fn render_package_spec() -> PackageSpec {
PackageSpec {
@@ -77,11 +76,13 @@ pub struct LessonWorld {
render_dir: PathBuf,
/// The render package spec (`@local/cph-render:0.1.0`).
render_spec: PackageSpec,
/// FileId of the entrypoint.
/// FileId of the template entrypoint (a real file under `root`).
main: FileId,
/// In-memory project files (manifest, or the outline entrypoint/data).
virtual_sources: HashMap<FileId, Source>,
/// Standard library inputs exposed to the Typst source.
/// FileId of the in-memory augmented manifest.
manifest_id: FileId,
/// The augmented-manifest source (in-memory; never on disk).
manifest_source: Source,
/// Standard library, with `sys.inputs.manifest` set.
library: LazyHash<Library>,
/// Shared font store (book + lazily-loaded fonts).
fonts: Arc<FontStore>,
@@ -94,8 +95,8 @@ impl LessonWorld {
/// whose injected manifest is `manifest_src` (served virtually at
/// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there).
///
/// `template` is the lesson-root-relative path taken from the target's
/// `Step::TypstCompile`.
/// `template` is the lesson-root-relative template path (e.g.
/// `exports/student.typ`), taken from the target's `Step::TypstCompile`.
pub fn new(
root: PathBuf,
render_dir: PathBuf,
@@ -106,55 +107,16 @@ impl LessonWorld {
let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template)))
.expect("template vpath is a valid virtual path");
let main = FileId::new(RootedPath::new(VirtualRoot::Project, main_vpath));
let manifest_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,
)
}
/// Build a world for a fully virtual outline document and its TOML data.
/// The caller never has to create temporary files in the engineering file.
pub fn new_outline(
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,
)
}
let manifest_vpath =
VirtualPath::new(MANIFEST_VPATH).expect("manifest vpath is a valid virtual path");
let manifest_id = FileId::new(RootedPath::new(VirtualRoot::Project, manifest_vpath));
let manifest_source = Source::new(manifest_id, manifest_src);
fn with_virtual_files(
root: PathBuf,
render_dir: PathBuf,
main: FileId,
virtual_sources: HashMap<FileId, Source>,
input_files: &[(&str, &str)],
fonts: Arc<FontStore>,
) -> Self {
// Inject `sys.inputs.manifest = "/.cph/manifest.toml"` so the template's
// `toml(sys.inputs.manifest)` reads the augmented manifest.
let mut inputs = Dict::new();
for (name, path) in input_files {
inputs.insert((*name).into(), Value::Str((*path).into()));
}
inputs.insert("manifest".into(), Value::Str(MANIFEST_VPATH.into()));
let library = Library::builder().with_inputs(inputs).build();
Self {
@@ -162,7 +124,8 @@ impl LessonWorld {
render_dir,
render_spec: render_package_spec(),
main,
virtual_sources,
manifest_id,
manifest_source,
library: LazyHash::new(library),
fonts,
sources: Mutex::new(HashMap::new()),
@@ -222,8 +185,8 @@ impl World for LessonWorld {
}
fn source(&self, id: FileId) -> FileResult<Source> {
if let Some(source) = self.virtual_sources.get(&id) {
return Ok(source.clone());
if id == self.manifest_id {
return Ok(self.manifest_source.clone());
}
// Cache hit?
if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) {
@@ -240,8 +203,8 @@ impl World for LessonWorld {
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
if let Some(source) = self.virtual_sources.get(&id) {
return Ok(Bytes::from_string(source.text().to_string()));
if id == self.manifest_id {
return Ok(Bytes::from_string(self.manifest_source.text().to_string()));
}
let bytes = self.read_bytes(id)?;
Ok(Bytes::new(bytes))
@@ -257,11 +220,6 @@ 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
/// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim.
fn path_to_forward_slash(path: &Path) -> String {
-122
View File
@@ -1,122 +0,0 @@
//! Integration tests for the bundle build path (ADR-0037): 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-0037).
#[test]
fn augmented_bundle_manifest_prefixes_member_paths() {
let bundle = load_bundle();
let src = build_augmented_bundle_manifest(&bundle);
assert!(
src.contains("测试合集"),
"bundle info.title present:\n{src}"
);
let doc: toml::Value = toml::from_str(&src).expect("augmented bundle manifest is valid TOML");
let lessons = doc
.get("lessons")
.and_then(|l| l.as_array())
.expect("lessons array present");
assert_eq!(lessons.len(), 2, "two member lessons:\n{src}");
// lesson-a: target defaults to its own first declared target ("student"),
// outline paths are prefixed with "lesson-a/".
let a_target = lessons[0].get("target").unwrap().as_str().unwrap();
assert_eq!(a_target, "student");
let a_outline = lessons[0].get("outline").unwrap().as_array().unwrap();
// segment + section heading + lemma = 3 outline entries.
assert_eq!(a_outline.len(), 3);
let a_seg_path = a_outline[0].get("path").unwrap().as_str().unwrap();
assert_eq!(a_seg_path, "lesson-a/segments/a");
let a_section_path = a_outline[1].get("path").unwrap().as_str().unwrap();
assert_eq!(a_section_path, "lesson-a/小节");
let a_lemma_path = a_outline[2].get("path").unwrap().as_str().unwrap();
assert_eq!(a_lemma_path, "lesson-a/小节/lemmas/引理甲");
// lesson-b: bundle.toml overrides `target = "teacher"`.
let b_target = lessons[1].get("target").unwrap().as_str().unwrap();
assert_eq!(b_target, "teacher");
let b_outline = lessons[1].get("outline").unwrap().as_array().unwrap();
assert_eq!(b_outline.len(), 1);
let b_seg_path = b_outline[0].get("path").unwrap().as_str().unwrap();
assert_eq!(b_seg_path, "lesson-b/segments/b");
}
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling
/// the bundle's `merged` target as main with the injected augmented bundle
/// manifest is clean.
#[test]
fn compile_check_clean_through_bundle_template() {
let bundle = load_bundle();
let engine = Engine::with_render_dir(real_render_dir());
let diags = engine.compile_check_bundle(&bundle, "merged");
let errors: Vec<_> = diags
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(errors.is_empty(), "unexpected compile errors: {errors:#?}");
}
/// THROUGH-TEMPLATE PDF export, fully offline: a non-trivial combined PDF is
/// produced from the two member lessons through the real bundle template.
#[test]
fn build_bundle_pdf_through_template_offline() {
let bundle = load_bundle();
let engine = Engine::with_render_dir(real_render_dir());
let pdf = engine
.build_bundle_pdf(&bundle, "merged")
.unwrap_or_else(|d| panic!("bundle PDF build failed: {d:#?}"));
assert!(pdf.starts_with(b"%PDF"), "output is a PDF");
assert!(
pdf.len() > 1024,
"bundle PDF is non-trivial (got {} bytes)",
pdf.len()
);
}
/// An undeclared bundle target name is a blocking `SchemaViolation`, exactly
/// like a lesson's own unknown-target path.
#[test]
fn unknown_bundle_target_is_blocking() {
let bundle = load_bundle();
let engine = Engine::with_render_dir(real_render_dir());
let diags = engine.compile_check_bundle(&bundle, "nonexistent");
assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}");
assert_eq!(diags[0].severity, Severity::Error);
assert!(
diags[0].message.contains("not declared"),
"expected an undeclared-target error: {diags:#?}"
);
}
+39 -75
View File
@@ -39,12 +39,10 @@ fn load_mini() -> cph_model::Lesson {
}
/// PURE UNIT TEST (no fonts, no render package): the augmented manifest carries
/// `[info]` and the ordered `[[outline]]` (ADR-0036) — elements (with a
/// per-element `fields` array of the content fields present on disk)
/// interleaved with the section heading the mini fixture nests its two lemmas
/// under.
/// `[info]`, the ordered `[[parts]]`, and a per-part `fields` array listing the
/// content fields present on disk.
#[test]
fn augmented_manifest_has_outline_with_section_and_fields() {
fn augmented_manifest_has_per_part_fields() {
let lesson = load_mini();
let src = build_augmented_manifest(&lesson);
@@ -52,98 +50,66 @@ fn augmented_manifest_has_outline_with_section_and_fields() {
assert!(src.contains("迷你示例课时"), "info.title present:\n{src}");
assert!(src.contains("测试作者"), "info.author present:\n{src}");
// Parse it back to inspect the outline entries precisely.
// Parse it back to inspect the per-part fields precisely.
let doc: toml::Value = toml::from_str(&src).expect("augmented manifest is valid TOML");
let outline = doc
.get("outline")
let parts = doc
.get("parts")
.and_then(|p| p.as_array())
.expect("outline array present");
// segment, section, lemma, lemma, example — 5 entries (ADR-0036: the
// section contributes a heading entry, not an element).
assert_eq!(outline.len(), 5, "five outline entries:\n{src}");
.expect("parts array present");
assert_eq!(parts.len(), 4, "four parts in declared order:\n{src}");
let entry_type = |idx: usize| {
outline[idx]
.get("type")
.unwrap()
.as_str()
.unwrap()
.to_string()
};
let kind_of = |idx: usize| {
outline[idx]
.get("kind")
.unwrap()
.as_str()
.unwrap()
.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]
let mut v: Vec<String> = parts[idx]
.get("fields")
.and_then(|f| f.as_array())
.expect("element entry has a fields array")
.expect("part has a fields array")
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
v.sort();
v
};
let path_of = |idx: usize| {
parts[idx]
.get("path")
.unwrap()
.as_str()
.unwrap()
.to_string()
};
let kind_of = |idx: usize| {
parts[idx]
.get("kind")
.unwrap()
.as_str()
.unwrap()
.to_string()
};
// Order preserved: segment, section (引理组), lemma (w/ proof), lemma (no
// proof), example.
assert_eq!(entry_type(0), "element");
// Order preserved: segment, lemma (w/ proof), lemma (no proof), example.
assert_eq!(kind_of(0), "segment");
assert_eq!(path_of(0), "segments/开场对照导言");
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(1), "lemma");
assert_eq!(kind_of(2), "lemma");
assert_eq!(path_of(2), "引理组/lemmas/量纲分析估计");
// lemma WITH proof.typ: both stmt + proof present (sorted).
assert_eq!(fields_of(2), vec!["proof", "stmt"]);
assert_eq!(kind_of(3), "example");
assert_eq!(entry_type(3), "element");
assert_eq!(kind_of(3), "lemma");
assert_eq!(path_of(3), "引理组/lemmas/无证明引理");
// Paths kept as forward-slash UTF-8.
assert_eq!(path_of(0), "segments/开场对照导言");
assert_eq!(path_of(2), "lemmas/无证明引理");
// segment: only `textbook` exists.
assert_eq!(fields_of(0), vec!["textbook"]);
// lemma WITH proof.typ: both stmt + proof present (sorted).
assert_eq!(fields_of(1), vec!["proof", "stmt"]);
// lemma WITHOUT proof.typ: only stmt present (the OPTIONAL-content path).
assert_eq!(
fields_of(3),
fields_of(2),
vec!["stmt"],
"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).
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");
assert_eq!(fields_of(3), vec!["problem", "solution"]);
}
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the
@@ -236,7 +202,6 @@ fn file_tree_artifact_is_deferred() {
authors: vec![],
},
parts: vec![],
outline: vec![],
targets: vec![TargetConfig {
name: "web".into(),
artifact: Artifact::FileTree {
@@ -276,7 +241,6 @@ fn shell_only_target_is_deferred() {
authors: vec![],
},
parts: vec![],
outline: vec![],
targets: vec![TargetConfig {
name: "packaged".into(),
artifact: Artifact::SingleFile {
-16
View File
@@ -1,16 +0,0 @@
[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"
@@ -1,76 +0,0 @@
// DEFAULT BUNDLE TEMPLATE (ADR-0037, outline shape ADR-0036).
//
// 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-0037 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,
)
@@ -1,17 +0,0 @@
[project]
id = "lesson-a"
name = "lesson-a"
[info]
title = "课时A"
author = "作者A"
[[children]]
kind = "segment"
path = "segments/a"
[[children]]
kind = "section"
path = "小节"
[targets.student]
@@ -1 +0,0 @@
= 课时A导言
@@ -1 +0,0 @@
引理甲陈述。
@@ -1,6 +0,0 @@
[group]
title = "小节"
[[children]]
kind = "lemma"
path = "lemmas/引理甲"
@@ -1,12 +0,0 @@
[project]
id = "lesson-b"
name = "lesson-b"
[info]
title = "课时B"
[[children]]
kind = "segment"
path = "segments/b"
[targets.teacher]
@@ -1 +0,0 @@
= 课时B导言
+34 -48
View File
@@ -1,4 +1,4 @@
// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0036).
// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011).
//
// This is a *real, editable* file that lives in an engineering file at
// `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the
@@ -15,16 +15,15 @@
// 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-0036).
// template loads content and hands cph-render an already-assembled `parts` array.
//
// OPEN CONTRACT POINT — optional-content presence. typst has no "does this file
// exist" primitive (a missing `include` is a hard compile error). So the
// template CANNOT probe disk the way the old Rust driver did for lemma `proof`.
// It relies on the manifest declaring which optional content fields are present,
// via a per-element `fields` array listing the content fields that exist on disk
// via a per-part `fields` array listing the content fields that exist on disk
// (the engine knows this — it walks the part dir). Required fields are loaded
// unconditionally; optional fields load only if listed in `fields`. If an element
// unconditionally; optional fields load only if listed in `fields`. If a part
// omits `fields`, optional content is skipped (conservative). The exact shape of
// this declaration is for the manifest/Rust contract to pin.
@@ -36,51 +35,38 @@
// 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: ())
#let raw-parts = manifest.at("parts", 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-0036): 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)
// Assemble each part: include its content fields (computed absolute paths,
// resolved against --root) and read scalar fields from <path>/element.toml.
// `part-fields` (from cph-render) is the single source of truth for kind->fields.
#let parts = raw-parts.map(raw => {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ())
let part = (kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content {
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
// Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ")
}
// Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content {
if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ")
}
}
// Scalar fields come from <path>/element.toml.
if spec.scalars.len() > 0 {
let element = toml("/" + path + "/element.toml")
for field in spec.scalars {
let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) }
}
}
part
})
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
@@ -88,6 +74,6 @@
#render-lesson(
info: info,
target: target,
outline: outline,
parts: parts,
heading-numbering: default-heading-numbering,
)
+31 -40
View File
@@ -1,4 +1,4 @@
// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0036).
// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011).
//
// Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with
// the manifest injected:
@@ -18,47 +18,38 @@
// 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: ())
#let raw-parts = manifest.at("parts", default: ())
// Assemble each outline entry: an "element" entry includes its content fields
// and reads scalars from element.toml; a "section" entry (ADR-0036) passes
// title/depth straight through as a heading, no content to load.
#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)
// Assemble each part: include its content fields (computed absolute paths,
// resolved against --root) and read scalar fields from <path>/element.toml.
// `part-fields` (from cph-render) is the single source of truth for kind->fields.
#let parts = raw-parts.map(raw => {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ())
let part = (kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content {
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
// Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ")
}
// Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content {
if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ")
}
}
// Scalar fields come from <path>/element.toml.
if spec.scalars.len() > 0 {
let element = toml("/" + path + "/element.toml")
for field in spec.scalars {
let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) }
}
}
part
})
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
@@ -66,6 +57,6 @@
#render-lesson(
info: info,
target: target,
outline: outline,
parts: parts,
heading-numbering: default-heading-numbering,
)
+9 -5
View File
@@ -6,15 +6,19 @@ name = "迷你课时"
title = "迷你示例课时"
author = "测试作者"
[[children]]
[[parts]]
kind = "segment"
path = "segments/开场对照导言"
[[children]]
kind = "section"
path = "引理组"
[[parts]]
kind = "lemma"
path = "lemmas/量纲分析估计"
[[children]]
[[parts]]
kind = "lemma"
path = "lemmas/无证明引理"
[[parts]]
kind = "example"
path = "examples/自由落体"
@@ -1,10 +0,0 @@
[group]
title = "引理组"
[[children]]
kind = "lemma"
path = "lemmas/量纲分析估计"
[[children]]
kind = "lemma"
path = "lemmas/无证明引理"
-81
View File
@@ -1,81 +0,0 @@
# 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.
@@ -1,153 +0,0 @@
# ADR 0028: Member Group Management And Resolution
## Status
Accepted.
## Context
ADR-0020 fixed `Organization` as the tenant root and ADR-0019 pinned the
principal-set permission model. The file library (《文件库-接口契约.md》) computes
effective permission over two principal kinds — `USER` and `GROUP` — and consumes
the group side through a single read-only port, `GroupResolver`
(`resolveMemberGroupIds(userId) → groupIds[]`, contract C2/G2).
The contract's v0.1 proposal framed the Group system as a *separate HTTP service*
owned by another team, consumed read-only. In practice the schema now carries the
group tables directly in the hub database (`MemberGroup`, `MemberGroupMembership`,
`MemberGroupClosure` — a global, unlimited-depth, closure-backed hierarchy), and
the product requirement is to build **group management in the backend admin**, not
to integrate a foreign service. Until this ADR, nothing read or wrote those tables:
the live `GroupResolver` was a transitional implementation reading flat hub `Team`
membership, and the admin "Group 管理" panel actually managed `Team`.
This ADR settles the semantics needed to make the `MemberGroup` tables the real,
in-hub group system.
## Decision
### Group system is in-hub, not a foreign service
`MemberGroup` is the platform's global member-group principal. It lives in the hub
database and is managed through the `/database` backend. The contract's "separate
service" framing was an unfrozen v0.1 proposal; the implementation aligns to the
tables that were actually built. The `GroupResolver` port stays — an external
`HUB_GROUP_SERVICE_URL` HTTP implementation remains a supported override — but the
default implementation reads the in-hub `MemberGroup` closure.
### Authority: website administrator only
Group create/delete and member add/remove are restricted to the **website
administrator**, defined (consistently with the rest of the file library, D19/C4
adaptation) as an `OWNER`/`ADMIN` of the silo Organization (`isWebsiteAdmin` in
`filelib/guards.ts`). ADR-0023's `PlatformIdentity` is the future "true" platform
control plane; the file library uniformly uses org OWNER/ADMIN today and this
feature stays consistent with that. Reading groups for the authorization selector
(`/groups/search`) is **not** admin-gated — picking a group to grant is a Manage
holder's ability, not an administrator's.
### Resolution semantics (the crux)
`resolveMemberGroupIds(user)` returns the user's **active direct groups the
active ancestors of those groups**, deduplicated (the closure's depth-0 self row
makes each direct group its own ancestor). This is the single query the permission
engine relies on; equivalently: a grant placed on group G applies to members of G
and of every descendant of G (requirement 3.2 — permission flows down the tree, so
resolution collects up the tree). It is computed **live, never cached** (contract
D4/G4): a membership change is visible on the very next protected request.
MemberGroup is global (no `organizationId`), so resolution is not org-scoped.
### Soft delete via `archivedAt`, cascading the subtree
Delete is soft: `MemberGroup.archivedAt` is a tag. Deleting a group
cascade-soft-deletes its **whole subtree** (walk `MemberGroupClosure` where
`ancestorId = G`, stamp `archivedAt` on each active descendant) — an application
operation, not a DB constraint. Closure and membership rows are **retained**;
resolution and listing filter by `archivedAt`, so an archived group and everything
under it stop contributing to permission at once.
### Closure maintenance
The closure is maintained on **create**: insert `(G, G, 0)`, then for a parent `P`
insert `(a.ancestorId, G, a.depth + 1)` for every `a` in
`closure where descendantId = P`. v1 does **not** support reparenting a group
(moving it under a new parent). The schema reserves reparent (closure rebuild plus
the cycle guard "reject a new parent inside the moved subtree"); it is a follow-on.
### Rename and description edits are in scope; reparent stays out
A group's `name` and `description` are mutable by the website administrator
(`PATCH /database/api/groups/:id`, audited as `group.update`). This is deliberately
separated from reparent: renaming touches **no** closure row and cannot create a
cycle, so it carries none of the invariant risk that keeps reparent out of v1. The
endpoint therefore **rejects** a `parentId` field outright rather than ignoring it,
so a future reparent cannot arrive silently through this route. Passing an empty
`description` clears it; omitting a field leaves it unchanged.
### Restore is deliberately asymmetric with delete
Archived groups stay visible to the administrator (`GET
/database/api/groups?includeArchived=1` returns them carrying `archivedAt`; the
console tags and greys them) and can be restored (`POST
/database/api/groups/:id/restore`, audited as `group.restore`).
Restore is **not** the mirror image of delete. Delete cascades down the whole
subtree; restore un-archives **the group plus every archived ancestor of it, and
nothing below it**:
- Restoring the ancestor chain is **mandatory**, not a convenience. An active group
whose parent is archived has no path in the tree, and the `depth` derivation
(closure row count) presumes "an active group's ancestors are active" — the
invariant that cascade-delete establishes. Restoring a node alone would break it.
- The subtree is deliberately **left archived**. A group's descendants may have been
archived for reasons of their own, and one click should not silently re-grant
permission across a whole historical branch. Descendants remain visible in their
archived state and are each restored explicitly.
Restore takes effect immediately, like every other membership change (D4/G4): the
group resumes contributing permission on the next resolution.
An archived group is **readable but not writable**. Its membership rows are never
revoked by archiving, so `listMembers` succeeds on an archived group — the console
must be able to show *who was in it* before deciding whether to restore it. Every
mutation, by contrast, still requires an active group (`requireActiveGroup` → 404):
rename, child creation, and member add/remove all reject. The group is inert for
permission purposes and frozen for editing, but not hidden and not forgotten.
### Member picker reads global users, admin-only
`GET /database/api/users/search` backs the "add member" picker: it matches `User`
by display name or Feishu open id and is gated to the website administrator, the
same authority that may add members. It widens no existing capability — adding a
member already accepts **any** global user (`resolveUser` does not require an org
membership), so the endpoint only replaces blind id entry with search. It is
deliberately **not** opened to the non-admin authorization-selector audience that
`/groups/search` serves: choosing a group to grant is a Manage-holder action,
whereas enumerating people is not. `excludeGroupId` filters out the target group's
active members so the picker cannot surface a candidate that must 409.
### Audit is written in-hub
The contract (C3 §6.3) originally deferred group actions to the foreign Group
service's own audit. With the group system in-hub, group mutations are audited
through the existing file-library sink (`filelib/audit.ts`, same-transaction
`AuditEntry`) under the silo Organization — `MemberGroup` has no `organizationId`,
so the audit row is attributed to the silo org. New actions: `group.create`,
`group.update`, `group.delete`, `group.restore`, `group.member_add`,
`group.member_remove`; new audit object type `group`.
## Consequences
- The default `GroupResolver` becomes the in-hub `MemberGroup` closure reader.
`createTeamGroupResolver` is retained but deprecated (no longer wired); existing
flat-Team group grants no longer resolve for the file library.
- Group grants take effect in real time through the existing `effectiveRole`
reducer (P6) with no change to the permission algebra — only the set of group ids
fed to it changes.
- v1 omits reparent; the closure invariants above must hold whenever reparent is
added later (rebuild descendants' ancestor rows, reject cycles).
- Group management is an admin-only surface; the authorization selector is not.
- Numeric limits (max depth, max members) and a hard-delete/restore path remain
follow-on operational decisions; they must not weaken the archived-filter,
admin-authority, or live-resolution invariants fixed here.
@@ -1,132 +0,0 @@
# ADR 0029: Web Surfaces Are Static SPAs; the Hub Serves JSON Only
## Status
Accepted.
## Context
The Hub exposes three browser surfaces: the org-admin console (`/admin`), the
teacher-facing file library (`/app`), and the database admin back office
(`/database`). They arrived at different times and diverged in how HTML reached
the browser.
`/admin` and `/app` were already separated: the backend serves a prebuilt static
`index.html` and never inspects the request; all data flows through JSON
endpoints. `/database` was not. Roughly 1770 lines across four modules
(`renderDashboard`/`renderLoginPage` in `routes/databaseRoutes.ts`,
`routes/adminPanels.ts`, `routes/libraryBrowser.ts`, `routes/libraryPage.ts`)
assembled HTML template strings server-side, reading the session cookie and
querying Prisma inside the page handler, with layout expressed as inline
`style="…"` attributes and behavior as `<script>` text.
A prior migration (`12628c9`) introduced a fourth frontend project,
`hub/database-admin/`, intended to replace those pages. It was never wired up:
the concrete route `/database/dashboard` is more specific than the SPA wildcard
`/database/*`, so the server-rendered handler always won and the SPA's dashboard
was unreachable. That project's file header claimed the SPA served the dashboard
and that `/database/config` existed; neither was true. The `npm run build` script
also never built it, so the `existsSync` guard in `database/static.ts` failed on
every deploy and the shell was permanently disabled.
Duplicated visual rules were the practical cost: card padding and type sizes were
restated in each render module, and only the CSS variables in `routes/uiTheme.ts`
were genuinely shared.
## Decision
**No Hub HTTP handler renders HTML.** Every browser surface is a prebuilt static
SPA. Page handlers send a byte-identical `index.html` that does not depend on the
request; all per-user and per-request data is fetched by the client from JSON
endpoints under `/api/*` or `/database/api/*`.
**`/app` and `/database` are one frontend project, `hub/filelib-web`, built once
and mounted at two prefixes.** They share the file library browser, the session
layer, the toast host, and the design tokens; splitting them would duplicate all
of it. `hub/database-admin` is deleted — superseded before it ever served a
request.
Two configuration constraints follow from co-hosting two SvelteKit SPAs on one
Fastify instance, and are load-bearing:
- `filelib-web` sets `appDir: '_filelib'`. The SvelteKit default `_app` collides
with the root `/_app/*` asset route that `admin-web` owns
(`src/admin/static.ts`); Fastify rejects duplicate routes at startup, so the
collision is a boot failure, not a silent misroute.
- `filelib-web` sets `paths.relative: false`. The same `index.html` is served at
different URL depths (`/app`, `/database/dashboard/users`), so relative asset
paths would resolve against the wrong base.
**Client-side navigation uses real URL routes, not hash fragments or hidden
sections.** The six back-office tabs are `/database/dashboard`,
`/database/dashboard/library`, `/users`, `/groups`, `/search`, `/settings`.
Refresh preserves position and links are shareable — the previous
`location.hash` + `display:none` scheme lost both.
Concrete routes must be registered before the SPA wildcards. This is an ordering
obligation on `database/plugin.ts`, not an incidental detail: the earlier
`/database/dashboard` shadowing bug is exactly what happens when a concrete page
route outranks the fallback.
## Consequences
- Authorization is enforced only by the JSON endpoints. A client-side guard (the
`isWebsiteAdmin` check in the dashboard layout) is a navigation convenience and
carries no security weight; every endpoint keeps its own `fail closed` guard.
- `/database/api/stats` is a new endpoint carrying what `loadDashboardStats` used
to compute inline. It requires silo org `OWNER`/`ADMIN` because it aggregates
org-wide counts and the audit stream rather than a per-node permission view.
- `/database/api/me` grew `displayName` and `avatarUrl`. Anything the old page
handler read from Prisma to render chrome has to become part of a JSON payload
or it is simply unavailable: the sidebar identity strip showed a raw `userId`
until these were added. When migrating a server-rendered surface, the data the
template closed over is part of the contract being ported, not an incidental
detail of the old implementation.
- Editing a page no longer requires a Hub restart in development; `vite dev`
serves the frontend and proxies data requests to the Hub. In production the
`index.html` is cached in memory at startup, so a frontend rebuild does require
a restart.
- Deploy scripts and the silo rate-limit exemption list name `filelib-web` and
`/_filelib/*`. Adding a fourth surface means picking another `appDir` and
extending that list.
- The design system is one file, `filelib-web/src/app.css`: an `@theme` block for
tokens plus an `@layer components` block for the shared component classes
(`.btn`, `.panel`, `.input`, `.select`, `.list`, `.tag`, `.quiet`, …).
`routes/uiTheme.ts` is deleted; both halves live there now.
The first cut of this migration kept only the tokens and restated button,
input, and panel styling inline in every component. That reproduced the
duplication the old code had — the admin panels visibly regressed — so the
component layer was ported too. Components carry layout utilities; they do not
restate component styling. The one admitted exception is a data-derived value
(tree indent computed from `depth`), which cannot be a static class.
The icon set (`lib/Icon.svelte`, 13 paths) is likewise shared rather than
restated. It came from `adminPanels.ts`; Group nodes deliberately use a
two-person silhouette, not a folder glyph, because `MemberGroup` and the file
library's `FOLDER`/`PROJECT` are unrelated hierarchies (ADR-0028, ADR-0021).
- **A migrated surface is only done when its endpoint coverage matches.** Two
panels were rebuilt from a superficially similar component that predated the
migration rather than from the server module they replaced, and the mismatch
was invisible in the rendered page:
- Group management called 5 of 8 endpoints. Rename (`PATCH`),
`?includeArchived=1`, `/restore`, and `/users/search` had no entry point, so
a soft-deleted group could not be restored through the UI at all even though
the backend fully supported it.
- The library browser dropped the `授权` tab entirely — `GET/PUT/DELETE
.../grants` and `PUT .../independent-permission` had no caller. Permission
editing is the point of the back office, and it was unreachable.
Diffing the route table against the frontend's `api()` call sites catches this;
reading the new page does not.
## Deferred
- `/admin` (admin-web) stays a separate project. It has its own design language
(`saas-*` classes, `surface-*`/`primary-*` scales) and a different audience;
merging it is not motivated by shared code.
- The `search` and `settings` tabs remain placeholders, as they were server-side.
- Serving `/admin` and `/database` from a single SPA, which would remove the
`appDir` collision constraint entirely.
@@ -1,157 +0,0 @@
# ADR 0030: The File Library VersionStore Is a Real Git Repository per Project
## Status
Accepted.
## Context
The file library (`hub/src/database/filelib/`, an independent subsystem that does
not reuse the Hub's own `Folder`/`Project` tree from ADR-0021) stores each project
as a versioned file tree behind the `VersionStore` port (contract C1). Until now
the only implementation was `createInMemoryVersionStore`: a `Map` of per-file
version chains, with `VersionId` as a per-repository monotonic counter
(`v1`, `v2`, …), a hand-written line differ, and an optional JSON snapshot of the
entire storage root written to `<storageRoot>/.version-store.json` so that a
process restart did not lose the demo data.
Two things about the surrounding design were already settled in code and are
confirmed here rather than changed:
- **A `FOLDER` node has no on-disk existence.** `FileLibNode.storageDir` is
`NULL` for folders. The tree is `parentId` plus the `pathIds` materialized path;
nothing in the filesystem mirrors it.
- **Projects are flat under one root, keyed by id.** `storageDir` is
`<storageRoot>/<nodeId>` where `nodeId` is a `randomUUID()`. Names never enter
the path, which is why `renameNode` touches no disk state and does not rewrite
descendant paths.
What was never true is the part the names implied. `HUB_FILELIB_STORAGE_ROOT` was
documented as "the project git repository root" and `fileService` was documented
as observing a "git first, then audit" ordering, but no code in the repository
ever invoked git. `versionStore.init(storageDir)` inserted a `Map` entry; the
directory was never created. Every project's entire content and history lived in
one process-global JSON file. The header comment and `README.md` both marked this
as a placeholder awaiting an npm package from the versioning team.
That package has not arrived, and the in-memory store's properties are not
acceptable for real teacher data: a corrupt or lost `.version-store.json` loses
every project at once, the whole storage root is rewritten on every commit, and
`VersionId` values are meaningless outside the process that minted them.
## Decision
**Each file library project is a real Git repository at
`<storageRoot>/<nodeId>`.** `VersionStore.init` creates the directory and runs
`git init` there. This is the production implementation;
`createInMemoryVersionStore` is retained for tests only.
**`VersionId` is a Git commit hash.** The full 40-hex object name, as printed by
`git rev-parse`. It is no longer a per-repository counter.
**File-level versioning (D16) maps onto commit history as follows.** A write
touches exactly one path and produces exactly one commit. The version of a file is
the hash of the most recent commit that modified that path — `git log -1 --
<path>`. Consequently:
- Two files in one project have independent versions, because a commit that
touches `a.md` does not appear in `git log -- b.md`. This preserves the D16
property that advancing one file does not invalidate another file's
`baseVersion`, even though commits are repository-global objects.
- `baseVersion` checking (S1/S2) compares the caller's id against the current
per-file version. `baseVersion: null` means create, and conflicts if the path
already exists at `HEAD`.
- Reading version `V` of a path means `git show V:<path>`, which is the content as
of that commit, not the content the commit introduced to some other file.
**Deletion is a commit, not a tombstone record.** `remove` runs `git rm` and
commits, so the path is absent from `HEAD` and `list` stops reporting it, while
`git show <olderVersion>:<path>` still resolves. The in-memory store expressed
this as a `deleted: true` chain entry; the observable API semantics are the same.
**Git is invoked as a subprocess, not through a library.** `node:child_process`
`execFile` with an argument array, no new npm dependency. Every invocation is
hardened, and the hardening is load-bearing rather than incidental:
- `-c core.hooksPath=` and `-c commit.gpgsign=false`, plus
`GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null`. A project
repository is *data*, uploaded by teachers. Without this, a committed
`.git/hooks/` entry or a developer's global `gitconfig` would execute or alter
server-side behavior.
- `GIT_LITERAL_PATHSPECS=1` and `--` before every path, so a filename is never
reinterpreted as an option or as pathspec magic (`:(glob)`).
- `GIT_TERMINAL_PROMPT=0`, so a repository never blocks a request waiting on
credentials.
- Author identity is passed per-commit via `GIT_AUTHOR_*`/`GIT_COMMITTER_*`
environment variables, never written into the repository's config. The git
author name is the acting user's `displayName` (falling back to `userId` when
absent), and the email is `<userId>@filelib.paradigm-edu.net`. The email
deliberately keys on `userId` rather than the display name, because nicknames
change and identity attribution must not drift with them. Characters that would
break git's ident line (`<`, `>`, newlines) are stripped from the name.
- `--git-dir=<projectDir>/.git` and `--work-tree=<projectDir>` are pinned on
every invocation, and `GIT_DIR`/`GIT_WORK_TREE`/`GIT_INDEX_FILE`/
`GIT_OBJECT_DIRECTORY` are removed from the child environment. Git otherwise
searches *upward* for a `.git`, and the storage root is frequently nested inside
another repository — the local development default `hub/.filelib-repos` sits
inside this very repo. Without pinning, operations on a project directory that
has no repository of its own silently retarget the enclosing repository.
Existence is therefore tested on the filesystem (`<projectDir>/.git`), not with
`git rev-parse --git-dir`, which merely echoes a pinned value back.
**Writes to one repository remain serialized in-process**, as under S4, because
concurrent git invocations contend on `index.lock`. This is a single-process
guarantee only; see Consequences.
## Consequences
- `.version-store.json` is not read or migrated by the new store. Existing
development data under `HUB_FILELIB_STORAGE_ROOT` does not appear in the git
store; those projects report `repo_not_found` until recreated. No production
data exists to migrate, since the in-memory store was never production-viable.
- `VersionId` changes shape in API responses (`GET .../files/*`, history, and the
409 `currentVersion` detail). Clients must keep treating it as an opaque
string; `filelib-web` already does.
- `VersionInfo.author` now comes back as the git author name, which is the acting
user's display name at commit time (or the `userId` when no display name is
known). Commits written without an author carry a fixed `filelib` identity
rather than `undefined`. Display names are point-in-time: renaming a user does
not rewrite existing commits, and the stable identifier stays in the email.
- `VersionStore.commit`/`remove` take a structured `CommitAuthor`
(`{ userId, displayName? }`) rather than a bare author string, so the port can
express both the stable key and the display label. Deletion carries the same
identity as any other commit.
- Serialization is per-process. Two Hub processes sharing a storage root can race
on the same repository and surface a git lock error rather than a clean
conflict. The alpha Silo deployment (ADR-0025) is one process per organization,
so this is not currently reachable; a multi-process deployment needs either a
database advisory lock keyed by project id or a single writer.
- `git` must be present on the host. Absence is a startup-visible failure of
project creation (`provision_failed`), not a silent degradation.
- Repository content is now attacker-influenced data on disk. The path validation
in `fileService.validateFilePath` (rejecting `..`, `.git`, absolute paths,
control characters) moves from hygiene to a security boundary, and
`versionStore` re-checks it rather than trusting callers.
## Alternatives considered
- **`isomorphic-git` or `simple-git`.** Both add a dependency to carry work that
three `execFile` calls do. `isomorphic-git` additionally reimplements the object
layer, so its bugs would be ours to diagnose.
- **One commit per repository state, with the repository head as the version.**
Simpler mapping, but it breaks D16: any write would invalidate every other
file's `baseVersion`, turning independent edits into false conflicts.
- **Keeping the counter as `VersionId` alongside git.** Requires a durable
counter-to-hash mapping outside git, which is the state the decision removes.
- **Bare repositories with a git index-only write path.** Avoids a working tree,
but every read and write becomes plumbing (`hash-object`, `update-index`,
`commit-tree`), for no benefit at this scale.
## Deferred
- Cross-process write serialization (advisory lock keyed by project id).
- Garbage collection and pack maintenance policy for long-lived repositories.
- Whether export builds (`exportService`) should read a git tree directly instead
of going through the `listFiles`/`readFile` port.
- Recovering `provisionStatus=FAILED` projects by re-running `init`; the status
machine records the failure but nothing retries it yet.
@@ -1,43 +0,0 @@
# ADR 0030: Project Grants Are Always Live; The Independent-Permission Toggle Is Removed
## Status
Accepted. Supersedes the file-library contract rule **D11 / P5** (《文件库-接口契约.md》,
since deleted; recoverable from git history) which introduced the per-project
"独立权限" (independent permission) switch.
## Context
D11 gave each PROJECT a toggle (`FileLibProjectSettings.independentPermissionsEnabled`,
default off). While off, project-level non-creator grants were **frozen** — present in
`FileLibGrant` but excluded from `effectiveRole`; ancestor-chain grants and the creator's
auto-grant were unaffected. The intent was to support two workflows: "project follows the
folder's ACL" (off) vs "project has its own ACL" (on).
In practice the toggle surprised operators twice: grants appeared to "not work" until
someone found and flipped a per-project switch buried in the 概览 tab, and the frozen state
was indistinguishable from missing grants in the UI. The product decision is that
project-level grants should simply always be live.
## Decision
- **Project-level grants always participate in `effectiveRole`.** The freeze branch in
`hub/src/database/filelib/permission.ts` is deleted; `EffectiveRoleInput` no longer
carries `independentPermissionsEnabled`.
- **The toggle surface is removed end-to-end**: `PUT /database/api/projects/:id/independent-permission`,
`grantService.setIndependentPermission`, the `independentPermission` field in the node
detail DTO, and the 概览 tab switch in `filelib-web`.
- **`FileLibProjectSettings` becomes vestigial.** The table stays (existing rows are
ignored, no data migration); new projects no longer get a default row. It may be dropped
in a future migration once nothing references it.
- Audit action vocabulary `independent_enable` / `independent_disable` is retained for
reading historical audit entries; no new entries are produced.
Behavior change for existing deployments: projects whose toggle was off now have their
project-level grants effective immediately — this is the intended effect of the decision.
## Consequences
- Permission semantics shrink to the single P6 rule: `effective = max(grants on self
ancestors for user resolved groups)`, no exceptions by node kind.
- One less state dimension in tests and in the admin UI.
@@ -1,58 +0,0 @@
# ADR 0031: File Library Recycle Bin And Recent-Visit Tracking
## Status
Accepted.
## Context
The teacher app (`/app`) gains a left navigation rail with three entries: 文件库 /
最近打开 / 回收站. Two of them need semantics that no prior decision covers:
- **回收站 (recycle bin)**: D15 defined soft delete (mark `deletedAt` on the node only;
a node is invisible when any ancestor is deleted) but never defined listing, restore,
or permanent deletion.
- **最近打开 (recent visits)**: nothing tracks opens.
## Decision
### Recycle bin
- **List**: shows nodes with `deletedAt != null` whose **ancestors are all active**
(the topmost deleted node per branch; descendants of a deleted node are represented
by it and not listed separately).
- **Visibility/auth**: a bin entry is visible to (a) the website administrator, or
(b) any actor holding an active MANAGE grant **on the deleted node itself**
(grants stay live through soft delete, so this is a plain grant query — no chain
walk, no inheritance; the bin is a management surface, not a browsing surface).
- **Restore** clears `deletedAt` on that node only (D15 symmetry: delete marks one
node, restore unmarks one node). The subtree becomes visible again immediately.
Same auth as the list entry. Audited (`folder_restore` / `project_restore`).
- **Permanent delete (彻底删除)** is **website-administrator only**: hard-deletes the
node **and its whole subtree** (descendants enumerated via the `pathIds` materialized
path, deleted deepest-first because the self-FK is `ON DELETE RESTRICT`), in one
transaction, with one audit entry (`node_purge`, detail carries removed count).
Grants/settings/export-jobs cascade. There is no recovery; the UI must confirm
explicitly.
### Recent visits
- **Model**: `FileLibRecentVisit(organizationId, userId, nodeId, filePath, openedAt)`,
unique on `(organizationId, userId, nodeId, filePath)` with `filePath` defaulting to
`""` (Postgres unique indexes treat NULLs as distinct). `filePath = ""` means the
visit is the node itself (drill into folder/project); non-empty means a file preview
inside that project.
- **Recording is client-driven**: the teacher app POSTs after a successful open
(folder drill, project open, file preview). The endpoint requires VIEW on the node
(D8: no VIEW → 404, leaking nothing). Upsert semantics: re-opening refreshes
`openedAt`. No audit entries — this is a per-user read model, not a权限-sensitive
mutation.
- **List**: the actor's own most recent 20, `openedAt` desc. Entries whose node is
deleted **or has any deleted ancestor** are filtered out (D8/D15 visibility holds
on every surface). Names are read live from `FileLibNode` (no denormalization).
## Consequences
- No change to existing permission algebra; both features are additive surfaces.
- The bin deliberately does not offer per-owner bins or inherited-MANAGE visibility —
if real usage demands it, that is a new decision.
@@ -1,30 +0,0 @@
# ADR 0032: Remove The Recent-Visit Module
## Status
Accepted. **Supersedes the "Recent visits" half of ADR-0031** (the recycle-bin half
is unaffected and remains in force).
## Context
ADR-0031 (same day) introduced 最近打开: a `FileLibRecentVisit` table, client-driven
visit recording, and a rail entry in the teacher app. After seeing it live, the product
call is that the module is not wanted — it adds a tracking surface, a table, and rail
noise without a compelling teacher workflow behind it.
## Decision
The recent-visit module is removed end-to-end:
- `FileLibRecentVisit` is dropped (hand-written migration
`20260731090000_drop_filelib_recent_visit`; the table was created the same day and
held no production data).
- `recentService` / `recentRoutes` (`/database/api/recent`) and the `RecentView`
component are deleted; the rail in `/app` keeps only 文件库 / 回收站.
- `GridLibraryView` visit recording and the `navTarget` navigation entry go with it.
- The `role` field added to breadcrumb entries for ADR-0031 is **kept** — it is a
cheap, additive field on an existing API and independent of the removed module.
If recent-visit tracking comes back as a requirement, it is a new decision (and
should then define why client-driven tracking is worth its surface) rather than a
revival of this one.
@@ -1,26 +0,0 @@
# ADR 0033: Restore De-Duplicates The Node Name On Sibling Conflict
## Status
Accepted.
## Context
ADR-0031 defined restore as "clear `deletedAt` on that node only". It did not cover
the case where a same-name sibling was created **after** the deletion: D14's partial
unique index (active siblings, case-insensitive) then rejects the restore with a 409
`conflict`, leaving the entry permanently stuck in the bin — unrecoverable for
non-admin users (who cannot purge) and cryptic for admins.
## Decision
Restore never fails on a name conflict. Before clearing `deletedAt`, the service
checks active siblings; if the node's name is taken, it restores as
`原名(已恢复)`, then `原名(已恢复 2)`, …, first free key wins (suffix is included
in the `NODE_NAME_MAX_LENGTH` budget by truncating the base). The rename is part of
the same transaction and is recorded in the restore audit entry as
`{ name, renamedFrom }`. The API returns the final name so the UI can tell the user.
Rationale: the bin's purpose is recovery; a restore that can deadlock on naming is a
trap, not a safeguard. Users who care about the name can rename afterwards (they have
MANAGE by definition of bin visibility).
-28
View File
@@ -1,28 +0,0 @@
# ADR 0034: Permanent Delete Follows MANAGE, Not Website Administrator
## Status
Accepted. **Supersedes one clause of ADR-0031**: "Permanent delete (彻底删除) is
website-administrator only".
## Context
ADR-0031 gated 彻底删除 to the website administrator as a high-risk-operation
precaution. The product call is that this is inconsistent with the rest of the
permission model: soft delete already requires only MANAGE on the node, and a
MANAGE holder who can delete a node into the bin should also be able to purge it —
the authority that grants deletion grants destruction. Admin-only purge strands
non-admin managers with bins they cannot empty.
## Decision
Permanent delete uses **the same visibility rule as the bin entry itself**: website
administrator, or an actor with an active MANAGE grant on the deleted node (direct
grant, USER or resolved GROUP). Anyone else gets 404 (D8). The double confirmation
in the UI and the `node.purge` audit entry are unchanged.
## Consequences
- Purge auth = restore auth = bin-entry visibility: one rule, three surfaces.
- The operation remains irreversible and audited; no new capability is granted to
anyone who could not already delete the node (soft) and see it in the bin.
@@ -1,20 +0,0 @@
# ADR 0035: Restore Keeps The Original Name; Conflict Is A Clear Error
## Status
Accepted. **Supersedes ADR-0033** (restore de-duplicates the node name on sibling
conflict).
## Context
ADR-0033 made restore auto-rename to `原名(已恢复)` on sibling name conflict so
restore never fails. In practice the suffix is unwanted noise - operators expect the
original name back and prefer to resolve conflicts themselves.
## Decision
Restore clears `deletedAt` and keeps the node's **original name**. If an active
sibling now occupies the same name (D14 partial unique index), the service throws a
`409 name_conflict_on_restore` with a human-readable message ("同名节点已存在,请先
重命名现有节点再恢复") - no silent renaming, no suffix. The restore audit records
the original name only.
@@ -1,193 +0,0 @@
# ADR 0036: 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
@@ -1,167 +0,0 @@
# ADR 0037: 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.
+1 -4
View File
@@ -32,7 +32,7 @@ App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
| 以应用身份发送消息 | `im:message:send_as_bot` |
| 读取触发消息和线程上下文 | `im:message:readonly` |
| 获取消息中的图片/文件,并向飞书上传图片或文件(含 Agent 回答中的图片发送) | `im:resource` |
| 获取与上传图片或文件 | `im:resource` |
| 添加、删除消息表情回复 | `im:message.reactions:write_only` |
| 获取用户基本信息 | `contact:user.base:readonly` |
| 获取用户基本资料 | `contact:user.basic_profile:readonly` |
@@ -66,9 +66,6 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
说明:`im:resource` 既用于下载用户发来的图片/文件,也用于 Agent 回复时把本地或远程图片上传为飞书 `image_key` 后嵌入消息卡片。缺少该权限时,带图回答会发送失败或降级为无图文本。已开通该 scope 的存量应用一般无需新增权限,但若权限尚未随最新版本发布,请创建新版本并审核发布。
## 4. 配置事件与卡片回调
进入“事件与回调”。
+34 -48
View File
@@ -1,4 +1,4 @@
// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0036).
// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011).
//
// This is a *real, editable* file that lives in an engineering file at
// `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the
@@ -15,16 +15,15 @@
// 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-0036).
// template loads content and hands cph-render an already-assembled `parts` array.
//
// OPEN CONTRACT POINT — optional-content presence. typst has no "does this file
// exist" primitive (a missing `include` is a hard compile error). So the
// template CANNOT probe disk the way the old Rust driver did for lemma `proof`.
// It relies on the manifest declaring which optional content fields are present,
// via a per-element `fields` array listing the content fields that exist on disk
// via a per-part `fields` array listing the content fields that exist on disk
// (the engine knows this — it walks the part dir). Required fields are loaded
// unconditionally; optional fields load only if listed in `fields`. If an element
// unconditionally; optional fields load only if listed in `fields`. If a part
// omits `fields`, optional content is skipped (conservative). The exact shape of
// this declaration is for the manifest/Rust contract to pin.
@@ -36,51 +35,38 @@
// 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: ())
#let raw-parts = manifest.at("parts", 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-0036): 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)
// Assemble each part: include its content fields (computed absolute paths,
// resolved against --root) and read scalar fields from <path>/element.toml.
// `part-fields` (from cph-render) is the single source of truth for kind->fields.
#let parts = raw-parts.map(raw => {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ())
let part = (kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content {
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
// Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ")
}
// Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content {
if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ")
}
}
// Scalar fields come from <path>/element.toml.
if spec.scalars.len() > 0 {
let element = toml("/" + path + "/element.toml")
for field in spec.scalars {
let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) }
}
}
part
})
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
@@ -88,6 +74,6 @@
#render-lesson(
info: info,
target: target,
outline: outline,
parts: parts,
heading-numbering: default-heading-numbering,
)
+31 -40
View File
@@ -1,4 +1,4 @@
// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0036).
// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011).
//
// Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with
// the manifest injected:
@@ -18,47 +18,38 @@
// 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: ())
#let raw-parts = manifest.at("parts", default: ())
// Assemble each outline entry: an "element" entry includes its content fields
// and reads scalars from element.toml; a "section" entry (ADR-0036) passes
// title/depth straight through as a heading, no content to load.
#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)
// Assemble each part: include its content fields (computed absolute paths,
// resolved against --root) and read scalar fields from <path>/element.toml.
// `part-fields` (from cph-render) is the single source of truth for kind->fields.
#let parts = raw-parts.map(raw => {
let kind = raw.at("kind", default: none)
let path = raw.at("path", default: none)
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
// Which optional content fields are present on disk (manifest-declared).
let present = raw.at("fields", default: ())
let part = (kind: kind)
// Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content {
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
// Required content fields: <path>/<field>.typ (absolute, root-relative).
for field in spec.content {
part.insert(field, include "/" + path + "/" + field + ".typ")
}
// Optional content fields: only when the manifest says the file exists.
for field in spec.optional-content {
if field in present {
part.insert(field, include "/" + path + "/" + field + ".typ")
}
}
// Scalar fields come from <path>/element.toml.
if spec.scalars.len() > 0 {
let element = toml("/" + path + "/element.toml")
for field in spec.scalars {
let v = element.at(field, default: none)
if v != none and v != "" { part.insert(field, v) }
}
}
part
})
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
@@ -66,6 +57,6 @@
#render-lesson(
info: info,
target: target,
outline: outline,
parts: parts,
heading-numbering: default-heading-numbering,
)

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