From 3a22669f0ac4b8effaa67637053084092384acc4 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Sun, 21 Jun 2026 22:00:18 +0800 Subject: [PATCH 01/29] chore: scaffold monorepo with spec/ Lean project and governance docs - spec/: freshly initialized Lean library project (lake init, v4.31.0), no deps - .gitea/workflows/spec-check.yml: CI running lake build on spec/ (well-formedness gate, not a spec-to-impl gate) - README.md: monorepo overview + the 5 "constitution" rules positioning spec/ as upstream semantic master - CLAUDE.md: global agent manual - spec/README.md: contract writing conventions (prose+type, PINNED/OPEN/ADR tags, no sorry) - docs/adr/0001-0004: system-level decision records (retrieved from main) Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/spec-check.yml | 20 +++++++ .gitignore | 6 ++ CLAUDE.md | 20 +++++++ README.md | 42 ++++++++++++++ ...01-project-group-is-collaboration-space.md | 29 ++++++++++ .../0002-project-lock-owned-by-agent-run.md | 30 ++++++++++ docs/adr/0003-on-demand-feishu-context.md | 29 ++++++++++ docs/adr/0004-feishu-docs-like-permissions.md | 54 ++++++++++++++++++ spec/.gitignore | 1 + spec/README.md | 55 +++++++++++++++++++ spec/Spec.lean | 3 + spec/Spec/Basic.lean | 1 + spec/lake-manifest.json | 6 ++ spec/lakefile.toml | 6 ++ spec/lean-toolchain | 1 + 15 files changed, 303 insertions(+) create mode 100644 .gitea/workflows/spec-check.yml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 docs/adr/0001-project-group-is-collaboration-space.md create mode 100644 docs/adr/0002-project-lock-owned-by-agent-run.md create mode 100644 docs/adr/0003-on-demand-feishu-context.md create mode 100644 docs/adr/0004-feishu-docs-like-permissions.md create mode 100644 spec/.gitignore create mode 100644 spec/README.md create mode 100644 spec/Spec.lean create mode 100644 spec/Spec/Basic.lean create mode 100644 spec/lake-manifest.json create mode 100644 spec/lakefile.toml create mode 100644 spec/lean-toolchain diff --git a/.gitea/workflows/spec-check.yml b/.gitea/workflows/spec-check.yml new file mode 100644 index 0000000..8e862dc --- /dev/null +++ b/.gitea/workflows/spec-check.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec17f5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Lean / Lake build artifacts (spec/ has its own .gitignore too) +.lake/ +**/.lake/ + +# OS / editor +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a076d38 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,20 @@ +# CLAUDE.md —— agent 操作手册(全 repo) + +本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。 + +## 这个 repo 是什么 + +- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。 +- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。 + +## 纪律 + +1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。 + +2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。 + +3. **改 `spec/` 必须保持其 `lake build` 通过。** 在 `spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。 + +4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。 + +5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f43a2a6 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# curriculum-project-hub + +教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。 + +这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。** + +## 仓库布局 + +``` +README.md ← 本文件:总览 + 宪法(下面 5 条) +CLAUDE.md ← 全局 agent 操作手册(管整个 repo) +docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用) +spec/ ← Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md +(hub/ checker/ …) ← 将来的各部件实现,平级于 spec/。尚未创建 +``` + +`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。 + +## 宪法 + +这 5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。 + +1. **角色 —— Lean 是研发侧的上游参照。** + `spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来。 + +2. **对齐机制 —— Lean 只做上游参照。** + 不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。 + (CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。) + +3. **资产性 —— 由 review 纪律承载,无机器兜底。** + 这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档。 + +4. **形态 —— 它是人机共识的契约。** + 契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞。 + +5. **深度判据 —— 只收录分歧点。** + 一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。 + 深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。 + +## CI + +`.gitea/workflows/spec-check.yml` 在每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。 diff --git a/docs/adr/0001-project-group-is-collaboration-space.md b/docs/adr/0001-project-group-is-collaboration-space.md new file mode 100644 index 0000000..85de1df --- /dev/null +++ b/docs/adr/0001-project-group-is-collaboration-space.md @@ -0,0 +1,29 @@ +# ADR 0001: Feishu Project Group Is The Collaboration Space + +## Status + +Accepted. + +## Context + +Teachers need to work on multiple curriculum projects at the same time. The previous mental model tied a teacher to one active project session, which makes multi-open workflows awkward and turns "exit project" into a confusing concept. + +The revised product model is: + +- one project has one long-lived Feishu project group; +- teachers discuss freely in the group; +- Claude does not process every group message; +- a teacher explicitly `@Claude` when Claude should join the current context and do work. + +## Decision + +Treat the project group as a long-lived collaboration space, not as a lock owner and not as a temporary processing session. + +The group can stay open while no Claude processing is active. A teacher leaving or muting a group is separate from project permissions and from Claude processing lifecycle. + +## Consequences + +- Multi-open is natural: a teacher participates in multiple project groups. +- Normal group discussion does not occupy the project. +- "Exit project" should not be the normal action in the group workflow. +- Project rebinding or group archival needs explicit product rules, separate from run cancellation. diff --git a/docs/adr/0002-project-lock-owned-by-agent-run.md b/docs/adr/0002-project-lock-owned-by-agent-run.md new file mode 100644 index 0000000..8dd3f8c --- /dev/null +++ b/docs/adr/0002-project-lock-owned-by-agent-run.md @@ -0,0 +1,30 @@ +# ADR 0002: Project Lock Is Owned By AgentRun + +## Status + +Accepted. + +## Context + +The older model effectively made a long-lived teacher/project session hold the project lock. That breaks down once project groups become long-lived collaboration spaces and Claude is invoked on demand. + +We need to prevent concurrent Claude executions from modifying the same project, while still allowing teachers to keep discussing the project in the group. + +## Decision + +Use a project-scoped lock whose owner is the current `AgentRun`. + +```text +scope = project_id +owner = run_id +lifetime = one Claude processing task +``` + +The lock is acquired after `@Claude` creates an `AgentRun`, and released when the run completes, fails, times out or is canceled. + +## Consequences + +- Group membership, teacher identity and Claude provider session do not directly own the lock. +- The same long-lived `AgentSession` can be reused by many runs. +- `cancel current processing` releases the lock by `run_id`. +- Admin `force release` remains an exceptional operation for stuck runs or lost processes. diff --git a/docs/adr/0003-on-demand-feishu-context.md b/docs/adr/0003-on-demand-feishu-context.md new file mode 100644 index 0000000..1d48882 --- /dev/null +++ b/docs/adr/0003-on-demand-feishu-context.md @@ -0,0 +1,29 @@ +# ADR 0003: Read Feishu Context On Demand + +## Status + +Proposed. + +## Context + +The system needs to give Claude enough context when a teacher invokes it from a project group. Storing full Feishu group history in the Hub adds retention and privacy concerns, while fixed time windows such as "recent 24 hours" are brittle. + +Feishu provides message history APIs, so the Hub can keep stable anchors and read nearby context when needed. + +## Decision + +For the first architecture, the Hub stores minimal anchors and project memory instead of full Feishu message history: + +- chat id and project binding; +- trigger message id; +- run id and status card id; +- reply/thread anchors; +- project memory, decisions and artifact references. + +Claude gets seed context from the current trigger message, project memory and recent anchors. If more context is needed, Claude calls MCP tools to read current message, reply chain, thread history or recent group messages through Feishu APIs. + +## Consequences + +- The Hub avoids becoming a full chat history store. +- Search over old vague discussions is limited until we add summary or indexing. +- MCP tools must authorize by run/project context; Claude cannot pass arbitrary chat ids. diff --git a/docs/adr/0004-feishu-docs-like-permissions.md b/docs/adr/0004-feishu-docs-like-permissions.md new file mode 100644 index 0000000..61ea6be --- /dev/null +++ b/docs/adr/0004-feishu-docs-like-permissions.md @@ -0,0 +1,54 @@ +# ADR 0004: Use Feishu Docs-like Permission Grants + +## Status + +Proposed. + +## Context + +Project collaboration needs more nuance than a hard-coded `owner / teacher / admin` model. Teachers may need to view a project, discuss it, trigger Claude, edit artifacts, manage members, or handle exceptional cancellation. These are related but not identical privileges. + +Feishu cloud docs separates collaborators from operation settings: + +- collaborators can be users, groups, departments, user groups, apps, or wiki members; +- collaborator roles are read, edit, or manage; +- settings separately control sharing outside the organization, copying/downloading, commenting, and collaborator management. + +That split maps well to Curriculum Project Hub. + +## Decision + +Model project permissions as: + +```text +PermissionGrant +- resource_type: project | artifact | project_group +- resource_id +- principal_type: user | feishu_chat | department | user_group | app +- principal_id +- role: read | edit | manage + +PermissionSettings +- resource_type +- resource_id +- external_share_policy +- comment_policy +- copy_download_policy +- collaborator_management_policy +- agent_trigger_policy +- agent_cancel_policy +``` + +Use role-derived capabilities for ordinary actions, and settings for policy knobs: + +- `read`: view project, artifacts, run history and project group metadata. +- `edit`: read plus discuss, comment, edit artifacts, trigger Claude runs and answer Claude choice cards. +- `manage`: edit plus manage collaborators, project settings, group binding and normal run cancellation policy. +- admin-only override: force release stuck locks and perform exceptional audit operations. + +## Consequences + +- We can grant access to a teacher, a whole Feishu project group, a department, or a custom user group using the same model. +- Claude invocation is explicit policy, not just "anyone in the group can run it" forever. +- "Who can cancel current processing" can be different from "who can force release a stuck lock". +- The model stays close to Feishu users' expectations without copying every Feishu-specific API detail. diff --git a/spec/.gitignore b/spec/.gitignore new file mode 100644 index 0000000..bfb30ec --- /dev/null +++ b/spec/.gitignore @@ -0,0 +1 @@ +/.lake diff --git a/spec/README.md b/spec/README.md new file mode 100644 index 0000000..851b345 --- /dev/null +++ b/spec/README.md @@ -0,0 +1,55 @@ +# spec —— Lean 语义母本 + +这是本 monorepo 的**契约**:产品各部件语义的上游参照,用 Lean 编写。它的定位与约束见仓库根 `README.md` 的"宪法"5 条——本文件只讲**怎么往这份契约里写东西**。 + +## 现状 + +刚初始化的 Lean 工程(`lake init`),目前只有占位内容(`Spec/Basic.lean`)。实质领域内容(System 平台层、Courseware 产品层)将逐个概念加入,每个都遵循下面的规范。 + +## 构建 + +```sh +cd spec +lake build +``` + +工具链锁定在 `lean-toolchain`(`leanprover/lean4:v4.31.0`)。无外部依赖——Mathlib / Batteries 等留待第一个真正需要它的定理出现时再引入(依赖碰到再加)。 + +## 写作规范 + +### 双半契约:prose + type + +每个 top-level 声明**必须**带 `/-- … -/` doc 注释,用自然语言陈述其语义意图。两半缺一不可: + +- **prose 半**给人读——说清"这在领域里是什么、为什么"。 +- **type 半**给机器读、给 type checker 把关——保证结构无洞。 + +agent 不得用预训练先验脑补本领域(领域很新,无先验);prose 是 agent 理解语义的唯一权威来源。 + +### 标签分类法 + +在 doc 注释里用以下标签标注每条语义的状态: + +- **`PINNED`** —— 已解决的分歧点,契约在此处权威,双方据此对齐。 +- **`OPEN`** —— 故意未规定。双方均**不得假设**其解;实现遇到时必须 surface 出来讨论,而不是擅自决定。 +- **`ADR-NNNN`** —— 链接到根 `docs/adr/` 下的对应决策记录(如 `ADR-0002`),交代该语义的决策出处。 + +### 分歧点测试(写之前先过一遍) + +新增任何概念前,先问:**"不写明,开发者与 agent 会不会各自做出不同假设?"** + +- 会 → 它是分歧点,入契约。 +- 不会(显然的东西 / 纯 plumbing / 普通 CRUD 字段)→ 不入。 + +详见根 README 宪法第 5 条。 + +### 不用 `sorry` + +无法陈述清楚的东西,用 `OPEN` 在 prose 里标注,而**不是**用 `sorry` 留一个假装成立的定理。`sorry` 会让 `lake build` 仍然变绿,却在契约里埋一个谎——这与"契约自包含、无洞"直接冲突。 + +### 命名 + +- **模块 / 命名空间**:PascalCase,对应分层,如 `Spec.System.Run`、`Spec.Courseware.Validity`。 +- **类型**:PascalCase。 +- **谓词 / `Prop`**:用意图清晰的命名,如 `Legal…`、`ValidTransition`、`Can…`。 +- **文件粒度**:原则上"一个带独立不变式的概念一个文件"。 diff --git a/spec/Spec.lean b/spec/Spec.lean new file mode 100644 index 0000000..9cdeec1 --- /dev/null +++ b/spec/Spec.lean @@ -0,0 +1,3 @@ +-- This module serves as the root of the `Spec` library. +-- Import modules here that should be built as part of the library. +import Spec.Basic diff --git a/spec/Spec/Basic.lean b/spec/Spec/Basic.lean new file mode 100644 index 0000000..99415d9 --- /dev/null +++ b/spec/Spec/Basic.lean @@ -0,0 +1 @@ +def hello := "world" diff --git a/spec/lake-manifest.json b/spec/lake-manifest.json new file mode 100644 index 0000000..d041e82 --- /dev/null +++ b/spec/lake-manifest.json @@ -0,0 +1,6 @@ +{"version": "1.2.0", + "packagesDir": ".lake/packages", + "packages": [], + "name": "Spec", + "lakeDir": ".lake", + "fixedToolchain": false} diff --git a/spec/lakefile.toml b/spec/lakefile.toml new file mode 100644 index 0000000..6db46dc --- /dev/null +++ b/spec/lakefile.toml @@ -0,0 +1,6 @@ +name = "Spec" +version = "0.1.0" +defaultTargets = ["Spec"] + +[[lean_lib]] +name = "Spec" diff --git a/spec/lean-toolchain b/spec/lean-toolchain new file mode 100644 index 0000000..18640c8 --- /dev/null +++ b/spec/lean-toolchain @@ -0,0 +1 @@ +leanprover/lean4:v4.31.0 From 4a828e07b958d48a5d4f305623bfc10832dbadb3 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Sun, 21 Jun 2026 23:11:47 +0800 Subject: [PATCH 02/29] feat(spec): add lesson model contract (ADR-0005) First product-core concept in the semantic master spec: a curriculum engineering file is one lesson, modeled as an ordered sequence of typed element instances over an open kind universe. - docs/adr/0005: lesson model decision narrative + deferred OPEN items - spec/Spec/Courseware/Lesson.lean: Primitives carrier (KindId/ElementData/ TargetId/RenderRule kept abstract), Element, Lesson, RenderConfig, and the first checker seed (WarnsIgnored: used-kind-with-no-render-rule => warning) - wire Spec.lean -> Spec.Courseware; drop placeholder Basic.lean Co-Authored-By: Claude Opus 4.8 (1M context) --- ...s-ordered-composition-of-typed-elements.md | 131 ++++++++++++++++++ spec/Spec.lean | 2 +- spec/Spec/Basic.lean | 1 - spec/Spec/Courseware.lean | 9 ++ spec/Spec/Courseware/Lesson.lean | 115 +++++++++++++++ 5 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0005-lesson-as-ordered-composition-of-typed-elements.md delete mode 100644 spec/Spec/Basic.lean create mode 100644 spec/Spec/Courseware.lean create mode 100644 spec/Spec/Courseware/Lesson.lean diff --git a/docs/adr/0005-lesson-as-ordered-composition-of-typed-elements.md b/docs/adr/0005-lesson-as-ordered-composition-of-typed-elements.md new file mode 100644 index 0000000..5cb5756 --- /dev/null +++ b/docs/adr/0005-lesson-as-ordered-composition-of-typed-elements.md @@ -0,0 +1,131 @@ +# ADR 0005: Lesson Is An Ordered Composition Of Typed Elements + +## Status + +Accepted — for the skeleton decisions below. Several sub-decisions are +deliberately left open; see *Open Questions / Deferred*. + +This is the first ADR describing the **product core** (the curriculum +"engineering file"). ADR 0001–0004 cover only the platform side (Feishu group, +AgentRun, lock, permissions, on-demand context). + +## Context + +We are building a digitalization solution for curriculum production. The central +artifact is a structured "engineering file" for a course — edited with the help +of a coding agent, validated by a compiler-like rule-based checker that emits +helpful fix hints. The domain is new; we do not assume LLM pretraining priors +about its shape. This ADR fixes the shape of that engineering file so that the +developer and the agent stop making divergent assumptions about it. + +What the file is *not*: it is not presentation. The same content drives multiple +export targets (student handout, teacher lesson plan, PPT, third-party platform +archive, an HTML interactive-courseware platform). The engineering file is the +target-agnostic content master; typst, markdown dialects, etc. are downstream. + +## Decision + +### Granularity: the engineering file is one lesson + +One engineering file = one single lesson. A course / unit is *not* an +engineering file; it is an arrangement (编排) of lessons, modeled elsewhere (see +*Deferred*). + +### A lesson is an ordered sequence of element instances + +A lesson is an **ordered** sequence of elements. Order is significant; duration +is **not** modeled (the file is content-oriented, like a lecture note / handout +/ slide content flow, not a time-driven timeline). Content is formula-heavy. + +### An element instance = a kind tag + data conforming to that kind's schema + +Every element carries a **kind** tag plus **data** that conforms to that kind's +schema. Examples of kinds: worked example (例题), theorem/lemma (定理), +exposition (讲述/阐述). + +### Element kinds form an open, extensible universe + +Element kinds are **not** a closed enumeration. They form an open universe — like +a language standard library: the framework ships a good set of defaults (stdlib), +and (longer-term) third parties may contribute kinds (like crates). The model +must therefore never encode kinds as a fixed/closed type. + +### A kind's minimal contract to the framework is its data schema only + +The single thing a kind must declare to the framework is its **data schema**. +Rendering is explicitly **not** part of the kind contract. + +### Export targets project/render the lesson + +An export target (student handout, teacher plan, PPT, third-party archive, HTML +courseware platform, …) is a projection/rendering of the one lesson. One lesson +feeds many targets. + +### Rendering is a per-(kind, target) configuration matrix that lives in the file + +How a given kind appears in a given target is a **configuration matrix** +indexed by (kind, target). This matrix lives **inside the engineering file**; +the framework provides good **defaults**, and the file may **override** them. +(This corresponds to the prototype's `exports/{target}.typ`.) + +When a lesson uses a kind for which the current target has **no** render rule, +the checker emits a **WARNING** (the element is ignored for that target) — **not +an error**. Missing rendering does not block. + +### Target-specific information splits into two categories (a / b) + +- **(a) semantic-but-target-specific** — information that genuinely belongs to an + element's meaning and only *surfaces* in some targets (e.g. a verbatim script + 本质上是某 exposition 的口播版本, shown only in the teacher plan; key-point + highlighting shown only in the student handout). This **moves up onto the + element** as nullable fields. +- **(b) medium-only** — information that exists only because of a target's medium + (HTML interactive behavior, build script, npm dependencies). This does **not** + go on the element; it belongs to the target / external artifacts. + +### Interactive courseware is an inline heavyweight element + +A clickable "teaching apparatus" (教具) that a student plays with on the HTML +platform is an **inline element** in the content flow (bound to this lesson), +**not** a whole-lesson render target and **not** (for now) an externally +referenced artifact. In the model it carries only the apparatus's **problem / +configuration data**; the heavy implementation (e.g. an npm package such as a +KenKen-Sudoku renderer) lives **outside** this model, and reuse of such +implementations is out of scope for this model. + +### Typst is a rendering backend, not the definition language + +Typst is one rendering backend among several. It is **not** the language in +which kinds, schemas, or rules are defined (typst's typing is too weak for the +upper-layer design we want). + +## Consequences + +- Because kinds are an open universe, the formal model must be parameterized over + kinds — no closed `inductive`/enum of element kinds anywhere. +- Because missing rendering is a warning, not an error, a lesson stays valid and + exportable even when some (kind, target) pair has no rule yet. +- Because the content master is decoupled from presentation, the same lesson + projects to many targets; adding a target does not change the lesson. +- Because a kind's contract is "schema only", rendering and kind-definition can + evolve independently of the kind set. +- The checker (the thing that will "stand in Lean's position" at product runtime) + gets its first concrete rule from this ADR: *used-kind-with-no-render-rule ⇒ + warning*. + +## Open Questions / Deferred + +Deliberately unspecified. Neither developer nor agent should assume a resolution; +surface them when they become relevant rather than picking one. + +- **Schema representation** — how a kind's data schema is itself expressed (the + schema language). +- **Kind-definition mechanism** — the language/mechanism used to define an element + kind (typst scripting is rejected; the replacement is TBD). +- **Render-rule payload** — the concrete representation of a render rule. +- **Full lesson legality** — the complete definition of a "legal lesson"; this ADR + fixes only the one seed rule (missing render rule ⇒ warning). +- **Question bank (题库)** — its relationship to elements (reference vs inline) is + undecided; a pure-reference model may not be adequate. +- **Course = arrangement of lessons** — the concrete rules for composing lessons + into a course. diff --git a/spec/Spec.lean b/spec/Spec.lean index 9cdeec1..9f7d538 100644 --- a/spec/Spec.lean +++ b/spec/Spec.lean @@ -1,3 +1,3 @@ -- This module serves as the root of the `Spec` library. -- Import modules here that should be built as part of the library. -import Spec.Basic +import Spec.Courseware diff --git a/spec/Spec/Basic.lean b/spec/Spec/Basic.lean deleted file mode 100644 index 99415d9..0000000 --- a/spec/Spec/Basic.lean +++ /dev/null @@ -1 +0,0 @@ -def hello := "world" diff --git a/spec/Spec/Courseware.lean b/spec/Spec/Courseware.lean new file mode 100644 index 0000000..866e466 --- /dev/null +++ b/spec/Spec/Courseware.lean @@ -0,0 +1,9 @@ +import Spec.Courseware.Lesson + +/-! +# Courseware —— 产品层契约(课程工程文件) + +本层是护城河:课程"工程文件"的语义母本与合法性规则。当前只有 lesson 骨架 +(见 `Spec.Courseware.Lesson`,ADR-0005);题库、course 编排、完整合法性判定 +等仍 `OPEN`,待各自 ADR 落定后逐个加入。 +-/ diff --git a/spec/Spec/Courseware/Lesson.lean b/spec/Spec/Courseware/Lesson.lean new file mode 100644 index 0000000..99d2330 --- /dev/null +++ b/spec/Spec/Courseware/Lesson.lean @@ -0,0 +1,115 @@ +/-! +# Lesson —— 课程工程文件的母本模型(单节课) + +本文件固定 ADR-0005 的骨架共识:一个**工程文件 = 一节课(lesson)**, +lesson 是**有类型 element 实例的有序序列**;element 实例 = kind 标签 + 符合该 +kind schema 的数据;export target 是对 lesson 的投影;渲染由一份 +**(kind × target) 配置矩阵**承载。 + +本轮**只承诺关系结构**。所有尚未决定的基元(schema 怎么表示、kind 怎么定义、 +render rule 的载荷长什么样)都收口进 `Primitives`,以抽象类型留白(`OPEN`), +契约因此对未定之物**零承诺**,同时仍 type-check 通过。完整的"合法 lesson"判定 +亦未定;本轮只落 checker 的第一颗种子(缺渲染 ⇒ warning)。 +-/ + +namespace Spec.Courseware + +/-- +契约基元载体(`PINNED` 关系结构 / `OPEN` 基元留白,`ADR-0005`)。 + +把本轮**尚未决定**的基元收口到一处,使 lesson 模型在其上参数化。每个字段都是 +一个 `OPEN` 点:契约只声明它**存在**、以及它在关系中扮演的角色,绝不假设其内部 +表示。具体表示待后续各自的 ADR 落定后再 refine。 +-/ +structure Primitives where + /-- + element 的**种类标识**(`PINNED` 开放宇宙 / `OPEN` 表示,`ADR-0005`)。 + + kind 构成一个**开放、可扩展的宇宙**(stdlib default + 远期第三方),因此**刻意 + 用一个抽象类型,而非封闭 `inductive`/枚举**——把"kind 集合不封闭"这条 PINNED + 直接编码进类型。`KindId` 的具体表示是 `OPEN`。 + -/ + KindId : Type + /-- + 某 kind 的**合法数据**类型(`PINNED` 依赖关系 / `OPEN` schema 表示,`ADR-0005`)。 + + 以 `KindId` 为索引的**依赖类型**,精确表达"element 数据必须**符合该 kind 的 + schema**":不同 kind 的合法数据类型不同。kind 对框架的最小契约**就是**这份 + schema(渲染不在其中)。schema 自身**怎么表示**是 `OPEN`,故此处只给出 + `KindId → Type` 这一依赖,不触碰其内部。 + -/ + ElementData : KindId → Type + /-- + **导出目标标识**(`PINNED` 角色 / `OPEN` 表示,`ADR-0005`)。 + + 一个 target(学生讲义 / 教师教案 / PPT / 第三方平台 archive / html 教具平台…) + 是对同一 lesson 的一种投影。target 同样是开放集合;其具体表示 `OPEN`。 + -/ + TargetId : Type + /-- + **渲染规则的载荷**(`OPEN` 表示,`ADR-0005`)。 + + "某 kind 在某 target 下如何呈现"的那条规则的**内容**。其表示完全 `OPEN`(typst + 只是渲染后端之一,不是定义语言)——本轮只关心规则**在不在**(见 `RenderConfig`), + 不关心它**是什么**。 + -/ + RenderRule : Type + +variable (P : Primitives) + +/-- +**element 实例**:一个 kind 标签 + 符合该 kind schema 的数据(`PINNED`,`ADR-0005`)。 + +`data` 的类型由 `kind` 决定(`P.ElementData kind`),从类型上保证"数据符合该 kind +的 schema"——构造一个 `Element` 时不可能给出与其 kind 不匹配的数据。 + +注:ADR-0005 的 (a) 类信息(逐字稿、重点圈划等"语义性但 target-specific"的字段) +属于具体 kind 的 schema 范畴,故落在 `P.ElementData` 内,不在此通用结构上出现; +(b) 类信息(html 交互、build/npm)不进 element。 +-/ +structure Element where + /-- 该 element 的种类(`PINNED`,`ADR-0005`)。 -/ + kind : P.KindId + /-- 符合 `kind` schema 的数据(`PINNED`,`ADR-0005`)。 -/ + data : P.ElementData kind + +/-- +**lesson = element 实例的有序序列**(`PINNED`,`ADR-0005`)。 + +用 `List` 编码"有序":**次序是有意义的**。**时长不建模**——lesson 是内容编排 +(讲义/PPT 式的内容流),不是时间轴。课程/单元不是 lesson,而是 lesson 的编排 +(`OPEN`,见 ADR-0005 Deferred),不在本文件。 +-/ +abbrev Lesson := List (Element P) + +/-- +**渲染配置矩阵**:(kind × target) → 可选的渲染规则(`PINNED`,`ADR-0005`)。 + +这份矩阵**住在工程文件里**:框架给好 default,文件可逐项 override。`rule k t = none` +表示该 (kind, target) 对**尚无渲染规则**(此处只建模规则的**有无**,不建模其内容)。 +-/ +structure RenderConfig where + /-- (kind, target) ↦ 该对是否配了渲染规则及其载荷(`PINNED`,`ADR-0005`)。 -/ + rule : P.KindId → P.TargetId → Option P.RenderRule + +/-- +kind `k` 在 target `t` 下**已配渲染规则**(`PINNED`,`ADR-0005`)。 + +即矩阵在该 (kind, target) 处非空。"覆盖"= 有规则可用;`covers` 为假即"缺渲染"。 +-/ +def RenderConfig.covers {P : Primitives} (c : RenderConfig P) (k : P.KindId) (t : P.TargetId) : Prop := + (c.rule k t).isSome + +/-- +**checker 种子规则**:lesson 在 target `t` 下**存在被忽略的 element**(`PINNED`,`ADR-0005`)。 + +成立 ⟺ lesson 里**存在**某 element,其 kind 在 `t` 下没有渲染规则。 + +ADR-0005 PIN 住该情形的**严重级别 = warning,而非 error**:它**不阻断**导出,只提示 +"这些 element 在该 target 下被忽略了"。这是 checker(产品里"站在 Lean 位置"的那个 +东西)的第一颗诊断种子;完整的"合法 lesson"判定仍 `OPEN`。 +-/ +def WarnsIgnored (l : Lesson P) (c : RenderConfig P) (t : P.TargetId) : Prop := + ∃ e ∈ l, ¬ c.covers e.kind t + +end Spec.Courseware From f1dce077897f8257b896328b7ab1802416cd9c37 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 00:31:14 +0800 Subject: [PATCH 03/29] feat(spec): build out System layer and element schema model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picture the whole framework, then pin the element-kind language layer. System platform layer — rebuild the semantics likec4 can't draw (ADR-0001..0004): - Prelude: opaque Identifiers carrier (ProjectId/RunId/SessionId/Principal) - System/Run: RunState + Terminal subset (set completeness left OPEN, no invented pending) - System/Lock: lock owner=run (typed), LockTable exclusivity, WellFormed invariant (a lock holder must be a non-terminal run — couples Lock and Run) - System/Permission: read --- .../0006-element-schema-and-rich-content.md | 116 ++++++++++++++++++ ...0007-engineering-file-as-directory-tree.md | 73 +++++++++++ spec/Spec.lean | 2 + spec/Spec/Courseware.lean | 23 +++- spec/Spec/Courseware/Course.lean | 16 +++ spec/Spec/Courseware/Diagnostic.lean | 45 +++++++ spec/Spec/Courseware/Element.lean | 32 +++++ spec/Spec/Courseware/Lesson.lean | 114 ++--------------- spec/Spec/Courseware/Primitives.lean | 54 ++++++++ spec/Spec/Courseware/QuestionBank.lean | 17 +++ spec/Spec/Courseware/Render.lean | 36 ++++++ spec/Spec/Courseware/RichContent.lean | 39 ++++++ spec/Spec/Prelude.lean | 37 ++++++ spec/Spec/System.lean | 19 +++ spec/Spec/System/Audit.lean | 27 ++++ spec/Spec/System/Lock.lean | 51 ++++++++ spec/Spec/System/Permission.lean | 99 +++++++++++++++ spec/Spec/System/Run.lean | 47 +++++++ 18 files changed, 740 insertions(+), 107 deletions(-) create mode 100644 docs/adr/0006-element-schema-and-rich-content.md create mode 100644 docs/adr/0007-engineering-file-as-directory-tree.md create mode 100644 spec/Spec/Courseware/Course.lean create mode 100644 spec/Spec/Courseware/Diagnostic.lean create mode 100644 spec/Spec/Courseware/Element.lean create mode 100644 spec/Spec/Courseware/Primitives.lean create mode 100644 spec/Spec/Courseware/QuestionBank.lean create mode 100644 spec/Spec/Courseware/Render.lean create mode 100644 spec/Spec/Courseware/RichContent.lean create mode 100644 spec/Spec/Prelude.lean create mode 100644 spec/Spec/System.lean create mode 100644 spec/Spec/System/Audit.lean create mode 100644 spec/Spec/System/Lock.lean create mode 100644 spec/Spec/System/Permission.lean create mode 100644 spec/Spec/System/Run.lean diff --git a/docs/adr/0006-element-schema-and-rich-content.md b/docs/adr/0006-element-schema-and-rich-content.md new file mode 100644 index 0000000..a3c79b3 --- /dev/null +++ b/docs/adr/0006-element-schema-and-rich-content.md @@ -0,0 +1,116 @@ +# ADR 0006: Element Schema Is Declarative, Rich Content Is Typst + +## Status + +Accepted — for the decisions below. Several sub-decisions are deliberately left +open; see *Open Questions / Deferred*. + +Builds on ADR-0005, which fixed that an element instance = a kind tag + data +conforming to that kind's schema, and that a kind's minimal contract to the +framework is its **data schema only**. This ADR fixes **how that schema is +expressed** and **what the schema's "rich content" leaves actually are**. + +## Context + +ADR-0005 left the schema representation and the kind-definition mechanism as the +largest OPEN items — and they are the foundation the rule-based checker stands +on (the checker validates "does this data conform to its kind's schema?"). The +domain is formula-heavy and content-structured: a worked example has a problem +statement, options, a solution; a theorem has a statement, a proof, prerequisite +lemmas. These fields are not bare strings — they are *rich content*. + +Two facts about Typst were verified against its source +(`/Users/sjfhsjfh/Typst/typst`) and drive the decisions below: + +- A `.typ` file evaluates to a `Module` that carries **both** a `scope` of + `#let` exports **and** a body `Content` (the rendered top-level markup). + `Content` retains a `Span` that resolves back to a `FileId` + byte range, so + click-to-jump from rendered content to source is possible. +- `Content` has no `Deserialize`; and Typst's `Source::new` **requires** a + `FileId`. Source evaluated with a detached span loses its file identity: + `span.id()` becomes `None` (click-to-jump breaks) and a relative + `import "../x"` fails with *"cannot access file system from here"*. Relative + imports and click-to-jump only work when the source is a real file (with a + `VirtualPath`) in the compiler's `World`. + +## Decision + +### The data schema is declarative — a JSON Schema + +A kind declares its data shape as a **declarative JSON Schema** (field names, +types, constraints), not as a program and not as a bespoke schema DSL. The +checker reads the schema and validates instance data structurally against it. +Whether stdlib kinds author their schema by hand-written JSON or via a host- +language builder is an implementation detail, not part of this contract. + +### Field types: built-in scalars plus a `content` leaf type + +Schema field types are the built-in scalars (string / number / boolean / enum / +list / object, as in ordinary JSON Schema) **plus one extension type: +`content`**. JSON Schema has no native notion of "typst content", so this is a +deliberate **extension** of JSON Schema (a custom type / `format`). Any +implementation must honor the same `content` extension rather than inventing its +own — this is the seam between the schema layer (A) and the rich-content layer. + +### A `content` value is a Typst source; its meaning is the module's body content + +The value of a `content` field is a **Typst source**. Its denotation is the +**body content** of that source evaluated as a module (Typst's `Module.content`) +— not its `#let` exports. The canonical stored form is the **source text**, not +a serialized `Content`: `Content` cannot be deserialized and its spans are only +meaningful with the source present, so content is re-evaluated from source when +needed, never round-tripped as data. + +### Rich content is never free-floating — it has a virtual path + +Because detached Typst source loses click-to-jump and cannot do relative +imports, a `content` value is **not** an anonymous text blob. It is a first-class +file in the compiler's `World`, carrying a **virtual path**. This is what makes +its spans resolve (click-to-jump) and its relative imports anchor. Concretely, +the engineering file constitutes a **virtual file system with internal path +structure**; each rich-content field sits at a path within it. + +(This ADR commits only to *"there is a path structure"*. Its concrete on-disk +form — that the engineering file is a real directory tree — is fixed separately +in ADR-0007. ADR-0006 does not assume the path structure is a disk directory.) + +### Import boundary: within the engineering file, plus packages + +A relative `import` inside a rich-content source may resolve only to other paths +**within the same engineering file's path structure** (reusing definitions local +to this lesson), plus Typst **`@package`** ecosystem imports. It may **not** +reach into other engineering files. Cross-lesson reuse, if any, goes through a +separate mechanism (question bank / reuse — still OPEN, see ADR-0005 Deferred), +not through raw cross-file imports. + +## Consequences + +- The checker can introspect a rendered `Content` (its element type, fields, + downcasts) to validate rich content beyond "is it present", and can use spans + to point diagnostics back at the offending source location. +- Canonical state is source text; `Content` is a transient product of + evaluation. Storage, diffing, and agent edits all operate on source, not on + serialized content. +- Extending JSON Schema with `content` means conformance has a shared extension + point: implementations align on one `content` semantics instead of each + inventing a rich-text encoding. +- Rich content requiring a virtual path means the engineering file is inherently + a path-structured container, not a bag of anonymous snippets — which is what + ADR-0007 then grounds on disk. +- The import boundary keeps an engineering file self-contained: it depends only + on itself and on published packages, never on a sibling lesson's internals. + +## Open Questions / Deferred + +- **Content element constraints (Y)** — whether a `content` field may be further + constrained to admit only certain Typst element kinds (e.g. "inline only, no + figure", "exactly one final expression"). Deferred: we will know which + constraints are needed only once real stdlib kinds exist. Until then a + `content` field means "a block of content", inner element shape unconstrained. +- **stdlib kind set** — the first batch of framework-provided kinds and their + individual schemas (each is later, per-kind work). +- **JSON Schema dialect** — which draft, and exactly which keywords are + permitted in a kind schema (a subset/profile is likely, to keep the checker + tractable). Not pinned here. +- **On-disk form** — fixed in ADR-0007 (directory tree); layout conventions + (manifest, element ordering) deferred beyond that. diff --git a/docs/adr/0007-engineering-file-as-directory-tree.md b/docs/adr/0007-engineering-file-as-directory-tree.md new file mode 100644 index 0000000..a752d57 --- /dev/null +++ b/docs/adr/0007-engineering-file-as-directory-tree.md @@ -0,0 +1,73 @@ +# ADR 0007: The Engineering File Is A Directory Tree On Disk + +## Status + +Accepted — for the on-disk decision below. The concrete layout conventions are +deliberately left open; see *Open Questions / Deferred*. + +Grounds the abstract premise ADR-0006 relied on. ADR-0006 established that rich +content needs a **virtual path** and that the engineering file therefore +constitutes a path-structured virtual file system — but it deliberately did not +say what that path structure *is* physically. This ADR fixes that: it is a real +directory tree on a real file system. + +## Context + +ADR-0006 showed that a rich-content (`content`) value cannot be free-floating +Typst source: it must be a first-class file with a virtual path so that +click-to-jump (spans) and relative imports work. That leaves a choice for the +physical form of the engineering file: a packed/opaque archive, a database blob, +or a plain directory tree. + +The decisive constraint is **who edits the engineering file**: a coding agent +(Claude) collaboratively with teachers (ADR-0005's premise), and ordinary tools. +An agent works best when it can read and modify files directly and use existing +tooling (`grep`, `find`, diff, version control) over plain files in plain +directories. A packed or database form would force a custom access layer for +every such operation. + +The developer's earlier prototype already had this shape implicitly — +`segments/`, `examples/`, `lemmas/` directories with per-item files — which is +the directory tree in embryo, not a crude stand-in. + +## Decision + +The on-disk form of an engineering file is a **directory tree** on a real file +system. + +- A `content` field's **virtual path** (ADR-0006) is a **real relative path** + within that directory tree. The compiler's `World` maps virtual paths to the + actual files in the tree; no synthetic/in-memory file system is needed. +- Relative imports inside rich content resolve relative to the importing file's + real position in the tree — which is exactly the import boundary ADR-0006 + pinned ("within the engineering file, plus `@package`"). This ADR gives that + boundary its physical basis: "within the engineering file" = "within this + directory tree". + +This keeps the engineering file legible to agents and to off-the-shelf tooling, +and makes the relationship between content fields and their sources directly +inspectable on disk. + +## Consequences + +- The engineering file is a directory of plain text/Typst files: directly + greppable, diffable, version-controllable, and editable by an agent without a + bespoke access layer. +- `World` is a thin mapping from the directory tree; virtual path = relative path + removes any need to invent or maintain a separate virtual file system. +- The engineering file remains self-contained on disk: its import closure is the + tree itself plus published packages (ADR-0006), so it can be moved, copied, or + archived as a directory without dangling references to sibling lessons. +- This is the **physical representation** of ADR-0005's abstract `Lesson` (an + ordered sequence of typed elements); how that ordered structure is encoded into + the directory is a separate question (below), not implied by "it's a tree". + +## Open Questions / Deferred + +- **Layout conventions** — the concrete directory layout is *not* fixed here. + Specifically deferred: the manifest file (name, location, what it holds); how + element instances are arranged in the tree and how their **order** (ADR-0005's + `Lesson` is an ordered sequence) is encoded; naming/organization of + rich-content `.typ` files. This is the on-disk encoding of ADR-0005's `Lesson` + and warrants its own ADR — kept separate so this one fixes only "it is a + directory tree", not "how the tree is arranged". diff --git a/spec/Spec.lean b/spec/Spec.lean index 9f7d538..46dd4a3 100644 --- a/spec/Spec.lean +++ b/spec/Spec.lean @@ -1,3 +1,5 @@ -- This module serves as the root of the `Spec` library. -- Import modules here that should be built as part of the library. +import Spec.Prelude +import Spec.System import Spec.Courseware diff --git a/spec/Spec/Courseware.lean b/spec/Spec/Courseware.lean index 866e466..a3369f7 100644 --- a/spec/Spec/Courseware.lean +++ b/spec/Spec/Courseware.lean @@ -1,9 +1,26 @@ +import Spec.Courseware.Primitives +import Spec.Courseware.RichContent +import Spec.Courseware.Element import Spec.Courseware.Lesson +import Spec.Courseware.Render +import Spec.Courseware.Diagnostic +import Spec.Courseware.QuestionBank +import Spec.Courseware.Course /-! # Courseware —— 产品层契约(课程工程文件) -本层是护城河:课程"工程文件"的语义母本与合法性规则。当前只有 lesson 骨架 -(见 `Spec.Courseware.Lesson`,ADR-0005);题库、course 编排、完整合法性判定 -等仍 `OPEN`,待各自 ADR 落定后逐个加入。 +护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005 / 0006 / 0007。 + +已填实(PINNED): +- `Primitives` —— 基元载体(表示留给实现;schema 形态见 ADR-0006)。 +- `RichContent` —— 富内容 = 带虚拟路径的 typst 源(ADR-0006 的 prose 母本)。 +- `Element` —— kind 标签 + 依赖 schema 的数据。 +- `Lesson` —— element 的有序序列(序载教学语义、不建模时长)。 +- `Render` —— (kind × target) 渲染配置矩阵 + covers。 +- `Diagnostic` —— Severity + checker 第一条诊断(缺渲染 ⇒ warning)。 + +留白骨架(核心关系 OPEN,已 surface 不臆造): +- `QuestionBank` —— 题库与 element 的关系(引用 vs 内联)。 +- `Course` —— course = lesson 编排的规则。 -/ diff --git a/spec/Spec/Courseware/Course.lean b/spec/Spec/Courseware/Course.lean new file mode 100644 index 0000000..30a165c --- /dev/null +++ b/spec/Spec/Courseware/Course.lean @@ -0,0 +1,16 @@ +import Spec.Courseware.Lesson + +/-! +# Course —— 课程编排(骨架,规则 OPEN) + +ADR-0005:工程文件的粒度是**单节课**;course / 单元**不是**工程文件,而是 lesson +的**编排**。 + +但"编排"的具体规则未决策:course 是 lesson 的有序列表,还是带层级(单元 → 课)的 +树?编排里 lesson 是被引用还是被包含?跨 lesson 有无约束(目标覆盖、前后置)?这些 +都是 `OPEN`。 + +按宪法第 2 条,本模块**不臆造**编排结构——不建 `Course := List Lesson`(那会偷偷 +承诺"扁平有序、无层级")。只在此 surface:课程编排待专门 ADR。本文件当前不引入任何 +承诺性声明。 +-/ diff --git a/spec/Spec/Courseware/Diagnostic.lean b/spec/Spec/Courseware/Diagnostic.lean new file mode 100644 index 0000000..2f75329 --- /dev/null +++ b/spec/Spec/Courseware/Diagnostic.lean @@ -0,0 +1,45 @@ +import Spec.Courseware.Lesson +import Spec.Courseware.Render + +/-! +# Diagnostic —— checker 诊断(种子) + +产品里"站在 Lean 位置"的那个 rule-based checker,语义在此沉淀。它对 lesson 提诊断, +每条诊断有**严重级别**。本模块固定级别类型,并落第一条规则——ADR-0005 钉死的"缺 +渲染 ⇒ warning"。完整的"合法 lesson"判定仍 OPEN,本模块只是它的起点。 +-/ + +namespace Spec.Courseware + +/-- +诊断的严重级别(`PINNED` 二分, ADR-0005 区分 warning 与阻断)。 + +`error` 阻断(产物不合法),`warning` 不阻断(产物仍可导出,只是有损/有忽略)。 +ADR-0005 已用到这个区分:缺渲染是 warning 而非 error。更细的级别(info/hint)未 +决策,故只二分。 +-/ +inductive Severity where + | warning + | error + +variable (P : Primitives) + +/-- +**缺渲染诊断**:lesson 在 target `t` 下存在无法渲染的 element(`PINNED`, ADR-0005)。 + +成立 ⟺ 存在某 element,其 kind 在 `t` 下未配渲染规则。这条对应 checker 的第一条 +诊断;其级别由下面的 `renderIgnoredSeverity` 钉死为 **warning**。 +-/ +def renderIgnored (l : Lesson P) (c : RenderConfig P) (t : P.TargetId) : Prop := + ∃ e ∈ l, ¬ c.covers e.kind t + +/-- +缺渲染诊断的级别 = **warning**(`PINNED`, ADR-0005,**非 error**)。 + +ADR-0005 明确:某 (kind, target) 无渲染规则时,checker 报 warning 说该 element 在 +该 target 被忽略,**不阻断导出**。把这条决策固定为一个常量,使"它是 warning 不是 +error"成为契约里可被引用、可被对齐的事实,而不是散落在 prose 里的口头约定。 +-/ +def renderIgnoredSeverity : Severity := .warning + +end Spec.Courseware diff --git a/spec/Spec/Courseware/Element.lean b/spec/Spec/Courseware/Element.lean new file mode 100644 index 0000000..d1bb7f9 --- /dev/null +++ b/spec/Spec/Courseware/Element.lean @@ -0,0 +1,32 @@ +import Spec.Courseware.Primitives + +/-! +# Element —— 课程内容的原子单位 + +ADR-0005:element 实例 = 一个 kind 标签 + 符合该 kind schema 的数据。本模块把这条 +编码成一个依赖结构,使"数据必须匹配其 kind"成为类型层面的事实而非运行时校验。 +-/ + +namespace Spec.Courseware + +variable (P : Primitives) + +/-- +element 实例(`PINNED`, ADR-0005)。 + +`data` 的类型 `P.ElementData kind` **由 `kind` 决定**——这意味着无法构造一个数据与 +其 kind 不符的 element:schema 合规由类型系统而非 checker 保证。 + +ADR-0005 的 (a) 类信息(逐字稿、重点圈划等"语义性但只在某些 target 浮现"的字段) +属于具体 kind 的 schema,落在 `P.ElementData` 内,不出现在这个通用结构上;(b) 类 +信息(html 交互、build/npm)按 ADR-0005 不进 element。交互教具是一种"重型 kind", +其 `ElementData` 只承载题目/配置数据,重实现在模型之外——它在此结构里与例题、定理 +同构,仅 `kind` 不同。 +-/ +structure Element where + /-- 该 element 的 kind。 -/ + kind : P.KindId + /-- 符合 `kind` schema 的数据(类型随 `kind` 而变)。 -/ + data : P.ElementData kind + +end Spec.Courseware diff --git a/spec/Spec/Courseware/Lesson.lean b/spec/Spec/Courseware/Lesson.lean index 99d2330..01564c9 100644 --- a/spec/Spec/Courseware/Lesson.lean +++ b/spec/Spec/Courseware/Lesson.lean @@ -1,115 +1,21 @@ +import Spec.Courseware.Element + /-! -# Lesson —— 课程工程文件的母本模型(单节课) +# Lesson —— 单节课工程文件 -本文件固定 ADR-0005 的骨架共识:一个**工程文件 = 一节课(lesson)**, -lesson 是**有类型 element 实例的有序序列**;element 实例 = kind 标签 + 符合该 -kind schema 的数据;export target 是对 lesson 的投影;渲染由一份 -**(kind × target) 配置矩阵**承载。 - -本轮**只承诺关系结构**。所有尚未决定的基元(schema 怎么表示、kind 怎么定义、 -render rule 的载荷长什么样)都收口进 `Primitives`,以抽象类型留白(`OPEN`), -契约因此对未定之物**零承诺**,同时仍 type-check 通过。完整的"合法 lesson"判定 -亦未定;本轮只落 checker 的第一颗种子(缺渲染 ⇒ warning)。 +ADR-0005:一个工程文件 = 一节课,是 element 实例的**有序序列**。课程/单元不是工程 +文件,而是 lesson 的编排(见 `Spec.Courseware.Course`,OPEN)。 -/ namespace Spec.Courseware /-- -契约基元载体(`PINNED` 关系结构 / `OPEN` 基元留白,`ADR-0005`)。 +一节课(`PINNED`, ADR-0005)。 -把本轮**尚未决定**的基元收口到一处,使 lesson 模型在其上参数化。每个字段都是 -一个 `OPEN` 点:契约只声明它**存在**、以及它在关系中扮演的角色,绝不假设其内部 -表示。具体表示待后续各自的 ADR 落定后再 refine。 +用 `List` 而非 `Multiset`/`Finset`,因为 **element 的先后次序承载教学语义**(讲义/ +PPT 式的内容流,先讲定义再举例不同于反过来)。**不建模时长**:ADR-0005 决定 lesson +是内容编排而非时间轴,故这里没有"每块多少分钟"这类字段——序是结构,时长不是。 -/ -structure Primitives where - /-- - element 的**种类标识**(`PINNED` 开放宇宙 / `OPEN` 表示,`ADR-0005`)。 - - kind 构成一个**开放、可扩展的宇宙**(stdlib default + 远期第三方),因此**刻意 - 用一个抽象类型,而非封闭 `inductive`/枚举**——把"kind 集合不封闭"这条 PINNED - 直接编码进类型。`KindId` 的具体表示是 `OPEN`。 - -/ - KindId : Type - /-- - 某 kind 的**合法数据**类型(`PINNED` 依赖关系 / `OPEN` schema 表示,`ADR-0005`)。 - - 以 `KindId` 为索引的**依赖类型**,精确表达"element 数据必须**符合该 kind 的 - schema**":不同 kind 的合法数据类型不同。kind 对框架的最小契约**就是**这份 - schema(渲染不在其中)。schema 自身**怎么表示**是 `OPEN`,故此处只给出 - `KindId → Type` 这一依赖,不触碰其内部。 - -/ - ElementData : KindId → Type - /-- - **导出目标标识**(`PINNED` 角色 / `OPEN` 表示,`ADR-0005`)。 - - 一个 target(学生讲义 / 教师教案 / PPT / 第三方平台 archive / html 教具平台…) - 是对同一 lesson 的一种投影。target 同样是开放集合;其具体表示 `OPEN`。 - -/ - TargetId : Type - /-- - **渲染规则的载荷**(`OPEN` 表示,`ADR-0005`)。 - - "某 kind 在某 target 下如何呈现"的那条规则的**内容**。其表示完全 `OPEN`(typst - 只是渲染后端之一,不是定义语言)——本轮只关心规则**在不在**(见 `RenderConfig`), - 不关心它**是什么**。 - -/ - RenderRule : Type - -variable (P : Primitives) - -/-- -**element 实例**:一个 kind 标签 + 符合该 kind schema 的数据(`PINNED`,`ADR-0005`)。 - -`data` 的类型由 `kind` 决定(`P.ElementData kind`),从类型上保证"数据符合该 kind -的 schema"——构造一个 `Element` 时不可能给出与其 kind 不匹配的数据。 - -注:ADR-0005 的 (a) 类信息(逐字稿、重点圈划等"语义性但 target-specific"的字段) -属于具体 kind 的 schema 范畴,故落在 `P.ElementData` 内,不在此通用结构上出现; -(b) 类信息(html 交互、build/npm)不进 element。 --/ -structure Element where - /-- 该 element 的种类(`PINNED`,`ADR-0005`)。 -/ - kind : P.KindId - /-- 符合 `kind` schema 的数据(`PINNED`,`ADR-0005`)。 -/ - data : P.ElementData kind - -/-- -**lesson = element 实例的有序序列**(`PINNED`,`ADR-0005`)。 - -用 `List` 编码"有序":**次序是有意义的**。**时长不建模**——lesson 是内容编排 -(讲义/PPT 式的内容流),不是时间轴。课程/单元不是 lesson,而是 lesson 的编排 -(`OPEN`,见 ADR-0005 Deferred),不在本文件。 --/ -abbrev Lesson := List (Element P) - -/-- -**渲染配置矩阵**:(kind × target) → 可选的渲染规则(`PINNED`,`ADR-0005`)。 - -这份矩阵**住在工程文件里**:框架给好 default,文件可逐项 override。`rule k t = none` -表示该 (kind, target) 对**尚无渲染规则**(此处只建模规则的**有无**,不建模其内容)。 --/ -structure RenderConfig where - /-- (kind, target) ↦ 该对是否配了渲染规则及其载荷(`PINNED`,`ADR-0005`)。 -/ - rule : P.KindId → P.TargetId → Option P.RenderRule - -/-- -kind `k` 在 target `t` 下**已配渲染规则**(`PINNED`,`ADR-0005`)。 - -即矩阵在该 (kind, target) 处非空。"覆盖"= 有规则可用;`covers` 为假即"缺渲染"。 --/ -def RenderConfig.covers {P : Primitives} (c : RenderConfig P) (k : P.KindId) (t : P.TargetId) : Prop := - (c.rule k t).isSome - -/-- -**checker 种子规则**:lesson 在 target `t` 下**存在被忽略的 element**(`PINNED`,`ADR-0005`)。 - -成立 ⟺ lesson 里**存在**某 element,其 kind 在 `t` 下没有渲染规则。 - -ADR-0005 PIN 住该情形的**严重级别 = warning,而非 error**:它**不阻断**导出,只提示 -"这些 element 在该 target 下被忽略了"。这是 checker(产品里"站在 Lean 位置"的那个 -东西)的第一颗诊断种子;完整的"合法 lesson"判定仍 `OPEN`。 --/ -def WarnsIgnored (l : Lesson P) (c : RenderConfig P) (t : P.TargetId) : Prop := - ∃ e ∈ l, ¬ c.covers e.kind t +abbrev Lesson (P : Primitives) := List (Element P) end Spec.Courseware diff --git a/spec/Spec/Courseware/Primitives.lean b/spec/Spec/Courseware/Primitives.lean new file mode 100644 index 0000000..ff91b45 --- /dev/null +++ b/spec/Spec/Courseware/Primitives.lean @@ -0,0 +1,54 @@ +/-! +# Primitives —— Courseware 契约的留白基元 + +课程工程文件模型(ADR-0005)依赖一组基元:element kind 怎么标识、某 kind 的数据 +schema 是什么、export target 怎么标识、渲染规则的载荷长什么样。 + +把它们收口成一个载体 `Primitives`,让 lesson 模型在其上参数化——契约因此能谈 +element / lesson / 渲染**之间的关系**,而把每个基元的**内部表示**留给实现。注意: +"某基元的语义已 PINNED"与"其表示进 Lean"是两回事——schema 的**形态**已由 ADR-0006 +钉死(声明式 JSON Schema + 富内容叶子 = typst content),但那是 JSON / typst 的内部 +结构、属实现细节,**不入 Lean**;Lean 只锚定"数据符合 kind schema"这条关系,故这些 +基元在此仍以抽象类型承载。富内容这一新概念的 prose 母本见 `Courseware.RichContent`。 +-/ + +namespace Spec.Courseware + +/-- +Courseware 契约基元载体(关系 `PINNED`, ADR-0005;各基元表示留给实现, ADR-0006)。 +-/ +structure Primitives where + /-- + element kind 的标识(`PINNED` 开放宇宙, ADR-0005;表示 `OPEN`)。 + + **刻意用抽象类型而非 `inductive`**:ADR-0005 决定 kind 是开放可扩展宇宙(stdlib + default + 第三方),封闭枚举会直接违背它。这与 `RunState` 的"未封闭"不同——此处 + 开放是**已决策的**。 + -/ + KindId : Type + /-- + 某 kind 的合法数据类型(`PINNED` 依赖关系, ADR-0005;schema 形态 `PINNED` ADR-0006, + 表示仍抽象)。 + + 以 kind 为索引:不同 kind 的合法数据类型不同。这正是 ADR-0005"kind 的最小契约 + = 数据 schema"的载体——`ElementData k` 即"符合 `k` 之 schema 的数据"。 + + schema 的**形态**已由 ADR-0006 定:声明式 **JSON Schema**,字段类型 = 内置标量 + + 一个 `content` 扩展类型;`content` 字段的值是一段 **typst 源**(canonical 存为带 + 虚拟路径的源,语义取其 module body)。但这些是 JSON / typst 的内部结构——**实现 + 细节,非分歧点**,不进 Lean。故 `ElementData` 在此**仍是抽象类型**:契约只承诺 + "存在一个由 kind 决定的合法数据类型",不复刻 JSON Schema / typst Content 的形状。 + -/ + ElementData : KindId → Type + /-- export target 的标识(`PINNED` 角色, ADR-0005;表示 `OPEN`)。一个 target 是对 lesson 的一种投影(讲义/教案/PPT/平台 archive…)。 -/ + TargetId : Type + /-- + 渲染规则的载荷(表示 `OPEN`, ADR-0005)。 + + "某 kind 在某 target 下如何呈现"那条规则的**内容**。typst 是渲染后端之一(`PINNED`, + ADR-0005)、不是定义语言;但渲染管线/规则载荷的具体表示仍 `OPEN`。契约只关心规则 + **在不在**(见 `Render`),不碰它是什么。 + -/ + RenderRule : Type + +end Spec.Courseware diff --git a/spec/Spec/Courseware/QuestionBank.lean b/spec/Spec/Courseware/QuestionBank.lean new file mode 100644 index 0000000..f0450be --- /dev/null +++ b/spec/Spec/Courseware/QuestionBank.lean @@ -0,0 +1,17 @@ +import Spec.Courseware.Element + +/-! +# QuestionBank —— 题库(骨架,核心关系 OPEN) + +题库是整个方案里与工程文件并列的资产(讨论里类比 DAW 的 sample library):有自身 +结构的被建模实体,又是最典型的可复用单元,lesson 会引用它。 + +但**题库与 element 的关系尚未决策**,且用户明确指出"纯引用可能不够": +- element 内联题目数据(如交互教具那样),还是 +- lesson 持一个指向题库条目的**引用**,还是 +- 两者并存? + +这是一个 `OPEN` 分歧点。按宪法第 2 条,本模块**不替它选解**——不建 `QuestionRef` +也不建内联结构,只在此 surface 该问题。待专门的 ADR 落定后再填。本文件当前只承载 +这条说明,不引入任何承诺性声明。 +-/ diff --git a/spec/Spec/Courseware/Render.lean b/spec/Spec/Courseware/Render.lean new file mode 100644 index 0000000..76a5895 --- /dev/null +++ b/spec/Spec/Courseware/Render.lean @@ -0,0 +1,36 @@ +import Spec.Courseware.Primitives + +/-! +# Render —— 渲染配置矩阵 + +ADR-0005:某 kind 在某 target 下如何呈现,由一份 **(kind × target) 配置矩阵**决定; +矩阵住在工程文件里,框架给 default、文件可 override。本模块只建矩阵结构与"是否已 +配置"的判定;规则的**内容**(`RenderRule`)是 OPEN,不碰。 +-/ + +namespace Spec.Courseware + +variable (P : Primitives) + +/-- +渲染配置矩阵(`PINNED`, ADR-0005)。 + +`rule k t = none` 表示 (kind `k`, target `t`) 这一对**尚无渲染规则**;`some _` 表示 +有。框架默认与文件 override 最终都坍缩成这一个映射——契约不区分"默认值"与"用户改 +过的值",只看最终矩阵在每一格的有无。 +-/ +structure RenderConfig where + /-- (kind, target) ↦ 该对的渲染规则(无则 `none`)。 -/ + rule : P.KindId → P.TargetId → Option P.RenderRule + +/-- +kind `k` 在 target `t` 下**已配渲染规则**(`PINNED`, ADR-0005)。 + +即矩阵该格非空。`covers` 为假即"此 kind 在此 target 下无从渲染"——checker 据此报 +警(见 `Spec.Courseware.Diagnostic`)。`P` 隐式,以便 `c.covers k t` 点记法可用。 +-/ +def RenderConfig.covers {P : Primitives} (c : RenderConfig P) + (k : P.KindId) (t : P.TargetId) : Prop := + (c.rule k t).isSome + +end Spec.Courseware diff --git a/spec/Spec/Courseware/RichContent.lean b/spec/Spec/Courseware/RichContent.lean new file mode 100644 index 0000000..eadc61e --- /dev/null +++ b/spec/Spec/Courseware/RichContent.lean @@ -0,0 +1,39 @@ +/-! +# RichContent —— 富内容(ADR-0006 的 prose 母本) + +ADR-0006 给 element schema 的"叶子"定了形:一个字段可以是 `content` 类型,其值是 +**一段 typst 源**,语义上取该源作为 module 求值后的 **body content**。 + +关键约束(均 ADR-0006,源自 typst 源码事实):富内容**不可无主**——typst 的源必须 +有 `FileId`,否则 span 脱锚(`span.id() = none`,点击跳转失效)且相对 `import` 报 +"cannot access file system from here"。故每段富内容是 World 里的一等文件,坐落在一个 +**虚拟路径**上;相对 import 限本工程路径结构内 + `@package`(不跨工程)。 + +本模块只为这个概念立一个 prose 锚点 + 一个**最小抽象签名**。typst 的 `Content` / +`Module` 内部结构、JSON Schema 的形状都是**实现细节、非分歧点**,不进 Lean——这里 +不复刻它们,只承诺"富内容由一个虚拟路径定位"这一条 ADR-0006 钉死的关系。 +-/ + +namespace Spec.Courseware + +/-- +富内容在工程文件路径结构中的**虚拟路径**(`OPEN` 表示, ADR-0006)。 + +它把一段富内容定位为 World 里的一等文件(span 可解析、相对 import 可锚定)。其具体 +表示——以及它落到磁盘后就是真实相对路径(ADR-0007)——不在本层承诺,故 opaque。 +-/ +opaque VPath : Type + +/-- +对一段富内容的**引用**:它坐落在某个虚拟路径上(`PINNED` 关系, ADR-0006)。 + +契约层面,一段 `content` 字段值由其虚拟路径标识(canonical 存储是该路径下的 typst +源文本,而非序列化的 `Content`——后者不可反序列化)。这里**刻意不**建模源文本、 +不建模求值出的 `Content`:那是实现侧的事。此结构只钉死"富内容经由一个 `VPath` +定位"这一条关系,作为 `Primitives.ElementData` 里 `content` 叶子的语义锚点。 +-/ +structure RichContentRef where + /-- 该富内容所在的虚拟路径(ADR-0006;落盘后为真实相对路径, ADR-0007)。 -/ + vpath : VPath + +end Spec.Courseware diff --git a/spec/Spec/Prelude.lean b/spec/Spec/Prelude.lean new file mode 100644 index 0000000..5c96fdf --- /dev/null +++ b/spec/Spec/Prelude.lean @@ -0,0 +1,37 @@ +/-! +# Prelude —— System 层共享标识符 + +平台层(`Spec.System`)反复引用一组**标识符**(项目、run、session、principal)。 +它们在 likec4 / ADR 里只作为引用键出现,**内部表示从未被决策**(是 UUID 还是 +复合键、principal 怎么分类),也不构成语义分歧点。 + +故把它们收口成一个 opaque 载体 `Identifiers`,System 各模块在其上参数化。这样 +契约谈得了"锁的 owner 是哪个 run""grant 授给哪个 principal"这类**关系**,却 +不对标识符的表示作任何承诺。 +-/ + +namespace Spec.System + +/-- +System 层标识符载体(`OPEN` 表示,关系结构 `PINNED`)。 + +每个字段是一个 opaque 类型:契约只用它做引用键,绝不假设其内部表示。System 各 +模块 `variable (I : Identifiers)` 后即可引用 `I.ProjectId` 等。 +-/ +structure Identifiers where + /-- 课程项目的标识(`OPEN` 表示;聚合根,见 likec4 `Project`)。 -/ + ProjectId : Type + /-- 一次 Claude 处理任务的标识(`OPEN` 表示;锁的 owner、审计主体,见 `AgentRun`)。 -/ + RunId : Type + /-- 长生命周期 Claude 会话的标识(`OPEN` 表示;跨多 run 复用,ADR-0002)。 -/ + SessionId : Type + /-- + 权限主体的标识(`OPEN` 表示及其**子类型学**)。 + + ADR-0004 列出 principal 可以是 user / feishu_chat / department / user_group / + app。这套子类型学本身**未定且非本层分歧点**(纯 plumbing),故此处只留 opaque + 键,不展开为 `inductive`。 + -/ + Principal : Type + +end Spec.System diff --git a/spec/Spec/System.lean b/spec/Spec/System.lean new file mode 100644 index 0000000..5659c01 --- /dev/null +++ b/spec/Spec/System.lean @@ -0,0 +1,19 @@ +import Spec.System.Run +import Spec.System.Lock +import Spec.System.Permission +import Spec.System.Audit + +/-! +# System —— Hub 平台层契约 + +协作与执行的平台:项目、飞书群、AgentRun、锁、权限、审计。`main` 分支的 likec4 +(`docs/architecture/likec4/`)已画出这一层的**结构**(9 实体 + 容器 + 视图);本层 +只补 likec4 画不出的**语义分歧点**,不重复结构: + +- `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。 +- `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。 +- `Permission` —— read⊂edit⊂manage 角色格、能力推导、单调性;force-release 在格外。 +- `Audit` —— 有意从简(内容多为 plumbing,OPEN)。 + +标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004。 +-/ diff --git a/spec/Spec/System/Audit.lean b/spec/Spec/System/Audit.lean new file mode 100644 index 0000000..93da09d --- /dev/null +++ b/spec/Spec/System/Audit.lean @@ -0,0 +1,27 @@ +import Spec.Prelude + +/-! +# Audit —— 审计日志(有意从简) + +likec4 把 `AuditLog` 列为实体:`AgentRun -> AuditLog 'records lifecycle events'`, +admin 经控制台审计。但**审计记录里装什么**(事件 schema、保留策略、可查询维度) +在任何 ADR / 散文里都未决策,且大多是 plumbing——按分歧点测试**不入契约**:写明 +与否不会让开发者与 agent 各做不同假设。 + +故本模块**刻意几乎为空**:只固定一条已决策的关系——审计以 run 为主体记录其生命 +周期事件——其余 `OPEN`。这里留白本身是契约的一部分(承诺"此处尚无答案,勿填")。 +-/ + +namespace Spec.System + +/-- +审计条目的最小骨架(关系 `PINNED` / 内容 `OPEN`, likec4 `records lifecycle events`)。 + +只承诺"一条审计记录关联到某个 run"。事件类型、时间、actor、详情等字段**未定** +(`OPEN`),待出现真实分歧点(如"取消必须记录 actor")时再由对应 ADR 落定。 +-/ +structure AuditEntry (I : Identifiers) where + /-- 该审计条目所属的 run(`PINNED` 关系, likec4)。 -/ + run : I.RunId + +end Spec.System diff --git a/spec/Spec/System/Lock.lean b/spec/Spec/System/Lock.lean new file mode 100644 index 0000000..db26b85 --- /dev/null +++ b/spec/Spec/System/Lock.lean @@ -0,0 +1,51 @@ +import Spec.Prelude +import Spec.System.Run + +/-! +# Lock —— 项目锁与排他不变式 + +ADR-0002 的核心:防止并发 Claude 执行同时改一个项目,而锁的 **owner 是当前 +`AgentRun`**(不是 teacher / chat / session)。本模块把这条决策编码进类型,并钉死 +likec4 画不出的那条语义不变式——**持锁者必为非终止 run**。 +-/ + +namespace Spec.System + +variable (I : Identifiers) + +/-- +项目级锁(`PINNED`, ADR-0002)。 + +`owner` 的类型是 `RunId` 而非 `SessionId`/`Principal`——这从类型上编码 ADR-0002 +"lock owner = run_id":锁不可能被一个 session 或 teacher 持有。`scope` 为项目级 +(一个项目一把锁,见 `LockTable`)。 +-/ +structure ProjectAgentLock where + /-- 锁的作用域:项目级(`PINNED`, ADR-0002 `scope = project_id`)。 -/ + scope : I.ProjectId + /-- 锁的持有者:一个 run(`PINNED`, ADR-0002 `owner = run_id`)。 -/ + owner : I.RunId + +/-- +锁表:每个项目当前的持锁 run(`PINNED` 排他性, ADR-0002)。 + +`Option` 编码"每项目至多一把锁":`none` = 无活动 run 占用,`some r` = run `r` +正持有。这个 `ProjectId → Option RunId` 的结构**本身**即排他——不可能为同一项目 +登记两个并发 owner。 +-/ +def LockTable := I.ProjectId → Option I.RunId + +/-- +锁表良构:**持锁者必为非终止 run**(`PINNED` 平台核心不变式, ADR-0002)。 + +ADR-0002 说锁在 run 终止时释放。其逻辑等价物:任何时刻,若项目 `p` 的锁被 `r` +持有,则 `r` 不在终止态。`statusOf` 给出每个 run 的当前状态。 + +这条不变式把 **Lock 与 Run 两个实体耦合**起来——likec4 能画"run owns lock while +running"这条边,但画不出"终止即必须释放"这个**约束**;它正是契约相对结构图的增量。 +-/ +def LockTable.WellFormed + (lt : LockTable I) (statusOf : I.RunId → RunState) : Prop := + ∀ p r, lt p = some r → ¬ (statusOf r).Terminal + +end Spec.System diff --git a/spec/Spec/System/Permission.lean b/spec/Spec/System/Permission.lean new file mode 100644 index 0000000..9edf44a --- /dev/null +++ b/spec/Spec/System/Permission.lean @@ -0,0 +1,99 @@ +import Spec.Prelude + +/-! +# Permission —— 角色、能力与授权 + +ADR-0004:权限走"飞书云文档式"——**协作者授权(grant)与操作设置(settings) +分离**;grant 是 `resource + principal + role`,role 取自封闭的 `read / edit / +manage`,且 **read ⊂ edit ⊂ manage** 累积赋能;强制释放锁是 **admin-only**,在 +role 体系之外。本模块把这套结构与"高 role 含低 role 全部能力"的单调性钉死。 +-/ + +namespace Spec.System + +/-- +协作者角色(`PINNED` 封闭, ADR-0004 逐字 "roles are read, edit, or manage")。 + +与 element-kind / RunState 不同,这里 ADR **明示**只有三个 role,故 `inductive` +是封闭授权,不是"尚未封闭"。 +-/ +inductive Role where + | read + | edit + | manage + +/-- +角色的**赋能层级**(`PINNED` 序, ADR-0004 read⊂edit⊂manage)。 + +把 ADR-0004 的累积包含关系数值化:read=0 ⊂ edit=1 ⊂ manage=2。它只服务于下面的 +`Role.le` 与能力推导,不对外承诺"层级就是个 `Nat`"。 +-/ +def Role.level : Role → Nat + | .read => 0 + | .edit => 1 + | .manage => 2 + +/-- 角色偏序:`r₁ ≤ r₂` 即 `r₁` 赋能不强于 `r₂`(`PINNED`, ADR-0004)。 -/ +def Role.le (r₁ r₂ : Role) : Prop := r₁.level ≤ r₂.level + +/-- +受 role 调控的操作能力(项 `PINNED` 取自 ADR-0004;**枚举完整性 `OPEN`**)。 + +逐项来源于 ADR-0004 对各 role 的展开:`view`(read);`discussComment` / +`editArtifact` / `triggerAgent` / `answerChoiceCard`(edit);`manageCollaborators` +/ `projectSettings` / `groupBinding` / `normalCancel`(manage)。 + +⚠ **完整性 OPEN**:这是 ADR 当前点名的能力,不保证穷尽;新增产品操作时可能扩。 +注意 **force-release 不在此列**——它是 admin-only override,见 `RequiresAdmin`。 +-/ +inductive Capability where + | view + | discussComment + | editArtifact + | triggerAgent + | answerChoiceCard + | manageCollaborators + | projectSettings + | groupBinding + | normalCancel + +/-- +某能力所**要求的最低角色**(`PINNED`, ADR-0004 各 role 的能力展开)。 + +read 级仅 `view`;edit 级是讨论/评论、编辑产物、触发 Claude、应答 choice card; +manage 级是协作者管理、项目设置、群绑定、常规取消。授权判定 `Role.can` 据此定义, +从而"谁能做什么"只有这一处真相。 +-/ +def Capability.requiredRole : Capability → Role + | .view => .read + | .discussComment | .editArtifact | .triggerAgent | .answerChoiceCard => .edit + | .manageCollaborators | .projectSettings | .groupBinding | .normalCancel => .manage + +/-- +角色 `r` **具备**能力 `c`(`PINNED`, ADR-0004)。 + +定义为"`c` 要求的最低角色 ≤ `r`"。这一处定义同时编码了 read⊂edit⊂manage 的累积性: +manage 自动具备所有 edit / read 能力,无需逐条列举。 +-/ +def Role.can (r : Role) (c : Capability) : Prop := c.requiredRole |>.le r + +/-- +**单调性**:角色越高,能力只增不减(`PINNED` 定理, ADR-0004 累积赋能)。 + +这是 read⊂edit⊂manage 的形式化保证:若 `r₁ ≤ r₂` 且 `r₁` 能做 `c`,则 `r₂` 也能。 +证明即偏序传递性——之所以成立得这么干净,正因为 `can` 是按"最低角色阈值"定义的。 +-/ +theorem Role.can_mono {r₁ r₂ : Role} {c : Capability} + (h : r₁.le r₂) (hc : r₁.can c) : r₂.can c := + Nat.le_trans hc h + +/-- +**强制释放锁**要求 admin,**在 role 体系之外**(`PINNED`, ADR-0004 admin-only override)。 + +ADR-0004 明确:force release 卡住的锁是异常操作,不是任何协作者 role 的能力—— +即便 `manage` 也不经由 `Role.can` 获得它。故它不是一个 `Capability`,而是这样一个 +独立谓词:仅当主体是 admin 时成立。`isAdmin` 由平台另行判定(本层不建 admin 模型)。 +-/ +def RequiresAdmin (isAdmin : Prop) : Prop := isAdmin + +end Spec.System diff --git a/spec/Spec/System/Run.lean b/spec/Spec/System/Run.lean new file mode 100644 index 0000000..ba7c1c8 --- /dev/null +++ b/spec/Spec/System/Run.lean @@ -0,0 +1,47 @@ +/-! +# Run —— AgentRun 状态机 + +一次 `@Claude` 创建一个 `AgentRun`(ADR-0001),它在生命周期里经历若干状态,并在 +**终止时释放项目锁**(ADR-0002)。本模块固定 run 的**状态**与**终止判定**——后者 +是 Lock 排他不变式(见 `Spec.System.Lock`)的依赖。 + +完整的合法转移关系**未在任何 ADR / likec4 散文里定下**,故本模块不臆造转移边, +只刻画状态本身与"何为终止"。 +-/ + +namespace Spec.System + +/-- +AgentRun 的运行状态(状态名 `PINNED` 取自 ADR-0001..0003 + likec4 散文; +**集合完整性 `OPEN`**)。 + +各状态来源:`active`(run 进行中)、`waitingForUser`(likec4 Run Orchestrator +"等待用户")、`completed` / `failed` / `timedOut` / `canceled`(ADR-0002 列出的 +释放锁时机)。 + +⚠ **完整性是 OPEN**:散文从未声明"状态恰好这些"。这里用 `inductive` 是为了能 +谈终止判定与不变式,**不等于**封闭授权——若实现侧发现还需要别的状态(如排队中 +的 pending),那是一个待 surface 的分歧点,**不得**默认本枚举已穷尽。这与 +element-kind 那种**明示开放**的宇宙性质不同:那里"开放"是决策,这里"未封闭"是 +尚未决策。 +-/ +inductive RunState where + | active + | waitingForUser + | completed + | failed + | timedOut + | canceled + +/-- +run 处于**终止态**(`PINNED`, ADR-0002)。 + +ADR-0002 钉死:锁在 run `completes / fails / times out / is canceled` 时释放。 +这四个状态即终止态;锁的释放时机由它定义(见 `Spec.System.Lock.LockTable.WellFormed`)。 +`active` 与 `waitingForUser` 非终止——后者虽在等待,run 仍占用项目(锁未释放)。 +-/ +def RunState.Terminal : RunState → Prop + | .completed | .failed | .timedOut | .canceled => True + | .active | .waitingForUser => False + +end Spec.System From 8599f472c0eda13b660f6a270dbfac841a0bcc39 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 01:18:48 +0800 Subject: [PATCH 04/29] =?UTF-8?q?docs(adr):=20add=20ADR-0008=20=E2=80=94?= =?UTF-8?q?=20declarative=20manifest=20+=20on-disk=20layout=20conventions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discharge the layout question ADR-0007 deferred. Fix the engineering-file on-disk arrangement so the checker recovers lesson order/membership without evaluating typst: - manifest.toml at root: [project] + [info] + ordered [[parts]] (kind+path) + [targets.*]. Array order IS lesson order (ADR-0005). Subsumes the samples' project.toml/info.toml and replaces main.typ's `#let parts`. - element.toml per element folder: explicit kind + scalar fields (self-describing, parent-dir grouping not load-bearing). - content fields are convention-named .typ siblings; the kind's JSON Schema (ADR-0006) is the source of truth for which .typ files must exist. Drops the samples' per-element main.typ wiring (now generated, not hand-authored). Surfaced gaps (deferred): per-file render override (ADR-0005 says the matrix is overridable in-file; MVP keeps rules in the render layer, no override), 题库 on-disk layout, manifest richness (per-part metadata, sectioning). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...008-engineering-file-layout-conventions.md | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/adr/0008-engineering-file-layout-conventions.md diff --git a/docs/adr/0008-engineering-file-layout-conventions.md b/docs/adr/0008-engineering-file-layout-conventions.md new file mode 100644 index 0000000..25bd3d9 --- /dev/null +++ b/docs/adr/0008-engineering-file-layout-conventions.md @@ -0,0 +1,128 @@ +# ADR 0008: Engineering-File Layout — Declarative Manifest + Element Descriptors + +## Status + +Accepted — for the layout conventions below. Some sub-decisions are deferred; +see *Open Questions / Deferred*. + +Discharges the layout question ADR-0007 left open. ADR-0007 fixed that the +engineering file is a real directory tree but explicitly deferred the concrete +layout: the manifest (name, location, contents), how element instances are +arranged and how their **order** (ADR-0005's `Lesson` is an ordered sequence) is +encoded, and how rich-content `.typ` files are named. This ADR fixes those. + +## Context + +Two real sample engineering files exist (TH-141, TH-144). They encode the lesson +as a typst file `main.typ` containing `#import`s of each element's `main.typ` +plus a `#let parts = (("segment", 模块), ("lemma", 模块), …)` ordered tuple list. +Each element is a folder under a per-kind directory (`segments/`, `examples/`, +`lemmas/`, `sop/`); its fields are typst source files (`textbook.typ`, +`problem.typ`/`solution.typ`, `stmt.typ`/`proof.typ`, `sop.typ`) wired up by the +element's own `main.typ` via `#let f = include "f.typ"`. + +That embryo has one property in tension with the contract: **the ordering +manifest is itself a typst script**. ADR-0005 rejected typst as the +definition/scripting language (its typing is too weak), and ADR-0006 moved the +kind schema off typst onto declarative JSON Schema. A checker that had to *parse +typst* to recover the lesson's element order would re-introduce exactly the +dependency those ADRs removed: the order — a first-class part of the `Lesson` +contract — would be locked inside an evaluated typst program. + +## Decision + +### The lesson manifest is declarative, not a typst script + +The lesson's order and membership live in a **declarative `manifest.toml`** at +the engineering-file root, read directly by the checker. TOML is chosen for +consistency with the samples' existing `project.toml` / `info.toml` / +`meta.toml`. It subsumes `project.toml` and `info.toml`. Shape: + +```toml +[project] +id = "…" # stable project id +name = "…" # folder/display name + +[info] # passed through to render targets verbatim +title = "…" +author = "…" + +[[parts]] # ORDER OF THIS ARRAY IS THE LESSON ORDER (ADR-0005) +kind = "segment" # one of the known kinds (ADR-0006 / stdlib set) +path = "segments/开场对照导言" # element folder, relative to root +# … one [[parts]] block per element, in order … + +[targets.student] # which export targets exist for this lesson +[targets.teacher] +``` + +The checker reads `parts` for order and membership. It **must not** parse typst +to recover ordering — the declarative array is the single source of truth. The +sample's `main.typ` `#let parts` is **replaced** by this; any typst entrypoint +that imports elements in order is a *generated build artifact* derived from the +manifest (see ADR-0007's note that the on-disk encoding of `Lesson` is separate +from "it's a tree"), never the canonical order. + +### An element folder is self-describing via `element.toml` + +Each element folder carries an **`element.toml`** declaring its kind and its +**scalar** fields (per the kind's JSON Schema, ADR-0006): + +```toml +kind = "example" +source = "41 届物理竞赛复赛第三大题(1)" # a scalar field of the example kind +``` + +`kind` is explicit in the descriptor (not inferred from the parent directory +name) so a folder is self-describing and the `segments/`-vs-`lemmas/` directory +grouping is a human convenience, not load-bearing for the checker. + +### `content` fields are convention-named `.typ` siblings + +A kind's schema (ADR-0006) marks some fields as the `content` extension type. +For each such field `F`, its value is the typst source file **`F.typ`** in the +element folder. The schema — not `element.toml`, not a per-element `main.typ` — +is the source of truth for *which* `.typ` files must exist. Concretely for the +MVP kind set: `segment` ⇒ `textbook.typ`; `example` ⇒ `problem.typ`, +`solution.typ`; `lemma` ⇒ `stmt.typ`, `proof.typ`; `sop` ⇒ `sop.typ`. This +**drops the samples' per-element `main.typ`** (`#let textbook = include …`): +that field-to-file wiring is now implied by the schema + naming convention and +materialized by the generated driver, not hand-authored. + +A `content` field's denotation remains ADR-0006's: the **module body content** +of `F.typ`, not its `#let` exports. The file sits at a real relative path +(ADR-0007), so its spans resolve and its relative imports anchor within the tree +(ADR-0006 import boundary). + +## Consequences + +- The checker recovers the full ordered lesson from `manifest.toml` + each + `element.toml` without evaluating any typst — order and membership are plain + data, diffable and greppable. +- A typst driver entrypoint (static `#import`s + the render call) becomes a + *generated artifact* the renderer produces from the manifest per target; it is + never the canonical lesson and is not hand-edited. +- Element folders lose their per-element `main.typ`; `element.toml` + the schema's + content-file convention replace it. Migrating the samples is a mechanical + rewrite (the migration is part of the MVP). +- `path` being a real relative folder makes "the lesson references element X" + inspectable on disk; a dangling `path` is a structural error the checker can + point at. + +## Open Questions / Deferred + +- **Per-file render override.** ADR-0005 says the (kind × target) render matrix + lives *in* the engineering file with framework **defaults** the file may + **override**. This ADR's `[targets.*]` only *declares which targets exist*; the + MVP keeps the render rules entirely in the framework render layer with **no + per-file override**. Whether/how a lesson overrides a render rule (a block in + `manifest.toml`? a per-target file?) is deferred until a real override need + appears. This is a known, surfaced gap against ADR-0005, not an oversight. +- **Question bank (题库) on disk.** TH-144 has a `题目/` tree of problem/answer + pairs outside `parts`. Its layout and its element relationship (ADR-0005 + Deferred: reference vs inline) stay open; out of MVP scope. +- **Manifest richness.** Per-part metadata, grouping/sectioning (TH-144's A/B/C + structure is only in folder names today), and target-specific options on a part + are not modeled here; add when needed rather than guessing now. +- **stdlib kind set & schema dialect** remain as ADR-0006 left them; this ADR + only fixes the on-disk *arrangement*, not the schemas themselves. From c810ea613766c43d910e4b3f863a387c7b4a0ad3 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 01:27:32 +0800 Subject: [PATCH 05/29] feat(checker): scaffold repo-root cargo workspace + cph-diag + cph-model (WU-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand up the Rust implementation of the rule-based checker (the thing that "stands in Lean's position" at product runtime). Single repo-wide cargo workspace at the repo root so future components (e.g. an exporter) can reuse shared crates. - workspace: 6 member crates under crates/; cph-schema/cph-typst/cph-check/ cph-cli are stubs for later WUs that compile clean today. - cph-diag: shared diagnostic vocabulary. Severity { Warning, Error } mirrors Spec.Courseware.Diagnostic.Severity exactly (two-valued, no info/note — a contract decision documented in-code since there's no CI gate for alignment). Closed DiagCode set with stable string forms; Diagnostic with optional span + fix hint; Display renders `error[CODE]: msg / --> file:line:col / hint: …`. - cph-model: the ADR-0008 loader. Reads manifest.toml ([project]/[info]/ordered [[parts]]/[targets.*]) + each part's element.toml into an ordered Lesson (mirrors Lean `Lesson = List (Element P)`). Structure-only: cross-checks part.kind == element.toml kind, rejects `..` traversal; does NOT do schema validation (WU-3), content-file existence (WU-3), or typst (WU-4). `load(root) -> (Option, Vec)`: Some even on collectible part errors, None only on hard manifest failure. - 13 tests (7 cph-diag, 6 cph-model incl. static fixtures doubling as format docs). build + test + clippy -D warnings + fmt --check all green. Judgment call flagged for review: cph-diag has no ManifestMalformed code, so manifest-structure errors map to SchemaViolation (TODO noted in-code). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 5 + Cargo.lock | 187 ++++++++ Cargo.toml | 23 + crates/README.md | 34 ++ crates/cph-check/Cargo.toml | 7 + crates/cph-check/src/lib.rs | 8 + crates/cph-cli/Cargo.toml | 11 + crates/cph-cli/src/main.rs | 10 + crates/cph-diag/Cargo.toml | 8 + crates/cph-diag/src/lib.rs | 275 ++++++++++++ crates/cph-model/Cargo.toml | 10 + crates/cph-model/src/lib.rs | 418 ++++++++++++++++++ .../fixtures/kind-mismatch/manifest.toml | 12 + .../kind-mismatch/segments/intro/element.toml | 1 + .../kind-mismatch/segments/intro/textbook.typ | 1 + .../fixtures/malformed-manifest/manifest.toml | 3 + .../tests/fixtures/missing-part/manifest.toml | 16 + .../missing-part/segments/intro/element.toml | 1 + .../missing-part/segments/intro/textbook.typ | 1 + .../fixtures/valid/lemmas/young/element.toml | 2 + .../fixtures/valid/lemmas/young/proof.typ | 1 + .../fixtures/valid/lemmas/young/stmt.typ | 1 + .../tests/fixtures/valid/manifest.toml | 18 + .../valid/segments/intro/element.toml | 1 + .../valid/segments/intro/textbook.typ | 1 + crates/cph-model/tests/load.rs | 125 ++++++ crates/cph-schema/Cargo.toml | 7 + crates/cph-schema/src/lib.rs | 9 + crates/cph-typst/Cargo.toml | 7 + crates/cph-typst/src/lib.rs | 9 + 30 files changed, 1212 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/README.md create mode 100644 crates/cph-check/Cargo.toml create mode 100644 crates/cph-check/src/lib.rs create mode 100644 crates/cph-cli/Cargo.toml create mode 100644 crates/cph-cli/src/main.rs create mode 100644 crates/cph-diag/Cargo.toml create mode 100644 crates/cph-diag/src/lib.rs create mode 100644 crates/cph-model/Cargo.toml create mode 100644 crates/cph-model/src/lib.rs create mode 100644 crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/element.toml create mode 100644 crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/textbook.typ create mode 100644 crates/cph-model/tests/fixtures/malformed-manifest/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/missing-part/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/missing-part/segments/intro/element.toml create mode 100644 crates/cph-model/tests/fixtures/missing-part/segments/intro/textbook.typ create mode 100644 crates/cph-model/tests/fixtures/valid/lemmas/young/element.toml create mode 100644 crates/cph-model/tests/fixtures/valid/lemmas/young/proof.typ create mode 100644 crates/cph-model/tests/fixtures/valid/lemmas/young/stmt.typ create mode 100644 crates/cph-model/tests/fixtures/valid/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/valid/segments/intro/element.toml create mode 100644 crates/cph-model/tests/fixtures/valid/segments/intro/textbook.typ create mode 100644 crates/cph-model/tests/load.rs create mode 100644 crates/cph-schema/Cargo.toml create mode 100644 crates/cph-schema/src/lib.rs create mode 100644 crates/cph-typst/Cargo.toml create mode 100644 crates/cph-typst/src/lib.rs diff --git a/.gitignore b/.gitignore index ec17f5f..3acbb49 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,10 @@ .lake/ **/.lake/ +# Rust / Cargo build artifacts (repo-wide cargo workspace at root) +/target +**/*.pdf +.cph/ + # OS / editor .DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b21cc36 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,187 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cph-check" +version = "0.1.0" + +[[package]] +name = "cph-cli" +version = "0.1.0" + +[[package]] +name = "cph-diag" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "cph-model" +version = "0.1.0" +dependencies = [ + "cph-diag", + "serde", + "toml", +] + +[[package]] +name = "cph-schema" +version = "0.1.0" + +[[package]] +name = "cph-typst" +version = "0.1.0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ca2868d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[workspace] +resolver = "2" +members = [ + "crates/cph-diag", + "crates/cph-model", + "crates/cph-schema", + "crates/cph-typst", + "crates/cph-check", + "crates/cph-cli", +] + +# Shared metadata for all workspace crates. Individual crates inherit these via +# `version.workspace = true` / `edition.workspace = true`. +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +toml = "0.8" +cph-diag = { path = "crates/cph-diag" } +cph-model = { path = "crates/cph-model" } diff --git a/crates/README.md b/crates/README.md new file mode 100644 index 0000000..11eb86a --- /dev/null +++ b/crates/README.md @@ -0,0 +1,34 @@ +# crates/ + +These crates implement the rule-based lesson checker that aligns to the +semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005) +laid out per ADR-0008 (declarative `manifest.toml` + per-element +`element.toml`), validates structure and content, and emits diagnostics. +`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader), +and `cph-typst` (the typst `World` / compile / span-mapping layer) are +deliberately reusable by future components such as an `exporter`, which is why +they live in this repo-wide `crates/` directory rather than under any single +component; `cph-schema` (kind JSON Schemas + validation), `cph-check` +(orchestration + render-coverage) and `cph-cli` (the `cph` command-line +entrypoint) are the checker proper. + +## Crate map + +| crate | owner | role | +|---------------|-------|------| +| `cph-diag` | WU-1 | shared diagnostic vocabulary (`Severity`, `DiagCode`, `Diagnostic`, `SourceSpan`) — reusable | +| `cph-model` | WU-1 | parses the ADR-0008 layout into an in-memory ordered `Lesson` — reusable | +| `cph-schema` | WU-3 | the 4 stdlib kind JSON Schemas + structural validation | +| `cph-typst` | WU-4 | typst `World`, driver generation, compile, PDF, span mapping — reusable | +| `cph-check` | WU-5 | orchestration: render-coverage and the full check pipeline | +| `cph-cli` | WU-5 | the `cph` command-line entrypoint | + +## Build + +```sh +# from the repository root (the cargo workspace root) +cargo build # all crates +cargo test # cph-diag + cph-model unit/integration tests +cargo clippy --all-targets -- -D warnings +cargo fmt --check +``` diff --git a/crates/cph-check/Cargo.toml b/crates/cph-check/Cargo.toml new file mode 100644 index 0000000..cb1c011 --- /dev/null +++ b/crates/cph-check/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cph-check" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] diff --git a/crates/cph-check/src/lib.rs b/crates/cph-check/src/lib.rs new file mode 100644 index 0000000..947c084 --- /dev/null +++ b/crates/cph-check/src/lib.rs @@ -0,0 +1,8 @@ +//! `cph-check` — check-pipeline orchestration. +//! +//! Owned by **WU-5**. Ties together `cph-model` (load), `cph-schema` +//! (validate), and `cph-typst` (compile), runs the render-coverage rule +//! (ADR-0005's "missing render ⇒ warning"), and collects all diagnostics. + +// WU-5: implement the orchestrator + render-coverage rule. Empty for now so the +// workspace compiles. diff --git a/crates/cph-cli/Cargo.toml b/crates/cph-cli/Cargo.toml new file mode 100644 index 0000000..58ecd0a --- /dev/null +++ b/crates/cph-cli/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "cph-cli" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "cph" +path = "src/main.rs" + +[dependencies] diff --git a/crates/cph-cli/src/main.rs b/crates/cph-cli/src/main.rs new file mode 100644 index 0000000..67255fa --- /dev/null +++ b/crates/cph-cli/src/main.rs @@ -0,0 +1,10 @@ +//! `cph` — the command-line entrypoint for the checker. +//! +//! Owned by **WU-5**. Will parse args, run the `cph-check` pipeline against an +//! engineering file, and print diagnostics. A stub for now so the workspace +//! builds and produces a runnable binary. + +// WU-5: wire up arg parsing + the check pipeline + diagnostic rendering. +fn main() { + println!("cph: not yet implemented"); +} diff --git a/crates/cph-diag/Cargo.toml b/crates/cph-diag/Cargo.toml new file mode 100644 index 0000000..fe44e86 --- /dev/null +++ b/crates/cph-diag/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "cph-diag" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true } diff --git a/crates/cph-diag/src/lib.rs b/crates/cph-diag/src/lib.rs new file mode 100644 index 0000000..c6fabd7 --- /dev/null +++ b/crates/cph-diag/src/lib.rs @@ -0,0 +1,275 @@ +//! `cph-diag` — the shared diagnostic vocabulary for the checker. +//! +//! Every other crate in the workspace depends on these types to report +//! problems. The vocabulary is intentionally small and stable: a [`Severity`] +//! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an +//! optional [`SourceSpan`] pointing back at the offending source, and a +//! [`Diagnostic`] tying them together with a human message and a fix hint. +//! +//! All public types derive `Serialize` (downstream prints diagnostics as JSON), +//! plus `Debug`, `Clone`, `PartialEq`, `Eq`. + +use std::fmt; +use std::ops::Range; +use std::path::PathBuf; + +use serde::Serialize; + +/// Severity of a diagnostic. +/// +/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic +/// master (`spec/Spec/Courseware/Diagnostic.lean`), whose definition is +/// exactly: +/// +/// ```text +/// inductive Severity where +/// | warning +/// | error +/// ``` +/// +/// This two-valued shape is a **contract decision**, not an accident: the Lean +/// module pins `Severity` to exactly `warning | error` and states the finer +/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore +/// do **not** add an info/note level here. `error` blocks (the artifact is +/// invalid); `warning` does not block (the artifact still exports, but with +/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning"). +/// +/// There is no CI gate enforcing this alignment (see the repo constitution); +/// it is maintained by review, which is why this correspondence is documented +/// here rather than only in the spec. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum Severity { + /// Non-blocking: the artifact still exports, but is lossy / has an ignored + /// element. Mirrors Lean `Severity.warning`. + Warning, + /// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`. + Error, +} + +/// A location in a source file within the engineering-file tree. +/// +/// `file` is relative to the engineering-file root so spans are portable and +/// diffable. `line` / `col` are 1-based (as editors display them); `byte_range` +/// is a 0-based byte offset range into the file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SourceSpan { + /// Path to the file, relative to the engineering-file root. + pub file: PathBuf, + /// Byte offset range into the file (0-based). + pub byte_range: Range, + /// 1-based line number. + pub line: u32, + /// 1-based column number. + pub col: u32, +} + +/// Stable, machine-readable diagnostic codes. +/// +/// This set is **closed**: codes are part of the checker's public contract, so +/// downstream tooling can match on the string form (see [`DiagCode::as_str`]). +/// Do not invent codes outside this enum without a deliberate decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum DiagCode { + /// A `[[parts]]` entry references a folder/path that does not exist. + PartPathMissing, + /// An element declares a `kind` that is not a known kind. + UnknownKind, + /// A required `content` `.typ` file (per the kind schema) is missing. + MissingContentFile, + /// A reference (e.g. a relative import or cross-element link) does not + /// resolve. + DanglingReference, + /// Instance data does not conform to its kind's JSON Schema. + SchemaViolation, + /// A typst source failed to compile. + TypstCompile, + /// An element is ignored under a render target (ADR-0005: warning). + RenderIgnored, +} + +impl DiagCode { + /// The stable string form of this code, e.g. `"E-PART-PATH"`. + /// + /// The `E-` / `W-` prefix reflects the *typical* severity of the code but + /// is not authoritative — the actual severity lives on the [`Diagnostic`]. + pub fn as_str(&self) -> &'static str { + match self { + DiagCode::PartPathMissing => "E-PART-PATH", + DiagCode::UnknownKind => "E-UNKNOWN-KIND", + DiagCode::MissingContentFile => "E-MISSING-CONTENT", + DiagCode::DanglingReference => "E-DANGLING-REF", + DiagCode::SchemaViolation => "E-SCHEMA", + DiagCode::TypstCompile => "E-TYPST-COMPILE", + DiagCode::RenderIgnored => "W-RENDER-IGNORED", + } + } +} + +impl fmt::Display for DiagCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A single diagnostic emitted by the checker. +/// +/// Construct via [`Diagnostic::error`] / [`Diagnostic::warning`] and attach an +/// optional [`SourceSpan`] / hint with the builder methods +/// [`Diagnostic::with_span`] / [`Diagnostic::with_hint`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Diagnostic { + /// How serious this diagnostic is. + pub severity: Severity, + /// The stable machine code. + pub code: DiagCode, + /// Human-readable message. + pub message: String, + /// Optional location this diagnostic points at. + pub span: Option, + /// Optional fix hint — the product's core value. + pub hint: Option, +} + +impl Diagnostic { + /// Create an `error`-severity diagnostic. + pub fn error(code: DiagCode, message: impl Into) -> Self { + Diagnostic { + severity: Severity::Error, + code, + message: message.into(), + span: None, + hint: None, + } + } + + /// Create a `warning`-severity diagnostic. + pub fn warning(code: DiagCode, message: impl Into) -> Self { + Diagnostic { + severity: Severity::Warning, + code, + message: message.into(), + span: None, + hint: None, + } + } + + /// Attach a source span (builder-style). + pub fn with_span(mut self, span: SourceSpan) -> Self { + self.span = Some(span); + self + } + + /// Attach a fix hint (builder-style). + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } +} + +impl fmt::Display for Severity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Severity::Warning => "warning", + Severity::Error => "error", + }) + } +} + +impl fmt::Display for Diagnostic { + /// Renders one line `error[E-PART-PATH]: `, plus an indented + /// `--> file:line:col` line when a span is present, and a `hint: ...` line + /// when a hint is present. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}[{}]: {}", self.severity, self.code, self.message)?; + if let Some(span) = &self.span { + write!( + f, + "\n --> {}:{}:{}", + span.file.display(), + span.line, + span.col + )?; + } + if let Some(hint) = &self.hint { + write!(f, "\n hint: {hint}")?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn code_as_str_is_stable() { + assert_eq!(DiagCode::PartPathMissing.as_str(), "E-PART-PATH"); + assert_eq!(DiagCode::RenderIgnored.as_str(), "W-RENDER-IGNORED"); + assert_eq!(DiagCode::SchemaViolation.as_str(), "E-SCHEMA"); + // Display matches as_str. + assert_eq!(DiagCode::UnknownKind.to_string(), "E-UNKNOWN-KIND"); + } + + #[test] + fn error_constructor() { + let d = Diagnostic::error(DiagCode::UnknownKind, "no such kind 'frob'"); + assert_eq!(d.severity, Severity::Error); + assert_eq!(d.code, DiagCode::UnknownKind); + assert_eq!(d.message, "no such kind 'frob'"); + assert!(d.span.is_none()); + assert!(d.hint.is_none()); + } + + #[test] + fn warning_constructor() { + let d = Diagnostic::warning(DiagCode::RenderIgnored, "ignored under 'teacher'"); + assert_eq!(d.severity, Severity::Warning); + assert_eq!(d.code, DiagCode::RenderIgnored); + } + + #[test] + fn builders_attach_span_and_hint() { + let span = SourceSpan { + file: PathBuf::from("segments/intro/textbook.typ"), + byte_range: 10..20, + line: 3, + col: 5, + }; + let d = Diagnostic::error(DiagCode::TypstCompile, "unclosed bracket") + .with_span(span.clone()) + .with_hint("close the '[' opened on line 2"); + assert_eq!(d.span, Some(span)); + assert_eq!(d.hint.as_deref(), Some("close the '[' opened on line 2")); + } + + #[test] + fn display_bare() { + let d = Diagnostic::error(DiagCode::PartPathMissing, "folder not found"); + assert_eq!(d.to_string(), "error[E-PART-PATH]: folder not found"); + } + + #[test] + fn display_with_span_and_hint() { + let d = Diagnostic::error(DiagCode::PartPathMissing, "folder not found") + .with_span(SourceSpan { + file: PathBuf::from("manifest.toml"), + byte_range: 0..0, + line: 12, + col: 3, + }) + .with_hint("create the folder 'segments/intro' or fix the path"); + assert_eq!( + d.to_string(), + "error[E-PART-PATH]: folder not found\n --> manifest.toml:12:3\n hint: create the folder 'segments/intro' or fix the path" + ); + } + + #[test] + fn display_warning() { + let d = Diagnostic::warning(DiagCode::RenderIgnored, "ignored under 'teacher'"); + assert_eq!( + d.to_string(), + "warning[W-RENDER-IGNORED]: ignored under 'teacher'" + ); + } +} diff --git a/crates/cph-model/Cargo.toml b/crates/cph-model/Cargo.toml new file mode 100644 index 0000000..6953601 --- /dev/null +++ b/crates/cph-model/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "cph-model" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +cph-diag = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } diff --git a/crates/cph-model/src/lib.rs b/crates/cph-model/src/lib.rs new file mode 100644 index 0000000..13263ce --- /dev/null +++ b/crates/cph-model/src/lib.rs @@ -0,0 +1,418 @@ +//! `cph-model` — load the ADR-0008 declarative layout into an in-memory lesson. +//! +//! This crate is the **loader**, not the full checker. It reads +//! `/manifest.toml` (project / info / ordered `[[parts]]` / declared +//! `[targets.*]`) and each part's `//element.toml`, and produces an +//! ordered [`Lesson`] — mirroring the Lean master's `Lesson = List (Element P)` +//! (`spec/Spec/Courseware/Lesson.lean`), where the order of `parts` carries +//! teaching semantics. +//! +//! Scope boundaries (deliberately staying in lane): +//! - It validates **structure** only: manifest shape, element.toml shape, and +//! the cross-check `part.kind == element.toml kind`. +//! - It does **not** validate instance data against a kind's JSON Schema (that +//! is WU-3 / `cph-schema`), does **not** check that `content` `.typ` files +//! exist (also schema-driven, WU-3), and does **not** compile typst (WU-4). +//! +//! Entry point: [`load`]. + +use std::path::{Component, Path, PathBuf}; + +use cph_diag::{DiagCode, Diagnostic}; +use serde::{Deserialize, Serialize}; + +/// An ordered, in-memory lesson loaded from an engineering file. +/// +/// Mirrors the Lean master's `Lesson = List (Element P)`: `parts` is an ordered +/// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest +/// is declarative" — the `[[parts]]` array order is the single source of truth). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Lesson { + /// `[project]` from the manifest (id, name). + pub project: Project, + /// `[info]` from the manifest (title, optional author). + pub info: Info, + /// The ordered parts — the lesson's element sequence. + pub parts: Vec, + /// Declared export target names, e.g. `["student", "teacher"]`, collected + /// from the `[targets.]` tables. + pub targets: Vec, + /// Engineering-file root (absolute), for resolving part paths. + pub root: PathBuf, +} + +/// `[project]` table. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Project { + /// Stable project id. + pub id: String, + /// Folder / display name. + pub name: String, +} + +/// `[info]` table (passed through to render targets verbatim). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Info { + /// Lesson title. + pub title: String, + /// Author, optional. + pub author: Option, +} + +/// One `[[parts]]` entry plus its loaded element descriptor. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Part { + /// Declared kind from the manifest (one of the known kinds; ADR-0006). + pub kind: String, + /// Element folder path **as written in the manifest** (relative to root), + /// kept verbatim for diagnostics / display. + pub path: PathBuf, + /// The element's self-description loaded from its `element.toml`. + pub descriptor: ElementDescriptor, +} + +/// An element's `element.toml`, parsed into kind + remaining scalar fields. +/// +/// ADR-0008: `kind` is explicit in the descriptor (not inferred from the parent +/// directory), so a folder is self-describing. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ElementDescriptor { + /// `kind` from `element.toml`. Must equal the part's `kind`; a mismatch is + /// reported as a diagnostic (see [`load`]). + pub kind: String, + /// Element folder, absolute path. + pub dir: PathBuf, + /// The remaining `element.toml` keys (the scalar fields). Schema validation + /// of these is WU-3's job, not this loader's. + pub scalars: toml::Table, +} + +// --- raw deserialization shapes (mirror the on-disk TOML) --------------------- + +#[derive(Debug, Deserialize)] +struct RawManifest { + project: Option, + info: Option, + #[serde(default)] + parts: Vec, + #[serde(default)] + targets: toml::Table, +} + +#[derive(Debug, Deserialize)] +struct RawProject { + id: String, + name: String, +} + +#[derive(Debug, Deserialize)] +struct RawInfo { + title: String, + author: Option, +} + +#[derive(Debug, Deserialize)] +struct RawPart { + kind: String, + path: String, +} + +/// Load the engineering file at `root` into a [`Lesson`]. +/// +/// Returns `(Option, Vec)`: +/// - `Some(lesson)` whenever the manifest parses into a `Lesson` at all. The +/// structure is produced even when individual parts have problems, so the +/// WU-5 orchestrator gets **both** the partial lesson and the collected +/// diagnostics. +/// - `None` only on a hard failure where no `Lesson` can be built: the +/// `manifest.toml` is missing/unreadable, is not valid TOML, or lacks the +/// required `[project]` / `[info]` tables. In that case the diagnostics +/// describe the hard failure. +/// +/// Collected (non-fatal) diagnostics include: a part path that does not exist +/// or escapes the root via `..`, a missing/malformed `element.toml`, and a +/// `part.kind != element.toml kind` mismatch. +/// +/// ## Diagnostic-code mapping (judgment call, WU-1) +/// +/// `cph-diag`'s code set is closed and has **no** dedicated "manifest +/// malformed" code. We deliberately do not invent one here. Until such a code +/// is added, manifest-level structural errors (bad TOML, missing `[project]` / +/// `[info]`) are mapped to the closest existing code, [`DiagCode::SchemaViolation`], +/// with a message making the real cause clear. A genuinely missing **part +/// path** uses [`DiagCode::PartPathMissing`] (its actual meaning); a missing / +/// unreadable / malformed `element.toml` also maps to `SchemaViolation`. +// TODO(cph-diag): consider adding a dedicated `ManifestMalformed` code so +// manifest-structure errors don't overload `SchemaViolation`. +pub fn load(root: &Path) -> (Option, Vec) { + let mut diags = Vec::new(); + + let manifest_path = root.join("manifest.toml"); + + let manifest_src = match std::fs::read_to_string(&manifest_path) { + Ok(s) => s, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("cannot read manifest.toml: {e}"), + ) + .with_hint(format!( + "expected an engineering-file manifest at {}", + manifest_path.display() + )), + ); + return (None, diags); + } + }; + + let raw: RawManifest = match toml::from_str(&manifest_src) { + Ok(r) => r, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("manifest.toml is not valid TOML: {e}"), + ) + .with_hint("fix the TOML syntax in manifest.toml"), + ); + return (None, diags); + } + }; + + // [project] and [info] are required to build a Lesson at all. + let project = match raw.project { + Some(p) => Project { + id: p.id, + name: p.name, + }, + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + "manifest.toml is missing the required [project] table", + ) + .with_hint("add a [project] table with `id` and `name`"), + ); + return (None, diags); + } + }; + + let info = match raw.info { + Some(i) => Info { + title: i.title, + author: i.author, + }, + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + "manifest.toml is missing the required [info] table", + ) + .with_hint("add an [info] table with at least `title`"), + ); + return (None, diags); + } + }; + + // Target names are just the keys of the [targets.*] table; order is not + // semantically meaningful, but we preserve the TOML document order. + let targets: Vec = raw.targets.keys().cloned().collect(); + + // Load each part. Problems are collected, not fatal: we still build the + // Part (with a best-effort descriptor) so order/membership is observable. + let mut parts = Vec::with_capacity(raw.parts.len()); + for raw_part in raw.parts { + let rel_path = PathBuf::from(&raw_part.path); + + // Reject `..` traversal: a part path must stay within the root. + if has_parent_traversal(&rel_path) { + diags.push( + Diagnostic::error( + DiagCode::PartPathMissing, + format!( + "part path '{}' escapes the engineering-file root via '..'", + raw_part.path + ), + ) + .with_hint("part paths must be relative folders inside the engineering file"), + ); + // Still record the part with an empty descriptor so order is kept. + parts.push(Part { + kind: raw_part.kind.clone(), + path: rel_path.clone(), + descriptor: ElementDescriptor { + kind: raw_part.kind, + dir: root.join(&rel_path), + scalars: toml::Table::new(), + }, + }); + continue; + } + + let dir = root.join(&rel_path); + + let descriptor = if !dir.is_dir() { + diags.push( + Diagnostic::error( + DiagCode::PartPathMissing, + format!("part folder '{}' does not exist", raw_part.path), + ) + .with_hint(format!( + "create the folder '{}' or fix the `path` in manifest.toml", + raw_part.path + )), + ); + ElementDescriptor { + kind: raw_part.kind.clone(), + dir, + scalars: toml::Table::new(), + } + } else { + load_descriptor(&dir, &rel_path, &raw_part.kind, &mut diags) + }; + + parts.push(Part { + kind: raw_part.kind, + path: rel_path, + descriptor, + }); + } + + let lesson = Lesson { + project, + info, + parts, + targets, + root: root.to_path_buf(), + }; + (Some(lesson), diags) +} + +/// Load one element's `element.toml` into an [`ElementDescriptor`], collecting +/// diagnostics. On a missing/malformed `element.toml` the descriptor falls back +/// to the part's declared kind with empty scalars so the lesson stays buildable. +fn load_descriptor( + dir: &Path, + rel_path: &Path, + part_kind: &str, + diags: &mut Vec, +) -> ElementDescriptor { + let element_toml = dir.join("element.toml"); + let rel_element_toml = rel_path.join("element.toml"); + + let src = match std::fs::read_to_string(&element_toml) { + Ok(s) => s, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "cannot read element.toml for part '{}': {e}", + rel_path.display() + ), + ) + .with_hint(format!( + "add an element.toml at {} with at least `kind = \"{part_kind}\"`", + rel_element_toml.display() + )), + ); + return ElementDescriptor { + kind: part_kind.to_string(), + dir: dir.to_path_buf(), + scalars: toml::Table::new(), + }; + } + }; + + let mut table: toml::Table = match toml::from_str(&src) { + Ok(t) => t, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "element.toml for part '{}' is not valid TOML: {e}", + rel_path.display() + ), + ) + .with_hint("fix the TOML syntax in element.toml"), + ); + return ElementDescriptor { + kind: part_kind.to_string(), + dir: dir.to_path_buf(), + scalars: toml::Table::new(), + }; + } + }; + + // `kind` is required and must be a string. Pull it out of the scalar map so + // `scalars` ends up being "the remaining keys". + let kind = match table.remove("kind") { + Some(toml::Value::String(k)) => k, + Some(_) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "element.toml for part '{}' has a non-string `kind`", + rel_path.display() + ), + ) + .with_hint("`kind` must be a string, e.g. `kind = \"lemma\"`"), + ); + part_kind.to_string() + } + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "element.toml for part '{}' is missing the required `kind` key", + rel_path.display() + ), + ) + .with_hint(format!("add `kind = \"{part_kind}\"` to element.toml")), + ); + part_kind.to_string() + } + }; + + // Cross-check: the descriptor's kind must equal the part's declared kind. + if kind != part_kind { + diags.push( + Diagnostic::error( + DiagCode::UnknownKind, + format!( + "kind mismatch for part '{}': manifest says '{part_kind}', element.toml says '{kind}'", + rel_path.display() + ), + ) + .with_hint("make the manifest `kind` and the element.toml `kind` agree"), + ); + } + + ElementDescriptor { + kind, + dir: dir.to_path_buf(), + scalars: table, + } +} + +/// True if `path` contains any `..` component (parent-dir traversal). +fn has_parent_traversal(path: &Path) -> bool { + path.components().any(|c| matches!(c, Component::ParentDir)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parent_traversal_detection() { + assert!(has_parent_traversal(Path::new("../x"))); + assert!(has_parent_traversal(Path::new("a/../b"))); + assert!(!has_parent_traversal(Path::new("a/b/c"))); + assert!(!has_parent_traversal(Path::new("segments/intro"))); + } +} diff --git a/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml b/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml new file mode 100644 index 0000000..fedd3aa --- /dev/null +++ b/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "fixture-kind-mismatch" +name = "kind-mismatch" + +[info] +title = "kind 不一致测试" + +[[parts]] +kind = "segment" +path = "segments/intro" + +[targets.student] diff --git a/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/element.toml b/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/textbook.typ b/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/textbook.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/textbook.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/fixtures/malformed-manifest/manifest.toml b/crates/cph-model/tests/fixtures/malformed-manifest/manifest.toml new file mode 100644 index 0000000..4f1b473 --- /dev/null +++ b/crates/cph-model/tests/fixtures/malformed-manifest/manifest.toml @@ -0,0 +1,3 @@ +[project +id = "broken" +this is not valid toml = = diff --git a/crates/cph-model/tests/fixtures/missing-part/manifest.toml b/crates/cph-model/tests/fixtures/missing-part/manifest.toml new file mode 100644 index 0000000..d54f585 --- /dev/null +++ b/crates/cph-model/tests/fixtures/missing-part/manifest.toml @@ -0,0 +1,16 @@ +[project] +id = "fixture-missing-part" +name = "missing-part" + +[info] +title = "缺部件测试" + +[[parts]] +kind = "segment" +path = "segments/intro" + +[[parts]] +kind = "lemma" +path = "lemmas/does-not-exist" + +[targets.student] diff --git a/crates/cph-model/tests/fixtures/missing-part/segments/intro/element.toml b/crates/cph-model/tests/fixtures/missing-part/segments/intro/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/crates/cph-model/tests/fixtures/missing-part/segments/intro/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/crates/cph-model/tests/fixtures/missing-part/segments/intro/textbook.typ b/crates/cph-model/tests/fixtures/missing-part/segments/intro/textbook.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/missing-part/segments/intro/textbook.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/fixtures/valid/lemmas/young/element.toml b/crates/cph-model/tests/fixtures/valid/lemmas/young/element.toml new file mode 100644 index 0000000..7dcea61 --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/lemmas/young/element.toml @@ -0,0 +1,2 @@ +kind = "lemma" +source = "测试引理" diff --git a/crates/cph-model/tests/fixtures/valid/lemmas/young/proof.typ b/crates/cph-model/tests/fixtures/valid/lemmas/young/proof.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/lemmas/young/proof.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/fixtures/valid/lemmas/young/stmt.typ b/crates/cph-model/tests/fixtures/valid/lemmas/young/stmt.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/lemmas/young/stmt.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/fixtures/valid/manifest.toml b/crates/cph-model/tests/fixtures/valid/manifest.toml new file mode 100644 index 0000000..227556b --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/manifest.toml @@ -0,0 +1,18 @@ +[project] +id = "fixture-valid" +name = "valid-2-part" + +[info] +title = "测试课:两个部件" +author = "范式教育教研组" + +[[parts]] +kind = "segment" +path = "segments/intro" + +[[parts]] +kind = "lemma" +path = "lemmas/young" + +[targets.student] +[targets.teacher] diff --git a/crates/cph-model/tests/fixtures/valid/segments/intro/element.toml b/crates/cph-model/tests/fixtures/valid/segments/intro/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/segments/intro/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/crates/cph-model/tests/fixtures/valid/segments/intro/textbook.typ b/crates/cph-model/tests/fixtures/valid/segments/intro/textbook.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/segments/intro/textbook.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/load.rs b/crates/cph-model/tests/load.rs new file mode 100644 index 0000000..3ab446b --- /dev/null +++ b/crates/cph-model/tests/load.rs @@ -0,0 +1,125 @@ +//! Integration tests for `cph_model::load`, driven by static fixtures under +//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0008 +//! on-disk format. + +use std::path::PathBuf; + +use cph_diag::DiagCode; +use cph_model::load; + +/// Absolute path to a fixture engineering-file root. +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +#[test] +fn valid_two_part_lesson_loads_in_order_with_no_errors() { + let (lesson, diags) = load(&fixture("valid")); + + let lesson = lesson.expect("valid fixture must produce a Lesson"); + assert!( + diags.is_empty(), + "valid fixture must have no diagnostics, got: {diags:?}" + ); + + assert_eq!(lesson.project.id, "fixture-valid"); + assert_eq!(lesson.project.name, "valid-2-part"); + assert_eq!(lesson.info.title, "测试课:两个部件"); + assert_eq!(lesson.info.author.as_deref(), Some("范式教育教研组")); + + // Parts preserve declared order: segment first, lemma second. + assert_eq!(lesson.parts.len(), 2); + assert_eq!(lesson.parts[0].kind, "segment"); + assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro")); + assert_eq!(lesson.parts[0].descriptor.kind, "segment"); + assert_eq!(lesson.parts[1].kind, "lemma"); + assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young")); + assert_eq!(lesson.parts[1].descriptor.kind, "lemma"); + + // `source` scalar survives on the lemma descriptor; `kind` is removed. + let scalars = &lesson.parts[1].descriptor.scalars; + assert_eq!( + scalars.get("source").and_then(|v| v.as_str()), + Some("测试引理") + ); + assert!(scalars.get("kind").is_none()); + + // Targets are collected from [targets.*]. + let mut targets = lesson.targets.clone(); + targets.sort(); + assert_eq!(targets, vec!["student".to_string(), "teacher".to_string()]); + + // Descriptor dir is absolute, anchored under the root. + assert!(lesson.parts[0].descriptor.dir.is_absolute()); + assert_eq!( + lesson.parts[0].descriptor.dir, + lesson.root.join("segments/intro") + ); +} + +#[test] +fn missing_part_folder_yields_part_path_missing() { + let (lesson, diags) = load(&fixture("missing-part")); + + // Still produces a Lesson (loader stays best-effort). + let lesson = lesson.expect("missing-part fixture must still produce a Lesson"); + assert_eq!(lesson.parts.len(), 2, "both parts are recorded for order"); + + let missing: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::PartPathMissing) + .collect(); + assert_eq!( + missing.len(), + 1, + "exactly one PartPathMissing expected, got diags: {diags:?}" + ); + assert_eq!(missing[0].severity, cph_diag::Severity::Error); +} + +#[test] +fn kind_mismatch_yields_unknown_kind_diagnostic() { + let (lesson, diags) = load(&fixture("kind-mismatch")); + + let lesson = lesson.expect("kind-mismatch fixture must still produce a Lesson"); + assert_eq!(lesson.parts.len(), 1); + + let mismatch: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::UnknownKind) + .collect(); + assert_eq!( + mismatch.len(), + 1, + "exactly one kind-mismatch diagnostic expected, got: {diags:?}" + ); + assert!( + mismatch[0].message.contains("segment") && mismatch[0].message.contains("lemma"), + "message should name both kinds, got: {}", + mismatch[0].message + ); +} + +#[test] +fn malformed_manifest_is_a_hard_failure() { + let (lesson, diags) = load(&fixture("malformed-manifest")); + + assert!( + lesson.is_none(), + "malformed manifest must be a hard failure (None)" + ); + assert_eq!(diags.len(), 1, "one hard-failure diagnostic expected"); + assert_eq!(diags[0].code, DiagCode::SchemaViolation); + assert_eq!(diags[0].severity, cph_diag::Severity::Error); +} + +#[test] +fn missing_manifest_is_a_hard_failure() { + // A directory with no manifest.toml at all. + let (lesson, diags) = load(&fixture("does-not-exist-at-all")); + assert!(lesson.is_none()); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].code, DiagCode::SchemaViolation); +} diff --git a/crates/cph-schema/Cargo.toml b/crates/cph-schema/Cargo.toml new file mode 100644 index 0000000..b8c32aa --- /dev/null +++ b/crates/cph-schema/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cph-schema" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] diff --git a/crates/cph-schema/src/lib.rs b/crates/cph-schema/src/lib.rs new file mode 100644 index 0000000..c27fd1f --- /dev/null +++ b/crates/cph-schema/src/lib.rs @@ -0,0 +1,9 @@ +//! `cph-schema` — kind JSON Schemas (ADR-0006) + structural validation. +//! +//! Owned by **WU-3**. The schema layer reads each kind's declarative JSON +//! Schema (built-in scalars plus the `content` extension type) and validates +//! element instance data against it, including which `content` `.typ` files +//! must exist. + +// WU-3: implement the 4 stdlib kind schemas (segment / example / lemma / sop) +// and the validation entry point. Empty for now so the workspace compiles. diff --git a/crates/cph-typst/Cargo.toml b/crates/cph-typst/Cargo.toml new file mode 100644 index 0000000..0b53ffa --- /dev/null +++ b/crates/cph-typst/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cph-typst" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] diff --git a/crates/cph-typst/src/lib.rs b/crates/cph-typst/src/lib.rs new file mode 100644 index 0000000..db52dcf --- /dev/null +++ b/crates/cph-typst/src/lib.rs @@ -0,0 +1,9 @@ +//! `cph-typst` — typst `World`, driver generation, compile, PDF, span mapping. +//! +//! Owned by **WU-4**. Builds the typst compiler `World` over the engineering +//! file's virtual file system (ADR-0006/0007), generates the driver entrypoint +//! from the manifest, compiles to PDF, and maps typst spans back to source +//! locations for diagnostics. + +// WU-4: implement the World + driver-gen + compile + span mapping. Empty for +// now so the workspace compiles. From c15196396706b2b4be3c24b6008ee028f9b704d9 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 01:28:12 +0800 Subject: [PATCH 06/29] ci: add Rust checker workflow (fmt + clippy -D warnings + test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors spec-check's framing: an internal health gate on the implementation (build/test/lint), not a spec→impl conformance gate (alignment is by review). Installs fonts-noto-cjk ahead of the typst-compiling crates (WU-4+), since the engineering files are CJK-heavy. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/checker-check.yml | 53 ++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .gitea/workflows/checker-check.yml diff --git a/.gitea/workflows/checker-check.yml b/.gitea/workflows/checker-check.yml new file mode 100644 index 0000000..a6ce325 --- /dev/null +++ b/.gitea/workflows/checker-check.yml @@ -0,0 +1,53 @@ +name: checker check + +# Builds and lints the Rust implementation crates under crates/ (the rule-based +# checker that "stands in Lean's position" at product runtime). +# +# Like spec-check, this is an INTERNAL gate on the implementation's own health +# (does it build, pass its tests, satisfy clippy + rustfmt?). It is NOT a +# spec-to-implementation conformance gate — implementations align to the Lean +# contract by human review, not by CI. See the repo README. + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + checker-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install Rust toolchain (stable + clippy + rustfmt) + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo registry + build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}- + + # Fonts for typst compilation (WU-4 onward): the engineering files are + # CJK-heavy, so the compile-check / build tests need a CJK font present. + # Harmless before those crates exist. + - name: Install CJK fonts + run: | + sudo apt-get update + sudo apt-get install -y fonts-noto-cjk + + - name: Format check + run: cargo fmt --all --check + + - name: Clippy (deny warnings) + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Test + run: cargo test --all-features --workspace From c17af60bb09a6e70375e4fe62da05824bff880ae Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 01:33:40 +0800 Subject: [PATCH 07/29] feat(render): rebuild typst render package cph-render (WU-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single entry `display(info, target, parts)` over an ordered parts array, the frozen contract the generated typst driver calls. Per-kind dispatch for segment/example/lemma/sop; student/teacher matrix derived internally from `target` (student hides example solution + lemma proof; teacher shows all). Content-field values are consumed as already-evaluated content (driver `include`s them ⇒ module body, ADR-0006), never imported/stringified. Optional fields (lemma proof, example source) handled gracefully; unknown kind/target degrade without crashing. Zero @preview deps (CI-robust); CJK + math styled. Smoke compiles to non-empty student/teacher PDFs. Co-Authored-By: Claude Opus 4.8 (1M context) --- render/examples/smoke-parts.typ | 70 ++++++++++++++++++++ render/examples/smoke-student.typ | 6 ++ render/examples/smoke-teacher.typ | 5 ++ render/lib.typ | 103 ++++++++++++++++++++++++++++++ render/src/elements/common.typ | 53 +++++++++++++++ render/src/elements/example.typ | 38 +++++++++++ render/src/elements/lemma.typ | 34 ++++++++++ render/src/elements/segment.typ | 16 +++++ render/src/elements/sop.typ | 19 ++++++ render/src/fonts.typ | 16 +++++ render/src/style.typ | 85 ++++++++++++++++++++++++ render/typst.toml | 8 +++ 12 files changed, 453 insertions(+) create mode 100644 render/examples/smoke-parts.typ create mode 100644 render/examples/smoke-student.typ create mode 100644 render/examples/smoke-teacher.typ create mode 100644 render/lib.typ create mode 100644 render/src/elements/common.typ create mode 100644 render/src/elements/example.typ create mode 100644 render/src/elements/lemma.typ create mode 100644 render/src/elements/segment.typ create mode 100644 render/src/elements/sop.typ create mode 100644 render/src/fonts.typ create mode 100644 render/src/style.typ create mode 100644 render/typst.toml diff --git a/render/examples/smoke-parts.typ b/render/examples/smoke-parts.typ new file mode 100644 index 0000000..fec937d --- /dev/null +++ b/render/examples/smoke-parts.typ @@ -0,0 +1,70 @@ +// Shared hand-written sample lesson exercising all 4 kinds. +// Content field values are plain content blocks — exactly what the Rust driver +// would hand us via `include`. + +#let info = ( + title: "向量与几何 · 示例讲义", + author: ("张老师", "李老师"), +) + +#let parts = ( + // segment + ( + kind: "segment", + textbook: [ + 本节研究平面向量的基本运算。设 $arrow(a)$、$arrow(b)$ 为平面内两个向量, + 其数量积定义为 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$, + 其中 $theta$ 为两向量的夹角。 + ], + ), + // example WITH source + ( + kind: "example", + source: "2024 高考甲卷", + problem: [ + 已知 $arrow(a) = (1, 2)$,$arrow(b) = (3, -1)$,求 $arrow(a) dot arrow(b)$。 + ], + solution: [ + 由坐标公式,$arrow(a) dot arrow(b) = 1 times 3 + 2 times (-1) = 3 - 2 = 1$。 + ], + ), + // example WITHOUT source + ( + kind: "example", + problem: [ + 求向量 $arrow(a) = (3, 4)$ 的模长 $|arrow(a)|$。 + ], + solution: [ + $|arrow(a)| = sqrt(3^2 + 4^2) = sqrt(25) = 5$。 + ], + ), + // lemma WITH proof + ( + kind: "lemma", + stmt: [ + 对任意向量 $arrow(a)$、$arrow(b)$,有 $|arrow(a) dot arrow(b)| <= |arrow(a)| |arrow(b)|$。 + ], + proof: [ + 由数量积定义 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$, + 又 $|cos theta| <= 1$,故 $|arrow(a) dot arrow(b)| = |arrow(a)| |arrow(b)| |cos theta| <= |arrow(a)| |arrow(b)|$。 + $qed$ + ], + ), + // lemma WITHOUT proof (proof key omitted entirely) + ( + kind: "lemma", + stmt: [ + 两个非零向量垂直当且仅当其数量积为零,即 $arrow(a) perp arrow(b) <==> arrow(a) dot arrow(b) = 0$。 + ], + ), + // sop + ( + kind: "sop", + sop: [ + 求两向量夹角的标准步骤: + + 计算数量积 $arrow(a) dot arrow(b)$; + + 分别计算模长 $|arrow(a)|$、$|arrow(b)|$; + + 代入 $cos theta = (arrow(a) dot arrow(b)) / (|arrow(a)| |arrow(b)|)$ 求出 $theta$。 + ], + ), +) diff --git a/render/examples/smoke-student.typ b/render/examples/smoke-student.typ new file mode 100644 index 0000000..37c6d68 --- /dev/null +++ b/render/examples/smoke-student.typ @@ -0,0 +1,6 @@ +// Smoke test — student target. Imports the package by RELATIVE path so it +// compiles in place (no @local install needed). +#import "../lib.typ": display +#import "smoke-parts.typ": info, parts + +#display(info: info, target: "student", parts: parts) diff --git a/render/examples/smoke-teacher.typ b/render/examples/smoke-teacher.typ new file mode 100644 index 0000000..47daac4 --- /dev/null +++ b/render/examples/smoke-teacher.typ @@ -0,0 +1,5 @@ +// Smoke test — teacher target. Imports the package by RELATIVE path. +#import "../lib.typ": display +#import "smoke-parts.typ": info, parts + +#display(info: info, target: "teacher", parts: parts) diff --git a/render/lib.typ b/render/lib.typ new file mode 100644 index 0000000..04f64e7 --- /dev/null +++ b/render/lib.typ @@ -0,0 +1,103 @@ +// cph-render — curriculum lesson render package. +// +// Single public entry: `display(info, target, parts)`. +// +// A lesson is an ORDERED array of typed parts. Each part is a dict with a +// `kind` plus that kind's content/scalar fields. Content field VALUES are +// already-evaluated typst content (the driver produces them via `include`) — +// never modules or strings; we render them directly. +// +// kind -> fields (MVP): +// segment : textbook (content, required) +// example : problem (content, required), solution (content, required), +// source (string, optional) +// lemma : stmt (content, required), proof (content, optional) +// sop : sop (content, required) +// +// target -> show/hide (the (kind x target) render matrix; defaults per +// ADR-0005/0008, derived internally — caller passes no flags): +// student : example problem only (hide solution); lemma stmt only (hide +// proof); segment textbook; sop shown. +// teacher : everything shown. +// unknown : rendered conservatively == student (show only required-public +// fields). Never crashes. The "no render rule => warning" +// diagnostic is the Rust side's job, not ours. + +#import "src/style.typ": base-style, title-block, subtitle-block +#import "src/elements/segment.typ": display-segment +#import "src/elements/example.typ": display-example +#import "src/elements/lemma.typ": display-lemma +#import "src/elements/sop.typ": display-sop +#import "src/elements/common.typ": example-counter, lemma-counter + +/// Resolve a target string to the set of show/hide booleans. +/// Unknown targets fall back to the conservative (student-like) profile. +#let _flags-for(target) = { + if target == "teacher" { + (show-solution: true, show-proof: true, subtitle: [教师版讲义]) + } else if target == "student" { + (show-solution: false, show-proof: false, subtitle: [学生版讲义]) + } else { + // Unknown target: conservative — show only required-public fields. + (show-solution: false, show-proof: false, subtitle: [讲义]) + } +} + +/// Render one part according to its `kind`. Unknown kinds render an inline +/// note instead of crashing. +#let _render-part(part, flags) = { + let kind = part.at("kind", default: none) + if kind == "segment" { + display-segment(part) + } else if kind == "example" { + display-example(part, show-solution: flags.show-solution) + } else if kind == "lemma" { + display-lemma(part, show-proof: flags.show-proof) + } else if kind == "sop" { + display-sop(part) + } else { + // Don't crash on an unrecognised kind; surface it visibly instead. + block( + width: 100%, + inset: 0.6em, + fill: red.lighten(85%), + stroke: red.darken(20%) + 0.6pt, + radius: 0.4em, + text(fill: red.darken(30%), weight: "bold")[ + [未知部件类型 / unknown kind: #repr(kind)] + ], + ) + } +} + +/// THE ENTRY POINT. +/// +/// - `info`: dict, e.g. (title: "…", author: "…"). `author` may be absent. +/// - `target`: string. MVP: "student" | "teacher". Unknown => conservative. +/// - `parts`: ordered array of part dicts (see file header). +#let display(info: (:), target: "student", parts: ()) = { + let title = info.at("title", default: []) + let author = info.at("author", default: none) + + // `document` author wants a string/array; normalise the optional field. + set document( + title: title, + author: if author == none { () } else { author }, + ) + + show: base-style + + // Reset shared counters so each rendered lesson numbers from 1. + example-counter.update(0) + lemma-counter.update(0) + + let flags = _flags-for(target) + + title-block() + subtitle-block(flags.subtitle) + + // Render strictly in array order. + for part in parts { + _render-part(part, flags) + } +} diff --git a/render/src/elements/common.typ b/render/src/elements/common.typ new file mode 100644 index 0000000..b15d16f --- /dev/null +++ b/render/src/elements/common.typ @@ -0,0 +1,53 @@ +// Shared building blocks for element renderers: counters, tag lines, framed +// boxes, divider lines. Zero external deps (no fontawesome/showybox) — tags use +// plain bold text labels instead of icon glyphs. + +#import "../fonts.typ" +#import "../style.typ": no-indent + +/// Counters for numbered elements. Shared across the package so numbering is +/// continuous across the lesson regardless of which file renders a part. +#let example-counter = counter("cph-example") +#let lemma-counter = counter("cph-lemma") + +/// A bold label line introducing a section of an element body, e.g. +/// "例题 1" or "解析". Starts flush-left (cancels paragraph indent). +#let tag-line(label) = { + no-indent + set text(font: fonts.sans, weight: "bold") + label + h(1em / 3) +} + +/// A faint horizontal divider between sections inside an element box. +#let divider(color) = block(spacing: 1em, { + set align(center) + line(length: 100%, stroke: color.darken(20%) + 0.5pt) +}) + +/// A framed, tinted, breakable box wrapping an element's content. +/// `hue` selects the accent color family (degrees on the oklch hue wheel). +#let element-box(hue, body) = { + let theme = oklch(70%, 0.08, hue * 1deg) + block( + inset: (x: 1.5em, y: 1em), + breakable: true, + width: 100%, + fill: theme.lighten(70%).transparentize(50%), + stroke: theme.darken(60%) + 0.8pt, + radius: 0.5em, + { + set par(first-line-indent: 2em, spacing: 1em) + body + parbreak() + }, + ) +} + +/// The accent hue per element kind (keeps the four kinds visually distinct). +#let hue-of = ( + example: 250, + lemma: 150, + segment: 90, + sop: 30, +) diff --git a/render/src/elements/example.typ b/render/src/elements/example.typ new file mode 100644 index 0000000..12c8ea3 --- /dev/null +++ b/render/src/elements/example.typ @@ -0,0 +1,38 @@ +// example: a worked example / problem. +// +// Contract fields: +// content: `problem` (required), `solution` (required) +// scalar: `source` (optional string) +// +// Numbered "例题 N" via the shared example-counter. +// student: show `problem`, HIDE `solution`. +// teacher: show both. +// `source`, when present, is shown as a small annotation on the problem line. + +#import "../fonts.typ" +#import "common.typ": element-box, tag-line, divider, example-counter, hue-of + +#let display-example(part, show-solution: true) = { + let theme = oklch(70%, 0.08, hue-of.example * 1deg) + example-counter.step() + + element-box(hue-of.example, { + // Problem + tag-line(context [例题 #example-counter.display()]) + let source = part.at("source", default: none) + if source != none and source != "" { + text(size: 0.85em, fill: gray.darken(30%))[(来源:#source)] + h(0.3em) + } + set text(font: fonts.accent) + part.problem + + // Solution (teacher-only by default) + if show-solution { + divider(theme) + tag-line[解析] + set text(font: fonts.accent) + part.solution + } + }) +} diff --git a/render/src/elements/lemma.typ b/render/src/elements/lemma.typ new file mode 100644 index 0000000..f3765c6 --- /dev/null +++ b/render/src/elements/lemma.typ @@ -0,0 +1,34 @@ +// lemma: a stated result, optionally with a proof. +// +// Contract fields: +// content: `stmt` (required), `proof` (optional) +// scalar: none +// +// Numbered "定理 N" via the shared lemma-counter. +// student: show `stmt`, HIDE `proof`. +// teacher: show both (when `proof` is present). +// `proof` may be absent (key omitted) — handled gracefully. + +#import "../fonts.typ" +#import "common.typ": element-box, tag-line, divider, lemma-counter, hue-of + +#let display-lemma(part, show-proof: true) = { + let theme = oklch(70%, 0.08, hue-of.lemma * 1deg) + lemma-counter.step() + + element-box(hue-of.lemma, { + // Statement + tag-line(context [定理 #lemma-counter.display()]) + set text(font: fonts.accent) + part.stmt + + // Proof: only when requested AND actually provided. + let proof = part.at("proof", default: none) + if show-proof and proof != none { + divider(theme) + tag-line[证明] + set text(font: fonts.accent) + proof + } + }) +} diff --git a/render/src/elements/segment.typ b/render/src/elements/segment.typ new file mode 100644 index 0000000..57e8954 --- /dev/null +++ b/render/src/elements/segment.typ @@ -0,0 +1,16 @@ +// segment: a stretch of textbook prose. +// +// Contract fields: +// content: `textbook` (required) +// scalar: none +// +// Rendered identically for all targets (textbook is always public). Not boxed +// or numbered — it is the connective body text of the lesson. + +/// Render a segment part. `show-textbook` is always true in MVP but kept for +/// symmetry with the other kinds' show/hide flags. +#let display-segment(part, show-textbook: true) = { + if show-textbook { + part.textbook + } +} diff --git a/render/src/elements/sop.typ b/render/src/elements/sop.typ new file mode 100644 index 0000000..73714eb --- /dev/null +++ b/render/src/elements/sop.typ @@ -0,0 +1,19 @@ +// sop: a "standard operating procedure" — a methods/steps block. +// +// Contract fields: +// content: `sop` (required) +// scalar: none +// +// Shown for all targets. Lightly boxed for visual separation; not numbered. + +#import "common.typ": element-box, tag-line, hue-of + +/// Render a sop part. +#let display-sop(part, show-sop: true) = { + if show-sop { + element-box(hue-of.sop, { + tag-line[方法] + part.sop + }) + } +} diff --git a/render/src/fonts.typ b/render/src/fonts.typ new file mode 100644 index 0000000..b1607a7 --- /dev/null +++ b/render/src/fonts.typ @@ -0,0 +1,16 @@ +// Font families. The lesson text is Chinese + heavy math. +// +// We pass FONT FALLBACK LISTS rather than single names: typst tries each in +// order and falls back to the next if a face is missing. This means we never +// hard-fail when one specific CJK font is absent on the compile host — a key +// MVP robustness requirement (CI may have a different font set). + +/// Serif body font (宋体). First broadly-shipped match wins; if none are +/// present typst falls back to its own default — we never hard-fail. +#let serif = ("Noto Serif CJK SC", "Source Han Serif SC", "Songti SC", "SimSun") + +/// Sans / heading font (黑体). +#let sans = ("Noto Sans CJK SC", "Source Han Sans SC", "PingFang SC", "Heiti SC", "SimHei") + +/// Accent font used for problem/proof bodies (仿宋 feel). +#let accent = ("FZFangSong-Z02S", "FangSong", "STFangsong", "Songti SC") diff --git a/render/src/style.typ b/render/src/style.typ new file mode 100644 index 0000000..cb97c20 --- /dev/null +++ b/render/src/style.typ @@ -0,0 +1,85 @@ +// Base document styling: CJK fonts, headings, math, paragraph layout, page. +// +// Zero external dependencies. Heading numbering uses builtin `numbering` +// strings (no @preview/numbly) so the package compiles with no network. + +#import "fonts.typ" + +/// Apply the base style to a document body. Used as `show: base-style`. +#let base-style(doc) = { + // Language / region drive CJK line-breaking and punctuation. + set text(lang: "zh", region: "cn", size: 12pt, font: fonts.serif) + set text(cjk-latin-spacing: auto) + + // Heading numbering: 一、 / 1.1 / 1.1.1 — builtin numbering, no deps. + set heading(numbering: "一、") + show heading: set text(font: fonts.sans) + + // Number display equations. + set math.equation(numbering: "(1)") + + set par( + first-line-indent: (amount: 2em, all: true), + leading: 0.8em, + spacing: 1.5em, + justify: true, + ) + + set page( + margin: (x: 2.2cm, y: 2.4cm), + footer: context align(center)[ + #counter(page).get().at(0) / #counter(page).final().at(0) + ], + ) + + set table(inset: 5pt, stroke: 0.75pt) + + // Inline quotes -> subtle highlight (matches prototype feel). + show quote.where(block: false): it => highlight( + it.body, + fill: gray.transparentize(60%), + top-edge: 1em, + bottom-edge: -0.2em, + extent: 0.35em, + radius: 0.25em, + ) + + show link: set text(fill: eastern) + show link: underline + + doc +} + +/// Cancel the active paragraph first-line indent (for tag lines that should +/// start flush-left). Defensive against both `length` and `dict` indent forms. +#let no-indent = context { + let fli = par.first-line-indent + if type(fli) == length { + h(-fli) + } else { + h(-fli.amount) + } +} + +/// Document title block. +#let title-block() = { + set align(center) + set text(font: fonts.sans, size: 22pt, weight: "bold") + context document.title +} + +/// Subtitle + authors block under the title. +#let subtitle-block(subtitle) = { + set align(center) + set text(size: 14pt) + v(1.2em, weak: true) + subtitle + context { + let authors = document.author + if authors != () { + v(0.8em, weak: true) + authors.join(h(1.5em)) + } + } + v(1.2em, weak: true) +} diff --git a/render/typst.toml b/render/typst.toml new file mode 100644 index 0000000..c389bf2 --- /dev/null +++ b/render/typst.toml @@ -0,0 +1,8 @@ +[package] +name = "cph-render" +version = "0.1.0" +entrypoint = "lib.typ" +compiler = ">=0.15.0" +authors = ["curriculum-project-hub"] +license = "MIT" +description = "Curriculum lesson render package: single `display` entry over an ordered list of typed parts (segment/example/lemma/sop), targeting student/teacher handouts." From 752a5c661d713cb57be31ed6679b947c57313ed0 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 01:33:40 +0800 Subject: [PATCH 08/29] =?UTF-8?q?feat(checker):=20cph-schema=20=E2=80=94?= =?UTF-8?q?=204=20kind=20JSON=20Schemas=20+=20validation=20(WU-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declarative JSON Schema per kind (segment/example/lemma/sop), embedded as real .json artifacts (ADR-0006) and validated by a small hand-rolled interpreter (properties/required/type/enum/additionalProperties + the `x-cph-content` marker) — no jsonschema-crate churn for a one-optional-string scalar surface. `x-cph-content: true` marks content fields (not in element.toml; require sibling .typ); everything else is an element.toml scalar. `validate(desc)` is self-contained: UnknownKind for unknown kinds, MissingContentFile (path named in message) for absent required .typ, SchemaViolation for bad scalars; lemma proof optional ⇒ no diagnostic when absent. 8 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 31 ++ crates/cph-schema/Cargo.toml | 7 + crates/cph-schema/schemas/example.json | 13 + crates/cph-schema/schemas/lemma.json | 12 + crates/cph-schema/schemas/segment.json | 11 + crates/cph-schema/schemas/sop.json | 11 + crates/cph-schema/src/lib.rs | 494 +++++++++++++++++- .../example_missing_solution/problem.typ | 2 + .../tests/fixtures/example_valid/problem.typ | 2 + .../tests/fixtures/example_valid/solution.typ | 2 + .../tests/fixtures/lemma_no_proof/stmt.typ | 2 + crates/cph-schema/tests/validate.rs | 76 +++ 12 files changed, 661 insertions(+), 2 deletions(-) create mode 100644 crates/cph-schema/schemas/example.json create mode 100644 crates/cph-schema/schemas/lemma.json create mode 100644 crates/cph-schema/schemas/segment.json create mode 100644 crates/cph-schema/schemas/sop.json create mode 100644 crates/cph-schema/tests/fixtures/example_missing_solution/problem.typ create mode 100644 crates/cph-schema/tests/fixtures/example_valid/problem.typ create mode 100644 crates/cph-schema/tests/fixtures/example_valid/solution.typ create mode 100644 crates/cph-schema/tests/fixtures/lemma_no_proof/stmt.typ create mode 100644 crates/cph-schema/tests/validate.rs diff --git a/Cargo.lock b/Cargo.lock index b21cc36..75107d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,12 @@ dependencies = [ [[package]] name = "cph-schema" version = "0.1.0" +dependencies = [ + "cph-diag", + "cph-model", + "serde_json", + "toml", +] [[package]] name = "cph-typst" @@ -56,6 +62,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "memchr" version = "2.8.2" @@ -110,6 +122,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -185,3 +210,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/cph-schema/Cargo.toml b/crates/cph-schema/Cargo.toml index b8c32aa..b374db4 100644 --- a/crates/cph-schema/Cargo.toml +++ b/crates/cph-schema/Cargo.toml @@ -5,3 +5,10 @@ edition.workspace = true license.workspace = true [dependencies] +cph-diag = { workspace = true } +cph-model = { workspace = true } +serde_json = "1" +toml = { workspace = true } + +[dev-dependencies] +toml = { workspace = true } diff --git a/crates/cph-schema/schemas/example.json b/crates/cph-schema/schemas/example.json new file mode 100644 index 0000000..01103d7 --- /dev/null +++ b/crates/cph-schema/schemas/example.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "cph:kind/example", + "title": "example", + "type": "object", + "properties": { + "problem": { "type": "string", "x-cph-content": true }, + "solution": { "type": "string", "x-cph-content": true }, + "source": { "type": "string" } + }, + "required": ["problem", "solution"], + "additionalProperties": false +} diff --git a/crates/cph-schema/schemas/lemma.json b/crates/cph-schema/schemas/lemma.json new file mode 100644 index 0000000..e21433f --- /dev/null +++ b/crates/cph-schema/schemas/lemma.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "cph:kind/lemma", + "title": "lemma", + "type": "object", + "properties": { + "stmt": { "type": "string", "x-cph-content": true }, + "proof": { "type": "string", "x-cph-content": true } + }, + "required": ["stmt"], + "additionalProperties": false +} diff --git a/crates/cph-schema/schemas/segment.json b/crates/cph-schema/schemas/segment.json new file mode 100644 index 0000000..91f8a37 --- /dev/null +++ b/crates/cph-schema/schemas/segment.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "cph:kind/segment", + "title": "segment", + "type": "object", + "properties": { + "textbook": { "type": "string", "x-cph-content": true } + }, + "required": ["textbook"], + "additionalProperties": false +} diff --git a/crates/cph-schema/schemas/sop.json b/crates/cph-schema/schemas/sop.json new file mode 100644 index 0000000..db6d944 --- /dev/null +++ b/crates/cph-schema/schemas/sop.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "cph:kind/sop", + "title": "sop", + "type": "object", + "properties": { + "sop": { "type": "string", "x-cph-content": true } + }, + "required": ["sop"], + "additionalProperties": false +} diff --git a/crates/cph-schema/src/lib.rs b/crates/cph-schema/src/lib.rs index c27fd1f..968db7b 100644 --- a/crates/cph-schema/src/lib.rs +++ b/crates/cph-schema/src/lib.rs @@ -4,6 +4,496 @@ //! Schema (built-in scalars plus the `content` extension type) and validates //! element instance data against it, including which `content` `.typ` files //! must exist. +//! +//! # The `content` extension convention +//! +//! ADR-0006 fixes that a kind's data schema is a declarative **JSON Schema** +//! (draft 2020-12 here) with one extension type — `content` — whose value is a +//! Typst source rather than inline data. JSON Schema has no native `content` +//! type, so this crate marks a content field with a **custom keyword**: +//! +//! ```json +//! { "type": "string", "x-cph-content": true } +//! ``` +//! +//! The `x-cph-content: true` marker is the single convention this crate honors. +//! A field carrying it is treated as a **content field**: it does NOT appear in +//! `element.toml`; instead the validator checks that the sibling file +//! `.typ` exists in the element's directory (ADR-0008). A required +//! content field whose `.typ` file is missing yields a +//! [`cph_diag::DiagCode::MissingContentFile`]; an *optional* content field +//! (one not in the schema's `required` list, e.g. a lemma's `proof`) may be +//! absent with no diagnostic. All other (non-`x-cph-content`) properties are +//! **scalar** fields that live in `element.toml` and are validated structurally. +//! +//! The four stdlib kind schemas live as real JSON artifacts under +//! `crates/cph-schema/schemas/{segment,example,lemma,sop}.json` and are +//! compiled in via `include_str!`, parsed once on first use. +//! +//! # Validation approach +//! +//! The MVP scalar surface is intentionally tiny (a single optional string on +//! `example`), so rather than pull in a full JSON Schema engine (and its +//! version churn), this crate uses `serde_json` to parse the schemas and a +//! small hand-rolled interpreter that understands exactly the keywords the +//! stdlib schemas use: `properties`, `required`, `type`, `enum`, +//! `additionalProperties`, plus the `x-cph-content` extension marker. This +//! keeps the schemas as declarative artifacts (ADR-0006 intent) while keeping +//! the validator dependency-light and fully under our control. -// WU-3: implement the 4 stdlib kind schemas (segment / example / lemma / sop) -// and the validation entry point. Empty for now so the workspace compiles. +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use cph_diag::{DiagCode, Diagnostic}; +use cph_model::ElementDescriptor; +use serde_json::Value as Json; + +/// The custom JSON Schema keyword marking a field as a `content` (Typst-source) +/// field rather than a scalar. See the crate docs for the convention. +const CONTENT_MARKER: &str = "x-cph-content"; + +/// The known stdlib kinds, in a stable order. +const KNOWN_KINDS: &[&str] = &["segment", "example", "lemma", "sop"]; + +/// Raw schema JSON for each kind, embedded at compile time. +const SCHEMA_SOURCES: &[(&str, &str)] = &[ + ("segment", include_str!("../schemas/segment.json")), + ("example", include_str!("../schemas/example.json")), + ("lemma", include_str!("../schemas/lemma.json")), + ("sop", include_str!("../schemas/sop.json")), +]; + +/// The scalar `type` a property declares (the subset the stdlib schemas use). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ScalarType { + String, + Number, + Integer, + Boolean, +} + +impl ScalarType { + fn from_json(s: &str) -> Option { + match s { + "string" => Some(ScalarType::String), + "number" => Some(ScalarType::Number), + "integer" => Some(ScalarType::Integer), + "boolean" => Some(ScalarType::Boolean), + _ => None, + } + } + + fn label(self) -> &'static str { + match self { + ScalarType::String => "string", + ScalarType::Number => "number", + ScalarType::Integer => "integer", + ScalarType::Boolean => "boolean", + } + } + + /// Whether `value` (a scalar coming from `element.toml`, already converted + /// to JSON) satisfies this declared type. + fn matches(self, value: &Json) -> bool { + match self { + ScalarType::String => value.is_string(), + ScalarType::Number => value.is_number(), + ScalarType::Integer => value.is_i64() || value.is_u64(), + ScalarType::Boolean => value.is_boolean(), + } + } +} + +/// One scalar property of a kind (a field that lives in `element.toml`). +#[derive(Debug, Clone)] +struct ScalarField { + name: String, + /// Declared `type`, if any. `None` means "unconstrained" (we still accept + /// the field but apply no type check). + ty: Option, + /// Allowed values from a JSON Schema `enum`, as JSON values. + allowed: Option>, + required: bool, +} + +/// One content property of a kind (a field whose value is a sibling `.typ` +/// file, marked with `x-cph-content`). +#[derive(Debug, Clone)] +struct ContentField { + name: String, + required: bool, +} + +/// A parsed kind schema: the content fields (which drive `.typ` existence +/// checks) and the scalar fields (validated against `element.toml`). +#[derive(Debug, Clone)] +pub struct KindSchema { + kind: String, + content_fields: Vec, + scalar_fields: Vec, + /// Whether the schema forbids properties beyond those declared + /// (`additionalProperties: false`). + deny_additional: bool, +} + +impl KindSchema { + /// The kind name this schema describes (e.g. `"example"`). + pub fn kind(&self) -> &str { + &self.kind + } + + /// The names of the `content` fields (those requiring a `.typ` + /// file), in schema order. + pub fn content_field_names(&self) -> Vec<&str> { + self.content_fields + .iter() + .map(|f| f.name.as_str()) + .collect() + } + + /// The names of the scalar fields (those living in `element.toml`), in + /// schema order. + pub fn scalar_field_names(&self) -> Vec<&str> { + self.scalar_fields.iter().map(|f| f.name.as_str()).collect() + } + + /// Parse a kind schema from its raw JSON source. Panics on a malformed + /// schema — schemas are compile-time artifacts owned by this crate, so a + /// bad one is a build-time bug, not a runtime condition. + fn parse(kind: &str, src: &str) -> Self { + let root: Json = serde_json::from_str(src) + .unwrap_or_else(|e| panic!("kind schema for '{kind}' is not valid JSON: {e}")); + + let deny_additional = root + .get("additionalProperties") + .and_then(Json::as_bool) + .map(|b| !b) + .unwrap_or(false); + + let required: Vec = root + .get("required") + .and_then(Json::as_array) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let is_required = |name: &str| required.iter().any(|r| r == name); + + let mut content_fields = Vec::new(); + let mut scalar_fields = Vec::new(); + + let props = root + .get("properties") + .and_then(Json::as_object) + .unwrap_or_else(|| panic!("kind schema for '{kind}' has no `properties` object")); + + for (name, prop) in props { + let is_content = prop + .get(CONTENT_MARKER) + .and_then(Json::as_bool) + .unwrap_or(false); + + if is_content { + content_fields.push(ContentField { + name: name.clone(), + required: is_required(name), + }); + } else { + let ty = prop + .get("type") + .and_then(Json::as_str) + .and_then(ScalarType::from_json); + let allowed = prop + .get("enum") + .and_then(Json::as_array) + .map(|a| a.to_vec()); + scalar_fields.push(ScalarField { + name: name.clone(), + ty, + allowed, + required: is_required(name), + }); + } + } + + KindSchema { + kind: kind.to_string(), + content_fields, + scalar_fields, + deny_additional, + } + } +} + +/// The lazily-built registry mapping kind name → parsed schema. +fn registry() -> &'static BTreeMap<&'static str, KindSchema> { + static REGISTRY: OnceLock> = OnceLock::new(); + REGISTRY.get_or_init(|| { + SCHEMA_SOURCES + .iter() + .map(|(kind, src)| (*kind, KindSchema::parse(kind, src))) + .collect() + }) +} + +/// Look up the parsed [`KindSchema`] for `kind`, or `None` if it is unknown. +pub fn schema_for(kind: &str) -> Option<&'static KindSchema> { + registry().get(kind) +} + +/// The known stdlib kind names (`["segment", "example", "lemma", "sop"]`), +/// for use by the orchestrator (`cph-check`). +pub fn known_kinds() -> &'static [&'static str] { + KNOWN_KINDS +} + +/// Validate an element instance against its kind's schema. +/// +/// Self-contained: looks up the schema by `desc.kind` and, if the kind is +/// unknown, returns a single [`cph_diag::DiagCode::UnknownKind`] diagnostic. +/// Otherwise it checks, in order: +/// +/// 1. **Scalar fields** (`desc.scalars`, a `toml::Table`) against the +/// non-content part of the schema — required-ness, declared `type`, and +/// `enum`. Violations are [`cph_diag::DiagCode::SchemaViolation`]. Under an +/// `additionalProperties: false` schema, an unexpected scalar key is also a +/// `SchemaViolation`. +/// 2. **Content fields**: for each required content field, that the sibling +/// file `desc.dir.join(".typ")` exists; if not, a +/// [`cph_diag::DiagCode::MissingContentFile`]. Optional content fields may be +/// absent with no diagnostic. +/// +/// Every diagnostic carries a fix `hint`. `MissingContentFile` names the +/// expected path in its message; `span` is left `None` (there is no line/col +/// for a file that does not exist). +pub fn validate(desc: &ElementDescriptor) -> Vec { + let Some(schema) = schema_for(&desc.kind) else { + return vec![Diagnostic::error( + DiagCode::UnknownKind, + format!("unknown element kind '{}'", desc.kind), + ) + .with_hint(format!("valid kinds are: {}", KNOWN_KINDS.join(", ")))]; + }; + + let mut diags = Vec::new(); + validate_scalars(schema, desc, &mut diags); + validate_content_files(schema, desc, &mut diags); + diags +} + +/// Validate the scalar fields in `desc.scalars` against the schema's scalar +/// part. +fn validate_scalars(schema: &KindSchema, desc: &ElementDescriptor, diags: &mut Vec) { + let scalars = &desc.scalars; + + // Required scalar fields must be present. + for field in &schema.scalar_fields { + if field.required && !scalars.contains_key(&field.name) { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "element of kind '{}' is missing required field '{}'", + schema.kind, field.name + ), + ) + .with_hint(format!("add `{} = ...` to element.toml", field.name)), + ); + } + } + + // Each present scalar: type / enum checks, and (if known) reject unexpected + // keys when additionalProperties is false. Content fields must never appear + // in element.toml. + let content_names: Vec<&str> = schema + .content_fields + .iter() + .map(|f| f.name.as_str()) + .collect(); + + for (key, value) in scalars { + if content_names.contains(&key.as_str()) { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "field '{}' of kind '{}' is a content field and must not appear in element.toml", + key, schema.kind + ), + ) + .with_hint(format!( + "remove '{key}' from element.toml; put its content in the sibling file '{key}.typ'" + )), + ); + continue; + } + + let Some(field) = schema.scalar_fields.iter().find(|f| f.name == *key) else { + if schema.deny_additional { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("unexpected field '{}' for kind '{}'", key, schema.kind), + ) + .with_hint(format!( + "remove '{key}' from element.toml (it is not part of the '{}' schema)", + schema.kind + )), + ); + } + continue; + }; + + let json = toml_to_json(value); + + if let Some(ty) = field.ty { + if !ty.matches(&json) { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "field '{}' of kind '{}' must be a {}, got {}", + key, + schema.kind, + ty.label(), + json_type_label(&json) + ), + ) + .with_hint(format!( + "set '{key}' to a {} value in element.toml", + ty.label() + )), + ); + continue; + } + } + + if let Some(allowed) = &field.allowed { + if !allowed.contains(&json) { + let choices: Vec = allowed.iter().map(render_json_choice).collect(); + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "field '{}' of kind '{}' has value {} which is not one of the allowed values", + key, + schema.kind, + render_json_choice(&json) + ), + ) + .with_hint(format!("'{key}' must be one of: {}", choices.join(", "))), + ); + } + } + } +} + +/// For each required content field, check that its `.typ` file exists in +/// the element directory. +fn validate_content_files( + schema: &KindSchema, + desc: &ElementDescriptor, + diags: &mut Vec, +) { + for field in &schema.content_fields { + if !field.required { + // Optional content (e.g. lemma's `proof`): absence is fine. + continue; + } + let file_name = format!("{}.typ", field.name); + let path = desc.dir.join(&file_name); + if !path.is_file() { + diags.push( + Diagnostic::error( + DiagCode::MissingContentFile, + format!( + "kind '{}' requires the content file '{}', which is missing", + schema.kind, + path.display() + ), + ) + .with_hint(format!( + "create '{file_name}' in the element folder with the typst source for '{}'", + field.name + )), + ); + } + } +} + +/// Convert a `toml::Value` scalar into the equivalent `serde_json::Value` for +/// type checking. Composite TOML values (arrays / tables) are converted +/// structurally too, though the MVP stdlib schemas only use scalars. +fn toml_to_json(value: &toml::Value) -> Json { + match value { + toml::Value::String(s) => Json::String(s.clone()), + toml::Value::Integer(i) => Json::from(*i), + toml::Value::Float(f) => Json::from(*f), + toml::Value::Boolean(b) => Json::Bool(*b), + toml::Value::Datetime(dt) => Json::String(dt.to_string()), + toml::Value::Array(arr) => Json::Array(arr.iter().map(toml_to_json).collect()), + toml::Value::Table(tbl) => Json::Object( + tbl.iter() + .map(|(k, v)| (k.clone(), toml_to_json(v))) + .collect(), + ), + } +} + +/// A short label for a JSON value's runtime type, for diagnostic messages. +fn json_type_label(value: &Json) -> &'static str { + match value { + Json::Null => "null", + Json::Bool(_) => "boolean", + Json::Number(n) => { + if n.is_i64() || n.is_u64() { + "integer" + } else { + "number" + } + } + Json::String(_) => "string", + Json::Array(_) => "array", + Json::Object(_) => "object", + } +} + +/// Render a JSON value for an enum-choice list (strings unquoted-ish, others as +/// JSON). +fn render_json_choice(value: &Json) -> String { + match value { + Json::String(s) => format!("\"{s}\""), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_has_all_known_kinds() { + for kind in known_kinds() { + assert!(schema_for(kind).is_some(), "missing schema for {kind}"); + } + assert!(schema_for("frob").is_none()); + } + + #[test] + fn example_field_split() { + let s = schema_for("example").unwrap(); + assert_eq!(s.content_field_names(), vec!["problem", "solution"]); + assert_eq!(s.scalar_field_names(), vec!["source"]); + } + + #[test] + fn lemma_proof_is_optional_content() { + let s = schema_for("lemma").unwrap(); + let proof = s.content_fields.iter().find(|f| f.name == "proof").unwrap(); + assert!(!proof.required); + let stmt = s.content_fields.iter().find(|f| f.name == "stmt").unwrap(); + assert!(stmt.required); + } +} diff --git a/crates/cph-schema/tests/fixtures/example_missing_solution/problem.typ b/crates/cph-schema/tests/fixtures/example_missing_solution/problem.typ new file mode 100644 index 0000000..3af134b --- /dev/null +++ b/crates/cph-schema/tests/fixtures/example_missing_solution/problem.typ @@ -0,0 +1,2 @@ += Problem +only the problem here diff --git a/crates/cph-schema/tests/fixtures/example_valid/problem.typ b/crates/cph-schema/tests/fixtures/example_valid/problem.typ new file mode 100644 index 0000000..200e0de --- /dev/null +++ b/crates/cph-schema/tests/fixtures/example_valid/problem.typ @@ -0,0 +1,2 @@ += Problem +What is 2+2? diff --git a/crates/cph-schema/tests/fixtures/example_valid/solution.typ b/crates/cph-schema/tests/fixtures/example_valid/solution.typ new file mode 100644 index 0000000..31152c0 --- /dev/null +++ b/crates/cph-schema/tests/fixtures/example_valid/solution.typ @@ -0,0 +1,2 @@ += Solution +4. diff --git a/crates/cph-schema/tests/fixtures/lemma_no_proof/stmt.typ b/crates/cph-schema/tests/fixtures/lemma_no_proof/stmt.typ new file mode 100644 index 0000000..1ae08b6 --- /dev/null +++ b/crates/cph-schema/tests/fixtures/lemma_no_proof/stmt.typ @@ -0,0 +1,2 @@ += Statement +A lemma. diff --git a/crates/cph-schema/tests/validate.rs b/crates/cph-schema/tests/validate.rs new file mode 100644 index 0000000..f742d2e --- /dev/null +++ b/crates/cph-schema/tests/validate.rs @@ -0,0 +1,76 @@ +//! Integration tests for `cph-schema::validate`, driven by tiny fixtures under +//! `tests/fixtures//` and in-code `ElementDescriptor`s. + +use std::path::{Path, PathBuf}; + +use cph_diag::DiagCode; +use cph_model::ElementDescriptor; +use cph_schema::validate; + +/// Absolute path to a fixture directory. +fn fixture(case: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(case) +} + +fn desc(kind: &str, dir: PathBuf, scalars: toml::Table) -> ElementDescriptor { + ElementDescriptor { + kind: kind.to_string(), + dir, + scalars, + } +} + +#[test] +fn valid_example_no_diagnostics() { + let mut scalars = toml::Table::new(); + scalars.insert("source".into(), toml::Value::String("Textbook §3".into())); + let d = desc("example", fixture("example_valid"), scalars); + let diags = validate(&d); + assert!(diags.is_empty(), "expected no diagnostics, got {diags:?}"); +} + +#[test] +fn example_missing_solution_typ_is_missing_content_file() { + let d = desc( + "example", + fixture("example_missing_solution"), + toml::Table::new(), + ); + let diags = validate(&d); + assert_eq!(diags.len(), 1, "got {diags:?}"); + assert_eq!(diags[0].code, DiagCode::MissingContentFile); + assert!(diags[0].message.contains("solution.typ")); + assert!(diags[0].hint.is_some()); +} + +#[test] +fn lemma_without_proof_typ_is_ok() { + // stmt.typ exists, proof.typ does not — proof is optional, so no diagnostic. + let d = desc("lemma", fixture("lemma_no_proof"), toml::Table::new()); + let diags = validate(&d); + assert!(diags.is_empty(), "expected no diagnostics, got {diags:?}"); +} + +#[test] +fn unknown_kind_is_reported() { + let d = desc("frob", fixture("example_valid"), toml::Table::new()); + let diags = validate(&d); + assert_eq!(diags.len(), 1, "got {diags:?}"); + assert_eq!(diags[0].code, DiagCode::UnknownKind); + assert!(diags[0].message.contains("frob")); +} + +#[test] +fn example_source_non_string_is_schema_violation() { + let mut scalars = toml::Table::new(); + scalars.insert("source".into(), toml::Value::Integer(42)); + let d = desc("example", fixture("example_valid"), scalars); + let diags = validate(&d); + assert_eq!(diags.len(), 1, "got {diags:?}"); + assert_eq!(diags[0].code, DiagCode::SchemaViolation); + assert!(diags[0].message.contains("source")); + assert!(diags[0].message.contains("string")); +} From c73a2c903f350e327bfbfc2b05a48a7884491e6e Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 01:33:40 +0800 Subject: [PATCH 09/29] feat(examples): migrate TH-141 sample to declarative layout (WU-6) Real-content fixture for the end-to-end pipeline. TH-141 (39 parts: 22 segment, 15 lemma, 2 example) migrated from the prototype's typst `#let parts` manifest to ADR-0008: declarative manifest.toml (project+info+ordered parts+targets) + per-element element.toml (kind + scalars; examples carry `source`). Content .typ files copied byte-identical (no math corruption); per-element main.typ + meta.toml dropped (wiring is now generated). Part order matches source exactly; 5 lemmas have no proof.typ (optional); no cross-file imports / paralearn refs / figs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../41届复赛三-1-混注石油/element.toml | 2 + .../41届复赛三-1-混注石油/problem.typ | 7 + .../41届复赛三-1-混注石油/solution.typ | 27 +++ .../41届复赛三-2-缺键模型/element.toml | 2 + .../41届复赛三-2-缺键模型/problem.typ | 1 + .../41届复赛三-2-缺键模型/solution.typ | 3 + examples/TH-141/lemmas/Dupré关系/element.toml | 1 + examples/TH-141/lemmas/Dupré关系/proof.typ | 5 + examples/TH-141/lemmas/Dupré关系/stmt.typ | 5 + .../TH-141/lemmas/Eötvös规则/element.toml | 1 + examples/TH-141/lemmas/Eötvös规则/stmt.typ | 5 + .../TH-141/lemmas/GGZ浸润判据/element.toml | 1 + examples/TH-141/lemmas/GGZ浸润判据/proof.typ | 13 ++ examples/TH-141/lemmas/GGZ浸润判据/stmt.typ | 5 + .../lemmas/Girifalco-Good公式/element.toml | 1 + .../lemmas/Girifalco-Good公式/proof.typ | 9 + .../TH-141/lemmas/Girifalco-Good公式/stmt.typ | 7 + .../Guggenheim-Katayama改良/element.toml | 1 + .../lemmas/Guggenheim-Katayama改良/stmt.typ | 5 + .../TH-141/lemmas/LJ对势积分标度/element.toml | 1 + .../TH-141/lemmas/LJ对势积分标度/proof.typ | 13 ++ .../TH-141/lemmas/LJ对势积分标度/stmt.typ | 9 + .../TH-141/lemmas/Stefan极简估算/element.toml | 1 + .../TH-141/lemmas/Stefan极简估算/proof.typ | 9 + .../TH-141/lemmas/Stefan极简估算/stmt.typ | 5 + examples/TH-141/lemmas/Young方程/element.toml | 1 + examples/TH-141/lemmas/Young方程/proof.typ | 9 + examples/TH-141/lemmas/Young方程/stmt.typ | 3 + .../固体表面能的晶面各向异性/element.toml | 1 + .../lemmas/固体表面能的晶面各向异性/stmt.typ | 3 + .../lemmas/拉伸固体的总应力/element.toml | 1 + .../TH-141/lemmas/拉伸固体的总应力/proof.typ | 9 + .../TH-141/lemmas/拉伸固体的总应力/stmt.typ | 5 + .../lemmas/立方格子下zeta具体值/element.toml | 1 + .../lemmas/立方格子下zeta具体值/proof.typ | 5 + .../lemmas/立方格子下zeta具体值/stmt.typ | 8 + .../lemmas/缺键模型一般公式/element.toml | 1 + .../TH-141/lemmas/缺键模型一般公式/proof.typ | 17 ++ .../TH-141/lemmas/缺键模型一般公式/stmt.typ | 3 + .../lemmas/缺键模型迁移到固气/element.toml | 1 + .../TH-141/lemmas/缺键模型迁移到固气/stmt.typ | 5 + .../lemmas/表面熵热力学关系/element.toml | 1 + .../TH-141/lemmas/表面熵热力学关系/proof.typ | 11 ++ .../TH-141/lemmas/表面熵热力学关系/stmt.typ | 5 + .../TH-141/lemmas/量纲分析估计/element.toml | 1 + examples/TH-141/lemmas/量纲分析估计/stmt.typ | 5 + examples/TH-141/manifest.toml | 166 ++++++++++++++++++ .../segments/Fowkes极性修正/element.toml | 1 + .../segments/Fowkes极性修正/textbook.typ | 5 + .../TH-141/segments/LJ积分导言/element.toml | 1 + .../TH-141/segments/LJ积分导言/textbook.typ | 3 + .../segments/σTp态函数导言/element.toml | 1 + .../segments/σTp态函数导言/textbook.typ | 3 + .../TH-141/segments/σT建模导言/element.toml | 1 + .../TH-141/segments/σT建模导言/textbook.typ | 3 + .../segments/σ作为态函数的图像/element.toml | 1 + .../segments/σ作为态函数的图像/textbook.typ | 3 + .../TH-141/segments/三相接触导言/element.toml | 1 + .../TH-141/segments/三相接触导言/textbook.typ | 3 + .../segments/不同物质γ量级对比/element.toml | 1 + .../segments/不同物质γ量级对比/textbook.typ | 16 ++ .../各模型对水的预测对照/element.toml | 1 + .../各模型对水的预测对照/textbook.typ | 16 ++ .../TH-141/segments/固气界面导言/element.toml | 1 + .../TH-141/segments/固气界面导言/textbook.typ | 3 + .../TH-141/segments/固液界面导言/element.toml | 1 + .../TH-141/segments/固液界面导言/textbook.typ | 3 + .../固液界面能的物理图像/element.toml | 1 + .../固液界面能的物理图像/textbook.typ | 3 + .../TH-141/segments/开场对照导言/element.toml | 1 + .../TH-141/segments/开场对照导言/textbook.typ | 3 + .../segments/微观建模的共同骨架/element.toml | 1 + .../segments/微观建模的共同骨架/textbook.typ | 7 + .../TH-141/segments/微观派Lm下降/element.toml | 1 + .../TH-141/segments/微观派Lm下降/textbook.typ | 1 + .../TH-141/segments/收束导言/element.toml | 1 + .../TH-141/segments/收束导言/textbook.typ | 3 + .../浸润全谱与高低能表面/element.toml | 1 + .../浸润全谱与高低能表面/textbook.typ | 17 ++ .../TH-141/segments/液气界面导言/element.toml | 1 + .../TH-141/segments/液气界面导言/textbook.typ | 3 + .../segments/液面拉伸的本质/element.toml | 1 + .../segments/液面拉伸的本质/textbook.typ | 15 ++ .../算不准背后的真实物理/element.toml | 1 + .../算不准背后的真实物理/textbook.typ | 11 ++ .../TH-141/segments/缺键模型导言/element.toml | 1 + .../TH-141/segments/缺键模型导言/textbook.typ | 3 + .../segments/胡克唯象模型回顾/element.toml | 1 + .../segments/胡克唯象模型回顾/textbook.typ | 3 + .../segments/表面能γ与表面应力f/element.toml | 1 + .../segments/表面能γ与表面应力f/textbook.typ | 9 + 91 files changed, 559 insertions(+) create mode 100644 examples/TH-141/examples/41届复赛三-1-混注石油/element.toml create mode 100644 examples/TH-141/examples/41届复赛三-1-混注石油/problem.typ create mode 100644 examples/TH-141/examples/41届复赛三-1-混注石油/solution.typ create mode 100644 examples/TH-141/examples/41届复赛三-2-缺键模型/element.toml create mode 100644 examples/TH-141/examples/41届复赛三-2-缺键模型/problem.typ create mode 100644 examples/TH-141/examples/41届复赛三-2-缺键模型/solution.typ create mode 100644 examples/TH-141/lemmas/Dupré关系/element.toml create mode 100644 examples/TH-141/lemmas/Dupré关系/proof.typ create mode 100644 examples/TH-141/lemmas/Dupré关系/stmt.typ create mode 100644 examples/TH-141/lemmas/Eötvös规则/element.toml create mode 100644 examples/TH-141/lemmas/Eötvös规则/stmt.typ create mode 100644 examples/TH-141/lemmas/GGZ浸润判据/element.toml create mode 100644 examples/TH-141/lemmas/GGZ浸润判据/proof.typ create mode 100644 examples/TH-141/lemmas/GGZ浸润判据/stmt.typ create mode 100644 examples/TH-141/lemmas/Girifalco-Good公式/element.toml create mode 100644 examples/TH-141/lemmas/Girifalco-Good公式/proof.typ create mode 100644 examples/TH-141/lemmas/Girifalco-Good公式/stmt.typ create mode 100644 examples/TH-141/lemmas/Guggenheim-Katayama改良/element.toml create mode 100644 examples/TH-141/lemmas/Guggenheim-Katayama改良/stmt.typ create mode 100644 examples/TH-141/lemmas/LJ对势积分标度/element.toml create mode 100644 examples/TH-141/lemmas/LJ对势积分标度/proof.typ create mode 100644 examples/TH-141/lemmas/LJ对势积分标度/stmt.typ create mode 100644 examples/TH-141/lemmas/Stefan极简估算/element.toml create mode 100644 examples/TH-141/lemmas/Stefan极简估算/proof.typ create mode 100644 examples/TH-141/lemmas/Stefan极简估算/stmt.typ create mode 100644 examples/TH-141/lemmas/Young方程/element.toml create mode 100644 examples/TH-141/lemmas/Young方程/proof.typ create mode 100644 examples/TH-141/lemmas/Young方程/stmt.typ create mode 100644 examples/TH-141/lemmas/固体表面能的晶面各向异性/element.toml create mode 100644 examples/TH-141/lemmas/固体表面能的晶面各向异性/stmt.typ create mode 100644 examples/TH-141/lemmas/拉伸固体的总应力/element.toml create mode 100644 examples/TH-141/lemmas/拉伸固体的总应力/proof.typ create mode 100644 examples/TH-141/lemmas/拉伸固体的总应力/stmt.typ create mode 100644 examples/TH-141/lemmas/立方格子下zeta具体值/element.toml create mode 100644 examples/TH-141/lemmas/立方格子下zeta具体值/proof.typ create mode 100644 examples/TH-141/lemmas/立方格子下zeta具体值/stmt.typ create mode 100644 examples/TH-141/lemmas/缺键模型一般公式/element.toml create mode 100644 examples/TH-141/lemmas/缺键模型一般公式/proof.typ create mode 100644 examples/TH-141/lemmas/缺键模型一般公式/stmt.typ create mode 100644 examples/TH-141/lemmas/缺键模型迁移到固气/element.toml create mode 100644 examples/TH-141/lemmas/缺键模型迁移到固气/stmt.typ create mode 100644 examples/TH-141/lemmas/表面熵热力学关系/element.toml create mode 100644 examples/TH-141/lemmas/表面熵热力学关系/proof.typ create mode 100644 examples/TH-141/lemmas/表面熵热力学关系/stmt.typ create mode 100644 examples/TH-141/lemmas/量纲分析估计/element.toml create mode 100644 examples/TH-141/lemmas/量纲分析估计/stmt.typ create mode 100644 examples/TH-141/manifest.toml create mode 100644 examples/TH-141/segments/Fowkes极性修正/element.toml create mode 100644 examples/TH-141/segments/Fowkes极性修正/textbook.typ create mode 100644 examples/TH-141/segments/LJ积分导言/element.toml create mode 100644 examples/TH-141/segments/LJ积分导言/textbook.typ create mode 100644 examples/TH-141/segments/σTp态函数导言/element.toml create mode 100644 examples/TH-141/segments/σTp态函数导言/textbook.typ create mode 100644 examples/TH-141/segments/σT建模导言/element.toml create mode 100644 examples/TH-141/segments/σT建模导言/textbook.typ create mode 100644 examples/TH-141/segments/σ作为态函数的图像/element.toml create mode 100644 examples/TH-141/segments/σ作为态函数的图像/textbook.typ create mode 100644 examples/TH-141/segments/三相接触导言/element.toml create mode 100644 examples/TH-141/segments/三相接触导言/textbook.typ create mode 100644 examples/TH-141/segments/不同物质γ量级对比/element.toml create mode 100644 examples/TH-141/segments/不同物质γ量级对比/textbook.typ create mode 100644 examples/TH-141/segments/各模型对水的预测对照/element.toml create mode 100644 examples/TH-141/segments/各模型对水的预测对照/textbook.typ create mode 100644 examples/TH-141/segments/固气界面导言/element.toml create mode 100644 examples/TH-141/segments/固气界面导言/textbook.typ create mode 100644 examples/TH-141/segments/固液界面导言/element.toml create mode 100644 examples/TH-141/segments/固液界面导言/textbook.typ create mode 100644 examples/TH-141/segments/固液界面能的物理图像/element.toml create mode 100644 examples/TH-141/segments/固液界面能的物理图像/textbook.typ create mode 100644 examples/TH-141/segments/开场对照导言/element.toml create mode 100644 examples/TH-141/segments/开场对照导言/textbook.typ create mode 100644 examples/TH-141/segments/微观建模的共同骨架/element.toml create mode 100644 examples/TH-141/segments/微观建模的共同骨架/textbook.typ create mode 100644 examples/TH-141/segments/微观派Lm下降/element.toml create mode 100644 examples/TH-141/segments/微观派Lm下降/textbook.typ create mode 100644 examples/TH-141/segments/收束导言/element.toml create mode 100644 examples/TH-141/segments/收束导言/textbook.typ create mode 100644 examples/TH-141/segments/浸润全谱与高低能表面/element.toml create mode 100644 examples/TH-141/segments/浸润全谱与高低能表面/textbook.typ create mode 100644 examples/TH-141/segments/液气界面导言/element.toml create mode 100644 examples/TH-141/segments/液气界面导言/textbook.typ create mode 100644 examples/TH-141/segments/液面拉伸的本质/element.toml create mode 100644 examples/TH-141/segments/液面拉伸的本质/textbook.typ create mode 100644 examples/TH-141/segments/算不准背后的真实物理/element.toml create mode 100644 examples/TH-141/segments/算不准背后的真实物理/textbook.typ create mode 100644 examples/TH-141/segments/缺键模型导言/element.toml create mode 100644 examples/TH-141/segments/缺键模型导言/textbook.typ create mode 100644 examples/TH-141/segments/胡克唯象模型回顾/element.toml create mode 100644 examples/TH-141/segments/胡克唯象模型回顾/textbook.typ create mode 100644 examples/TH-141/segments/表面能γ与表面应力f/element.toml create mode 100644 examples/TH-141/segments/表面能γ与表面应力f/textbook.typ diff --git a/examples/TH-141/examples/41届复赛三-1-混注石油/element.toml b/examples/TH-141/examples/41届复赛三-1-混注石油/element.toml new file mode 100644 index 0000000..8a62024 --- /dev/null +++ b/examples/TH-141/examples/41届复赛三-1-混注石油/element.toml @@ -0,0 +1,2 @@ +kind = "example" +source = "41 届物理竞赛复赛第三大题(1)" diff --git a/examples/TH-141/examples/41届复赛三-1-混注石油/problem.typ b/examples/TH-141/examples/41届复赛三-1-混注石油/problem.typ new file mode 100644 index 0000000..1826e85 --- /dev/null +++ b/examples/TH-141/examples/41届复赛三-1-混注石油/problem.typ @@ -0,0 +1,7 @@ +某油井内未抽出的石油温度 $100 thin "℃"$,密度为水的 $80%$、比热容为水的 $60%$,记此为参考态。混合物表面张力系数 + +$ sigma \/ (10^(-3) thin "N/m") = 60 + 0.065 thin T \/ "K" - 24.0 thin p \/ "bar" + 3.15 thin (p \/ "bar")^2 . $ + +参考态下 $p$ 与 $T$ 成正比,比例系数为等容压强系数 $beta = 7.28 times 10^(-3) thin "bar/K"$。 + +某区域原本充满参考态石油,抽出一半的同时等体积注入水,混合后总体积等于两者之和,抽注过程绝热。问为使混合物 $sigma$ 最小,注入水的温度应为多少 ℃。 diff --git a/examples/TH-141/examples/41届复赛三-1-混注石油/solution.typ b/examples/TH-141/examples/41届复赛三-1-混注石油/solution.typ new file mode 100644 index 0000000..31706ae --- /dev/null +++ b/examples/TH-141/examples/41届复赛三-1-混注石油/solution.typ @@ -0,0 +1,27 @@ +参考态满足 $p = beta T$。混合过程总体积不变,仍属等容过程,故混合后亦满足 $p = beta T$。即整个过程始终有 $p = beta T$。 + +代入 $sigma(T, p)$,令 $tau equiv T \/ "K"$,得 $sigma$ 退化为 $tau$ 的单变量函数 + +$ sigma(tau) \/ (10^(-3) "N/m") = 60 + 0.065 tau - 24.0 thin (beta tau) + 3.15 thin (beta tau)^2 . $ + +代入 $beta = 7.28 times 10^(-3)$,注意 $24.0 times 7.28 times 10^(-3) = 0.1747$、$3.15 times (7.28 times 10^(-3))^2 = 1.670 times 10^(-4)$: + +$ sigma(tau) \/ (10^(-3) "N/m") = 60 - 0.1097 tau + 1.670 times 10^(-4) tau^2 . $ + +对 $tau$ 求导取零: + +$ tau_("mix") = 0.1097 / (2 times 1.670 times 10^(-4)) approx 328.4 , $ + +即 $T_("mix") approx 328.4 thin "K" approx 55.3 thin "℃"$。 + +混合的热平衡。设参考态石油温度 $T_0 = 373.15 thin "K"$、密度 $rho_0 = 0.8 rho_w$、比热容 $c_0 = 0.6 c_w$;注入水温度 $T_w$、密度 $rho_w$、比热容 $c_w$。等体积混合且绝热给出 + +$ rho_0 c_0 (T_0 - T_("mix")) = rho_w c_w (T_("mix") - T_w) , $ + +代入 $rho_0 c_0 = 0.48 thin rho_w c_w$, + +$ 0.48 (T_0 - T_("mix")) = T_("mix") - T_w , $ + +解出 + +$ T_w = T_("mix") - 0.48 (T_0 - T_("mix")) approx 306.9 thin "K" approx 33.7 thin "℃" . $ diff --git a/examples/TH-141/examples/41届复赛三-2-缺键模型/element.toml b/examples/TH-141/examples/41届复赛三-2-缺键模型/element.toml new file mode 100644 index 0000000..f813285 --- /dev/null +++ b/examples/TH-141/examples/41届复赛三-2-缺键模型/element.toml @@ -0,0 +1,2 @@ +kind = "example" +source = "41 届物理竞赛复赛第三大题(2)" diff --git a/examples/TH-141/examples/41届复赛三-2-缺键模型/problem.typ b/examples/TH-141/examples/41届复赛三-2-缺键模型/problem.typ new file mode 100644 index 0000000..b278dcf --- /dev/null +++ b/examples/TH-141/examples/41届复赛三-2-缺键模型/problem.typ @@ -0,0 +1 @@ +假设液体表面张力的存在可以全部归结为表面层内与液体内部每个粒子邻近粒子数目的不同,且表面层内粒子间距与液体内部相同。设表面层每个粒子邻近粒子数是液体内部的 $zeta$ 倍($0 < zeta < 1$)。已知液体摩尔质量 $mu$、摩尔汽化热 $L_m$、质量密度 $rho$、阿伏伽德罗常量 $N_A$,导出液气界面张力系数 $sigma$ 的表达式。 diff --git a/examples/TH-141/examples/41届复赛三-2-缺键模型/solution.typ b/examples/TH-141/examples/41届复赛三-2-缺键模型/solution.typ new file mode 100644 index 0000000..04e77b8 --- /dev/null +++ b/examples/TH-141/examples/41届复赛三-2-缺键模型/solution.typ @@ -0,0 +1,3 @@ +完整推导见 @缺键一般式。要点为:设体相分子最近邻数 $Z$、单键能 $epsilon$,由共享键计数得 $epsilon = 2 L_m \/ (N_A Z)$;表面分子缺 $(1 - zeta) Z$ 根键,按半键计赔账得亏损能 $Delta U = (1 - zeta) L_m \/ N_A$;分子占体积 $d^3 = mu \/ (rho N_A)$ 给出面密度 $n_s = (rho N_A \/ mu)^(2\/3)$。代入 @骨架公式 + +$ sigma = Delta U dot n_s = (1 - zeta) thin L_m thin rho^(2\/3) / (mu^(2\/3) thin N_A^(1\/3)) . $ diff --git a/examples/TH-141/lemmas/Dupré关系/element.toml b/examples/TH-141/lemmas/Dupré关系/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/Dupré关系/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/Dupré关系/proof.typ b/examples/TH-141/lemmas/Dupré关系/proof.typ new file mode 100644 index 0000000..dc51544 --- /dev/null +++ b/examples/TH-141/lemmas/Dupré关系/proof.typ @@ -0,0 +1,5 @@ +剥离单位面积固液界面前后的界面能账目变化为:消失一份固液界面贡献 $-gamma_(S L)$,新生成一份固气面与一份液气面贡献 $+(gamma_(S G) + gamma_(L G))$。剥离所做的可逆功 $W_(S L)$ 等于界面能的净增量 + +$ W_(S L) = gamma_(S G) + gamma_(L G) - gamma_(S L) , $ + +整理即得 @Dupré关系。 diff --git a/examples/TH-141/lemmas/Dupré关系/stmt.typ b/examples/TH-141/lemmas/Dupré关系/stmt.typ new file mode 100644 index 0000000..157c7b3 --- /dev/null +++ b/examples/TH-141/lemmas/Dupré关系/stmt.typ @@ -0,0 +1,5 @@ +设把单位面积固液界面剥离成各自暴露的固气面与液气面所需的可逆功为粘附功 $W_(S L)$,则 + +$ gamma_(S L) = gamma_(S G) + gamma_(L G) - W_(S L) . $ + +$W_(S L)$ 越大,固液之间越"粘"得紧,$gamma_(S L)$ 越小。 diff --git a/examples/TH-141/lemmas/Eötvös规则/element.toml b/examples/TH-141/lemmas/Eötvös规则/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/Eötvös规则/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/Eötvös规则/stmt.typ b/examples/TH-141/lemmas/Eötvös规则/stmt.typ new file mode 100644 index 0000000..5d65dac --- /dev/null +++ b/examples/TH-141/lemmas/Eötvös规则/stmt.typ @@ -0,0 +1,5 @@ +设液体的摩尔体积为 $V_m$、临界温度为 $T_c$,则其表面张力随温度的标度律为 + +$ sigma thin V_m^(2\/3) = k (T_c - T) , $ + +其中 $k approx 2.1 times 10^(-7) thin "J" dot "K"^(-1) dot "mol"^(-2\/3)$ 是一个对所有液体共用的普适常数,称为 Eötvös 常数。 diff --git a/examples/TH-141/lemmas/GGZ浸润判据/element.toml b/examples/TH-141/lemmas/GGZ浸润判据/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/GGZ浸润判据/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/GGZ浸润判据/proof.typ b/examples/TH-141/lemmas/GGZ浸润判据/proof.typ new file mode 100644 index 0000000..d5a39dc --- /dev/null +++ b/examples/TH-141/lemmas/GGZ浸润判据/proof.typ @@ -0,0 +1,13 @@ +把 @GG公式 给出的 $gamma_(S L) = (sqrt(gamma_(S G)) - sqrt(gamma_(L G)))^2$ 代入 @Young方程: + +$ cos theta = (gamma_(S G) - (sqrt(gamma_(S G)) - sqrt(gamma_(L G)))^2) / gamma_(L G) . $ + +展开右端分子: + +$ gamma_(S G) - (gamma_(S G) - 2 sqrt(gamma_(S G) gamma_(L G)) + gamma_(L G)) = 2 sqrt(gamma_(S G) gamma_(L G)) - gamma_(L G) , $ + +代回得 + +$ cos theta = 2 sqrt(gamma_(S G) / gamma_(L G)) - 1 , $ + +即 @GGZ判据。 diff --git a/examples/TH-141/lemmas/GGZ浸润判据/stmt.typ b/examples/TH-141/lemmas/GGZ浸润判据/stmt.typ new file mode 100644 index 0000000..6fb29d0 --- /dev/null +++ b/examples/TH-141/lemmas/GGZ浸润判据/stmt.typ @@ -0,0 +1,5 @@ +把 Berthelot 估计 @GG公式 代回 Young 方程 @Young方程,得到 Girifalco–Good–Zisman 浸润判据 + +$ cos theta = 2 sqrt(gamma_(S G) / gamma_(L G)) - 1 . $ + +接触角完全由比值 $gamma_(S G) \/ gamma_(L G)$ 一个无量纲数决定。 diff --git a/examples/TH-141/lemmas/Girifalco-Good公式/element.toml b/examples/TH-141/lemmas/Girifalco-Good公式/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/Girifalco-Good公式/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/Girifalco-Good公式/proof.typ b/examples/TH-141/lemmas/Girifalco-Good公式/proof.typ new file mode 100644 index 0000000..2bb6bf0 --- /dev/null +++ b/examples/TH-141/lemmas/Girifalco-Good公式/proof.typ @@ -0,0 +1,9 @@ +把 @Berthelot粘附 代入 @Dupré关系: + +$ gamma_(S L) = gamma_(S G) + gamma_(L G) - 2 sqrt(gamma_(S G) thin gamma_(L G)) , $ + +右端是完全平方差,化简得 + +$ gamma_(S L) approx (sqrt(gamma_(S G)) - sqrt(gamma_(L G)))^2 , $ + +即 @GG公式。 diff --git a/examples/TH-141/lemmas/Girifalco-Good公式/stmt.typ b/examples/TH-141/lemmas/Girifalco-Good公式/stmt.typ new file mode 100644 index 0000000..6d1fb67 --- /dev/null +++ b/examples/TH-141/lemmas/Girifalco-Good公式/stmt.typ @@ -0,0 +1,7 @@ +对色散力为主的固液体系,异种相互作用能由同种相互作用能的几何平均近似:$epsilon_(S L) approx sqrt(epsilon_(S S) thin epsilon_(L L))$。相应地粘附功 + +$ W_(S L) approx 2 sqrt(gamma_(S G) thin gamma_(L G)) . $ + +代入 @Dupré关系 得 Girifalco–Good 公式 + +$ gamma_(S L) approx (sqrt(gamma_(S G)) - sqrt(gamma_(L G)))^2 . $ diff --git a/examples/TH-141/lemmas/Guggenheim-Katayama改良/element.toml b/examples/TH-141/lemmas/Guggenheim-Katayama改良/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/Guggenheim-Katayama改良/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/Guggenheim-Katayama改良/stmt.typ b/examples/TH-141/lemmas/Guggenheim-Katayama改良/stmt.typ new file mode 100644 index 0000000..cc4018e --- /dev/null +++ b/examples/TH-141/lemmas/Guggenheim-Katayama改良/stmt.typ @@ -0,0 +1,5 @@ +Eötvös 规则 @Eötvös规则 在 $T arrow.r T_c$ 附近预言 $sigma prop (T_c - T)$ 线性趋零。实验观测显示趋零行为更接近幂律: + +$ sigma = sigma_0 (1 - T \/ T_c)^n , quad n approx 11 \/ 9 . $ + +@GK规则 在临界点附近比 @Eötvös规则 更准确。 diff --git a/examples/TH-141/lemmas/LJ对势积分标度/element.toml b/examples/TH-141/lemmas/LJ对势积分标度/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/LJ对势积分标度/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/LJ对势积分标度/proof.typ b/examples/TH-141/lemmas/LJ对势积分标度/proof.typ new file mode 100644 index 0000000..4451f19 --- /dev/null +++ b/examples/TH-141/lemmas/LJ对势积分标度/proof.typ @@ -0,0 +1,13 @@ +设下半空间体相分子的数密度为 $n_v = 1 \/ d^3$。固定一个表面分子作为参考分子,对其与下半空间所有体相分子的相互作用求和。由对称性,相对参考分子距离 $r$ 在下半空间内的体积元为 $dif V = 2 pi r^2 dif r$。下限取 $r = d$,上限取 $infinity$: + +$ Delta U = 2 pi n_v integral_d^infinity u(r) thin r^2 dif r . $ + +代入 $u(r)$,用 $integral r^2 dot r^(-n) dif r = r^(3 - n) \/ (3 - n)$ 化简,得 + +$ Delta U tilde.op epsilon . $ + +把 $Delta U$ 乘以单位面积分子数 $n_s tilde.op 1 \/ d^2$,由 @骨架公式 即得 + +$ sigma tilde.op epsilon / d^2 , $ + +即 @LJ标度。 diff --git a/examples/TH-141/lemmas/LJ对势积分标度/stmt.typ b/examples/TH-141/lemmas/LJ对势积分标度/stmt.typ new file mode 100644 index 0000000..532bf82 --- /dev/null +++ b/examples/TH-141/lemmas/LJ对势积分标度/stmt.typ @@ -0,0 +1,9 @@ +设分子间对势为 Lennard–Jones 6-12 形式 + +$ u(r) = 4 epsilon [(d \/ r)^(12) - (d \/ r)^6] . $ + +把一个表面分子与下半空间所有分子的相互作用积分求和,得到 + +$ sigma tilde.op epsilon / d^2 . $ + +此式与量纲估计 @量纲估计 同标度。 diff --git a/examples/TH-141/lemmas/Stefan极简估算/element.toml b/examples/TH-141/lemmas/Stefan极简估算/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/Stefan极简估算/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/Stefan极简估算/proof.typ b/examples/TH-141/lemmas/Stefan极简估算/proof.typ new file mode 100644 index 0000000..255e3fe --- /dev/null +++ b/examples/TH-141/lemmas/Stefan极简估算/proof.typ @@ -0,0 +1,9 @@ +在 @缺键一般式 中取 $zeta = 1\/2$, + +$ sigma_(L G) = 1/2 dot L_m thin rho^(2\/3) / (mu^(2\/3) N_A^(1\/3)) . $ + +由 $d^3 = mu \/ (rho N_A)$ 得 $rho^(2\/3) \/ (mu^(2\/3) N_A^(1\/3)) = 1 \/ (d^2 N_A)$,代入即得 + +$ sigma_(L G) thin d^2 approx L_m / (2 N_A) , $ + +即 @Stefan估算。 diff --git a/examples/TH-141/lemmas/Stefan极简估算/stmt.typ b/examples/TH-141/lemmas/Stefan极简估算/stmt.typ new file mode 100644 index 0000000..39040aa --- /dev/null +++ b/examples/TH-141/lemmas/Stefan极简估算/stmt.typ @@ -0,0 +1,5 @@ +在 @缺键一般式 中取 $zeta = 1\/2$,得 + +$ sigma_(L G) thin d^2 approx L_m / (2 N_A) . $ + +它是缺键模型的最简退化形式,不含任何晶格信息。 diff --git a/examples/TH-141/lemmas/Young方程/element.toml b/examples/TH-141/lemmas/Young方程/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/Young方程/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/Young方程/proof.typ b/examples/TH-141/lemmas/Young方程/proof.typ new file mode 100644 index 0000000..2c8961d --- /dev/null +++ b/examples/TH-141/lemmas/Young方程/proof.typ @@ -0,0 +1,9 @@ +沿固体表面把接触线挪动 $dif x$,单位接触线长度上:固液界面增 $dif x$,固气界面减 $dif x$,液气界面长度按 $cos theta$ 投影增 $dif x cos theta$。总界面能的变化 + +$ dif F = gamma_(S L) dif x - gamma_(S G) dif x + gamma_(L G) cos theta thin dif x . $ + +平衡条件 $dif F = 0$ 给出 + +$ gamma_(S G) = gamma_(S L) + gamma_(L G) cos theta , $ + +即 @Young方程。 diff --git a/examples/TH-141/lemmas/Young方程/stmt.typ b/examples/TH-141/lemmas/Young方程/stmt.typ new file mode 100644 index 0000000..33e04e4 --- /dev/null +++ b/examples/TH-141/lemmas/Young方程/stmt.typ @@ -0,0 +1,3 @@ +一滴液体落在固体表面上,与固气界面之间形成三相接触线。设浸润角即接触角为 $theta$,则三种界面张力满足 + +$ gamma_(S G) = gamma_(S L) + gamma_(L G) cos theta , quad arrow.r.double quad cos theta = (gamma_(S G) - gamma_(S L)) / gamma_(L G) . $ diff --git a/examples/TH-141/lemmas/固体表面能的晶面各向异性/element.toml b/examples/TH-141/lemmas/固体表面能的晶面各向异性/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/固体表面能的晶面各向异性/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/固体表面能的晶面各向异性/stmt.typ b/examples/TH-141/lemmas/固体表面能的晶面各向异性/stmt.typ new file mode 100644 index 0000000..af1b939 --- /dev/null +++ b/examples/TH-141/lemmas/固体表面能的晶面各向异性/stmt.typ @@ -0,0 +1,3 @@ +不同晶面的表面原子缺键数不同,故 $gamma_(S G)$ 随晶面指数 $(h k l)$ 而变。以简单立方为例:(100) 缺 1 键、(110) 缺 2 键、(111) 缺 3 键,从而 + +$ gamma_(S G)^((100)) < gamma_(S G)^((110)) < gamma_(S G)^((111)) . $ <晶面各向异性> diff --git a/examples/TH-141/lemmas/拉伸固体的总应力/element.toml b/examples/TH-141/lemmas/拉伸固体的总应力/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/拉伸固体的总应力/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/拉伸固体的总应力/proof.typ b/examples/TH-141/lemmas/拉伸固体的总应力/proof.typ new file mode 100644 index 0000000..34a8b42 --- /dev/null +++ b/examples/TH-141/lemmas/拉伸固体的总应力/proof.typ @@ -0,0 +1,9 @@ +设固体体积 $V$、表面积 $A$,应变 $epsilon$ 为均匀小量。体相单位体积的自由能 $f_b = Y epsilon^2 \/ 2$,表面单位面积的自由能 $gamma(epsilon)$,总自由能 + +$ F = V f_b + A gamma(epsilon) . $ + +对均匀拉伸 $V epsilon$ 求导得单位横截面承担的应力 + +$ sigma_("tot") = (partial F) / (partial (V epsilon)) = Y epsilon + A / V dot (dif gamma) / (dif epsilon) , $ + +即 @总应力。 diff --git a/examples/TH-141/lemmas/拉伸固体的总应力/stmt.typ b/examples/TH-141/lemmas/拉伸固体的总应力/stmt.typ new file mode 100644 index 0000000..2c0bbf1 --- /dev/null +++ b/examples/TH-141/lemmas/拉伸固体的总应力/stmt.typ @@ -0,0 +1,5 @@ +对横截面被均匀拉伸的固体,单位横截面承担的总应力为 + +$ sigma_("tot") = Y epsilon + A / V dot (dif gamma) / (dif epsilon) , $ <总应力> + +其中 $Y$ 为杨氏模量、$epsilon$ 为应变、$A \/ V$ 为表面积与体积之比。第一项为体相弹性贡献,第二项为表面贡献。 diff --git a/examples/TH-141/lemmas/立方格子下zeta具体值/element.toml b/examples/TH-141/lemmas/立方格子下zeta具体值/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/立方格子下zeta具体值/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/立方格子下zeta具体值/proof.typ b/examples/TH-141/lemmas/立方格子下zeta具体值/proof.typ new file mode 100644 index 0000000..b593aea --- /dev/null +++ b/examples/TH-141/lemmas/立方格子下zeta具体值/proof.typ @@ -0,0 +1,5 @@ +数缺键以几何方式进行。 + +简单立方中每个体相原子的 6 个最近邻分布在上下左右前后六个方向。(100) 面上的表面原子失去上方那 1 个邻居,故 $Z_s = 5$、$zeta = 5\/6$。 + +面心立方中每个体相原子的 12 个最近邻分布在该原子周围三个 (111) 面上。(111) 面是最密堆积,表面原子失去上方一层的 3 个邻居,$Z_s = 9$、$zeta = 3\/4$。(100) 面失去 4 个上方邻居,$Z_s = 8$、$zeta = 2\/3$。(110) 面失去的近邻更多,$zeta$ 进一步减小。 diff --git a/examples/TH-141/lemmas/立方格子下zeta具体值/stmt.typ b/examples/TH-141/lemmas/立方格子下zeta具体值/stmt.typ new file mode 100644 index 0000000..5507866 --- /dev/null +++ b/examples/TH-141/lemmas/立方格子下zeta具体值/stmt.typ @@ -0,0 +1,8 @@ +通过对几何晶格直接数缺键数,可以把 @缺键一般式 中的 $zeta$ 落到具体数字: + +- 简单立方 (100) 面:$Z = 6$、$Z_s = 5$,$zeta = 5\/6$。 +- 面心立方 (111) 面:$Z = 12$、$Z_s = 9$,$zeta = 3\/4$。 +- 面心立方 (100) 面:$Z_s = 8$,$zeta = 2\/3$。 +- 面心立方 (110) 面:$zeta < 2\/3$。 + +后续讨论以 FCC (111) 面为代表,因其密度最大、$gamma$ 最低、平衡形貌中最易出现。 diff --git a/examples/TH-141/lemmas/缺键模型一般公式/element.toml b/examples/TH-141/lemmas/缺键模型一般公式/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/缺键模型一般公式/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/缺键模型一般公式/proof.typ b/examples/TH-141/lemmas/缺键模型一般公式/proof.typ new file mode 100644 index 0000000..8a73fff --- /dev/null +++ b/examples/TH-141/lemmas/缺键模型一般公式/proof.typ @@ -0,0 +1,17 @@ +设体相分子的最近邻数为 $Z$、每根键的能量为 $epsilon$。每根键被两个分子共享,一摩尔液体的独立键数为 $N_A Z \/ 2$,把它们全部断开所需的能量即摩尔汽化热 + +$ L_m = N_A Z epsilon / 2 , quad arrow.r.double quad epsilon = (2 L_m) / (N_A Z) . $ <缺键-单键> + +表面分子的近邻数为 $zeta Z$,相比体相少 $(1 - zeta) Z$ 个近邻。按共享原则,每个缺键的能量代价为 $epsilon \/ 2$,于是每个表面分子的亏损能为 + +$ Delta U = (1 - zeta) Z dot epsilon / 2 = ((1 - zeta) L_m) / N_A . $ <缺键-亏损能> + +设每个分子占体积 $d^3 = mu \/ (rho N_A)$,则单位面积分子数 + +$ n_s = d^(-2) = (rho N_A / mu)^(2\/3) . $ <缺键-面密度> + +代入 @骨架公式 即得 + +$ sigma_(L G) = Delta U dot n_s = (1 - zeta) thin L_m thin rho^(2\/3) / (mu^(2\/3) thin N_A^(1\/3)) , $ + +即 @缺键一般式。 diff --git a/examples/TH-141/lemmas/缺键模型一般公式/stmt.typ b/examples/TH-141/lemmas/缺键模型一般公式/stmt.typ new file mode 100644 index 0000000..3282d90 --- /dev/null +++ b/examples/TH-141/lemmas/缺键模型一般公式/stmt.typ @@ -0,0 +1,3 @@ +设液体表面层每个分子的近邻数是体相分子近邻数的 $zeta$ 倍($0 < zeta < 1$),摩尔汽化热为 $L_m$、摩尔质量为 $mu$、质量密度为 $rho$、阿伏伽德罗常量为 $N_A$,则液气界面张力系数为 + +$ sigma_(L G) = (1 - zeta) thin L_m thin rho^(2\/3) / (mu^(2\/3) thin N_A^(1\/3)) . $ <缺键一般式> diff --git a/examples/TH-141/lemmas/缺键模型迁移到固气/element.toml b/examples/TH-141/lemmas/缺键模型迁移到固气/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/缺键模型迁移到固气/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/缺键模型迁移到固气/stmt.typ b/examples/TH-141/lemmas/缺键模型迁移到固气/stmt.typ new file mode 100644 index 0000000..58813e3 --- /dev/null +++ b/examples/TH-141/lemmas/缺键模型迁移到固气/stmt.typ @@ -0,0 +1,5 @@ +固气界面 $gamma_(S G)$ 的缺键模型公式由 @缺键一般式 直接迁移,只把摩尔汽化热 $L_m$ 替换为摩尔升华热 $L_s$: + +$ gamma_(S G) = (1 - zeta) thin L_s thin rho^(2\/3) / (mu^(2\/3) thin N_A^(1\/3)) . $ <固气缺键式> + +物理来源也只是把"把分子从液体里拉出来"换成"把原子从晶格里拉出来",骨架公式 @骨架公式 与单键计数过程不变。 diff --git a/examples/TH-141/lemmas/表面熵热力学关系/element.toml b/examples/TH-141/lemmas/表面熵热力学关系/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/表面熵热力学关系/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/表面熵热力学关系/proof.typ b/examples/TH-141/lemmas/表面熵热力学关系/proof.typ new file mode 100644 index 0000000..c12f954 --- /dev/null +++ b/examples/TH-141/lemmas/表面熵热力学关系/proof.typ @@ -0,0 +1,11 @@ +$sigma$ 的热力学定义是恒温恒容下单位面积自由能:$sigma = (partial F \/ partial A)_(T, V)$。自由能微分 + +$ dif F = - S dif T - p dif V + sigma dif A , $ + +给出 Maxwell 关系 + +$ ((partial sigma) / (partial T))_(A, V) = - ((partial S) / (partial A))_(T, V) = - s_s , $ + +其中 $s_s equiv (partial S \/ partial A)_(T, V)$。代回即 @表面熵。 + +液面分子被约束在准二维层内,可达微观态数比体相分子少,故 $s_s > 0$,进而 $dif sigma \/ dif T < 0$。 diff --git a/examples/TH-141/lemmas/表面熵热力学关系/stmt.typ b/examples/TH-141/lemmas/表面熵热力学关系/stmt.typ new file mode 100644 index 0000000..ee50bbe --- /dev/null +++ b/examples/TH-141/lemmas/表面熵热力学关系/stmt.typ @@ -0,0 +1,5 @@ +设单位面积表面熵为 $s_s$,则表面张力对温度的变化率满足 + +$ (dif sigma) / (dif T) = - s_s . $ <表面熵> + +物理上 $s_s > 0$,故 $dif sigma \/ dif T < 0$,$sigma$ 随温度升高单调下降。 diff --git a/examples/TH-141/lemmas/量纲分析估计/element.toml b/examples/TH-141/lemmas/量纲分析估计/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/lemmas/量纲分析估计/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/量纲分析估计/stmt.typ b/examples/TH-141/lemmas/量纲分析估计/stmt.typ new file mode 100644 index 0000000..136b2a4 --- /dev/null +++ b/examples/TH-141/lemmas/量纲分析估计/stmt.typ @@ -0,0 +1,5 @@ +设液体的单键能为 $epsilon$、分子间距为 $d$,仅由量纲组合可得液气界面张力的标度 + +$ sigma tilde.op epsilon / d^2 . $ <量纲估计> + +此式不依赖具体势函数、晶格结构或相变热,是后续更精细模型的下限基准。 diff --git a/examples/TH-141/manifest.toml b/examples/TH-141/manifest.toml new file mode 100644 index 0000000..78199ee --- /dev/null +++ b/examples/TH-141/manifest.toml @@ -0,0 +1,166 @@ +[project] +id = "local-mpttwr3t-sn1oma" +name = "TH-141_表面张力的严肃理论" + +[info] +title = "TH-141:表面张力的严肃理论" +author = "范式教育教研组" + +[[parts]] +kind = "segment" +path = "segments/开场对照导言" + +[[parts]] +kind = "segment" +path = "segments/胡克唯象模型回顾" + +[[parts]] +kind = "segment" +path = "segments/液面拉伸的本质" + +[[parts]] +kind = "segment" +path = "segments/液气界面导言" + +[[parts]] +kind = "segment" +path = "segments/微观建模的共同骨架" + +[[parts]] +kind = "lemma" +path = "lemmas/量纲分析估计" + +[[parts]] +kind = "segment" +path = "segments/缺键模型导言" + +[[parts]] +kind = "lemma" +path = "lemmas/缺键模型一般公式" + +[[parts]] +kind = "example" +path = "examples/41届复赛三-2-缺键模型" + +[[parts]] +kind = "lemma" +path = "lemmas/Stefan极简估算" + +[[parts]] +kind = "lemma" +path = "lemmas/立方格子下zeta具体值" + +[[parts]] +kind = "segment" +path = "segments/LJ积分导言" + +[[parts]] +kind = "lemma" +path = "lemmas/LJ对势积分标度" + +[[parts]] +kind = "segment" +path = "segments/固气界面导言" + +[[parts]] +kind = "segment" +path = "segments/表面能γ与表面应力f" + +[[parts]] +kind = "lemma" +path = "lemmas/拉伸固体的总应力" + +[[parts]] +kind = "lemma" +path = "lemmas/缺键模型迁移到固气" + +[[parts]] +kind = "lemma" +path = "lemmas/固体表面能的晶面各向异性" + +[[parts]] +kind = "segment" +path = "segments/不同物质γ量级对比" + +[[parts]] +kind = "segment" +path = "segments/固液界面导言" + +[[parts]] +kind = "segment" +path = "segments/固液界面能的物理图像" + +[[parts]] +kind = "lemma" +path = "lemmas/Dupré关系" + +[[parts]] +kind = "lemma" +path = "lemmas/Girifalco-Good公式" + +[[parts]] +kind = "segment" +path = "segments/Fowkes极性修正" + +[[parts]] +kind = "segment" +path = "segments/三相接触导言" + +[[parts]] +kind = "lemma" +path = "lemmas/Young方程" + +[[parts]] +kind = "lemma" +path = "lemmas/GGZ浸润判据" + +[[parts]] +kind = "segment" +path = "segments/浸润全谱与高低能表面" + +[[parts]] +kind = "segment" +path = "segments/σT建模导言" + +[[parts]] +kind = "segment" +path = "segments/微观派Lm下降" + +[[parts]] +kind = "lemma" +path = "lemmas/Eötvös规则" + +[[parts]] +kind = "lemma" +path = "lemmas/Guggenheim-Katayama改良" + +[[parts]] +kind = "lemma" +path = "lemmas/表面熵热力学关系" + +[[parts]] +kind = "segment" +path = "segments/σTp态函数导言" + +[[parts]] +kind = "segment" +path = "segments/σ作为态函数的图像" + +[[parts]] +kind = "example" +path = "examples/41届复赛三-1-混注石油" + +[[parts]] +kind = "segment" +path = "segments/收束导言" + +[[parts]] +kind = "segment" +path = "segments/各模型对水的预测对照" + +[[parts]] +kind = "segment" +path = "segments/算不准背后的真实物理" + +[targets.student] +[targets.teacher] diff --git a/examples/TH-141/segments/Fowkes极性修正/element.toml b/examples/TH-141/segments/Fowkes极性修正/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/Fowkes极性修正/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/Fowkes极性修正/textbook.typ b/examples/TH-141/segments/Fowkes极性修正/textbook.typ new file mode 100644 index 0000000..ae4f16a --- /dev/null +++ b/examples/TH-141/segments/Fowkes极性修正/textbook.typ @@ -0,0 +1,5 @@ +Girifalco–Good 公式 @GG公式 建立在纯色散力假设上,对水这类强极性或氢键液体已不够精确。Fowkes 给出的修正路径是把表面能 $gamma$ 拆为色散分量 $gamma^d$ 与极性分量 $gamma^p$,分别做几何平均: + +$ W_(S L) = 2 sqrt(gamma_(S G)^d thin gamma_(L G)^d) + 2 sqrt(gamma_(S G)^p thin gamma_(L G)^p) . $ + +工程实际计算中水、聚合物等强极性体系的接触角分析走的就是这条路。本节只列出公式作为知识地图的一站,不要求会用。 diff --git a/examples/TH-141/segments/LJ积分导言/element.toml b/examples/TH-141/segments/LJ积分导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/LJ积分导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/LJ积分导言/textbook.typ b/examples/TH-141/segments/LJ积分导言/textbook.typ new file mode 100644 index 0000000..964ac16 --- /dev/null +++ b/examples/TH-141/segments/LJ积分导言/textbook.typ @@ -0,0 +1,3 @@ +== Lennard–Jones 对势积分 + +缺键模型把分子间作用力简化为最近邻间存在的离散键。若代之以连续对势函数 $u(r)$、对表面分子与下半空间所有分子之间的相互作用直接积分,得到的标度律应当与缺键模型一致,并与最初的量纲分析 @量纲估计 互为印证。本节以 Lennard–Jones 6-12 势为例完成这一积分。 diff --git a/examples/TH-141/segments/σTp态函数导言/element.toml b/examples/TH-141/segments/σTp态函数导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/σTp态函数导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/σTp态函数导言/textbook.typ b/examples/TH-141/segments/σTp态函数导言/textbook.typ new file mode 100644 index 0000000..5d13e21 --- /dev/null +++ b/examples/TH-141/segments/σTp态函数导言/textbook.typ @@ -0,0 +1,3 @@ += $sigma(T, p)$ 态函数与工程应用 + +以上所有模型都从微观或唯象规律推出 $sigma$。在工程实际中,$sigma$ 常被直接当作状态变量 $(T, p, "组分")$ 的态函数处理,由实验数据拟合给出。本节给出一道典型的工程应用题:把 $sigma(T, p)$ 当作给定的多项式态函数,用它解一个混合优化问题。 diff --git a/examples/TH-141/segments/σT建模导言/element.toml b/examples/TH-141/segments/σT建模导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/σT建模导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/σT建模导言/textbook.typ b/examples/TH-141/segments/σT建模导言/textbook.typ new file mode 100644 index 0000000..fc38804 --- /dev/null +++ b/examples/TH-141/segments/σT建模导言/textbook.typ @@ -0,0 +1,3 @@ += $sigma(T)$ 的建模 + +以上讨论都默认温度固定。但 $sigma$ 随温度变化的图像是物理学的常识——温度升高时分子热运动加剧,表面层的亏损能下降,$sigma$ 随之下降,在临界点处趋零。本节给出 $sigma(T)$ 的几条关系:从缺键模型直接读出的 $L_m(T)$ 单调下降图像、唯象的 Eötvös 规则及其幂律修正、以及把 $sigma$ 升级为自由能的热力学关系 $dif sigma \/ dif T = - s_s$。 diff --git a/examples/TH-141/segments/σ作为态函数的图像/element.toml b/examples/TH-141/segments/σ作为态函数的图像/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/σ作为态函数的图像/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/σ作为态函数的图像/textbook.typ b/examples/TH-141/segments/σ作为态函数的图像/textbook.typ new file mode 100644 index 0000000..f6eb5cf --- /dev/null +++ b/examples/TH-141/segments/σ作为态函数的图像/textbook.typ @@ -0,0 +1,3 @@ +$sigma$ 是温度、压强、组分的态函数。在工程实际中,对某一类流体(如石油、混合液烃、电池电解液)做大量实验测量后用多项式拟合 $sigma(T, p)$ 给出经验公式,并直接代入应用。这种"输入最多、假设最少"的建模方式与前面从分子层面推出 $sigma$ 的方式互不冲突,是同一物理量在不同抽象层级的两种表达。 + +下题给出一道把 $sigma(T, p)$ 当作给定的多项式态函数、求工程优化条件的真实问题。题中所有外加参数都是给定的——本题考查的是把态函数代入混合过程后如何利用其变量关系简化为单变量优化。 diff --git a/examples/TH-141/segments/三相接触导言/element.toml b/examples/TH-141/segments/三相接触导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/三相接触导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/三相接触导言/textbook.typ b/examples/TH-141/segments/三相接触导言/textbook.typ new file mode 100644 index 0000000..c980020 --- /dev/null +++ b/examples/TH-141/segments/三相接触导言/textbook.typ @@ -0,0 +1,3 @@ += 三相接触:Young 方程与浸润 + +把三种界面能 $gamma_(L G)$、$gamma_(S G)$、$gamma_(S L)$ 放在固体表面上一滴液体的三相接触线处,用能量极小化条件可得到接触角 $theta$ 的表达式,即 Young 方程。把 Berthelot 估计 @GG公式 代回 Young 方程,$theta$ 进一步退化为只依赖比值 $gamma_(S G) \/ gamma_(L G)$ 的形式,由此得到浸润判据并把"什么液体在什么固体上铺得开"翻译成一个无量纲数的取值。 diff --git a/examples/TH-141/segments/不同物质γ量级对比/element.toml b/examples/TH-141/segments/不同物质γ量级对比/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/不同物质γ量级对比/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/不同物质γ量级对比/textbook.typ b/examples/TH-141/segments/不同物质γ量级对比/textbook.typ new file mode 100644 index 0000000..081f66f --- /dev/null +++ b/examples/TH-141/segments/不同物质γ量级对比/textbook.typ @@ -0,0 +1,16 @@ +不同物质的表面张力系数差异可达两个量级。 + +#figure( + table( + columns: (auto, auto), + align: (left, left), + table.header[*物质*][*$gamma$ ($"J/m"^2$)*], + [水(液气界面)], [$approx 0.072$], + [分子晶体], [$0.05 thin "—" thin 0.1$], + [离子晶体], [$0.2 thin "—" thin 1$], + [金属(Au、Fe、W)], [$1 thin "—" thin 3$], + ), + caption: [常见物质表面张力系数的典型量级] +) <γ量级表> + +该量级谱可由缺键模型 @固气缺键式 解释。分子晶体的键合主要是范德瓦尔斯力,$L_s$ 小、$gamma$ 小;离子晶体的键合包含库仑作用,$L_s$ 上升一个量级,$gamma$ 也随之上升;金属内部的金属键键合最强,$L_s$ 最大、$gamma$ 最大。同一公式横跨四个数量级的物理体系都给出对的量级。 diff --git a/examples/TH-141/segments/各模型对水的预测对照/element.toml b/examples/TH-141/segments/各模型对水的预测对照/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/各模型对水的预测对照/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/各模型对水的预测对照/textbook.typ b/examples/TH-141/segments/各模型对水的预测对照/textbook.typ new file mode 100644 index 0000000..d3826cd --- /dev/null +++ b/examples/TH-141/segments/各模型对水的预测对照/textbook.typ @@ -0,0 +1,16 @@ +把本章四种模型对常温水液气界面 $sigma_(L G)$ 的预测整理如下: + +#figure( + table( + columns: (auto, auto, auto), + align: (left, left, left), + table.header[*模型*][*$sigma$ 预测($"N/m"$)*][*相对实测 $0.072$*], + [量纲分析], [$tilde.op 0.1$], [量级正确], + [Stefan ($zeta = 1\/2$)], [$approx 0.38$], [偏大约 5 倍], + [缺键模型 ($zeta = 3\/4$,FCC (111))], [$approx 0.13$], [偏大约 2 倍], + [Eötvös 规则(外推)], [$approx 0.072$], [量级与具体值都接近], + ), + caption: [本章各模型对常温水 $sigma_(L G)$ 的预测] +) <模型对照表> + +从表上读出的事实有两条:其一,所有模型都能给出对的量级;其二,量纲分析与 Stefan 这类"几乎不假设"的模型反而偏离最大,缺键模型代入具体晶面 $zeta$ 后精度提升一档,而完全唯象的 Eötvös 规则反而最接近实测。最简单的微观模型并不是最准的——粗略的微观模型给出量级,唯象的拟合规则给出具体值。 diff --git a/examples/TH-141/segments/固气界面导言/element.toml b/examples/TH-141/segments/固气界面导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/固气界面导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/固气界面导言/textbook.typ b/examples/TH-141/segments/固气界面导言/textbook.typ new file mode 100644 index 0000000..58f6ec3 --- /dev/null +++ b/examples/TH-141/segments/固气界面导言/textbook.typ @@ -0,0 +1,3 @@ += 固气界面 $sigma_(S G)$ 的微观建模 + +固体表面没有"流动补缺"这条退路。从物理上讲,"创造新表面"与"拉伸已存在表面"在固体中是两件独立的事,对应两个不同的物理量——表面能 $gamma$ 与表面应力 $f$。本节先把这二者分清,再把缺键模型 @缺键一般式 中的摩尔汽化热替换为摩尔升华热,得到固气界面的 $gamma_(S G)$。最后讨论 $gamma_(S G)$ 的晶面各向异性以及不同物质的 $gamma$ 量级谱。 diff --git a/examples/TH-141/segments/固液界面导言/element.toml b/examples/TH-141/segments/固液界面导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/固液界面导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/固液界面导言/textbook.typ b/examples/TH-141/segments/固液界面导言/textbook.typ new file mode 100644 index 0000000..33b18eb --- /dev/null +++ b/examples/TH-141/segments/固液界面导言/textbook.typ @@ -0,0 +1,3 @@ += 固液界面 $sigma_(S L)$ 的微观建模 + +固液界面的能量由两件事的净结果决定:固体表面原子失去固–固近邻、同时获得固–液异种近邻。前者可继续沿用缺键模型,后者引入新的物理——异种相互作用强度怎么估。本节先用 Dupré 思想实验把 $gamma_(S L)$ 改写为粘附功 $W_(S L)$,再用 Berthelot 几何平均估计 $W_(S L)$,最终给出 Girifalco–Good 公式。 diff --git a/examples/TH-141/segments/固液界面能的物理图像/element.toml b/examples/TH-141/segments/固液界面能的物理图像/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/固液界面能的物理图像/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/固液界面能的物理图像/textbook.typ b/examples/TH-141/segments/固液界面能的物理图像/textbook.typ new file mode 100644 index 0000000..4a18fa5 --- /dev/null +++ b/examples/TH-141/segments/固液界面能的物理图像/textbook.typ @@ -0,0 +1,3 @@ +把固体与液体贴在一起形成固液界面时,固体表面原子损失部分同种近邻(固–固键),同时获得异种近邻(固–液相互作用)。$gamma_(S L)$ 是这两个效应的净结果:若异种作用恰好补偿同种损失,则 $gamma_(S L) = 0$;若异种作用很弱,则 $gamma_(S L)$ 大。 + +仅有缺键模型 @缺键一般式 已不够用——它只算了"损失同种键"那一半,没算"获得异种键"那一半。固液界面建模真正的难点在于异种相互作用怎么估。下面用 Dupré 关系把 $gamma_(S L)$ 改写为粘附功 $W_(S L)$,再用 Berthelot 规则估 $W_(S L)$,从而绕过对异种相互作用的直接计算。 diff --git a/examples/TH-141/segments/开场对照导言/element.toml b/examples/TH-141/segments/开场对照导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/开场对照导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/开场对照导言/textbook.typ b/examples/TH-141/segments/开场对照导言/textbook.typ new file mode 100644 index 0000000..60acdb1 --- /dev/null +++ b/examples/TH-141/segments/开场对照导言/textbook.typ @@ -0,0 +1,3 @@ += 开场对照:弹性膜与液面 + +$sigma$ 与机械张力同名,却有一个显著不同的性质:肥皂膜的 $sigma$ 与膜的面积无关,而弹性膜的张力随拉伸单调增大。本章从这一对照入手,目的是定位液面张力的微观来源——液面 $sigma$ 来自体相向表面补充新分子,而非把已有分子拉远。本节先把弹性膜的张力机制写一遍,再把液面的拉伸过程写一遍,两者对照得到液面 $sigma$ 与面积无关的物理原因。 diff --git a/examples/TH-141/segments/微观建模的共同骨架/element.toml b/examples/TH-141/segments/微观建模的共同骨架/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/微观建模的共同骨架/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/微观建模的共同骨架/textbook.typ b/examples/TH-141/segments/微观建模的共同骨架/textbook.typ new file mode 100644 index 0000000..9d0e7af --- /dev/null +++ b/examples/TH-141/segments/微观建模的共同骨架/textbook.typ @@ -0,0 +1,7 @@ +前一节得到的关系 + +$ sigma = ("每个表面分子的亏损能") times ("单位面积分子数") $ <骨架公式> + +适用于所有以"分子搬到表面要付出键能代价"为出发点的微观模型。两个因子分别对应两类物理:前者衡量把一个分子从体相搬到表面的能量代价,后者衡量单位面积上有多少这种亏损分子。不同模型的差异只在于这两个因子的估法。 + +接下来的几个模型都可以视为对 @骨架公式 的不同填法。量纲分析直接用单键能 $epsilon$ 与分子间距 $d$ 给出两因子的标度;缺键模型把亏损能精细化为"缺键数 $times$ 单键能"、把面密度精细化为 $(rho N_A \/ mu)^(2\/3)$;LJ 模型用 6-12 对势对半空间积分得到两因子的标度。同一骨架在固气界面也会被原样套用,仅把摩尔汽化热替换为摩尔升华热。 diff --git a/examples/TH-141/segments/微观派Lm下降/element.toml b/examples/TH-141/segments/微观派Lm下降/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/微观派Lm下降/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/微观派Lm下降/textbook.typ b/examples/TH-141/segments/微观派Lm下降/textbook.typ new file mode 100644 index 0000000..76d4335 --- /dev/null +++ b/examples/TH-141/segments/微观派Lm下降/textbook.typ @@ -0,0 +1 @@ +从缺键模型 @缺键一般式 看,$sigma$ 对 $T$ 的依赖完全由 $L_m(T)$ 携带——另几个量 $rho$、$mu$、$N_A$ 与温度关系微弱,可视为常数。摩尔汽化热 $L_m$ 随 $T$ 升高单调下降:分子热运动加剧,平均键合能减弱,把分子从液体里拉出来所需的能量变小。临界点处 $L_m arrow.r 0$,故由 @缺键一般式 有 $sigma arrow.r 0$。这一行为是微观模型内禀给出的,不依赖任何外加假设。 diff --git a/examples/TH-141/segments/收束导言/element.toml b/examples/TH-141/segments/收束导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/收束导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/收束导言/textbook.typ b/examples/TH-141/segments/收束导言/textbook.typ new file mode 100644 index 0000000..b004290 --- /dev/null +++ b/examples/TH-141/segments/收束导言/textbook.typ @@ -0,0 +1,3 @@ += 收束:各模型横向对比 + +本章沿着"假设由少到多"的方向给出了多种模型:量纲分析、Stefan 极简估算、缺键模型一般式、Eötvös 规则、$sigma(T, p)$ 多项式拟合。模型的输入复杂度不同,对常温水 $sigma_(L G)$ 的预测精度也不同。把几种典型预测并排列出,与实测 $0.072 thin "N/m"$ 对照,可以看到一个略反预期的现象——最简单的微观模型并不是最准的。下面给出对照表并逐一讨论偏差来源。 diff --git a/examples/TH-141/segments/浸润全谱与高低能表面/element.toml b/examples/TH-141/segments/浸润全谱与高低能表面/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/浸润全谱与高低能表面/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/浸润全谱与高低能表面/textbook.typ b/examples/TH-141/segments/浸润全谱与高低能表面/textbook.typ new file mode 100644 index 0000000..150e04c --- /dev/null +++ b/examples/TH-141/segments/浸润全谱与高低能表面/textbook.typ @@ -0,0 +1,17 @@ +由 @GGZ判据 按 $r equiv gamma_(S G) \/ gamma_(L G)$ 切段: + +#figure( + table( + columns: (auto, auto, auto), + align: (left, left, left), + table.header[*比值范围*][*接触角*][*现象*], + [$r > 1$], [无解], [完全铺展(superwetting)], + [$r = 1$], [$theta = 0$], [临界完全浸润], + [$1\/4 < r < 1$], [$0 < theta < pi\/2$], [部分浸润], + [$r = 1\/4$], [$theta = pi\/2$], [临界不浸润], + [$r < 1\/4$], [$theta > pi\/2$], [不浸润、成珠状], + ), + caption: [浸润全谱] +) <浸润全谱表> + +把 @γ量级表 与浸润全谱并置就得到日常现象的解释。高能表面如金属、清洁玻璃,$gamma_(S G) tilde.op 1 thin "J/m"^2$,远大于水的 $gamma_(L G) tilde.op 0.072 thin "J/m"^2$,$r >> 1$,水在其上完全铺展。低能表面如石蜡、Teflon,$gamma_(S G) tilde.op 0.02 thin "J/m"^2$ 小于水的 $gamma_(L G)$,$r < 1\/4$,水在其上不浸润、成珠状。"水在玻璃上铺、在荷叶上成珠"这两件日常现象就是 $r$ 取值在 @GGZ判据 中位于 $r >> 1$ 与 $r < 1\/4$ 两端的直接体现。 diff --git a/examples/TH-141/segments/液气界面导言/element.toml b/examples/TH-141/segments/液气界面导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/液气界面导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/液气界面导言/textbook.typ b/examples/TH-141/segments/液气界面导言/textbook.typ new file mode 100644 index 0000000..301c9ee --- /dev/null +++ b/examples/TH-141/segments/液气界面导言/textbook.typ @@ -0,0 +1,3 @@ += 液气界面 $sigma_(L G)$ 的微观建模 + +液气界面的微观建模是表面张力理论的主菜。以下沿着"假设由少到多"的方向给出几个模型:从仅由量纲组合给出标度的量纲分析,到带一个自由参数 $zeta$ 的缺键模型,再到用真实势函数对半空间积分的 Lennard–Jones 模型。每个模型给出 $sigma_(L G)$ 的一个表达式,依次接受更多的物理输入。 diff --git a/examples/TH-141/segments/液面拉伸的本质/element.toml b/examples/TH-141/segments/液面拉伸的本质/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/液面拉伸的本质/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/液面拉伸的本质/textbook.typ b/examples/TH-141/segments/液面拉伸的本质/textbook.typ new file mode 100644 index 0000000..19c3dfb --- /dev/null +++ b/examples/TH-141/segments/液面拉伸的本质/textbook.typ @@ -0,0 +1,15 @@ +把同样的拉伸过程搬到液面上。设想用框架拉动一个肥皂膜,使液面面积由 $A$ 增至 $A + dif A$。若沿用弹性膜的图像,表面分子之间的距离应当被拉远,分子间键被拉长,张力随面积单调增大。但实验上肥皂膜的 $sigma$ 与面积 $A$ 无关。这意味着液面被拉大时发生的事情并非分子被拉远。 + +液体内部分子可以自由流动。面积由 $A$ 增至 $A + dif A$ 时,体相中一部分分子被搬到表面,使表面分子数 $N_s$ 按 $N_s prop A$ 同步增大。每个表面分子相对体相分子的"亏损能"由局域近邻结构决定,与表面大小无关,记为 $Delta U$。总表面能为 + +$ E_s = N_s Delta U prop A , $ + +单位面积能量 + +$ sigma = E_s / A = "(每个表面分子的亏损能)" times "(单位面积分子数)" $ + +是一个与 $A$ 无关的常数。这就是液面 $sigma$ 与面积无关的微观原因。 + +需要顺手澄清的是表面层的厚度。液体分子的作用程在 nm 量级,宏观尺度下表面层可视为零厚度膜——它不是一层有厚度的"皮",只是体相分子近邻环境与表面分子近邻环境不同所定义出来的一个抽象切面。 + +"流动补缺"这一图像是液体独有的。固体内部分子不能自由流动,扩大固体表面时不能从体相搬一个分子上来。后续讨论固体表面时这条退路被切断,将出现新的物理。 diff --git a/examples/TH-141/segments/算不准背后的真实物理/element.toml b/examples/TH-141/segments/算不准背后的真实物理/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/算不准背后的真实物理/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/算不准背后的真实物理/textbook.typ b/examples/TH-141/segments/算不准背后的真实物理/textbook.typ new file mode 100644 index 0000000..936e8af --- /dev/null +++ b/examples/TH-141/segments/算不准背后的真实物理/textbook.typ @@ -0,0 +1,11 @@ +@模型对照表 中的偏差并非来源不明,每一条都可追溯到一项物理简化。 + +分子作用力的各向异性。缺键模型把每对分子之间的键能简化为同一个 $epsilon$,但真实的分子间相互作用具有方向性,水的氢键尤甚。各向异性对应的研究方向是液晶与晶体表面物理。 + +参数 $zeta$ 的有效性。Stefan 取 $zeta = 1\/2$ 只是为得到不依赖晶格的最简表达;FCC (111) 给出 $zeta = 3\/4$ 是基于严格晶格的几何计数。真实液体并没有严格晶格,$zeta$ 应当被视为有效参数。如何从液体的局部结构出发反推 $zeta$ 是液体统计物理的一个分支。 + +氢键带来的极性贡献。缺键模型把所有相互作用都归为单一键能,忽略了水分子之间的氢键贡献。@GG公式 在水/聚合物体系下与实验偏差较大就是这一简化的具体后果,Fowkes 分解就是针对这一偏差的修正路径。 + +表面层并非严格二维。我们建模时把表面分子当作一个零厚度的层,但真实液体的表面层有 nm 量级的厚度,密度随深度连续变化。这条偏差对应的研究方向是液气界面的密度剖面与梯度理论。 + +逐条看下来,每一条偏差都对应一个仍在活跃的研究方向。本章的微观模型给出"理论从分子到宏观浸润如何一步步推出来"的完整链条,但同时也指明了链条上每一段还能被怎么继续精细化。这才是建模在物理学里的常态。 diff --git a/examples/TH-141/segments/缺键模型导言/element.toml b/examples/TH-141/segments/缺键模型导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/缺键模型导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/缺键模型导言/textbook.typ b/examples/TH-141/segments/缺键模型导言/textbook.typ new file mode 100644 index 0000000..dee31ae --- /dev/null +++ b/examples/TH-141/segments/缺键模型导言/textbook.typ @@ -0,0 +1,3 @@ +== 缺键模型 + +缺键模型在 @骨架公式 的两个因子上各加一层物理输入。亏损能由"表面分子比体相分子缺多少根键"乘以"每根键的能量"给出,单键能由摩尔汽化热反推;面密度由"每个分子占的体积"开 2/3 次方给出。模型只保留一个无量纲参数 $zeta$——表面分子近邻数与体相近邻数之比。下面给出一般式,再讨论它的两个特例:$zeta = 1\/2$ 对应不带任何晶格信息的 Stefan 估算,立方格子下 $zeta$ 可以精确取到 $5\/6$、$3\/4$ 等具体数值。 diff --git a/examples/TH-141/segments/胡克唯象模型回顾/element.toml b/examples/TH-141/segments/胡克唯象模型回顾/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/胡克唯象模型回顾/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/胡克唯象模型回顾/textbook.typ b/examples/TH-141/segments/胡克唯象模型回顾/textbook.typ new file mode 100644 index 0000000..045159b --- /dev/null +++ b/examples/TH-141/segments/胡克唯象模型回顾/textbook.typ @@ -0,0 +1,3 @@ +考虑一根原长 $L_0$、劲度系数 $k$ 的弹性绳,把它拉伸至长度 $L$。宏观上张力为 $F = k(L - L_0)$,随长度线性增大。从微观上看,弹性绳内部的所有分子被一同拉远,分子间距由 $d_0$ 增至 $d_0 (L \/ L_0)$,键能上升,张力即为这些键被拉长所提供的回复力之和。 + +二维弹性膜的情形与此完全平行。设原面积 $A_0$、二维劲度系数 $k_("2D")$,把面积拉至 $A$,则单位长度上的张力沿两个主方向各贡献一份,随应变 $(A - A_0) \/ A_0$ 线性增大。无论一维还是二维,弹性体被拉伸时承担应力的始终是同一批分子——拉伸前后分子数不变,变的是它们之间的距离。 diff --git a/examples/TH-141/segments/表面能γ与表面应力f/element.toml b/examples/TH-141/segments/表面能γ与表面应力f/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/segments/表面能γ与表面应力f/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/表面能γ与表面应力f/textbook.typ b/examples/TH-141/segments/表面能γ与表面应力f/textbook.typ new file mode 100644 index 0000000..585d7bd --- /dev/null +++ b/examples/TH-141/segments/表面能γ与表面应力f/textbook.typ @@ -0,0 +1,9 @@ +液体的 $sigma$ 同时描述两件事:创造单位新表面要付出的能量代价,以及拉伸单位已有表面的弹性反应。它们在液体里合二为一——液体可自由流动,"拉伸"等价于"补充新分子"。 + +固体不能流动,这两件事必须分开。设单位面积的表面能为 $gamma$,其定义为创造单位新表面所需的可逆功。再设单位长度的表面应力为 $f$,其定义为把已有的单位面积表面拉伸应变 $epsilon$ 所需的功面密度。两者之间的关系由 Shuttleworth 给出 + +$ f = gamma + (dif gamma) / (dif epsilon) , $ + +其中右端第二项反映 $gamma$ 本身随应变变化的部分。一般有 $f eq.not gamma$。 + +本节及之后所有讨论以 $gamma$ 为对象。$f$ 仅在真正"拉固体"的场景下登场,例如薄膜应力、纳米颗粒形变,本节不展开。 From 7e76482a3188e29a9aef8f1f589ea4664cea1b94 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 01:49:15 +0800 Subject: [PATCH 10/29] =?UTF-8?q?feat(checker):=20cph-typst=20=E2=80=94=20?= =?UTF-8?q?embed=20typst=200.15,=20driver-gen,=20compile,=20PDF=20(WU-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heart of the pipeline. Embeds the typst 0.15 compiler as a library to compile-check a lesson and export a PDF, with diagnostics mapped back to source spans (the product's value). - LessonWorld: a typst World over the engineering-file directory tree. vpath = real relative path (ADR-0007); the per-target driver is mounted in-memory as `main` (.cph/driver-.typ, never written to disk); @local/cph-render is resolved from render_dir (CPH_RENDER_DIR / with_render_dir), no package install or network. Fonts via typst-kit FontStore (system CJK). - Driver generation: from the manifest, per target — static root-relative `include` of each content file (⇒ module body content, ADR-0006), assembled into the frozen `display(info, target, parts)` call; optional lemma proof emitted only if proof.typ exists; example `source` scalar threaded through. - Diagnostics: typst SourceDiagnostic (errors + warnings) → cph_diag::Diagnostic. 0.15 DiagSpan handled via span.id() + WorldExt::range → file-relative SourceSpan with 1-based line/col; driver-internal spans re-pointed with a "generated driver" hint, never dropped. typst hints/trace carried through. - Engine API: new / with_render_dir / compile_check / build_pdf. - 6 tests incl. build_pdf_with_real_render — full World→driver→cph-render→PDF producing real PDF-1.7 output (student 42KB / teacher 54KB). clippy/fmt clean. Also fixes render/typst.toml `compiler = ">=0.15.0"` → `"0.15.0"`: typst 0.15's manifest parser rejects a `>=` comparator on the compiler field (the 0.14 CLU WU-2 tested against was lenient). This unblocks loading the real render package. Pinned: typst/typst-pdf/typst-syntax/typst-library/typst-layout/typst-kit =0.15.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 2918 +++++++++++++++++ crates/cph-typst/Cargo.toml | 17 + crates/cph-typst/src/diag.rs | 110 + crates/cph-typst/src/driver.rs | 201 ++ crates/cph-typst/src/lib.rs | 144 +- crates/cph-typst/src/world.rs | 165 + crates/cph-typst/tests/compile.rs | 189 ++ .../mini/examples/自由落体/element.toml | 2 + .../mini/examples/自由落体/problem.typ | 1 + .../mini/examples/自由落体/solution.typ | 1 + .../mini/lemmas/量纲分析估计/element.toml | 1 + .../mini/lemmas/量纲分析估计/proof.typ | 3 + .../mini/lemmas/量纲分析估计/stmt.typ | 3 + .../tests/fixtures/mini/manifest.toml | 23 + .../mini/segments/开场对照导言/element.toml | 1 + .../mini/segments/开场对照导言/textbook.typ | 2 + .../tests/fixtures/render-stub/lib.typ | 29 + .../tests/fixtures/render-stub/typst.toml | 7 + render/typst.toml | 2 +- 19 files changed, 3812 insertions(+), 7 deletions(-) create mode 100644 crates/cph-typst/src/diag.rs create mode 100644 crates/cph-typst/src/driver.rs create mode 100644 crates/cph-typst/src/world.rs create mode 100644 crates/cph-typst/tests/compile.rs create mode 100644 crates/cph-typst/tests/fixtures/mini/examples/自由落体/element.toml create mode 100644 crates/cph-typst/tests/fixtures/mini/examples/自由落体/problem.typ create mode 100644 crates/cph-typst/tests/fixtures/mini/examples/自由落体/solution.typ create mode 100644 crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/element.toml create mode 100644 crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/proof.typ create mode 100644 crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/stmt.typ create mode 100644 crates/cph-typst/tests/fixtures/mini/manifest.toml create mode 100644 crates/cph-typst/tests/fixtures/mini/segments/开场对照导言/element.toml create mode 100644 crates/cph-typst/tests/fixtures/mini/segments/开场对照导言/textbook.typ create mode 100644 crates/cph-typst/tests/fixtures/render-stub/lib.typ create mode 100644 crates/cph-typst/tests/fixtures/render-stub/typst.toml diff --git a/Cargo.lock b/Cargo.lock index 75107d5..e1af56a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,292 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +dependencies = [ + "object", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "az" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "biblatex" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591eba69b7c085632e22ca03606f0bbea68d53bbe26c8962e2d549af20c6a561" +dependencies = [ + "paste", + "roman-numerals-rs", + "strum", + "unic-langid", + "unicode-normalization", + "unscanny", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chinese-number" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e964125508474a83c95eb935697abbeb446ff4e9d62c71ce880e3986d1c606b" +dependencies = [ + "chinese-variant", + "enum-ordinalize", + "num-bigint", + "num-traits", +] + +[[package]] +name = "chinese-variant" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b52a9840ffff5d4d0058ae529fa066a75e794e3125546acfc61c23ad755e49" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "citationberg" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756ff1e3d43a9ecc8183932fb4d9fd3971236f3ce4acb62fe51d1cd43297547d" +dependencies = [ + "quick-xml 0.38.4", + "serde", + "serde_path_to_error", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "codex" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0732ab1a27b4ea05e6f9f60a5122c9924dd5123defde0d8e907f58cf643d40e6" +dependencies = [ + "chinese-number", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "comemo" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c963350b2b08aa4b725d7802593245380ab53dacfedcaa971385fc33306c0d4" +dependencies = [ + "comemo-macros", + "parking_lot", + "rustc-hash", + "siphasher", + "slab", +] + +[[package]] +name = "comemo-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3c400139ba1389ef9e20ad2d87cda68b437a66483aa0da616bdf2cea7413853" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cph-check" version = "0.1.0" @@ -39,6 +325,158 @@ dependencies = [ [[package]] name = "cph-typst" version = "0.1.0" +dependencies = [ + "cph-diag", + "cph-model", + "tempfile", + "toml", + "typst", + "typst-kit", + "typst-layout", + "typst-library", + "typst-pdf", + "typst-syntax", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ecow" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78e4f79b296fbaab6ce2e22d52cb4c7f010fe0ebe7a32e34fa25885fd797bd02" +dependencies = [ + "serde", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "equivalent" @@ -46,11 +484,603 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glidesort" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0" + +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown", + "log", + "peniko", + "png", + "skrifa", + "smallvec", + "vello_common 0.0.9", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hayagriva" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b6c185c5c72546d50b25b70187f01ab9a1a2fa21c4db8691e2426fa5fce805c" +dependencies = [ + "biblatex", + "ciborium", + "citationberg", + "icu_collator", + "icu_locale", + "indexmap", + "paste", + "roman-numerals-rs", + "serde", + "serde_yaml", + "thiserror", + "unic-langid", + "unicode-segmentation", + "unscanny", + "url", +] + +[[package]] +name = "hayro" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +dependencies = [ + "bytemuck", + "hayro-interpret", + "image", + "kurbo", + "pic-scale", + "vello_cpu", +] + +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-cmap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +dependencies = [ + "hayro-postscript", +] + +[[package]] +name = "hayro-interpret" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +dependencies = [ + "bitflags", + "hayro-cmap", + "hayro-syntax", + "kurbo", + "moxcms", + "phf", + "rustc-hash", + "siphasher", + "skrifa", + "smallvec", + "yoke", +] + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +dependencies = [ + "fearless_simd", + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +dependencies = [ + "fearless_simd", +] + +[[package]] +name = "hayro-postscript" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" + +[[package]] +name = "hayro-svg" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7434c89e920d84e077214a29e69b462a6518754610f732f1124af3948410cb8c" +dependencies = [ + "base64", + "hayro-interpret", + "image", + "kurbo", + "siphasher", + "xmlwriter", +] + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash", + "smallvec", + "zune-jpeg", +] + +[[package]] +name = "hayro-write" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a" +dependencies = [ + "flate2", + "hayro-syntax", + "pdf-writer", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hypher" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef68590049bab63a464eee1a1158ac04c6f6613a546d8d90f78636b8b94f171" + +[[package]] +name = "icu_collator" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b521b92a2666061ddda902769d8a4cf730b5c9529a845cc1b69770b12a6c9a71" +dependencies = [ + "icu_collator_data", + "icu_collections", + "icu_locale", + "icu_locale_core", + "icu_normalizer", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_collator_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "038ed8e5817f2059c2f3efb0945ba78d060d3d25e8f1a1bea5139f821a21a2f0" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "serde", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "serde", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_provider_blob" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99a43c4436b6c9a37c27a822ff05238bbad134bcdf6176732208acfae46a3f" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +dependencies = [ + "core_maths", + "icu_collections", + "icu_locale", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "serde", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "indexmap" @@ -60,20 +1090,486 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown", + "rayon", + "serde", + "serde_core", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + +[[package]] +name = "krilla" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33" +dependencies = [ + "base64", + "bumpalo", + "comemo", + "flate2", + "float-cmp", + "gif", + "hayro-write", + "image-webp", + "imagesize", + "indexmap", + "pdf-writer", + "png", + "rayon", + "rustc-hash", + "rustybuzz", + "siphasher", + "skrifa", + "smallvec", + "subsetter", + "tiny-skia-path", + "xmp-writer", + "yoke", + "zune-jpeg", +] + +[[package]] +name = "krilla-svg" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1237d7c37b16ca9fbc2e72dde13a10d321f9a44aceee35c062bc0a43bfc7ce16" +dependencies = [ + "flate2", + "fontdb", + "krilla", + "resvg", + "skrifa", + "smallvec", + "tiny-skia", + "usvg", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lipsum" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636860251af8963cc40f6b4baadee105f02e21b28131d76eba8e40ce84ab8064" +dependencies = [ + "rand", + "rand_chacha", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pdf-writer" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753" +dependencies = [ + "bitflags", + "itoa", + "memchr", + "ryu", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pic-scale" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b05a62c3762cb26cb62665b915fea652def8a9f609b0b289b7f782a7394307b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64", + "indexmap", + "quick-xml 0.39.4", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -83,6 +1579,47 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.45" @@ -92,6 +1629,237 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roman-numerals-rs" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85cd47a33a4510b1424fe796498e174c6a9cf94e606460ef022a19f3e4ff85e" + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "num-traits", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -135,6 +1903,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -144,6 +1923,154 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys", +] + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subsetter" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6" +dependencies = [ + "kurbo", + "rustc-hash", + "skrifa", + "write-fonts", +] + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "syn" version = "2.0.118" @@ -155,6 +2082,159 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thin-vec" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "toml" version = "0.8.23" @@ -196,12 +2276,682 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "two-face" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e51b6e60e545cfdae5a4639ff423818f52372211a8d9a3e892b4b0761f76b2" +dependencies = [ + "serde", + "serde_derive", + "syntect", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typst" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8bf2a5a9d58cc542764a88dd43c8a679b683db4ae23e36267219339ab36b01" +dependencies = [ + "arrayvec", + "comemo", + "ecow", + "rustc-hash", + "typst-eval", + "typst-html", + "typst-layout", + "typst-library", + "typst-macros", + "typst-realize", + "typst-syntax", + "typst-timing", + "typst-utils", +] + +[[package]] +name = "typst-assets" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9b236d339dce0ec443dc44afe4ba4c6d76b8917bd4b1ae80158d2111f6637b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "typst-eval" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d064876305073ab75d5af039ae5e8dcb32c4cac4aa9f43d0f5aae347baef7c3" +dependencies = [ + "comemo", + "ecow", + "indexmap", + "rustc-hash", + "stacker", + "toml", + "typst-library", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", + "unicode-segmentation", +] + +[[package]] +name = "typst-html" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d442f92bae44087735efc8b83508b259c573bbccf027e098ac68c8f4ca879f76" +dependencies = [ + "az", + "bumpalo", + "comemo", + "ecow", + "palette", + "rustc-hash", + "time", + "typst-assets", + "typst-library", + "typst-macros", + "typst-svg", + "typst-syntax", + "typst-timing", + "typst-utils", + "unicode-math-class", +] + +[[package]] +name = "typst-kit" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a24650fc436d32deb55b49fd63f7bcad3a864c96f52587b9934c85e0092d6f" +dependencies = [ + "dirs", + "ecow", + "fontdb", + "once_cell", + "parking_lot", + "rustc-hash", + "serde", + "serde_json", + "typst-assets", + "typst-library", + "typst-syntax", + "typst-timing", + "typst-utils", + "url", +] + +[[package]] +name = "typst-layout" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5af3949efc6d02e3d55e79430c9096415fcf68166414c3a0e5fed1c3325d5f" +dependencies = [ + "az", + "bumpalo", + "codex", + "comemo", + "ecow", + "either", + "hypher", + "icu_properties", + "icu_provider", + "icu_provider_blob", + "icu_segmenter", + "kurbo", + "libm", + "memchr", + "rustc-hash", + "rustybuzz", + "smallvec", + "ttf-parser", + "typst-assets", + "typst-library", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", + "unicode-bidi", + "unicode-math-class", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "typst-library" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9550a33d0f4e1965fd7d69abcf10135e965a4ad3a3aee24573b52dda9234e491" +dependencies = [ + "arrayvec", + "az", + "bitflags", + "bumpalo", + "ciborium", + "codex", + "comemo", + "csv", + "ecow", + "either", + "flate2", + "fontdb", + "glidesort", + "hayagriva", + "hayro-syntax", + "icu_properties", + "image", + "indexmap", + "kamadak-exif", + "kurbo", + "libm", + "lipsum", + "memchr", + "moxcms", + "palette", + "percent-encoding", + "phf", + "png", + "rayon", + "regex", + "regex-syntax", + "roxmltree 0.21.1", + "rust_decimal", + "rustc-hash", + "rustybuzz", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "smallvec", + "syntect", + "time", + "toml", + "ttf-parser", + "two-face", + "typed-arena", + "typst-assets", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", + "unicode-math-class", + "unicode-normalization", + "unicode-segmentation", + "unscanny", + "usvg", + "utf8_iter", + "wasmi", + "xmlwriter", +] + +[[package]] +name = "typst-macros" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500e2873a544ca161f98183eea7ddb00030d16f4585b5ffa9e9c8e24f53148e1" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typst-pdf" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd7b33bcabc3357480768f6c78dda99a838d621c71b4738b25e09ac30ac063c9" +dependencies = [ + "az", + "bytemuck", + "codex", + "comemo", + "ecow", + "flate2", + "image", + "indexmap", + "infer", + "krilla", + "krilla-svg", + "rustc-hash", + "serde", + "smallvec", + "typst-assets", + "typst-layout", + "typst-library", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", +] + +[[package]] +name = "typst-realize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "917e09614771258b13409b9a8d4f3915b2a2f7f28ca987701695f82fc6a7c0b9" +dependencies = [ + "arrayvec", + "bumpalo", + "comemo", + "ecow", + "regex", + "typst-html", + "typst-library", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", +] + +[[package]] +name = "typst-svg" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "878b6e1293c2bea77a8be50670d1cbca4e676af96480313a105cba539da51d1c" +dependencies = [ + "base64", + "comemo", + "ecow", + "flate2", + "hayro", + "hayro-svg", + "image", + "indexmap", + "itoa", + "rustc-hash", + "ryu", + "ttf-parser", + "typst-assets", + "typst-layout", + "typst-library", + "typst-macros", + "typst-timing", + "typst-utils", + "xmlwriter", +] + +[[package]] +name = "typst-syntax" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22bf7f87450e5841debd4986b0f912b0f7a2251e600c08aca406305ff52c67b4" +dependencies = [ + "ecow", + "rustc-hash", + "serde", + "toml", + "typst-timing", + "typst-utils", + "unicode-ident", + "unicode-math-class", + "unicode-script", + "unicode-segmentation", + "unscanny", +] + +[[package]] +name = "typst-timing" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc0c78ece2ade6ef73d00a13f9c474e02efc3bcfdb770e16d7c4d24e7492773" +dependencies = [ + "parking_lot", + "serde", + "serde_json", +] + +[[package]] +name = "typst-utils" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40215eb2541102ecfb4cf3bef29da53033a82e22dbc2308c43bdb855701bf8d2" +dependencies = [ + "libm", + "once_cell", + "portable-atomic", + "rayon", + "rustc-hash", + "semver", + "siphasher", + "smallvec", + "thin-vec", + "unicode-math-class", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", + "unic-langid-impl", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-math-class" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d246cf599d5fae3c8d56e04b20eb519adb89a8af8d0b0fbcded369aa3647d65" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "unscanny" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "usvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "ttf-parser", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vello_common" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown", + "log", + "peniko", + "png", + "smallvec", + "thiserror", +] + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown", + "log", + "peniko", + "png", + "smallvec", + "thiserror", +] + +[[package]] +name = "vello_cpu" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown", + "png", + "vello_common 0.0.8", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasmi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2300d0f78cba12f14e29e8dd157ea64050c0a688179aefdb2050105805594a0c" +dependencies = [ + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser", +] + +[[package]] +name = "wasmi_collections" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a8c42a2a76148d43097b1d7cc2a5bf33d5c23bd4dd69015fc887e311767884" + +[[package]] +name = "wasmi_core" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9013136083d988725953390bf668b64b7a218fabf26f8b913bbc59546b97ee27" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1fa003f79156f406d62ef0e1464dc03e11ace37170e9fa7524299a75ad8f68" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "0.7.15" @@ -211,8 +2961,176 @@ dependencies = [ "memchr", ] +[[package]] +name = "write-fonts" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8" +dependencies = [ + "font-types", + "indexmap", + "kurbo", + "log", + "read-fonts", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "xmp-writer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "litemap", + "serde_core", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/crates/cph-typst/Cargo.toml b/crates/cph-typst/Cargo.toml index 0b53ffa..cf7f3e7 100644 --- a/crates/cph-typst/Cargo.toml +++ b/crates/cph-typst/Cargo.toml @@ -5,3 +5,20 @@ edition.workspace = true license.workspace = true [dependencies] +cph-diag = { workspace = true } +cph-model = { workspace = true } +toml = { workspace = true } + +typst = "=0.15.0" +typst-layout = "=0.15.0" +typst-pdf = "=0.15.0" +typst-syntax = "=0.15.0" +typst-library = "=0.15.0" +typst-kit = { version = "=0.15.0", features = [ + "scan-fonts", + "embedded-fonts", +] } + +[dev-dependencies] +cph-model = { workspace = true } +tempfile = "3" diff --git a/crates/cph-typst/src/diag.rs b/crates/cph-typst/src/diag.rs new file mode 100644 index 0000000..f954227 --- /dev/null +++ b/crates/cph-typst/src/diag.rs @@ -0,0 +1,110 @@ +//! Map typst [`SourceDiagnostic`]s to [`cph_diag::Diagnostic`]s. +//! +//! ## Span mapping (typst 0.15 specifics) +//! +//! In 0.15 a diagnostic's `span` is a [`typst::syntax::DiagSpan`] (not the bare +//! `Span` of 0.14). To locate it we: +//! 1. `span.id() -> Option` for the owning file (`None` ⇒ detached, so +//! no span is emitted); +//! 2. `WorldExt::range(span) -> Option>` for the byte range — this +//! handles every `DiagSpanKind` variant internally (numbered source spans +//! *and* raw external byte ranges), so we never match the enum by hand; +//! 3. compute 1-based line/col from the file's [`typst::syntax::Source`] via +//! `lines().byte_to_line_column` (0-based ⇒ `+1`). +//! +//! A span whose `FileId` is the generated **driver** is re-pointed: its `file` +//! is set to the driver vpath and a hint flags it as driver-internal (a +//! driver-gen bug indicator) rather than dropped or mis-attributed to a user +//! file. Spans in the `cph-render` package are reported with an +//! `@local/cph-render:…` pseudo-path. + +use std::path::PathBuf; + +use cph_diag::{DiagCode, Diagnostic, Severity, SourceSpan}; +use typst::diag::{Severity as TypstSeverity, SourceDiagnostic}; +use typst::syntax::{FileId, VirtualRoot}; +use typst::{World, WorldExt}; + +use crate::world::LessonWorld; + +/// Convert one typst diagnostic into a `cph-diag` [`Diagnostic`], resolving its +/// span against `world`. +pub fn map_diagnostic(world: &LessonWorld, d: &SourceDiagnostic) -> Diagnostic { + let severity = match d.severity { + TypstSeverity::Error => Severity::Error, + TypstSeverity::Warning => Severity::Warning, + }; + + // Flatten the typst call trace into the message tail so call-site context + // isn't lost (the primary span points at the innermost node). + let mut message = d.message.to_string(); + for tp in &d.trace { + message.push_str(&format!("\n in {}", tp.v)); + } + + let mut diag = Diagnostic { + severity, + code: DiagCode::TypstCompile, + message, + span: None, + hint: None, + }; + + // typst's own hints are the product's core value — carry them through. + let mut hints: Vec = d.hints.iter().map(|h| h.v.to_string()).collect(); + + if let Some(id) = d.span.id() { + if let Some(file) = file_for(world, id) { + if id == world.main() { + hints.push( + "this span is inside the generated driver (.cph/driver-*.typ), \ + not a lesson source file — likely a driver-generation issue" + .to_string(), + ); + } + // Byte range via WorldExt::range (DiagSpan is Copy). + let byte_range = world.range(d.span).unwrap_or(0..0); + let (line, col) = line_col(world, id, byte_range.start); + diag.span = Some(SourceSpan { + file, + byte_range, + line, + col, + }); + } + } + + if !hints.is_empty() { + diag.hint = Some(hints.join("; ")); + } + diag +} + +/// 1-based (line, col) for a byte offset in file `id`, defaulting to `(1, 1)` +/// when the source can't be read or the offset is out of range. +fn line_col(world: &LessonWorld, id: FileId, byte: usize) -> (u32, u32) { + let Ok(source) = world.source(id) else { + return (1, 1); + }; + match source.lines().byte_to_line_column(byte) { + // byte_to_line_column is 0-based; editors are 1-based. + Some((line, col)) => (line as u32 + 1, col as u32 + 1), + None => (1, 1), + } +} + +/// File path for a `FileId`. Project files are relative to the lesson root (no +/// leading slash); package files use an `@namespace/name:version/path` form. +fn file_for(world: &LessonWorld, id: FileId) -> Option { + let _ = world; + match id.root() { + VirtualRoot::Project => Some(PathBuf::from(id.vpath().get_without_slash())), + VirtualRoot::Package(spec) => Some(PathBuf::from(format!( + "@{}/{}:{}{}", + spec.namespace, + spec.name, + spec.version, + id.vpath().get_with_slash() + ))), + } +} diff --git a/crates/cph-typst/src/driver.rs b/crates/cph-typst/src/driver.rs new file mode 100644 index 0000000..6757e00 --- /dev/null +++ b/crates/cph-typst/src/driver.rs @@ -0,0 +1,201 @@ +//! Driver generation: turn a [`Lesson`] + target into a typst entrypoint that +//! `include`s each part's content files and calls `cph-render`'s `display`. +//! +//! ## Decisions +//! +//! - **Include-path style: root-relative (`/segments/…`).** The lesson root is +//! mounted as the typst project root ([`crate::world::LessonWorld`] uses +//! `VirtualRoot::Project`), so a leading-`/` path resolves against the root +//! regardless of where the driver itself sits. This keeps include paths +//! identical to the part `path`s recorded in the manifest, independent of the +//! driver's `.cph/` location. +//! - **Content via `include`, not `import`** (ADR-0006): a `content` field's +//! value is the module *body*, which `include` yields (`import` would expose +//! only `#let` exports). +//! - **`@local/cph-render`** is referenced by the standard package import; the +//! World resolves it from the render directory (see `crate::world`). +//! - **Optional `proof`** (lemma) is emitted only when `/proof.typ` +//! exists on disk. +//! - **Scalars** (example `source`) come from `part.descriptor.scalars` and are +//! emitted as escaped typst string literals. + +use cph_model::{Lesson, Part}; + +/// In-memory directory the generated driver is mounted under (relative to the +/// lesson root). Not written to disk. +pub const DRIVER_DIR: &str = ".cph"; + +/// The package import line the driver opens with. +const RENDER_IMPORT: &str = "#import \"@local/cph-render:0.1.0\": display"; + +/// The content fields of each kind, in render-contract order. The `bool` is +/// whether the field is optional (only emitted if its `.typ` exists). +fn content_fields(kind: &str) -> &'static [(&'static str, bool)] { + match kind { + "segment" => &[("textbook", false)], + "example" => &[("problem", false), ("solution", false)], + "lemma" => &[("stmt", false), ("proof", true)], + "sop" => &[("sop", false)], + // Unknown kind: emit nothing for it; the render package surfaces the + // unknown kind visibly, and cph-model/cph-schema report it upstream. + _ => &[], + } +} + +/// The optional **scalar** string fields per kind (read from `element.toml`). +fn scalar_fields(kind: &str) -> &'static [&'static str] { + match kind { + "example" => &["source"], + _ => &[], + } +} + +/// Generate the driver source for `lesson` under `target`. +/// +/// The result is deterministic and order-preserving (parts emitted in +/// `lesson.parts` order, matching the render contract). It is pure text +/// generation — it touches the filesystem only to test for optional content +/// files (e.g. lemma `proof.typ`). +pub fn generate_driver(lesson: &Lesson, target: &str) -> String { + let mut s = String::new(); + s.push_str(RENDER_IMPORT); + s.push('\n'); + + // One `#let _pN_field = include "…"` per content file, then the parts array + // referencing those bindings. + let mut part_dicts: Vec = Vec::with_capacity(lesson.parts.len()); + + for (i, part) in lesson.parts.iter().enumerate() { + let mut entries: Vec = Vec::new(); + entries.push(format!("kind: {}", typst_str(&part.kind))); + + for &(field, optional) in content_fields(&part.kind) { + if optional && !content_file_exists(part, field) { + continue; + } + let binding = format!("_p{i}_{field}"); + let inc_path = include_path(part, field); + s.push_str(&format!( + "#let {binding} = include {}\n", + typst_str(&inc_path) + )); + entries.push(format!("{field}: {binding}")); + } + + for &field in scalar_fields(&part.kind) { + if let Some(value) = scalar_string(part, field) { + entries.push(format!("{field}: {}", typst_str(&value))); + } + } + + part_dicts.push(format!("({})", entries.join(", "))); + } + + s.push_str("#display(\n"); + s.push_str(&format!(" info: {},\n", info_dict(lesson))); + s.push_str(&format!(" target: {},\n", typst_str(target))); + s.push_str(" parts: (\n"); + for dict in &part_dicts { + s.push_str(&format!(" {dict},\n")); + } + // Trailing comma already present per element; ensure a 1-element array is + // still an array (typst needs the trailing comma, which we always emit). + s.push_str(" ),\n"); + s.push_str(")\n"); + s +} + +/// `(title: "…", author: "…")` — author key omitted when absent. +fn info_dict(lesson: &Lesson) -> String { + let mut parts = vec![format!("title: {}", typst_str(&lesson.info.title))]; + if let Some(author) = &lesson.info.author { + parts.push(format!("author: {}", typst_str(author))); + } + format!("({})", parts.join(", ")) +} + +/// Root-relative include path for `part`'s `field` content file, e.g. +/// `/segments/开场对照导言/textbook.typ`. Always forward-slashed and +/// leading-`/` so it resolves against the typst project root. +fn include_path(part: &Part, field: &str) -> String { + let mut p = String::from("/"); + // `part.path` is the manifest-relative folder; join the `.typ`. + p.push_str(&path_to_forward_slash(&part.path)); + if !p.ends_with('/') { + p.push('/'); + } + p.push_str(field); + p.push_str(".typ"); + p +} + +/// Does `/.typ` exist on disk? Used to gate optional content. +fn content_file_exists(part: &Part, field: &str) -> bool { + part.descriptor.dir.join(format!("{field}.typ")).is_file() +} + +/// Pull a scalar string field out of the element descriptor, if present and a +/// string (and non-empty). +fn scalar_string(part: &Part, field: &str) -> Option { + match part.descriptor.scalars.get(field) { + Some(toml::Value::String(s)) if !s.is_empty() => Some(s.clone()), + _ => None, + } +} + +/// Render a `PathBuf` (folder) as a forward-slash string, dropping any leading +/// `./` or `/`. Element folder names are UTF-8 (Chinese) and kept verbatim. +fn path_to_forward_slash(path: &std::path::Path) -> String { + use std::path::Component; + let mut out = String::new(); + for comp in path.components() { + // Only `Normal` segments contribute; RootDir / CurDir / Prefix / + // ParentDir are ignored. `..` is already rejected by cph-model's loader, + // and the part path is relative. + if let Component::Normal(s) = comp { + if !out.is_empty() { + out.push('/'); + } + out.push_str(&s.to_string_lossy()); + } + } + out +} + +/// Quote `value` as a typst string literal, escaping `\` and `"`. Unicode is +/// passed through as UTF-8 (typst sources are UTF-8). Control chars that would +/// break a single-line literal (newline, CR, tab) are escaped too. +fn typst_str(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escapes_quotes_and_backslashes() { + assert_eq!(typst_str(r#"a"b\c"#), r#""a\"b\\c""#); + // Unicode passes through. + assert_eq!(typst_str("自拟"), "\"自拟\""); + } + + #[test] + fn forward_slash_path_keeps_unicode() { + let p = std::path::Path::new("segments/开场对照导言"); + assert_eq!(path_to_forward_slash(p), "segments/开场对照导言"); + } +} diff --git a/crates/cph-typst/src/lib.rs b/crates/cph-typst/src/lib.rs index db52dcf..7197673 100644 --- a/crates/cph-typst/src/lib.rs +++ b/crates/cph-typst/src/lib.rs @@ -1,9 +1,141 @@ //! `cph-typst` — typst `World`, driver generation, compile, PDF, span mapping. //! -//! Owned by **WU-4**. Builds the typst compiler `World` over the engineering -//! file's virtual file system (ADR-0006/0007), generates the driver entrypoint -//! from the manifest, compiles to PDF, and maps typst spans back to source -//! locations for diagnostics. +//! Owned by **WU-4**. Embeds the typst 0.15 compiler as a library to: +//! - **compile-check** a [`cph_model::Lesson`] (collect typst errors+warnings as +//! [`cph_diag::Diagnostic`]s), and +//! - **export a PDF** for a render target. +//! +//! The lesson's engineering-file directory tree (ADR-0007) is mounted as the +//! typst project root; a generated *driver* entrypoint (one per target) lives +//! in-memory under `.cph/driver-.typ` and `include`s each part's +//! content files (ADR-0006: a `content` value denotes the module **body**, so +//! the driver uses `include`, never `import`). The driver hands an ordered +//! `parts` array to the `@local/cph-render` package's `display` entry. +//! +//! ## typst version +//! +//! Pinned to the `typst` family `=0.15.0`. The 0.15 path/file API differs from +//! 0.14: `FileId::new(RootedPath)` where `RootedPath::new(VirtualRoot, +//! VirtualPath)`, and a diagnostic span is a `DiagSpan` (not a bare `Span`). -// WU-4: implement the World + driver-gen + compile + span mapping. Empty for -// now so the workspace compiles. +mod diag; +mod driver; +mod world; + +use std::path::PathBuf; + +use cph_diag::Diagnostic; +use cph_model::Lesson; +use typst_kit::fonts::{self, FontStore}; +use typst_layout::PagedDocument; +use typst_pdf::PdfOptions; + +pub use driver::{generate_driver, DRIVER_DIR}; +pub use world::{render_package_spec, LessonWorld}; + +/// The compile/PDF engine: holds the shared font store and the on-disk location +/// of the `cph-render` package. +/// +/// Fonts are discovered once (system + embedded) and shared across every +/// compilation via an [`std::sync::Arc`] inside the per-compile [`LessonWorld`]. +pub struct Engine { + fonts: std::sync::Arc, + render_dir: PathBuf, +} + +impl Engine { + /// Construct an engine, discovering system + embedded fonts. The render + /// package directory is taken from the `CPH_RENDER_DIR` environment + /// variable when set, otherwise defaults to `/render` resolved + /// relative to this crate (`CARGO_MANIFEST_DIR/../../render`). + pub fn new() -> Self { + Self::with_render_dir(default_render_dir()) + } + + /// Construct an engine pointing at an explicit `cph-render` package + /// directory (the folder containing `lib.typ` / `typst.toml`). + pub fn with_render_dir(render_dir: PathBuf) -> Self { + let mut store = FontStore::new(); + // System fonts first (so the host's CJK fonts are preferred), then the + // embedded fallbacks. `scan-fonts` / `embedded-fonts` features gate + // these iterators. + store.extend(fonts::system()); + store.extend(fonts::embedded()); + Self { + fonts: std::sync::Arc::new(store), + render_dir, + } + } + + /// The directory this engine resolves `@local/cph-render` from. + pub fn render_dir(&self) -> &std::path::Path { + &self.render_dir + } + + /// Compile-check `lesson` for `target`, returning every typst diagnostic + /// (errors **and** warnings) mapped to [`Diagnostic`]s. An empty `Vec` means + /// a clean compile. Spans are resolved to files relative to `lesson.root`; + /// spans landing in the generated driver are re-pointed with a hint marking + /// them as driver-internal (a driver-gen bug indicator) rather than dropped. + pub fn compile_check(&self, lesson: &Lesson, target: &str) -> Vec { + let world = self.world_for(lesson, target); + let warned = typst::compile::(&world); + + let mut out = Vec::new(); + if let Err(errors) = &warned.output { + out.extend(map_all(&world, errors)); + } + out.extend(map_all(&world, &warned.warnings)); + out + } + + /// Build a PDF for `lesson` / `target`. On a clean compile returns the PDF + /// bytes; on fatal errors returns the mapped diagnostics. Compile warnings + /// are not surfaced here (use [`Engine::compile_check`] for those). + pub fn build_pdf(&self, lesson: &Lesson, target: &str) -> Result, Vec> { + let world = self.world_for(lesson, target); + let warned = typst::compile::(&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)) + } + + fn world_for(&self, lesson: &Lesson, target: &str) -> LessonWorld { + let driver_src = generate_driver(lesson, target); + LessonWorld::new( + lesson.root.clone(), + self.render_dir.clone(), + target, + driver_src, + self.fonts.clone(), + ) + } +} + +impl Default for Engine { + fn default() -> Self { + Self::new() + } +} + +/// Map a batch of typst diagnostics to `cph-diag` ones against `world`. +fn map_all(world: &LessonWorld, diags: &[typst::diag::SourceDiagnostic]) -> Vec { + diags + .iter() + .map(|d| diag::map_diagnostic(world, d)) + .collect() +} + +/// `/../../render` — the repo's render package, used when `CPH_RENDER_DIR` +/// is unset. +fn default_render_dir() -> PathBuf { + if let Ok(dir) = std::env::var("CPH_RENDER_DIR") { + return PathBuf::from(dir); + } + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("render") +} diff --git a/crates/cph-typst/src/world.rs b/crates/cph-typst/src/world.rs new file mode 100644 index 0000000..8a2e3a8 --- /dev/null +++ b/crates/cph-typst/src/world.rs @@ -0,0 +1,165 @@ +//! [`LessonWorld`] — a typst [`World`] over an engineering-file directory tree +//! plus an in-memory generated driver and a disk-mounted `cph-render` package. +//! +//! ## File resolution +//! +//! - **The driver** (`main()`): mounted in-memory at `VirtualRoot::Project` + +//! vpath `/.cph/driver-.typ`. `source()` returns it from the field; +//! it is never written to disk. Its root-relative `include "/segments/…"` +//! paths therefore anchor at the lesson root. +//! - **Project files**: any `FileId` with `VirtualRoot::Project` is read from +//! `root.join(vpath.realize)`. Used for content `.typ`, images, `toml()`, etc. +//! - **`@local/cph-render`**: a `FileId` whose root is the render package spec +//! is read from `render_dir.join(vpath.realize)`. This avoids installing the +//! package into the typst local-package directory (MVP: no environment +//! coupling). Any *other* `@package` id yields a `NotFound` error (the MVP +//! render package has no external `@preview` dependencies). +//! +//! Sources are cached in a `Mutex>` so repeated +//! accesses during one compilation are cheap, as the `World` contract expects. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use typst::diag::{FileError, FileResult}; +use typst::foundations::{Bytes, Datetime, Duration}; +use typst::syntax::package::{PackageSpec, PackageVersion}; +use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot}; +use typst::text::{Font, FontBook}; +use typst::utils::LazyHash; +use typst::{Library, LibraryExt, World}; +use typst_kit::fonts::FontStore; + +use crate::driver::DRIVER_DIR; + +/// The package spec the driver imports and the World mounts from `render_dir`. +pub fn render_package_spec() -> PackageSpec { + PackageSpec { + namespace: "local".into(), + name: "cph-render".into(), + version: PackageVersion { + major: 0, + minor: 1, + patch: 0, + }, + } +} + +/// A typst `World` for one lesson + target compilation. +pub struct LessonWorld { + /// Engineering-file root (the typst project root). + root: PathBuf, + /// On-disk location of the `cph-render` package. + render_dir: PathBuf, + /// The render package spec (`@local/cph-render:0.1.0`). + render_spec: PackageSpec, + /// FileId of the in-memory driver entrypoint. + main: FileId, + /// The driver source, parsed once. + driver_source: Source, + /// Standard library (default configuration). + library: LazyHash, + /// Shared font store (book + lazily-loaded fonts). + fonts: Arc, + /// Source cache for on-disk files. + sources: Mutex>, +} + +impl LessonWorld { + /// Build a world. `driver_src` is the generated entrypoint text; it is + /// mounted in-memory at `/.cph/driver-.typ` under the project root. + pub fn new( + root: PathBuf, + render_dir: PathBuf, + target: &str, + driver_src: String, + fonts: Arc, + ) -> Self { + let driver_vpath = VirtualPath::new(format!("/{DRIVER_DIR}/driver-{target}.typ")) + .expect("driver vpath is a valid virtual path"); + let main = FileId::new(RootedPath::new(VirtualRoot::Project, driver_vpath)); + let driver_source = Source::new(main, driver_src); + + Self { + root, + render_dir, + render_spec: render_package_spec(), + main, + driver_source, + library: LazyHash::new(Library::default()), + fonts, + sources: Mutex::new(HashMap::new()), + } + } + + /// Map a `FileId` to its on-disk path, honoring the project / render-package + /// roots. Returns a `FileError` for any other package root. + fn resolve(&self, id: FileId) -> FileResult { + let vpath = id.vpath(); + match id.root() { + VirtualRoot::Project => vpath.realize(&self.root).map_err(Into::into), + VirtualRoot::Package(spec) if *spec == self.render_spec => { + vpath.realize(&self.render_dir).map_err(Into::into) + } + VirtualRoot::Package(spec) => Err(FileError::Package( + typst::diag::PackageError::NotFound(spec.clone()), + )), + } + } + + /// Read raw bytes for `id` from disk (driver excluded — handled by callers). + fn read_bytes(&self, id: FileId) -> FileResult> { + let path = self.resolve(id)?; + std::fs::read(&path).map_err(|e| FileError::from_io(e, &path)) + } +} + +impl World for LessonWorld { + fn library(&self) -> &LazyHash { + &self.library + } + + fn book(&self) -> &LazyHash { + self.fonts.book() + } + + fn main(&self) -> FileId { + self.main + } + + fn source(&self, id: FileId) -> FileResult { + if id == self.main { + return Ok(self.driver_source.clone()); + } + // Cache hit? + if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) { + return Ok(src.clone()); + } + let bytes = self.read_bytes(id)?; + let text = String::from_utf8(bytes).map_err(|_| FileError::InvalidUtf8)?; + let source = Source::new(id, text); + self.sources + .lock() + .expect("sources mutex") + .insert(id, source.clone()); + Ok(source) + } + + fn file(&self, id: FileId) -> FileResult { + if id == self.main { + return Ok(Bytes::from_string(self.driver_source.text().to_string())); + } + let bytes = self.read_bytes(id)?; + Ok(Bytes::new(bytes)) + } + + fn font(&self, index: usize) -> Option { + self.fonts.font(index) + } + + fn today(&self, _offset: Option) -> Option { + // No clock dependence needed; lessons are reproducible. + None + } +} diff --git a/crates/cph-typst/tests/compile.rs b/crates/cph-typst/tests/compile.rs new file mode 100644 index 0000000..6f645c4 --- /dev/null +++ b/crates/cph-typst/tests/compile.rs @@ -0,0 +1,189 @@ +//! Integration tests for `cph-typst`: driver generation, World, compile-check, +//! and PDF export over the `tests/fixtures/mini` lesson. +//! +//! Render-package dependency: most tests point the [`Engine`] at the bundled +//! **render-stub** (`tests/fixtures/render-stub`) so the World/compile/PDF path +//! is proven independently of WU-2. One `#[ignore]`d test points at the real +//! repo `render/` package for when it is ready / for manual runs. + +use std::path::PathBuf; + +use cph_diag::Severity; +use cph_typst::{generate_driver, Engine}; + +fn fixture_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mini") +} + +fn stub_render_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/render-stub") +} + +fn real_render_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("render") +} + +fn load_mini() -> cph_model::Lesson { + let (lesson, diags) = cph_model::load(&fixture_root()); + let lesson = lesson.expect("mini fixture loads into a Lesson"); + let errors: Vec<_> = diags + .iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "fixture has loader errors: {errors:?}"); + lesson +} + +/// PURE UNIT TEST (no fonts, no render package): the generated driver string +/// has the expected shape, order, includes, and scalar. +#[test] +fn driver_gen_shape_and_order() { + let lesson = load_mini(); + let src = generate_driver(&lesson, "student"); + + // Imports the render package and calls display. + assert!(src.contains("#import \"@local/cph-render:0.1.0\": display")); + assert!(src.contains("#display(")); + assert!(src.contains("target: \"student\"")); + assert!(src.contains("title: \"迷你示例课时\"")); + assert!(src.contains("author: \"测试作者\"")); + + // Root-relative includes with UTF-8 folder names, one per content field. + assert!(src.contains("include \"/segments/开场对照导言/textbook.typ\"")); + assert!(src.contains("include \"/lemmas/量纲分析估计/stmt.typ\"")); + assert!(src.contains("include \"/lemmas/量纲分析估计/proof.typ\"")); + assert!(src.contains("include \"/examples/自由落体/problem.typ\"")); + assert!(src.contains("include \"/examples/自由落体/solution.typ\"")); + + // Example scalar `source` is emitted from element.toml. + assert!(src.contains("source: \"自拟\"")); + + // Parts appear in manifest order: segment, then lemma, then example. + let seg = src.find("kind: \"segment\"").expect("segment present"); + let lem = src.find("kind: \"lemma\"").expect("lemma present"); + let exa = src.find("kind: \"example\"").expect("example present"); + assert!(seg < lem && lem < exa, "parts must keep manifest order"); +} + +/// Optional `proof.typ` is only included when it exists on disk. (It exists in +/// the mini fixture, so we assert presence; absence is covered by construction +/// — a kind without the file would simply omit the binding.) +#[test] +fn optional_proof_included_when_present() { + let lesson = load_mini(); + let src = generate_driver(&lesson, "teacher"); + assert!(src.contains("_p1_proof = include")); +} + +/// FONT-DEPENDENT: compile the mini lesson through the render-stub. Should +/// produce zero Error-severity diagnostics. If this fails purely because the +/// test host lacks fonts, the failure will be a typst font error — see the +/// note in the crate docs. +#[test] +fn compile_check_clean_with_stub() { + let lesson = load_mini(); + let engine = Engine::with_render_dir(stub_render_dir()); + let diags = engine.compile_check(&lesson, "student"); + let errors: Vec<_> = diags + .iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "unexpected compile errors: {errors:#?}"); +} + +/// FONT-DEPENDENT: a real PDF is produced through the render-stub and is +/// non-trivial in size. +#[test] +fn build_pdf_with_stub() { + let lesson = load_mini(); + let engine = Engine::with_render_dir(stub_render_dir()); + let pdf = engine + .build_pdf(&lesson, "student") + .unwrap_or_else(|d| panic!("PDF build failed: {d:#?}")); + assert!(pdf.starts_with(b"%PDF"), "output is a PDF"); + assert!( + pdf.len() > 1024, + "PDF is non-trivial (got {} bytes)", + pdf.len() + ); + + let out = std::env::temp_dir().join("cph-typst-mini-student.pdf"); + std::fs::write(&out, &pdf).expect("write pdf"); + assert!(std::fs::metadata(&out).unwrap().len() > 1024); +} + +/// A diagnostic that lands inside the generated driver is re-pointed at the +/// driver path with a "generated driver" hint, not dropped. We force this by +/// compiling against a render dir whose `display` does not exist, so the +/// driver's `#display(...)` call is unknown — the error span is in the driver. +#[test] +fn driver_internal_span_is_repointed() { + let lesson = load_mini(); + // Point at a render dir with an EMPTY lib.typ (no `display`), so calling + // `#display(...)` errors inside the driver. + let empty = std::env::temp_dir().join("cph-typst-empty-render"); + std::fs::create_dir_all(&empty).unwrap(); + std::fs::write( + empty.join("typst.toml"), + "[package]\nname=\"cph-render\"\nversion=\"0.1.0\"\nentrypoint=\"lib.typ\"\n\ + authors=[\"t\"]\nlicense=\"MIT\"\ndescription=\"empty\"\n", + ) + .unwrap(); + std::fs::write(empty.join("lib.typ"), "// no display defined\n").unwrap(); + + let engine = Engine::with_render_dir(empty); + let diags = engine.compile_check(&lesson, "student"); + let errors: Vec<_> = diags + .iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(!errors.is_empty(), "expected an unknown-`display` error"); + // At least one error should point at the driver and carry the driver hint. + let driver_err = errors.iter().find(|d| { + d.span + .as_ref() + .is_some_and(|s| s.file.to_string_lossy().contains(".cph/driver-")) + }); + assert!( + driver_err.is_some(), + "an error span should be re-pointed at the generated driver: {errors:#?}" + ); + assert!( + driver_err + .unwrap() + .hint + .as_deref() + .is_some_and(|h| h.contains("generated driver")), + "driver-internal error should carry the generated-driver hint" + ); +} + +/// Full end-to-end against the REAL repo `render/` package: load the mini +/// fixture, compile through `cph-render`'s `display`, export a PDF for both +/// targets. This proves the whole World → driver → render-package → PDF path, +/// not just the stub. Requires system CJK fonts (present on dev + CI via +/// fonts-noto-cjk). +#[test] +fn build_pdf_with_real_render() { + let lesson = load_mini(); + let render_dir = real_render_dir(); + assert!( + render_dir.join("lib.typ").is_file(), + "real render package missing at {}", + render_dir.display() + ); + let engine = Engine::with_render_dir(render_dir); + + for target in ["student", "teacher"] { + let pdf = engine + .build_pdf(&lesson, target) + .unwrap_or_else(|d| panic!("{target} PDF build failed: {d:#?}")); + assert!(pdf.starts_with(b"%PDF")); + assert!(pdf.len() > 1024, "{target} PDF too small"); + let out = std::env::temp_dir().join(format!("cph-typst-mini-real-{target}.pdf")); + std::fs::write(&out, &pdf).expect("write pdf"); + } +} diff --git a/crates/cph-typst/tests/fixtures/mini/examples/自由落体/element.toml b/crates/cph-typst/tests/fixtures/mini/examples/自由落体/element.toml new file mode 100644 index 0000000..b926a34 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/mini/examples/自由落体/element.toml @@ -0,0 +1,2 @@ +kind = "example" +source = "自拟" diff --git a/crates/cph-typst/tests/fixtures/mini/examples/自由落体/problem.typ b/crates/cph-typst/tests/fixtures/mini/examples/自由落体/problem.typ new file mode 100644 index 0000000..bd1b529 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/mini/examples/自由落体/problem.typ @@ -0,0 +1 @@ +一物体从静止自由下落,求下落时间 $t$ 后的速度 $v$ 与位移 $h$。 diff --git a/crates/cph-typst/tests/fixtures/mini/examples/自由落体/solution.typ b/crates/cph-typst/tests/fixtures/mini/examples/自由落体/solution.typ new file mode 100644 index 0000000..c232427 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/mini/examples/自由落体/solution.typ @@ -0,0 +1 @@ +由匀加速运动公式,$v = g t$,$h = 1/2 g t^2$。代入 $g approx 9.8 "m/s"^2$ 即得数值结果。 diff --git a/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/element.toml b/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/proof.typ b/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/proof.typ new file mode 100644 index 0000000..d1c6bb4 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/proof.typ @@ -0,0 +1,3 @@ +设 $T = l^a g^b$。量纲为 $[T] = "T"$,$[l] = "L"$,$[g] = "L T"^(-2)$。 +比较两端得 $a + b = 0$ 与 $-2 b = 1$,解得 $a = 1 slash 2$,$b = -1 slash 2$, +故 $T prop sqrt(l slash g)$。$qed$ diff --git a/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/stmt.typ b/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/stmt.typ new file mode 100644 index 0000000..a88a0fc --- /dev/null +++ b/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/stmt.typ @@ -0,0 +1,3 @@ +设单摆周期 $T$ 仅依赖摆长 $l$ 与重力加速度 $g$,则由量纲分析必有 +$ T = k sqrt(l / g) $ +其中 $k$ 为无量纲常数。 diff --git a/crates/cph-typst/tests/fixtures/mini/manifest.toml b/crates/cph-typst/tests/fixtures/mini/manifest.toml new file mode 100644 index 0000000..9215ca8 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/mini/manifest.toml @@ -0,0 +1,23 @@ +[project] +id = "mini" +name = "迷你课时" + +[info] +title = "迷你示例课时" +author = "测试作者" + +[[parts]] +kind = "segment" +path = "segments/开场对照导言" + +[[parts]] +kind = "lemma" +path = "lemmas/量纲分析估计" + +[[parts]] +kind = "example" +path = "examples/自由落体" + +[targets.student] + +[targets.teacher] diff --git a/crates/cph-typst/tests/fixtures/mini/segments/开场对照导言/element.toml b/crates/cph-typst/tests/fixtures/mini/segments/开场对照导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/mini/segments/开场对照导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/crates/cph-typst/tests/fixtures/mini/segments/开场对照导言/textbook.typ b/crates/cph-typst/tests/fixtures/mini/segments/开场对照导言/textbook.typ new file mode 100644 index 0000000..c38b680 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/mini/segments/开场对照导言/textbook.typ @@ -0,0 +1,2 @@ +本节通过对照两种估计方法,引入量纲分析这一工具。我们先回顾自由落体, +其中能量与质量的关系可写作 $E = m c^2$(此处仅作记号示例)。 diff --git a/crates/cph-typst/tests/fixtures/render-stub/lib.typ b/crates/cph-typst/tests/fixtures/render-stub/lib.typ new file mode 100644 index 0000000..1b9184b --- /dev/null +++ b/crates/cph-typst/tests/fixtures/render-stub/lib.typ @@ -0,0 +1,29 @@ +// Minimal stand-in for the real `cph-render` package, used so cph-typst's +// World / compile / PDF path can be proven independently of WU-2. +// +// It exercises the same call shape as the real entry: it reads `info`, walks +// `parts` IN ORDER, and renders each part's content fields by `kind`. Content +// values are already-evaluated content (the driver produced them via include). +#let display(info: (:), target: "student", parts: ()) = { + set document(title: info.at("title", default: "")) + [= #info.at("title", default: "")] + [target: #target] + for p in parts { + let kind = p.at("kind", default: none) + [== #kind] + if kind == "segment" { + p.textbook + } else if kind == "lemma" { + p.stmt + let proof = p.at("proof", default: none) + if proof != none { proof } + } else if kind == "example" { + p.problem + p.solution + let src = p.at("source", default: none) + if src != none [来源:#src] + } else if kind == "sop" { + p.sop + } + } +} diff --git a/crates/cph-typst/tests/fixtures/render-stub/typst.toml b/crates/cph-typst/tests/fixtures/render-stub/typst.toml new file mode 100644 index 0000000..9199835 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/render-stub/typst.toml @@ -0,0 +1,7 @@ +[package] +name = "cph-render" +version = "0.1.0" +entrypoint = "lib.typ" +authors = ["cph-typst tests"] +license = "MIT" +description = "Minimal stand-in for cph-render used by cph-typst tests." diff --git a/render/typst.toml b/render/typst.toml index c389bf2..058fabc 100644 --- a/render/typst.toml +++ b/render/typst.toml @@ -2,7 +2,7 @@ name = "cph-render" version = "0.1.0" entrypoint = "lib.typ" -compiler = ">=0.15.0" +compiler = "0.15.0" authors = ["curriculum-project-hub"] license = "MIT" description = "Curriculum lesson render package: single `display` entry over an ordered list of typed parts (segment/example/lemma/sop), targeting student/teacher handouts." From b86ee24a261c9eafb0e50f2bd0db7f8532ad764d Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 01:57:34 +0800 Subject: [PATCH 11/29] feat(checker): cph-check orchestrator + cph-cli `cph` binary (WU-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the core pipeline: the `cph` CLI checks an engineering file and compiles a PDF. cph-check — phases (a) load → (b) structural (known-kind set) → (c) schema → (d) typst compile → (e) render-coverage. Compile is gated: skipped if (a)-(c) produced any Error (broken input would only add noise); `check` compiles every declared target (dedup identical diags), `build` compiles the one requested. Render-coverage emits a non-blocking RenderIgnored *warning* when a (kind, target) has no render rule — severity pinned via RENDER_IGNORED_SEVERITY const citing spec Diagnostic.renderIgnoredSeverity + ADR-0005. No double-emit: unknown/missing parts are skipped downstream. API: check(root, engine) -> CheckReport, build(root, engine, target) -> (Option, CheckReport). cph-cli — clap: `cph check ` (exit 1 on any Error, warnings → 0) and `cph build --target [-o out]` (default target student, default out /build/.pdf). Diagnostics → stderr via Display, results → stdout. --render-dir override. End-to-end on examples/TH-141 (39 parts): `check` → 0 errors/0 warnings; `build` → real PDF student 9pp/395KB, teacher 12pp/474KB. Broken-element check (unclosed delimiter in a stmt.typ) → span-accurate `error[E-TYPST-COMPILE]: unclosed delimiter --> lemmas/…/stmt.typ:7:8`, exit 1. Workspace: fmt + clippy -D warnings + test all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 132 ++++++++++++ crates/cph-check/Cargo.toml | 4 + crates/cph-check/src/lib.rs | 312 ++++++++++++++++++++++++++++- crates/cph-check/tests/pipeline.rs | 157 +++++++++++++++ crates/cph-cli/Cargo.toml | 4 + crates/cph-cli/src/main.rs | 135 ++++++++++++- 6 files changed, 736 insertions(+), 8 deletions(-) create mode 100644 crates/cph-check/tests/pipeline.rs diff --git a/Cargo.lock b/Cargo.lock index e1af56a..4475d92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "approx" version = "0.5.1" @@ -222,6 +272,46 @@ dependencies = [ "serde_path_to_error", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cobs" version = "0.3.0" @@ -255,6 +345,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "comemo" version = "0.5.1" @@ -291,10 +387,22 @@ dependencies = [ [[package]] name = "cph-check" version = "0.1.0" +dependencies = [ + "cph-diag", + "cph-model", + "cph-schema", + "cph-typst", +] [[package]] name = "cph-cli" version = "0.1.0" +dependencies = [ + "clap", + "cph-check", + "cph-diag", + "cph-typst", +] [[package]] name = "cph-diag" @@ -1101,6 +1209,12 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -1338,6 +1452,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2028,6 +2148,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.27.2" @@ -2807,6 +2933,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "vello_common" version = "0.0.8" diff --git a/crates/cph-check/Cargo.toml b/crates/cph-check/Cargo.toml index cb1c011..5408e07 100644 --- a/crates/cph-check/Cargo.toml +++ b/crates/cph-check/Cargo.toml @@ -5,3 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +cph-diag = { workspace = true } +cph-model = { workspace = true } +cph-schema = { path = "../cph-schema" } +cph-typst = { path = "../cph-typst" } diff --git a/crates/cph-check/src/lib.rs b/crates/cph-check/src/lib.rs index 947c084..5926a22 100644 --- a/crates/cph-check/src/lib.rs +++ b/crates/cph-check/src/lib.rs @@ -3,6 +3,314 @@ //! Owned by **WU-5**. Ties together `cph-model` (load), `cph-schema` //! (validate), and `cph-typst` (compile), runs the render-coverage rule //! (ADR-0005's "missing render ⇒ warning"), and collects all diagnostics. +//! +//! The orchestrator owns *sequencing and gating*, not the individual checks: +//! each phase is implemented by an upstream crate, and `cph-check` decides the +//! order, what gates what, and how the results are merged / deduped. See +//! [`check`] for the full pipeline and [`build`] for the export path. -// WU-5: implement the orchestrator + render-coverage rule. Empty for now so the -// workspace compiles. +use std::path::Path; + +use cph_diag::{DiagCode, Diagnostic, Severity}; +use cph_typst::Engine; + +/// The default render target when a lesson declares no `[targets.*]` at all. +const DEFAULT_TARGET: &str = "student"; + +/// The render targets the MVP render package actually covers (student handout + +/// teacher plan). Used by the render-coverage rule (phase (e)). +const COVERED_TARGETS: &[&str] = &["student", "teacher"]; + +/// Severity of the render-coverage ("element ignored under a target") diagnostic. +/// +/// **PINNED to `warning` by the contract.** Mirrors the Lean master's +/// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning` +/// (`spec/Spec/Courseware/Diagnostic.lean`), itself citing ADR-0005: when a +/// `(kind, target)` pair has no render rule the checker reports that the element +/// is ignored under that target and **does not block the export**. Naming the +/// severity as a const makes "it is a warning, not an error" a greppable, +/// alignable fact rather than an inline literal. +const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning; + +/// The result of running [`check`] (or the check phases of [`build`]). +#[derive(Debug, Clone, PartialEq)] +pub struct CheckReport { + /// Every diagnostic collected across all phases, deduplicated. + pub diagnostics: Vec, + /// `false` if `cph_model::load` returned `None` (a hard manifest failure + /// that prevents any further phase from running). + pub lesson_loaded: bool, +} + +impl CheckReport { + /// How many collected diagnostics are `Error`-severity. + pub fn error_count(&self) -> usize { + self.diagnostics + .iter() + .filter(|d| d.severity == Severity::Error) + .count() + } + + /// How many collected diagnostics are `Warning`-severity. + pub fn warning_count(&self) -> usize { + self.diagnostics + .iter() + .filter(|d| d.severity == Severity::Warning) + .count() + } + + /// Whether any collected diagnostic is `Error`-severity. + pub fn has_errors(&self) -> bool { + self.diagnostics + .iter() + .any(|d| d.severity == Severity::Error) + } +} + +/// Run the full check pipeline against the engineering file at `root`. +/// +/// Phases, in order, with gating: +/// - **(a) load** — `cph_model::load`. Its diagnostics are collected. On a hard +/// failure (`None`) the report is returned immediately with +/// `lesson_loaded == false`; nothing downstream can run. +/// - **(b) structural** — for each part, check `part.kind` is in +/// `cph_schema::known_kinds()`; an unknown kind is an `UnknownKind` error. +/// (cph-model already checked path existence, `..` traversal, and +/// part/element kind *mismatch*; this complements that with the *known-set* +/// check and does not duplicate it.) Parts whose folder cph-model already +/// flagged as missing are skipped for the rest of the pipeline. +/// - **(c) schema** — for each part whose folder exists and whose kind is +/// known, `cph_schema::validate(&part.descriptor)`. +/// - **(d) typst compile** — only if phases (a)–(c) produced **zero** errors +/// (otherwise the compile is noise on known-broken input). Compiles each +/// declared `lesson.targets` (defaulting to `"student"` if none), deduping +/// identical diagnostics across targets. +/// - **(e) render-coverage** — a `warning` (never an error) for each +/// `(kind, target)` lacking a render rule. Runs regardless of (d) gating. +/// +/// The engine is taken as a parameter so the caller owns it (and tests can +/// inject `Engine::with_render_dir`). +pub fn check(root: &Path, engine: &Engine) -> CheckReport { + let mut diags = Vec::new(); + + // (a) load. + let (lesson, load_diags) = cph_model::load(root); + diags.extend(load_diags); + + let Some(lesson) = lesson else { + // Hard manifest failure: nothing else can run. + return CheckReport { + diagnostics: dedup(diags), + lesson_loaded: false, + }; + }; + + let known = cph_schema::known_kinds(); + + // (b) structural + (c) schema. + run_structural_and_schema(&lesson, known, &mut diags); + + // (d) typst compile — gated on zero errors from (a)–(c). + let pre_compile_has_error = diags.iter().any(|d| d.severity == Severity::Error); + if !pre_compile_has_error { + for target in compile_targets(&lesson) { + diags.extend(engine.compile_check(&lesson, target)); + } + } + + // (e) render-coverage — always runs; only a warning. Skips parts already + // broken in (b) (missing folder / unknown kind). + diags.extend(render_coverage(&lesson, known)); + + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + } +} + +/// Build a PDF for `target`. +/// +/// Runs the check phases **(a)–(c)** (load → structural → schema). If those +/// produce any `Error`, the build is refused: returns `(None, report)` with the +/// blocking diagnostics. Otherwise it compiles + exports the PDF for `target` +/// via `engine.build_pdf`; on a clean compile returns `(Some(bytes), report)`, +/// and on a typst error returns `(None, report)` with the compile diagnostics +/// folded into the report. +/// +/// Note `build` does **not** run render-coverage (phase (e)): coverage warnings +/// describe export *loss*, which is informational for a `check`, not a gate on +/// producing a single target's PDF. +pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option>, CheckReport) { + let mut diags = Vec::new(); + + // (a) load. + let (lesson, load_diags) = cph_model::load(root); + diags.extend(load_diags); + + let Some(lesson) = lesson else { + return ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: false, + }, + ); + }; + + let known = cph_schema::known_kinds(); + + // (b) structural + (c) schema. + run_structural_and_schema(&lesson, known, &mut diags); + + if diags.iter().any(|d| d.severity == Severity::Error) { + return ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + ); + } + + // Phases passed: compile + export the requested target. + match engine.build_pdf(&lesson, target) { + Ok(bytes) => ( + Some(bytes), + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + ), + Err(compile_diags) => { + diags.extend(compile_diags); + ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + ) + } + } +} + +/// Whether a part's folder exists on disk. cph-model emits `PartPathMissing` +/// for parts whose folder is absent, so the orchestrator skips those parts in +/// the structural/schema/coverage phases to avoid piling diagnostics on the +/// same root cause. +fn part_exists(p: &cph_model::Part) -> bool { + p.descriptor.dir.is_dir() +} + +/// Phases (b) + (c): the known-kind structural check and per-part schema +/// validation. Shared by [`check`] and [`build`] so their gating stays +/// identical. +fn run_structural_and_schema( + lesson: &cph_model::Lesson, + known: &[&str], + diags: &mut Vec, +) { + // (b) structural: known-kind check. + for part in &lesson.parts { + if !part_exists(part) { + continue; + } + if !known.contains(&part.kind.as_str()) { + diags.push( + Diagnostic::error( + DiagCode::UnknownKind, + format!( + "part '{}' has unknown kind '{}'", + part.path.display(), + part.kind + ), + ) + .with_hint(format!("valid kinds are: {}", known.join(", "))), + ); + } + } + + // (c) schema: validate each existing part with a known kind. Skipping + // unknown kinds avoids a second `UnknownKind` from `cph_schema::validate` + // (already reported in (b)). + for part in &lesson.parts { + if !part_exists(part) || !known.contains(&part.kind.as_str()) { + continue; + } + diags.extend(cph_schema::validate(&part.descriptor)); + } +} + +/// The targets to compile-check for [`check`]: every declared target, or +/// `["student"]` if the lesson declares none. +fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> { + if lesson.targets.is_empty() { + vec![DEFAULT_TARGET] + } else { + lesson.targets.iter().map(String::as_str).collect() + } +} + +/// Phase (e): for each `(part.kind, target)` over the lesson's declared targets, +/// emit a `RenderIgnored` **warning** when the pair has no render rule. +/// +/// The render matrix lives in the render package and is not introspectable from +/// Rust in the MVP, so coverage is defined operationally: a kind is covered when +/// it is a known stdlib kind (`known`) AND the target is one the render package +/// handles (`COVERED_TARGETS` = student/teacher). A declared target outside that +/// set, or a kind not in the known set, yields the warning. Parts already broken +/// in (b) (missing folder / unknown kind) are skipped — they are reported there. +fn render_coverage(lesson: &cph_model::Lesson, known: &[&str]) -> Vec { + let mut out = Vec::new(); + // De-dupe (kind, target) pairs so we emit one warning per pair, not per part. + let mut seen: Vec<(String, String)> = Vec::new(); + + for part in &lesson.parts { + // Skip parts already flagged structurally. + if !part_exists(part) || !known.contains(&part.kind.as_str()) { + continue; + } + for target in &lesson.targets { + let covered = COVERED_TARGETS.contains(&target.as_str()); + if covered { + continue; + } + let key = (part.kind.clone(), target.clone()); + if seen.contains(&key) { + continue; + } + seen.push(key); + let mut d = Diagnostic::warning( + DiagCode::RenderIgnored, + format!( + "kind '{}' has no render rule for target '{}'; it will be omitted from that export", + part.kind, target + ), + ) + .with_hint(format!( + "add a render rule for kind '{}' under target '{}', or remove the target", + part.kind, target + )); + // Pin the severity via the named contract const. `Diagnostic::warning` + // already produces a warning, but assigning the const keeps the + // alignment to `renderIgnoredSeverity` load-bearing and greppable. + d.severity = RENDER_IGNORED_SEVERITY; + out.push(d); + } + } + out +} + +/// Deduplicate diagnostics that are identical (same code+severity+message+span+ +/// hint). The same typst error can surface once per compiled target, and the +/// same root cause can be reached by more than one phase; callers see each +/// distinct problem once. +fn dedup(diags: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(diags.len()); + for d in diags { + if !out.contains(&d) { + out.push(d); + } + } + out +} diff --git a/crates/cph-check/tests/pipeline.rs b/crates/cph-check/tests/pipeline.rs new file mode 100644 index 0000000..e9a3be5 --- /dev/null +++ b/crates/cph-check/tests/pipeline.rs @@ -0,0 +1,157 @@ +//! Integration tests for the `cph-check` orchestrator. +//! +//! These exercise phase gating and the public API against the shared `mini` +//! fixture (`crates/cph-typst/tests/fixtures/mini/`) and small throwaway +//! fixtures built in a temp dir. The typst Engine is pointed at the repo's real +//! `render/` package via `Engine::with_render_dir`. + +use std::path::PathBuf; + +use cph_diag::DiagCode; +use cph_typst::Engine; + +/// `/../cph-typst/tests/fixtures/mini` — the known-good lesson. +fn mini_fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("cph-typst") + .join("tests") + .join("fixtures") + .join("mini") +} + +/// `/../../render` — the repo render package. +fn render_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("render") +} + +fn engine() -> Engine { + Engine::with_render_dir(render_dir()) +} + +#[test] +fn good_fixture_has_no_errors() { + let report = cph_check::check(&mini_fixture(), &engine()); + assert!(report.lesson_loaded, "mini fixture should load"); + assert_eq!( + report.error_count(), + 0, + "mini fixture should have zero errors, got: {:#?}", + report.diagnostics + ); + assert!(!report.has_errors()); +} + +#[test] +fn unknown_kind_is_an_error() { + // Build a throwaway lesson whose part declares kind "frob". + let tmp = tempdir(); + std::fs::write( + tmp.join("manifest.toml"), + r#" +[project] +id = "broken" +name = "broken" + +[info] +title = "broken" + +[[parts]] +kind = "frob" +path = "elements/widget" +"#, + ) + .unwrap(); + let part_dir = tmp.join("elements").join("widget"); + std::fs::create_dir_all(&part_dir).unwrap(); + std::fs::write(part_dir.join("element.toml"), "kind = \"frob\"\n").unwrap(); + + let report = cph_check::check(&tmp, &engine()); + assert!(report.lesson_loaded); + assert!(report.has_errors()); + assert!( + report + .diagnostics + .iter() + .any(|d| d.code == DiagCode::UnknownKind && d.message.contains("frob")), + "expected an UnknownKind error mentioning 'frob', got: {:#?}", + report.diagnostics + ); + cleanup(&tmp); +} + +#[test] +fn hard_manifest_failure_sets_lesson_not_loaded() { + let tmp = tempdir(); + // No manifest.toml at all → cph_model::load returns None. + let report = cph_check::check(&tmp, &engine()); + assert!(!report.lesson_loaded, "missing manifest is a hard failure"); + assert!(report.has_errors()); + cleanup(&tmp); +} + +#[test] +fn build_returns_pdf_on_good_fixture() { + let (pdf, report) = cph_check::build(&mini_fixture(), &engine(), "student"); + assert!(report.lesson_loaded); + assert_eq!( + report.error_count(), + 0, + "no check errors expected, got: {:#?}", + report.diagnostics + ); + let pdf = pdf.expect("expected PDF bytes for the good fixture"); + assert!(pdf.starts_with(b"%PDF"), "output should be a PDF"); + assert!(pdf.len() > 1000, "PDF should be non-trivial"); +} + +#[test] +fn build_returns_none_on_broken_fixture() { + // A part folder is missing → cph-model flags PartPathMissing (an error), + // so build refuses before ever compiling. + let tmp = tempdir(); + std::fs::write( + tmp.join("manifest.toml"), + r#" +[project] +id = "broken" +name = "broken" + +[info] +title = "broken" + +[[parts]] +kind = "segment" +path = "segments/does-not-exist" +"#, + ) + .unwrap(); + + let (pdf, report) = cph_check::build(&tmp, &engine(), "student"); + assert!(pdf.is_none(), "broken lesson must not produce a PDF"); + assert!(report.has_errors()); + cleanup(&tmp); +} + +// --- tiny temp-dir helpers (no external dev-dep) ------------------------------ + +fn tempdir() -> PathBuf { + let mut p = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + p.push(format!( + "cph-check-test-{nanos}-{:?}", + std::thread::current().id() + )); + std::fs::create_dir_all(&p).unwrap(); + p +} + +fn cleanup(p: &PathBuf) { + let _ = std::fs::remove_dir_all(p); +} diff --git a/crates/cph-cli/Cargo.toml b/crates/cph-cli/Cargo.toml index 58ecd0a..ba6f955 100644 --- a/crates/cph-cli/Cargo.toml +++ b/crates/cph-cli/Cargo.toml @@ -9,3 +9,7 @@ name = "cph" path = "src/main.rs" [dependencies] +cph-check = { path = "../cph-check" } +cph-diag = { workspace = true } +cph-typst = { path = "../cph-typst" } +clap = { version = "4", features = ["derive"] } diff --git a/crates/cph-cli/src/main.rs b/crates/cph-cli/src/main.rs index 67255fa..b564458 100644 --- a/crates/cph-cli/src/main.rs +++ b/crates/cph-cli/src/main.rs @@ -1,10 +1,133 @@ //! `cph` — the command-line entrypoint for the checker. //! -//! Owned by **WU-5**. Will parse args, run the `cph-check` pipeline against an -//! engineering file, and print diagnostics. A stub for now so the workspace -//! builds and produces a runnable binary. +//! Owned by **WU-5**. Parses args, constructs the typst [`Engine`], runs the +//! `cph-check` pipeline against an engineering file, and prints diagnostics. +//! +//! Convention: **diagnostics go to stderr, results go to stdout.** `check` +//! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit +//! 0); `build` exits 1 when the PDF could not be produced. -// WU-5: wire up arg parsing + the check pipeline + diagnostic rendering. -fn main() { - println!("cph: not yet implemented"); +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; +use cph_check::CheckReport; +use cph_typst::Engine; + +/// The `cph` checker for curriculum engineering files. +#[derive(Debug, Parser)] +#[command(name = "cph", version, about, long_about = None)] +struct Cli { + /// Override the `cph-render` package directory (the folder containing + /// `lib.typ` / `typst.toml`). Defaults to the engine's own resolution + /// (`CPH_RENDER_DIR` env var, else the repo `render/`). + #[arg(long, global = true, value_name = "DIR")] + render_dir: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Run the full check pipeline and print diagnostics. Exits 1 on any error. + Check { + /// Path to the engineering-file root (the folder with `manifest.toml`). + path: PathBuf, + }, + /// Build a PDF for a render target. Exits 1 if the build fails. + Build { + /// Path to the engineering-file root (the folder with `manifest.toml`). + path: PathBuf, + /// Render target to export. + #[arg(long, default_value = "student")] + target: String, + /// Output PDF path. Defaults to `/build/.pdf`. + #[arg(short = 'o', long, value_name = "OUT")] + out: Option, + }, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + + let engine = match &cli.render_dir { + Some(dir) => Engine::with_render_dir(dir.clone()), + None => Engine::new(), + }; + + match cli.command { + Command::Check { path } => run_check(&path, &engine), + Command::Build { path, target, out } => run_build(&path, &engine, &target, out), + } +} + +/// Print every diagnostic in `report` to stderr, followed by a summary line. +fn print_diagnostics(report: &CheckReport) { + for d in &report.diagnostics { + eprintln!("{d}"); + } + eprintln!( + "{} error{}, {} warning{}", + report.error_count(), + plural(report.error_count()), + report.warning_count(), + plural(report.warning_count()), + ); +} + +fn plural(n: usize) -> &'static str { + if n == 1 { + "" + } else { + "s" + } +} + +fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode { + let report = cph_check::check(path, engine); + print_diagnostics(&report); + + if report.has_errors() { + ExitCode::FAILURE + } else { + println!("check passed: {}", path.display()); + ExitCode::SUCCESS + } +} + +fn run_build( + path: &std::path::Path, + engine: &Engine, + target: &str, + out: Option, +) -> ExitCode { + let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf"))); + + let (pdf, report) = cph_check::build(path, engine, target); + print_diagnostics(&report); + + match pdf { + Some(bytes) => { + if let Some(parent) = out_path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + eprintln!( + "error: cannot create output directory '{}': {e}", + parent.display() + ); + return ExitCode::FAILURE; + } + } + if let Err(e) = std::fs::write(&out_path, &bytes) { + eprintln!("error: cannot write '{}': {e}", out_path.display()); + return ExitCode::FAILURE; + } + println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); + ExitCode::SUCCESS + } + None => { + eprintln!("build failed: {} errors", report.error_count()); + ExitCode::FAILURE + } + } } From 9309a175c07eca423e1f8f44da34b43d430c484c Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 01:58:11 +0800 Subject: [PATCH 12/29] docs(readme): reflect the checker workspace, render package, and examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo-layout block said implementation parts were "尚未创建"; they now exist. Document the repo-root cargo workspace (so future parts reuse base crates), the crates/ map (cph-diag/model/schema/typst reusable; cph-check/cli the checker), the render/ typst package, and examples/. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f43a2a6..62285d9 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,18 @@ README.md ← 本文件:总览 + 宪法(下面 5 条) CLAUDE.md ← 全局 agent 操作手册(管整个 repo) docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用) spec/ ← Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md -(hub/ checker/ …) ← 将来的各部件实现,平级于 spec/。尚未创建 +Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate) +crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md + cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎) + cph-check / cph-cli ← checker 本体 + `cph` 命令行 +render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005) +examples/ ← 样例工程文件(如 TH-141),流水线的真实输入 +(hub/ exporter/ …) ← 将来的其他部件,平级于 spec/。尚未创建 ``` `spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。 +实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被 +未来部件(如 exporter)复用,而非各自重造。 ## 宪法 From 81cf762a0ca0b6f62f6440116de222b0c2ceb6c1 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 08:33:11 +0800 Subject: [PATCH 13/29] docs(adr): add ADR-0009 (export target = build) + ADR-0010 (diagnostics/legality/pipeline) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift business decisions made during the MVP build into the contract, per constitution rule 5 (divergence points belong in the spec). ADR-0009 — amends ADR-0005's render-matrix framing: an export target is a *build* producing a typed Artifact (SingleFile | FileTree). build = map (per-kind, the field-visibility lives here) + reduce (assemble, determined by artifact type: SingleFile concatenates in lesson order then compiles — which is why @ref and counters work). Build form = declarative schema + a run-shell hatch (where ADR-0005's medium-only category (b) and npm/HTML builds live), not a Makefile. typst compile is hidden backend mechanism, not part of a target. Defaults seeded at project creation, overridable in the file — the heading-numbering bug (一、 reused → 二、一、) is the worked example of that missing override layer. ADR-0010 — backfills ADR-0005's deferred "legal lesson": the 7-class diagnostic taxonomy with per-class meaning + severity; legal lesson = no error-level diagnostic; the 5-phase pipeline (load→structural→schema→compile→coverage) with compile gated on zero prior errors. External-facility diagnostics (TypstCompile, DanglingReference, schema conformance) modeled as abstract predicates whose verdict comes from an implementation oracle, not from Lean — keeps the contract honest about what it does and doesn't decide. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/adr/0009-export-target-is-a-build.md | 149 ++++++++++++++++++ ...nostics-legal-lesson-and-check-pipeline.md | 131 +++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 docs/adr/0009-export-target-is-a-build.md create mode 100644 docs/adr/0010-diagnostics-legal-lesson-and-check-pipeline.md diff --git a/docs/adr/0009-export-target-is-a-build.md b/docs/adr/0009-export-target-is-a-build.md new file mode 100644 index 0000000..f0c718f --- /dev/null +++ b/docs/adr/0009-export-target-is-a-build.md @@ -0,0 +1,149 @@ +# ADR 0009: An Export Target Is A Build Producing A Typed Artifact + +## Status + +Accepted — for the decisions below. **Amends ADR-0005**: where ADR-0005 framed +rendering as a passive `(kind × target)` configuration matrix looked up per +element, this ADR reframes an export target as an active **build** that consumes +the lesson and produces a typed **artifact**. The seed rule ADR-0005 fixed +(missing render rule for a used kind ⇒ warning) survives, restated against the +new structure. Several sub-decisions are deferred; see *Open Questions*. + +## Context + +ADR-0005 said an export target "projects/renders the lesson", with a render +matrix that "lives in the engineering file; the framework provides defaults, the +file may override." The MVP implementation did not realize that override path: +it hardcoded one target's behavior as a `target == "student"` branch inside the +render package and baked presentation defaults (heading numbering) into the +package with no way for an engineering file to change them. That produced a +concrete bug — a single-symbol numbering pattern (`一、`) reused across heading +levels, so level-2 headings rendered as `二、一、…` — which is not a typo but the +**symptom of a missing layer**: targets were behavior welded into code, not +configuration a lesson can carry and override. + +Looking at the prototype confirms the real shape of the problem. It had two +near-identical export entrypoints (`exports/学生版.typ`, `exports/教师版.typ`) +plus a `display-control.typ` switching on `sys.inputs("x-export-target")` with a +scatter of per-concern booleans (`show-answer`, `show-discussion-script`, +`show-eg-comment`, …). The two scripts are ~95% identical; the duplication — +load, assemble, compile, page setup — is the same across every target, and only +a thin policy delta differs. **That repetition is the thing to factor out.** + +A target is therefore best understood as **a build script with the repeated +mechanism abstracted away** — closer in spirit to a GitHub Actions workflow +(declarative steps, plus a `run`-shell escape hatch) than to a Makefile. + +## Decision + +### A target is a build that produces a typed artifact + +An export target is **a build**: it consumes the (ordered, typed) lesson and +produces one **artifact**. The artifact's *shape* is part of what the target +*is*, modeled as an ADT value the target carries: + +- `SingleFile` — one bundled document (a 讲义 PDF, a teacher plan PDF). +- `FileTree` — many files plus an index/manifest (a third-party platform + archive). + +"Single file vs. many files" is a genuine divergence point — it changes what +`cph build` emits and what a consumer expects — so the artifact type is part of +the contract, not an implementation afterthought. (Backend/format beyond this — +which concrete PDF engine, which markdown dialect — stays open.) + +### A build is map + reduce; the reduce is determined by the artifact + +A build has two phases, kept distinct: + +- **map (per-kind).** For each element kind, how that kind becomes an + intermediate fragment. This is declarative and keyed by kind — the part a + GitHub-Actions-style schema expresses naturally. **Field visibility lives + here**: "student hides `example.solution` and `lemma.proof`; teacher shows + them" is simply *what this target's per-kind map does* — not a separate + concept and not a property of the element. +- **reduce / assemble.** The ordered fragments are folded into the artifact. + This fold is **determined by the artifact type**, not written per target: + - `SingleFile` ⇒ concatenate fragments in lesson order into one document, then + compile it. (This is *why* cross-references `@label`/`@ref` and the + 例题1/例题2 counters work at all: they resolve only when the whole lesson is + one compiled document. The fold's statefulness is carried by the backend's + own counters at assemble-compile time, not re-implemented in the map.) + - `FileTree` ⇒ each part to its own file, plus a generated index. + +The framework provides the assembler for each artifact shape; a build only +*chooses* its artifact type. This is the answer to "a per-kind rule can't express +aggregation": aggregation isn't crammed into the per-kind schema — the artifact +value picks a framework assembler, and the **shell escape hatch** is reserved for +the genuinely irregular case (a third-party archive that runs an npm build). + +### The build form: declarative schema plus a `run`-shell hatch + +A build is expressed declaratively (a schema — concretely a YAML-ish document, +consistent with the `manifest.toml`/`element.toml` lineage) with one **`run` +escape hatch** for steps that resist declaration. Explicitly **not** a Makefile +or a general scripting language: the declarative surface carries the common +cases (per-kind map, artifact choice, presentation knobs), and the shell hatch is +the bounded exception. This mirrors ADR-0006's stance on schemas: fix the *form* +here, keep the concrete payload an implementation detail. + +### typst compilation is a hidden backend mechanism, not part of a target + +Compiling typst to PDF is the *shared, repeated* mechanism every PDF-producing +target needs. It is therefore **backend mechanism, hidden below the target**, not +something a target re-specifies. A target says "I produce a `SingleFile` PDF"; +*how* the assembled document becomes bytes is the framework's business. (This is +also where the checker's typst-compile diagnostic belongs — an external facility, +see ADR-0010.) + +### Medium-only information rides the build's shell hatch + +ADR-0005's category **(b)** — medium-only information that exists only because of +a target's medium (HTML interactive behavior, a build script, npm dependencies) +— finally has a home: it belongs to the **target's build** (its shell hatch / +artifact assembler), never to the element. This complements ADR-0005's +category-(a) decision (semantic-but-target-specific fields ride the element); (b) +rides the build. + +### Defaults are seeded at project creation and overridable in the file + +The framework ships **default builds** for its stock targets (e.g. student, +teacher), written into the engineering file when a project is created. The file +may then **override** pieces of them. The numbering bug is the worked example: +the fix is not patching the render package but **overriding the student/teacher +build's numbering** in the engineering file (e.g. +`numbly("{1:一}、", "{1:1}.{2:1}")` — level-1 `一、`, level-2 `1.1`). This realizes +ADR-0005's "matrix lives in the file, framework gives defaults, file overrides" +as an actual editable layer rather than a slogan. + +## Consequences + +- The two duplicated export scripts collapse into one shared build mechanism plus + two small target *configurations* (artifact type + per-kind map + presentation + overrides). Adding a target is adding a config value, not copying a script. +- `cph build --target T` is defined by T's artifact type: it writes one file for + `SingleFile`, a directory for `FileTree`. +- Presentation defaults (numbering, labels, styling) are file-resident and + overridable; a wrong default is a one-line edit in the engineering file, not a + package change. +- The contract gains structure (an `Artifact` ADT; a target = build with a + per-kind map + an artifact-determined assemble) without committing the concrete + build-schema fields — those stay implementation, per the depth ceiling + (constitution rule 5). +- ADR-0005's render-matrix language is superseded by this build framing; its + warning rule (missing render for a used kind ⇒ warning, non-blocking) is + preserved and restated in ADR-0010 against the new `covers`. + +## Open Questions / Deferred + +- **Concrete build-schema fields** — the exact declarative keys (per-kind map + syntax, where the shell `run` step sits, how an override is written) are an + implementation-level schema, fixed later, not in Lean. +- **Backend / format enumeration** — beyond `SingleFile`/`FileTree`: which PDF + engine, and the markdown-dialect / React-template export ADR-0005 mentioned for + third-party platforms. Open. +- **`FileTree` and the shell hatch implementation** — specified here but not + implemented in this round (no third-party target instance yet); the MVP + implements `SingleFile`/PDF only and marks the rest deferred. +- **Override granularity** — exactly which pieces of a build a file may override + (numbering only? arbitrary presentation? the per-kind map itself?) is fixed as + needed; this ADR commits only that an override layer exists and is file-resident. diff --git a/docs/adr/0010-diagnostics-legal-lesson-and-check-pipeline.md b/docs/adr/0010-diagnostics-legal-lesson-and-check-pipeline.md new file mode 100644 index 0000000..7b601e4 --- /dev/null +++ b/docs/adr/0010-diagnostics-legal-lesson-and-check-pipeline.md @@ -0,0 +1,131 @@ +# ADR 0010: Checker Diagnostics, Legal Lesson, And The Check Pipeline + +## Status + +Accepted — for the decisions below. **Backfills the "full lesson legality"** item +ADR-0005 explicitly deferred ("the complete definition of a legal lesson; this +ADR fixes only the one seed rule"). Builds on ADR-0005's seed (missing render ⇒ +warning) and ADR-0009 (target = build). Some items deferred; see *Open Questions*. + +## Context + +The checker is the product's moat — the thing that "stands in Lean's position" at +runtime, validating an engineering file and emitting helpful fix hints. The MVP +implementation grew a **diagnostic taxonomy** (seven codes), a notion of when a +lesson is **legal** (publishable / buildable), and an ordered **check pipeline** — +and then treated all three as "implementation detail." They are not. What each +error class *means*, whether it blocks, what "legal" *is*, and in what order +checks run with what gating are exactly the non-obvious domain decisions the +constitution (rule 5) says must be pinned: the developer and the agent would +otherwise each assume a different answer. This ADR lifts them into the contract. + +A deliberate boundary runs through the taxonomy. Some checks the model can state +structurally (a manifest part points at a folder that exists; a declared kind is +known). Others are **external facilities** the semantic model cannot itself +decide — does this typst source compile? do its cross-references resolve? does +this data conform to its JSON Schema? Those are real, contractually-named +diagnostics, but their *verdict* comes from an implementation oracle (the typst +compiler, the schema validator), not from Lean. The contract names them and fixes +their severity; it does not pretend to decide them. + +## Decision + +### The diagnostic taxonomy (seven classes) + +Each diagnostic carries a **code**, a **severity** (`error` | `warning`, per +ADR-0005 / `Spec.Courseware.Severity` — two-valued, no info/note), a message, an +optional source span, and a fix hint. The MVP classes: + +| Code | Meaning | Severity | Kind | +|------|---------|----------|------| +| `PartPathMissing` | A manifest `[[parts]]` entry points at a folder that does not exist (or escapes the root via `..`). | error | structural | +| `UnknownKind` | A part declares a kind not in the known kind set (and: a part/`element.toml` kind mismatch). | error | structural | +| `MissingContentFile` | A `content` field required by the kind's schema has no corresponding `.typ` on disk. | error | structural/schema | +| `SchemaViolation` | Instance data does not conform to its kind's JSON Schema (bad scalar type, missing required, stray key); also the fallback for a malformed `manifest.toml`/`element.toml`. | error | schema / external | +| `DanglingReference` | A reference does not resolve — a relative import outside the import boundary, or an unresolved cross-reference. | error | external | +| `TypstCompile` | The assembled typst source fails to compile (syntax error, unresolved `@ref`, etc.). | error | external | +| `RenderIgnored` | A used kind has no render rule for a declared target; the element is omitted from that export. | **warning** | semantic | + +Six are `error` (blocking); **`RenderIgnored` alone is a `warning`** — the +ADR-0005 seed rule, restated. The split between *structural* (model decides), +*schema/external* (oracle decides), and *semantic* (the coverage rule) is the +boundary described above. + +`RenderIgnored`'s severity is pinned to `warning` as a named contract fact (Lean: +`renderIgnoredSeverity`), so "it does not block export" is greppable and +alignable, not an inline literal. + +### A legal lesson has no error-level diagnostic + +A lesson is **legal** (valid, publishable) iff the check pipeline produces **no +`error`-severity diagnostic**. Warnings do not affect legality — a lesson with +`RenderIgnored` warnings is still legal and still exports (lossily). This is the +complete legality predicate ADR-0005 deferred: legality is defined *by the +diagnostic set*, not by a separate hand-written rulebook. New rules enter +legality by adding diagnostics, keeping one source of truth. + +### External-facility diagnostics are oracle verdicts, not model judgments + +`TypstCompile`, `DanglingReference`, and the schema-conformance face of +`SchemaViolation` assert facts the semantic model **cannot decide on its own**: +they depend on running the typst compiler and the schema validator. The contract +therefore models them as **abstract predicates** (e.g. "this source compiles", +"these references resolve", "this data conforms") **parameterized over an +implementation-provided oracle**. The Lean spec states *that* such a diagnostic +exists, what it means, and its severity — and explicitly marks that its truth +value is supplied by the implementation, not computed in Lean. This keeps the +contract honest (it does not embed a typst compiler) while still naming the +diagnostic as part of the legality definition. + +### The check pipeline: ordered phases with compile gating + +`check` runs five phases in a fixed order, collecting all diagnostics: + +1. **load** — parse `manifest.toml` + each `element.toml`. A hard failure (no + parseable lesson) stops the pipeline; nothing downstream can run. +2. **structural** — every part path exists, no `..`, kind is known, part/element + kinds agree. Parts already flagged missing here are skipped downstream (no + piling diagnostics on one root cause). +3. **schema** — each present, known-kind part's data validated against its kind + schema (scalars + required content files). +4. **typst compile** — the external-facility phase. **Gated: it runs only if + phases 1–3 produced zero errors**, because compiling known-broken input yields + noise on top of the real cause. When it runs, it compiles each declared target + (ADR-0009), deduping identical diagnostics across targets. +5. **render-coverage** — the semantic warning rule; runs regardless of gating. + +`build --target T` runs phases 1–3, refuses (no artifact) if any error, else +compiles + exports T's artifact (ADR-0009). It does **not** run coverage — +coverage describes export loss, informational for `check`, not a gate on one +target's build. + +The **order and the gating are contract**, because they determine which +diagnostics a user sees (a syntax error behind a missing file is hidden until the +file exists — deliberate). The per-phase *algorithms* are not pinned in Lean +(depth ceiling, rule 5): the spec fixes the phases, their order, and the gate, not +how each check is computed. + +## Consequences + +- "Is this lesson done / publishable?" has one precise answer: no error-level + diagnostic. The CLI's exit code (1 on any error) is this predicate. +- Adding a checker rule = adding a diagnostic with a severity; legality updates + automatically, no separate legality rulebook to keep in sync. +- The contract can name compile/reference/schema diagnostics as part of legality + without embedding the oracles that decide them — the spec stays a semantic + master, not a re-implementation. +- The implementation's `DiagCode` enum, `CheckReport`, and phase sequence now have + a contract to align to (by review, per the constitution — no CI gate); the + alignment is documented at both ends. + +## Open Questions / Deferred + +- **Additional codes.** A dedicated `ManifestMalformed` (today folded into + `SchemaViolation`) and finer schema/reference codes may be added; the taxonomy + is open to growth, each addition a deliberate decision. +- **Cross-lesson diagnostics.** Anything about a *course* (arrangement of lessons) + or the question bank is out of scope — those models are still OPEN (ADR-0005). +- **Severity of future classes.** Each new diagnostic's blocking/non-blocking + status is decided when introduced; only the seven above are fixed here. +- **Warning sub-structure.** Whether warnings ever need ordering/grouping or a + third "info" level remains deferred (ADR-0005 kept `Severity` two-valued). From 09b026f5989b9db4bc64b71f6988871a18f4475d Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 08:36:29 +0800 Subject: [PATCH 14/29] feat(spec): backfill Export terminology + diagnostics/legality/pipeline (ADR-0009/0010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the business decisions the MVP made into the Lean master, per constitution rule 5. lake build green (20 jobs), no sorry. - Artifact.lean: `inductive Artifact | singleFile | fileTree` — product shape is part of a target's value (ADR-0009); it determines the assemble/reduce step. - Render.lean reworked target-centric (ADR-0009, amends ADR-0005's matrix): `TargetSpec` carries `artifact` + a per-kind `renders` map (field visibility lives in this map); `RenderConfig.spec : TargetId → Option TargetSpec`; `covers` rewritten (target must exist AND its map covers the kind) — semantics continuous with ADR-0005 so renderIgnored still typechecks. `BuildStepForm` (declarative | shell) is a structural anchor for the build form (shell hatch = ADR-0005's medium-only category b) without replicating the schema. - Diagnostic.lean extended: `DiagKind` (7 classes) + `DiagKind.severity` (six error, renderIgnored warning); `Oracle` makes external-facility verdicts (compiles/refsResolve/dataConforms/contentFilesPresent) an explicit implementation boundary; `Legal` = no error-level diagnostic (backfills ADR-0005's deferred full-legality), with renderIgnored deliberately excluded (it's a warning). - Pipeline.lean: the 5 phases (load→structural→schema→compile→coverage) as structure — order + the compile gate (compile runs only on zero prior errors) + load halts the pipeline. Algorithms stay out of Lean (depth ceiling). Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/Spec/Courseware.lean | 13 ++- spec/Spec/Courseware/Artifact.lean | 30 ++++++ spec/Spec/Courseware/Diagnostic.lean | 133 +++++++++++++++++++++++---- spec/Spec/Courseware/Pipeline.lean | 72 +++++++++++++++ spec/Spec/Courseware/Render.lean | 80 +++++++++++++--- 5 files changed, 293 insertions(+), 35 deletions(-) create mode 100644 spec/Spec/Courseware/Artifact.lean create mode 100644 spec/Spec/Courseware/Pipeline.lean diff --git a/spec/Spec/Courseware.lean b/spec/Spec/Courseware.lean index a3369f7..ab5c799 100644 --- a/spec/Spec/Courseware.lean +++ b/spec/Spec/Courseware.lean @@ -1,24 +1,31 @@ import Spec.Courseware.Primitives import Spec.Courseware.RichContent +import Spec.Courseware.Artifact import Spec.Courseware.Element import Spec.Courseware.Lesson import Spec.Courseware.Render import Spec.Courseware.Diagnostic +import Spec.Courseware.Pipeline import Spec.Courseware.QuestionBank import Spec.Courseware.Course /-! # Courseware —— 产品层契约(课程工程文件) -护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005 / 0006 / 0007。 +护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005 / 0006 / 0007 / 0008 +/ 0009 / 0010。 已填实(PINNED): - `Primitives` —— 基元载体(表示留给实现;schema 形态见 ADR-0006)。 - `RichContent` —— 富内容 = 带虚拟路径的 typst 源(ADR-0006 的 prose 母本)。 +- `Artifact` —— export 产物形状(单文件 / 文件树),target 值的一部分(ADR-0009)。 - `Element` —— kind 标签 + 依赖 schema 的数据。 - `Lesson` —— element 的有序序列(序载教学语义、不建模时长)。 -- `Render` —— (kind × target) 渲染配置矩阵 + covers。 -- `Diagnostic` —— Severity + checker 第一条诊断(缺渲染 ⇒ warning)。 +- `Render` —— export target = 一次 build(map + reduce):target-中心的 `TargetSpec` + 携产物形状 + per-kind map;`covers` 判定;build 形态结构锚点(ADR-0009,修订 ADR-0005)。 +- `Diagnostic` —— Severity + 7 类诊断的含义与级别 + **合法 lesson = 无 error 级诊断**; + 模型外设施诊断以抽象谓词 + `Oracle` 实现边界表示(ADR-0010)。 +- `Pipeline` —— 检查管线的 5 阶段、执行序、compile 门控(ADR-0010)。 留白骨架(核心关系 OPEN,已 surface 不臆造): - `QuestionBank` —— 题库与 element 的关系(引用 vs 内联)。 diff --git a/spec/Spec/Courseware/Artifact.lean b/spec/Spec/Courseware/Artifact.lean new file mode 100644 index 0000000..2f7ca52 --- /dev/null +++ b/spec/Spec/Courseware/Artifact.lean @@ -0,0 +1,30 @@ +/-! +# Artifact —— export target 的产物形状 + +ADR-0009:一个 export target 是**一次 build**,产出一个**有类型的产物**(artifact)。 +产物的**形状**是 target 值的一部分——"产出单个文件还是一棵文件树"是真分歧点,因为它 +改变 `cph build` 到底吐出什么、消费方期待什么,故进契约。 + +本模块只钉**形状**这一层。具体后端/格式(用哪个 PDF 引擎、哪种 markdown 方言)是 +ADR-0009 显式 OPEN 的,不在此承诺。 +-/ + +namespace Spec.Courseware + +/-- +export 产物的形状(`PINNED`, ADR-0009)。 + +- `singleFile` —— 一份打包文档(讲义 PDF、教案 PDF)。 +- `fileTree` —— 一棵文件树 + 索引(第三方平台 archive)。 + +**为什么这是分歧点而非实现细节**:产物是单件还是多件,决定 reduce/assemble 这一步 +怎么折叠(见 `Render`:`singleFile` 按 lesson 序拼成一份文档再编译——这也是交叉引用 +`@ref` 与 例题N 计数器能工作的前提;`fileTree` 每 part 一文件 + 索引),也决定 +`cph build` 写一个文件还是一个目录。后端/格式(typst→PDF、markdown 方言…)仍 OPEN, +故此处**只枚举形状**,不带后端标签。 +-/ +inductive Artifact where + | singleFile + | fileTree + +end Spec.Courseware diff --git a/spec/Spec/Courseware/Diagnostic.lean b/spec/Spec/Courseware/Diagnostic.lean index 2f75329..f57bdc6 100644 --- a/spec/Spec/Courseware/Diagnostic.lean +++ b/spec/Spec/Courseware/Diagnostic.lean @@ -2,44 +2,143 @@ import Spec.Courseware.Lesson import Spec.Courseware.Render /-! -# Diagnostic —— checker 诊断(种子) +# Diagnostic —— checker 诊断:分类、严重级别、合法 lesson -产品里"站在 Lean 位置"的那个 rule-based checker,语义在此沉淀。它对 lesson 提诊断, -每条诊断有**严重级别**。本模块固定级别类型,并落第一条规则——ADR-0005 钉死的"缺 -渲染 ⇒ warning"。完整的"合法 lesson"判定仍 OPEN,本模块只是它的起点。 +产品里"站在 Lean 位置"的那个 rule-based checker,语义在此沉淀(ADR-0010)。它对 lesson +提诊断,每条诊断有**分类**(`DiagKind`)与**严重级别**(`Severity`)。本模块: + +1. 钉级别类型(二分,ADR-0005)。 +2. 钉 7 类诊断各自的含义与级别(ADR-0010),并把"**合法 lesson = 无 error 级诊断**" + 建成判定——这是 ADR-0005 deferred 的"完整合法判定"的回填。 +3. 对**模型外设施**型诊断(typst 能否编过、引用悬否、数据合 schema 否),用**抽象谓词 + + 显式实现边界**表示:契约说"存在这样一条诊断、它什么意思、什么级别",但其**真值由 + 实现(checker)提供**,不在 Lean 内计算——保持契约诚实(不内嵌 typst 编译器)。 -/ namespace Spec.Courseware /-- -诊断的严重级别(`PINNED` 二分, ADR-0005 区分 warning 与阻断)。 +诊断的严重级别(`PINNED` 二分, ADR-0005)。 -`error` 阻断(产物不合法),`warning` 不阻断(产物仍可导出,只是有损/有忽略)。 -ADR-0005 已用到这个区分:缺渲染是 warning 而非 error。更细的级别(info/hint)未 -决策,故只二分。 +`error` 阻断(产物不合法),`warning` 不阻断(产物仍可导出,只是有损/有忽略)。更细的 +级别(info/hint)未决策,故只二分。 -/ inductive Severity where | warning | error +/-- +诊断的**分类**(`PINNED` 7 类, ADR-0010)。每类含义见下;级别见 `DiagKind.severity`。 + +按"谁来判定"分三层(ADR-0010 的边界): +- **结构型**(模型自身可判):`partPathMissing` / `unknownKind`。 +- **schema / 外部设施型**(判定靠实现 oracle):`missingContentFile` / `schemaViolation` + / `danglingReference` / `typstCompile`。 +- **语义型**(coverage 规则):`renderIgnored`。 +-/ +inductive DiagKind where + /-- manifest 的 part 指向不存在的文件夹(或经 `..` 逃出根)。结构型。 -/ + | partPathMissing + /-- part 声明了未知 kind(含 part 与 element.toml 的 kind 不一致)。结构型。 -/ + | unknownKind + /-- kind schema 要求的某 `content` 字段缺对应 `.typ`。结构/schema 型。 -/ + | missingContentFile + /-- 实例数据不合其 kind 的 JSON Schema(标量类型错/缺必填/多余键);亦作 manifest/ + element.toml 畸形的兜底。schema / 外部设施型。 -/ + | schemaViolation + /-- 引用不解析:越界的相对 import,或悬空的交叉引用。外部设施型。 -/ + | danglingReference + /-- 拼装出的 typst 源编译失败(语法错、`@ref` 未解析…)。外部设施型。 -/ + | typstCompile + /-- 某被用到的 kind 在某声明的 target 下无渲染规则,该 element 被该 target 忽略。语义型。 -/ + | renderIgnored + +/-- +每类诊断的**严重级别**(`PINNED`, ADR-0010)。 + +六类为 `error`(阻断);**唯 `renderIgnored` 为 `warning`**——即 ADR-0005 的种子规则 +"缺渲染 ⇒ warning,不阻断导出"。把级别钉成一个全函数,使"哪类阻断、哪类不阻断"成为 +契约里可引用、可对齐的事实(实现侧 `DiagCode` 的级别据此对齐)。 +-/ +def DiagKind.severity : DiagKind → Severity + | .partPathMissing => .error + | .unknownKind => .error + | .missingContentFile => .error + | .schemaViolation => .error + | .danglingReference => .error + | .typstCompile => .error + | .renderIgnored => .warning + +/-- 缺渲染诊断的级别 = **warning**(`PINNED`, ADR-0005/0010,**非 error**)。 + +保留为具名常量,使"它是 warning 不是 error"可被实现侧 grep 对齐(实现里同名常量 +`RENDER_IGNORED_SEVERITY` 引用本定义)。等价于 `DiagKind.severity .renderIgnored`。 -/ +def renderIgnoredSeverity : Severity := DiagKind.renderIgnored.severity + variable (P : Primitives) /-- -**缺渲染诊断**:lesson 在 target `t` 下存在无法渲染的 element(`PINNED`, ADR-0005)。 +**缺渲染诊断**:lesson 在 target `t` 下存在无法渲染的 element(`PINNED`, ADR-0005/0009)。 -成立 ⟺ 存在某 element,其 kind 在 `t` 下未配渲染规则。这条对应 checker 的第一条 -诊断;其级别由下面的 `renderIgnoredSeverity` 钉死为 **warning**。 +成立 ⟺ 存在某 element,其 kind 在 `t` 下未配渲染规则(`covers` 为假,见 `Render`)。这条 +是 checker 的**语义型**诊断,级别由 `renderIgnoredSeverity` 钉为 warning。`covers` 已随 +ADR-0009 下沉到 target 的 build 规格,但本判定式不变。 -/ def renderIgnored (l : Lesson P) (c : RenderConfig P) (t : P.TargetId) : Prop := ∃ e ∈ l, ¬ c.covers e.kind t -/-- -缺渲染诊断的级别 = **warning**(`PINNED`, ADR-0005,**非 error**)。 +/-! +## 模型外设施型诊断:抽象谓词 + 实现边界(ADR-0010) -ADR-0005 明确:某 (kind, target) 无渲染规则时,checker 报 warning 说该 element 在 -该 target 被忽略,**不阻断导出**。把这条决策固定为一个常量,使"它是 warning 不是 -error"成为契约里可被引用、可被对齐的事实,而不是散落在 prose 里的口头约定。 +`typstCompile` / `danglingReference` / `schemaViolation`(的 schema-合规面)断言的是 +**模型自身无法判定**的事实——要跑 typst 编译器、要跑 schema 校验器。契约因此把它们建成 +**抽象谓词**,真值由一个**实现提供的 oracle** 给出。Lean 只说"这条诊断存在、什么意思、 +什么级别",**不**在 Lean 内计算其真假。 + +下面用一个 `Oracle` 结构收口这些实现侧判定。它**不是**要在 Lean 里实现 checker,而是 +把"这些事实来自模型外"这件事显式化、类型化:谁想谈论一节 lesson 合不合法,就得先有一个 +oracle 提供这些外部判定。 -/ -def renderIgnoredSeverity : Severity := .warning + +/-- +**实现侧判定 oracle**(`PINNED` 实现边界, ADR-0010)。 + +收口那些"模型外设施"才能回答的判定。每个字段是一个谓词,**真值由实现(checker)提供**: + +- `compiles` —— 该 lesson 在 target `t` 下拼装出的 typst 源能否编译通过(否 ⇒ `typstCompile`)。 +- `refsResolve` —— 该 lesson 的引用(相对 import / 交叉引用)是否全部解析(否 ⇒ `danglingReference`)。 +- `dataConforms` —— 每个 element 的数据是否合其 kind schema(否 ⇒ `schemaViolation`)。 +- `contentFilesPresent` —— schema 要求的 `content` 文件是否齐备(否 ⇒ `missingContentFile`)。 + +把它们作为参数,正是"显式实现边界":契约能据此**定义**合法性,却不假装自己能算出这些 +外部事实。结构型诊断(part 路径、未知 kind)不入此 oracle——那些模型自身可判。 +-/ +structure Oracle (l : Lesson P) (c : RenderConfig P) where + /-- target `t` 下拼装源可编译。 -/ + compiles : P.TargetId → Prop + /-- 引用全部解析。 -/ + refsResolve : Prop + /-- 每个 element 数据合 schema。 -/ + dataConforms : Prop + /-- schema 要求的 content 文件齐备。 -/ + contentFilesPresent : Prop + +/-- +**合法 lesson**(`PINNED`, ADR-0010;回填 ADR-0005 deferred 的"完整合法判定")。 + +一节 lesson 合法 ⟺ 检查管线产出**零条 error 级诊断**。warning(如 `renderIgnored`) +不影响合法性——带 warning 的 lesson 仍合法、仍可(有损)导出。 + +这里把"无 error 级诊断"展开为:模型外设施判定(经 `Oracle`)全部为真,**且**每个声明的 +target 都编译通过。结构型诊断(part 路径、未知 kind、kind 不一致)由 `cph-model` 在加载 +期判定;能走到这一步谈合法性,意味着已加载成功,故此处聚焦 schema/外部设施层。注意 +`renderIgnored` 是 warning,**不**进合法性合取——这正是 ADR-0005 种子规则的体现。 + +`targets` 是该工程文件声明的 target 列表(`RenderConfig.spec t` 非 `none` 者);为保持 +本层抽象、不绑定 `TargetId` 的可枚举性,这里用"对所有满足前提的 t"的全称式表达。 +-/ +def Legal (l : Lesson P) (c : RenderConfig P) (o : Oracle P l c) : Prop := + o.refsResolve ∧ o.dataConforms ∧ o.contentFilesPresent ∧ + (∀ t : P.TargetId, (c.spec t).isSome → o.compiles t) end Spec.Courseware diff --git a/spec/Spec/Courseware/Pipeline.lean b/spec/Spec/Courseware/Pipeline.lean new file mode 100644 index 0000000..16bf937 --- /dev/null +++ b/spec/Spec/Courseware/Pipeline.lean @@ -0,0 +1,72 @@ +import Spec.Courseware.Diagnostic + +/-! +# Pipeline —— checker 检查管线的阶段与序(ADR-0010) + +checker 的 `check` 按**固定顺序**跑五个阶段,逐阶段收集诊断;`compile` 阶段有**门控**。 +顺序与门控是契约——它决定用户看到哪些诊断(藏在缺文件背后的语法错,在文件补齐前不显示, +这是**有意**的)。每阶段的**算法**不进 Lean(宪法第 5 条深度上限):本模块只钉**阶段、序、 +门控**,不形式化每步怎么算。 + +阶段与上游模块的对应:`load` ← `cph-model`;`structural` ← part 路径/未知 kind;`schema` +← `cph-schema`;`compile` ← `cph-typst`(模型外设施,见 `Diagnostic.Oracle`);`coverage` +← `Diagnostic.renderIgnored`。 +-/ + +namespace Spec.Courseware + +/-- +检查管线的**阶段**(`PINNED` 5 阶段, ADR-0010)。 + +- `load` —— 解析 manifest + 各 element.toml。硬失败(无可解析 lesson)则**停**整条管线。 +- `structural` —— part 路径存在、无 `..`、kind 已知、part 与 element kind 一致。此处已被 + 判缺的 part,在后续阶段**跳过**(不在同一根因上堆诊断)。 +- `schema` —— 每个"存在且 kind 已知"的 part 的数据按其 kind schema 校验(标量 + content 文件)。 +- `compile` —— 模型外设施阶段(typst 编译)。**门控:仅当 `load`/`structural`/`schema` 零 + error 才跑**(否则在已知坏输入上编译只是噪声)。跑时对每个声明的 target 编译(ADR-0009)。 +- `coverage` —— 语义型 warning 规则(`renderIgnored`);**不**受门控,总跑。 +-/ +inductive Phase where + | load + | structural + | schema + | compile + | coverage +deriving DecidableEq + +/-- +管线阶段的**执行序**(`PINNED`, ADR-0010)。`order p` 越小越先跑。 + +序是契约:`compile`(3)排在 `structural`(1)/`schema`(2)之后,正因前者门控于后者无 error。 +`coverage`(4)排最后但不门控(见 `gated`)。 +-/ +def Phase.order : Phase → Nat + | .load => 0 + | .structural => 1 + | .schema => 2 + | .compile => 3 + | .coverage => 4 + +/-- +某阶段是否**受"前序零 error"门控**(`PINNED`, ADR-0010)。 + +唯 `compile` 受门控:前序(load/structural/schema)出任何 error 则跳过它——藏在结构/schema +错背后的编译错,在前者修好前不显示,这是有意的降噪。其余阶段不门控:`load` 是入口, +`structural`/`schema` 是门控的**依据**(自身得先跑),`coverage` 产 warning 故无条件跑。 +-/ +def Phase.gated : Phase → Bool + | .compile => true + | _ => false + +/-- +管线在 `load` 硬失败时**停**(`PINNED`, ADR-0010)。 + +`load` 拿不到可解析的 lesson(`cph-model` 返回 `none`)时,没有 lesson 可喂给下游, +整条管线就此终止——这是唯一会"截断"后续所有阶段的情形(区别于 `compile` 的门控只跳过 +自己)。 +-/ +def Phase.haltsPipelineOnFailure : Phase → Bool + | .load => true + | _ => false + +end Spec.Courseware diff --git a/spec/Spec/Courseware/Render.lean b/spec/Spec/Courseware/Render.lean index 76a5895..5f50941 100644 --- a/spec/Spec/Courseware/Render.lean +++ b/spec/Spec/Courseware/Render.lean @@ -1,11 +1,23 @@ import Spec.Courseware.Primitives +import Spec.Courseware.Artifact /-! -# Render —— 渲染配置矩阵 +# Render —— export target = 一次 build(ADR-0009,修订 ADR-0005) -ADR-0005:某 kind 在某 target 下如何呈现,由一份 **(kind × target) 配置矩阵**决定; -矩阵住在工程文件里,框架给 default、文件可 override。本模块只建矩阵结构与"是否已 -配置"的判定;规则的**内容**(`RenderRule`)是 OPEN,不碰。 +ADR-0005 曾把渲染建成被动的 (kind × target) 查表矩阵。ADR-0009 **修订**之:一个 export +target 是**一次 build**,消费整节 lesson、产出一个有类型的 `Artifact`。一次 build = +**map + reduce**: + +- **map(per-kind)**:每个 kind 如何变成中间片段——声明式、按 kind 索引;`RenderRule` + 是这张 map 的载荷。**字段可见性**(student 隐 `solution`/`proof`,teacher 全显)就住在 + 这张 per-kind map 里,不是独立概念、也不在 element 上。 +- **reduce/assemble**:有序片段如何折叠成产物——**由 `Artifact` 形状决定**(见 + `Courseware.Artifact`),框架按形状提供 assembler,build 只挑形状。 + +本模块建 target-中心的结构与"某 kind 在某 target 下是否已配规则"的判定(`covers`, +`Diagnostic` 依赖它)。`RenderRule` 的载荷仍由 `Primitives` 抽象承载(ADR-0009 显式 +OPEN);build 形态(声明式 + shell 口子)以一个**结构锚点** `BuildStepForm` 体现——钉 +"该有的结构"而**不**复刻具体 schema(宪法第 5 条)。 -/ namespace Spec.Courseware @@ -13,24 +25,62 @@ namespace Spec.Courseware variable (P : Primitives) /-- -渲染配置矩阵(`PINNED`, ADR-0005)。 +build 步骤的**形态**(结构锚点, ADR-0009;具体 schema 字段 `OPEN`)。 -`rule k t = none` 表示 (kind `k`, target `t`) 这一对**尚无渲染规则**;`some _` 表示 -有。框架默认与文件 override 最终都坍缩成这一个映射——契约不区分"默认值"与"用户改 -过的值",只看最终矩阵在每一格的有无。 +ADR-0009 定 build 的表达形态 = **声明式 schema + 一个 `run`-shell 逃生口**(近似 +GitHub Actions、**非** Makefile)。这里只把这个二分立成一个具名结构,让"build 要么声明 +式、要么走 shell 口子"成为契约里可引用的事实: + +- `declarative` —— 声明式步骤(per-kind map 的常规情形)。 +- `shell` —— shell 逃生口。ADR-0005 的 (b) 类 medium-only 信息(HTML 交互、npm build) + 正是落在这里——它属于 target 的 build,**不**上 element。 + +**刻意不**把 schema 的字段、shell 命令的内部结构写进来:那是实现细节(ADR-0009 OPEN), +此锚点只承诺"build 步骤分声明式与 shell 两形态"这一骨架。 -/ -structure RenderConfig where - /-- (kind, target) ↦ 该对的渲染规则(无则 `none`)。 -/ - rule : P.KindId → P.TargetId → Option P.RenderRule +inductive BuildStepForm where + | declarative + | shell /-- -kind `k` 在 target `t` 下**已配渲染规则**(`PINNED`, ADR-0005)。 +一个 export target 的 build 规格(`PINNED` target-中心结构, ADR-0009)。 -即矩阵该格非空。`covers` 为假即"此 kind 在此 target 下无从渲染"——checker 据此报 -警(见 `Spec.Courseware.Diagnostic`)。`P` 隐式,以便 `c.covers k t` 点记法可用。 +- `artifact` —— 产物形状(`Courseware.Artifact`)。它**决定** reduce/assemble 怎么折叠 + (单文件 vs 文件树),故是 target 值的一部分,而非事后的输出选项。 +- `renders` —— per-kind map:`renders k = none` 表示 kind `k` 在此 target 下**无渲染 + 规则**(该 element 会被该 target 忽略,见 `Diagnostic.renderIgnored`);`some _` 表示 + 有。框架默认与文件 override 最终坍缩成这一个映射(ADR-0009 的 default-at-creation + + 文件内 override);契约只看最终每格的有无,不区分"默认"与"改过"。 +-/ +structure TargetSpec where + /-- 该 target 产物的形状(decides assemble, ADR-0009)。 -/ + artifact : Artifact + /-- per-kind map:kind ↦ 该 kind 在此 target 下的渲染规则(无则 `none`)。 -/ + renders : P.KindId → Option P.RenderRule + +/-- +渲染配置(`PINNED` target-中心, ADR-0009;修订 ADR-0005 的矩阵)。 + +`spec t = none` 表示 target `t` **未声明**(此工程文件不导出该 target);`some s` 给出该 +target 的 build 规格 `s`。注意这比旧矩阵多了一层:旧矩阵默认每个 (kind,target) 格都在, +现在先要 target **存在**,其 build 规格再各自决定每个 kind 渲不渲。 +-/ +structure RenderConfig where + /-- target ↦ 该 target 的 build 规格(未声明则 `none`)。 -/ + spec : P.TargetId → Option (TargetSpec P) + +/-- +kind `k` 在 target `t` 下**已配渲染规则**(`PINNED`, ADR-0009;承接 ADR-0005 `covers`)。 + +成立 ⟺ target `t` 已声明(`spec t = some s`)**且**其 per-kind map 对 `k` 非空 +(`s.renders k = some _`)。`covers` 为假即"此 kind 在此 target 下无从渲染"——checker +据此报 warning(见 `Spec.Courseware.Diagnostic.renderIgnored`)。语义与 ADR-0005 的 +`covers` 连续,只是判定下沉到 target 的 build 规格里。`P` 隐式以便点记法。 -/ def RenderConfig.covers {P : Primitives} (c : RenderConfig P) (k : P.KindId) (t : P.TargetId) : Prop := - (c.rule k t).isSome + match c.spec t with + | none => False + | some s => (s.renders k).isSome end Spec.Courseware From b5bf9d60d17665d593f0e9f74b075586740cac04 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 08:42:15 +0800 Subject: [PATCH 15/29] =?UTF-8?q?feat(checker):=20cph-model=20=E2=80=94=20?= =?UTF-8?q?structured=20export-target=20build=20config=20(WU-B,=20ADR-0009?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifest.toml [targets.*] upgraded from bare names to structured build configs. Lesson.targets: Vec → Vec { name, artifact, numbering }. ArtifactKind { SingleFile, FileTree } mirrors Spec.Courseware.Artifact. NumberingConfig.heading: Option> — None means "use framework default" (render package owns it), so absence is distinguishable from a value. Empty [targets.x] body = all defaults, no diagnostics. Malformed config (bad artifact value, non-array numbering.heading) → non-fatal SchemaViolation, target kept with defaults. Enabled toml `preserve_order` so target declaration order is real (the old Vec never actually preserved order). target_names() helper for call sites that only want names. 8 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + crates/cph-model/Cargo.toml | 4 +- crates/cph-model/src/lib.rs | 241 +++++++++++++++++- .../fixtures/bad-target-config/manifest.toml | 16 ++ .../fixtures/target-configs/manifest.toml | 19 ++ crates/cph-model/tests/load.rs | 102 +++++++- 6 files changed, 372 insertions(+), 11 deletions(-) create mode 100644 crates/cph-model/tests/fixtures/bad-target-config/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/target-configs/manifest.toml diff --git a/Cargo.lock b/Cargo.lock index 4475d92..d27621d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2367,6 +2367,7 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ + "indexmap", "serde", "serde_spanned", "toml_datetime", diff --git a/crates/cph-model/Cargo.toml b/crates/cph-model/Cargo.toml index 6953601..145ca5c 100644 --- a/crates/cph-model/Cargo.toml +++ b/crates/cph-model/Cargo.toml @@ -7,4 +7,6 @@ license.workspace = true [dependencies] cph-diag = { workspace = true } serde = { workspace = true } -toml = { workspace = true } +# `preserve_order` keeps `[targets.*]` tables in TOML document order, which is +# the declared order this crate exposes via `Lesson.targets` (ADR-0009). +toml = { workspace = true, features = ["preserve_order"] } diff --git a/crates/cph-model/src/lib.rs b/crates/cph-model/src/lib.rs index 13263ce..2b46004 100644 --- a/crates/cph-model/src/lib.rs +++ b/crates/cph-model/src/lib.rs @@ -34,13 +34,93 @@ pub struct Lesson { pub info: Info, /// The ordered parts — the lesson's element sequence. pub parts: Vec, - /// Declared export target names, e.g. `["student", "teacher"]`, collected - /// from the `[targets.]` tables. - pub targets: Vec, + /// Declared export targets, collected from the `[targets.]` tables. + /// + /// Per ADR-0009 an export target is a *build* producing a typed artifact, + /// with a file-resident, overridable presentation config. This carries the + /// file-resident slice of that (artifact choice + presentation overrides), + /// in declared (TOML document) order. See [`TargetConfig`]. + pub targets: Vec, /// Engineering-file root (absolute), for resolving part paths. pub root: PathBuf, } +impl Lesson { + /// The declared export-target names, in declared order. + /// + /// Convenience for callers that only need the names (the shape this crate + /// exposed before ADR-0009 turned `targets` into structured + /// [`TargetConfig`]s). + pub fn target_names(&self) -> Vec<&str> { + self.targets.iter().map(|t| t.name.as_str()).collect() + } +} + +/// One declared export target's *file-resident* build config (ADR-0009). +/// +/// An export target is a build producing a typed [`ArtifactKind`]; its defaults +/// are seeded at project creation and **overridable in the engineering file**. +/// This struct models the slice of that build config that lives in the file: +/// the artifact choice and presentation overrides (currently heading +/// numbering). The per-kind render *map* itself stays in the render package +/// (`Spec.Courseware.TargetSpec.renders`), not here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TargetConfig { + /// Target name, e.g. `"student"` (the `[targets.]` table key). + pub name: String, + /// The artifact shape this target produces. Defaults to + /// [`ArtifactKind::SingleFile`] when the `artifact` field is absent. + pub artifact: ArtifactKind, + /// Presentation override for this target. `None` when no presentation + /// config is given in the file (the render layer applies its defaults). + pub numbering: Option, +} + +/// The shape of the artifact an export target produces (ADR-0009). +/// +/// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master +/// (`spec/Spec/Courseware/Artifact.lean`), whose definition is exactly: +/// +/// ```text +/// inductive Artifact where +/// | singleFile +/// | fileTree +/// ``` +/// +/// `SingleFile` is one bundled document (a 讲义 / 教案 PDF); `FileTree` is a +/// tree of files plus an index (a third-party-platform archive). The on-disk +/// `artifact` string maps `"single-file"` → [`ArtifactKind::SingleFile`] and +/// `"file-tree"` → [`ArtifactKind::FileTree`]. +/// +/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this +/// alignment (see the repo constitution) — it is maintained by review, which is +/// why the correspondence is documented here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum ArtifactKind { + /// One bundled document. Mirrors Lean `Artifact.singleFile`. Default. + SingleFile, + /// A tree of files plus an index. Mirrors Lean `Artifact.fileTree`. + FileTree, +} + +impl ArtifactKind { + /// The default artifact shape when `artifact` is omitted: `SingleFile`. + const DEFAULT: ArtifactKind = ArtifactKind::SingleFile; +} + +/// File-resident presentation overrides for a target (ADR-0009). +/// +/// Each field is `Option` so **absence is distinguishable from a value**: when +/// a field is `None`, the render package applies its framework default; only an +/// explicitly-given value overrides it. This crate deliberately invents no +/// presentation defaults — that ownership stays in the render layer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct NumberingConfig { + /// Per-heading-level numbering patterns (index 0 = level 1, etc.), e.g. + /// `["{1:一}、", "{1:1}.{2:1}"]`. `None` when `numbering.heading` is absent. + pub heading: Option>, +} + /// `[project]` table. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Project { @@ -215,9 +295,15 @@ pub fn load(root: &Path) -> (Option, Vec) { } }; - // Target names are just the keys of the [targets.*] table; order is not - // semantically meaningful, but we preserve the TOML document order. - let targets: Vec = raw.targets.keys().cloned().collect(); + // Parse each [targets.] table into a structured build config + // (ADR-0009). Order is the TOML document order, as before. Malformed + // target config is non-fatal: it is reported and the target is kept with + // defaults so loading continues. + let targets: Vec = raw + .targets + .into_iter() + .map(|(name, value)| parse_target(name, value, &mut diags)) + .collect(); // Load each part. Problems are collected, not fatal: we still build the // Part (with a best-effort descriptor) so order/membership is observable. @@ -399,6 +485,149 @@ fn load_descriptor( } } +/// Parse one `[targets.]` table into a [`TargetConfig`] (ADR-0009), +/// collecting [`DiagCode::SchemaViolation`] diagnostics for malformed fields. +/// +/// Malformed config is **non-fatal**: a bad `artifact` value falls back to the +/// default ([`ArtifactKind::SingleFile`]); a malformed `numbering.heading` +/// leaves the override absent — both reported, never dropping the target. An +/// empty `[targets.]` body is valid (all defaults, no diagnostics). +fn parse_target(name: String, value: toml::Value, diags: &mut Vec) -> TargetConfig { + // A target body must be a table; anything else (`student = 1`) is malformed. + let mut table = match value { + toml::Value::Table(t) => t, + _ => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{name}' must be a table, e.g. `[targets.{name}]`"), + ) + .with_hint("declare a target as a `[targets.]` table"), + ); + return TargetConfig { + name, + artifact: ArtifactKind::DEFAULT, + numbering: None, + }; + } + }; + + let artifact = match table.remove("artifact") { + None => ArtifactKind::DEFAULT, + Some(toml::Value::String(s)) => match s.as_str() { + "single-file" => ArtifactKind::SingleFile, + "file-tree" => ArtifactKind::FileTree, + other => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' has an invalid artifact '{other}'; \ + valid values are \"single-file\", \"file-tree\"" + ), + ) + .with_hint("set `artifact` to \"single-file\" or \"file-tree\""), + ); + ArtifactKind::DEFAULT + } + }, + Some(_) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' has a non-string `artifact`; \ + valid values are \"single-file\", \"file-tree\"" + ), + ) + .with_hint("set `artifact` to \"single-file\" or \"file-tree\""), + ); + ArtifactKind::DEFAULT + } + }; + + let numbering = parse_numbering(&name, &mut table, diags); + + TargetConfig { + name, + artifact, + numbering, + } +} + +/// Parse the optional `[targets..numbering]` sub-table into a +/// [`NumberingConfig`]. Returns `None` when no `numbering` table is present. +/// A malformed `heading` (not an array of strings) is reported as a +/// [`DiagCode::SchemaViolation`] and left absent (`None`). +fn parse_numbering( + name: &str, + table: &mut toml::Table, + diags: &mut Vec, +) -> Option { + let numbering = table.remove("numbering")?; + + let mut numbering_table = match numbering { + toml::Value::Table(t) => t, + _ => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{name}' has a non-table `numbering`"), + ) + .with_hint(format!("declare numbering as `[targets.{name}.numbering]`")), + ); + return Some(NumberingConfig { heading: None }); + } + }; + + let heading = match numbering_table.remove("heading") { + None => None, + Some(toml::Value::Array(items)) => { + let mut patterns = Vec::with_capacity(items.len()); + let mut malformed = false; + for item in items { + match item { + toml::Value::String(s) => patterns.push(s), + _ => { + malformed = true; + break; + } + } + } + if malformed { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' has a `numbering.heading` that is not an array of strings" + ), + ) + .with_hint( + "`numbering.heading` must be an array of per-level pattern strings", + ), + ); + None + } else { + Some(patterns) + } + } + Some(_) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' has a `numbering.heading` that is not an array of strings" + ), + ) + .with_hint("`numbering.heading` must be an array of per-level pattern strings"), + ); + None + } + }; + + Some(NumberingConfig { heading }) +} + /// True if `path` contains any `..` component (parent-dir traversal). fn has_parent_traversal(path: &Path) -> bool { path.components().any(|c| matches!(c, Component::ParentDir)) diff --git a/crates/cph-model/tests/fixtures/bad-target-config/manifest.toml b/crates/cph-model/tests/fixtures/bad-target-config/manifest.toml new file mode 100644 index 0000000..d47aa1b --- /dev/null +++ b/crates/cph-model/tests/fixtures/bad-target-config/manifest.toml @@ -0,0 +1,16 @@ +[project] +id = "fixture-bad-target-config" +name = "bad-target-config" + +[info] +title = "测试课:非法导出目标配置" + +# Invalid artifact value → SchemaViolation, falls back to default (single-file). +[targets.student] +artifact = "pdf-thing" + +# numbering.heading is not an array of strings → SchemaViolation, heading None. +[targets.teacher] +artifact = "file-tree" +[targets.teacher.numbering] +heading = "not-an-array" diff --git a/crates/cph-model/tests/fixtures/target-configs/manifest.toml b/crates/cph-model/tests/fixtures/target-configs/manifest.toml new file mode 100644 index 0000000..fe06232 --- /dev/null +++ b/crates/cph-model/tests/fixtures/target-configs/manifest.toml @@ -0,0 +1,19 @@ +[project] +id = "fixture-target-configs" +name = "target-configs" + +[info] +title = "测试课:导出目标的结构化 build 配置" + +# Full config: explicit artifact + numbering.heading override. +[targets.student] +artifact = "single-file" +[targets.student.numbering] +heading = ["{1:一}、", "{1:1}.{2:1}", "{1:1}.{2:1}.{3:1}"] + +# file-tree artifact, no presentation override. +[targets.archive] +artifact = "file-tree" + +# Empty body → all defaults. +[targets.teacher] diff --git a/crates/cph-model/tests/load.rs b/crates/cph-model/tests/load.rs index 3ab446b..511e53e 100644 --- a/crates/cph-model/tests/load.rs +++ b/crates/cph-model/tests/load.rs @@ -46,10 +46,13 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() { ); assert!(scalars.get("kind").is_none()); - // Targets are collected from [targets.*]. - let mut targets = lesson.targets.clone(); - targets.sort(); - assert_eq!(targets, vec!["student".to_string(), "teacher".to_string()]); + // Targets are collected from [targets.*], in declared order. + assert_eq!(lesson.target_names(), vec!["student", "teacher"]); + // Empty `[targets.x]` bodies → all-default build config, no diagnostics. + for target in &lesson.targets { + assert_eq!(target.artifact, cph_model::ArtifactKind::SingleFile); + assert_eq!(target.numbering, None); + } // Descriptor dir is absolute, anchored under the root. assert!(lesson.parts[0].descriptor.dir.is_absolute()); @@ -123,3 +126,94 @@ fn missing_manifest_is_a_hard_failure() { assert_eq!(diags.len(), 1); assert_eq!(diags[0].code, DiagCode::SchemaViolation); } + +#[test] +fn structured_target_configs_parse_into_typed_builds() { + use cph_model::ArtifactKind; + + let (lesson, diags) = load(&fixture("target-configs")); + let lesson = lesson.expect("target-configs fixture must produce a Lesson"); + assert!( + diags.is_empty(), + "well-formed target configs must have no diagnostics, got: {diags:?}" + ); + + // Declared order is preserved. + assert_eq!(lesson.target_names(), vec!["student", "archive", "teacher"]); + + // student: explicit single-file + heading numbering override, in order. + let student = &lesson.targets[0]; + assert_eq!(student.name, "student"); + assert_eq!(student.artifact, ArtifactKind::SingleFile); + let numbering = student + .numbering + .as_ref() + .expect("student declares a numbering override"); + assert_eq!( + numbering.heading.as_deref(), + Some( + &[ + "{1:一}、".to_string(), + "{1:1}.{2:1}".to_string(), + "{1:1}.{2:1}.{3:1}".to_string(), + ][..] + ) + ); + + // archive: file-tree artifact, no presentation override. + let archive = &lesson.targets[1]; + assert_eq!(archive.name, "archive"); + assert_eq!(archive.artifact, ArtifactKind::FileTree); + assert_eq!(archive.numbering, None); + + // teacher: empty body → all defaults, numbering absent. + let teacher = &lesson.targets[2]; + assert_eq!(teacher.name, "teacher"); + assert_eq!(teacher.artifact, ArtifactKind::SingleFile); + assert_eq!(teacher.numbering, None); +} + +#[test] +fn malformed_target_config_is_non_fatal_with_schema_violations() { + use cph_model::ArtifactKind; + + let (lesson, diags) = load(&fixture("bad-target-config")); + let lesson = lesson.expect("bad-target-config must still produce a Lesson"); + + // Both targets survive despite their malformed fields. + assert_eq!(lesson.target_names(), vec!["student", "teacher"]); + + // Bad `artifact` value → SchemaViolation, target kept with default artifact. + let student = &lesson.targets[0]; + assert_eq!(student.artifact, ArtifactKind::SingleFile); + + // Bad `numbering.heading` (not an array of strings) → heading stays None, + // but the numbering table itself is present. + let teacher = &lesson.targets[1]; + assert_eq!(teacher.artifact, ArtifactKind::FileTree); + let teacher_numbering = teacher + .numbering + .as_ref() + .expect("teacher declares a numbering table"); + assert_eq!(teacher_numbering.heading, None); + + let violations: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::SchemaViolation) + .collect(); + assert_eq!( + violations.len(), + 2, + "expected one SchemaViolation per malformed field, got: {diags:?}" + ); + assert!( + violations.iter().any(|d| d.message.contains("pdf-thing")), + "a diagnostic should name the bad artifact value, got: {diags:?}" + ); + assert!( + violations + .iter() + .any(|d| d.message.contains("numbering.heading")), + "a diagnostic should name the malformed numbering.heading, got: {diags:?}" + ); +} From 6f46aa708a84c0deb55df43d8c239a522ca62233 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 08:42:15 +0800 Subject: [PATCH 16/29] fix(render): per-level heading numbering via numbly; config-driven, offline (WU-C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the numbering bug (single pattern "一、" reused across levels → level-2 rendered "二、一、…"). display now takes a `config` dict; heading numbering uses numbly per level — framework default ("{1:一}、","{1:1}.{2:1}","{1:1}.{2:1}.{3:1}") → 一、 / 1.1 / 1.1.1 — overridable via config.numbering.heading from the engineering file (ADR-0009's file-resident override path, the layer whose absence caused the bug). Verified: grep "二、一" = 0 across all smoke PDFs. Adds @preview/numbly:0.1.0 (pure typst, zero transitive deps), VENDORED in-repo at render/vendor/typst-packages/preview/numbly/0.1.0/ for network-free CI; resolve via --package-cache-path render/vendor/typst-packages. parts contract + student/teacher field visibility unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- render/examples/smoke-config.typ | 14 ++++++ render/examples/smoke-parts.typ | 15 ++++++- render/lib.typ | 23 ++++++++-- render/src/style.typ | 30 ++++++++++--- render/typst.toml | 10 +++++ .../preview/numbly/0.1.0/LICENSE | 21 +++++++++ .../preview/numbly/0.1.0/README.md | 43 +++++++++++++++++++ .../preview/numbly/0.1.0/lib.typ | 31 +++++++++++++ .../preview/numbly/0.1.0/typst.toml | 10 +++++ 9 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 render/examples/smoke-config.typ create mode 100644 render/vendor/typst-packages/preview/numbly/0.1.0/LICENSE create mode 100644 render/vendor/typst-packages/preview/numbly/0.1.0/README.md create mode 100644 render/vendor/typst-packages/preview/numbly/0.1.0/lib.typ create mode 100644 render/vendor/typst-packages/preview/numbly/0.1.0/typst.toml diff --git a/render/examples/smoke-config.typ b/render/examples/smoke-config.typ new file mode 100644 index 0000000..9847344 --- /dev/null +++ b/render/examples/smoke-config.typ @@ -0,0 +1,14 @@ +// Smoke test — exercises the per-target presentation `config` override path +// (ADR-0009). Here the engineering-file-supplied `config.numbering.heading` +// overrides the framework-default heading numbering. The patterns happen to +// match the correct per-level scheme (level-1 `一、`, level-2 `1.1`), proving +// the override is wired AND yields correct (NOT `二、一、`) output. +#import "../lib.typ": display +#import "smoke-parts.typ": info, parts + +#display( + info: info, + target: "teacher", + parts: parts, + config: (numbering: (heading: ("{1:一}、", "{1:1}.{2:1}"))), +) diff --git a/render/examples/smoke-parts.typ b/render/examples/smoke-parts.typ index fec937d..2a118c1 100644 --- a/render/examples/smoke-parts.typ +++ b/render/examples/smoke-parts.typ @@ -8,13 +8,26 @@ ) #let parts = ( - // segment + // segment — with NESTED headings so per-level numbering is visible: + // level-1 `=` should render `一、`, level-2 `==` should render `1.1` + // (NOT the old buggy `二、一、`). ( kind: "segment", textbook: [ + = 平面向量的数量积 + 本节研究平面向量的基本运算。设 $arrow(a)$、$arrow(b)$ 为平面内两个向量, 其数量积定义为 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$, 其中 $theta$ 为两向量的夹角。 + + == 坐标表示 + + 在直角坐标系下,若 $arrow(a) = (x_1, y_1)$、$arrow(b) = (x_2, y_2)$,则 + $arrow(a) dot arrow(b) = x_1 x_2 + y_1 y_2$。 + + == 几何意义 + + 数量积等于一个向量的模与另一向量在其方向上投影之积。 ], ), // example WITH source diff --git a/render/lib.typ b/render/lib.typ index 04f64e7..ac45b1d 100644 --- a/render/lib.typ +++ b/render/lib.typ @@ -23,7 +23,7 @@ // fields). Never crashes. The "no render rule => warning" // diagnostic is the Rust side's job, not ours. -#import "src/style.typ": base-style, title-block, subtitle-block +#import "src/style.typ": base-style, default-heading-numbering, title-block, subtitle-block #import "src/elements/segment.typ": display-segment #import "src/elements/example.typ": display-example #import "src/elements/lemma.typ": display-lemma @@ -75,17 +75,34 @@ /// - `info`: dict, e.g. (title: "…", author: "…"). `author` may be absent. /// - `target`: string. MVP: "student" | "teacher". Unknown => conservative. /// - `parts`: ordered array of part dicts (see file header). -#let display(info: (:), target: "student", parts: ()) = { +/// - `config`: dict carrying the target's build/presentation overrides +/// (ADR-0009). Open/forward-compatible — read keys with +/// `.at(.., default: ..)`. Recognised keys (MVP): +/// `config.numbering.heading`: array of per-level numbly pattern +/// strings, e.g. `("{1:一}、", "{1:1}.{2:1}")`. Optional; when +/// absent the framework default (correct per-level scheme) is +/// used. Future presentation knobs slot in without touching +/// this signature. +#let display(info: (:), target: "student", parts: (), config: (:)) = { let title = info.at("title", default: []) let author = info.at("author", default: none) + // Resolve presentation config -> framework defaults, file may override. + // `config.numbering.heading` (array of numbly patterns) overrides the + // framework default per-level numbering when present. + let numbering-cfg = config.at("numbering", default: (:)) + let heading-numbering = numbering-cfg.at( + "heading", + default: default-heading-numbering, + ) + // `document` author wants a string/array; normalise the optional field. set document( title: title, author: if author == none { () } else { author }, ) - show: base-style + show: base-style.with(heading-numbering: heading-numbering) // Reset shared counters so each rendered lesson numbers from 1. example-counter.update(0) diff --git a/render/src/style.typ b/render/src/style.typ index cb97c20..b26313f 100644 --- a/render/src/style.typ +++ b/render/src/style.typ @@ -1,18 +1,36 @@ // Base document styling: CJK fonts, headings, math, paragraph layout, page. // -// Zero external dependencies. Heading numbering uses builtin `numbering` -// strings (no @preview/numbly) so the package compiles with no network. +// Heading numbering is PER-LEVEL and built from a numbly pattern array passed +// in by the caller (lib.typ resolves it from config + framework default). This +// fixes the old single-pattern bug where one `numbering: "一、"` was reused for +// every level (a level-2 heading rendered as `二、一、…`). The only @preview +// dependency is `@preview/numbly`, vendored under `render/vendor/...` for +// network-free CI; see render/typst.toml's note. +#import "@preview/numbly:0.1.0": numbly #import "fonts.typ" -/// Apply the base style to a document body. Used as `show: base-style`. -#let base-style(doc) = { +/// Framework-default per-level heading numbering patterns (numbly syntax). +/// Level 1 -> `一、`, level 2 -> `1.1`, level 3 -> `1.1.1`. This is the +/// correct-by-default scheme; `config.numbering.heading` overrides it. +#let default-heading-numbering = ( + "{1:一}、", + "{1:1}.{2:1}", + "{1:1}.{2:1}.{3:1}", +) + +/// Apply the base style to a document body. Used as `show: base-style(..)`. +/// +/// `heading-numbering` is an array of per-level numbly pattern strings; it is +/// spread into `numbly(..)` to build the per-level heading numbering function. +#let base-style(heading-numbering: default-heading-numbering, doc) = { // Language / region drive CJK line-breaking and punctuation. set text(lang: "zh", region: "cn", size: 12pt, font: fonts.serif) set text(cjk-latin-spacing: auto) - // Heading numbering: 一、 / 1.1 / 1.1.1 — builtin numbering, no deps. - set heading(numbering: "一、") + // Per-level heading numbering via numbly: each level gets its own pattern, + // so level 2 renders `1.1`, not the old buggy `二、一、`. + set heading(numbering: numbly(..heading-numbering)) show heading: set text(font: fonts.sans) // Number display equations. diff --git a/render/typst.toml b/render/typst.toml index 058fabc..b45f153 100644 --- a/render/typst.toml +++ b/render/typst.toml @@ -6,3 +6,13 @@ compiler = "0.15.0" authors = ["curriculum-project-hub"] license = "MIT" description = "Curriculum lesson render package: single `display` entry over an ordered list of typed parts (segment/example/lemma/sop), targeting student/teacher handouts." + +# External @preview dependencies (resolved via the typst package registry): +# @preview/numbly:0.1.0 — per-level heading numbering (src/style.typ). +# numbly is dependency-free (pure typst). For network-free CI the package is +# VENDORED in-repo at: +# render/vendor/typst-packages/preview/numbly/0.1.0/ +# mirroring the typst preview-cache layout +# ({cache}/typst/packages/preview/numbly/0.1.0/). Point typst's package cache / +# resolver at render/vendor/typst-packages (e.g. TYPST_PACKAGE_CACHE_PATH or the +# embedded World's preview dir) so `@preview/numbly:0.1.0` resolves offline. diff --git a/render/vendor/typst-packages/preview/numbly/0.1.0/LICENSE b/render/vendor/typst-packages/preview/numbly/0.1.0/LICENSE new file mode 100644 index 0000000..9162d3e --- /dev/null +++ b/render/vendor/typst-packages/preview/numbly/0.1.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 梦飞翔 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/render/vendor/typst-packages/preview/numbly/0.1.0/README.md b/render/vendor/typst-packages/preview/numbly/0.1.0/README.md new file mode 100644 index 0000000..c8547fe --- /dev/null +++ b/render/vendor/typst-packages/preview/numbly/0.1.0/README.md @@ -0,0 +1,43 @@ +# numbly + +A package that helps you to specify different numbering formats for different levels of headings. + +Suppose you want to specify the following numbering format for your document: + +- Appendix A. Guide + - A.1. Installation + - Step 1. Download + - Step 2. Install + - A.2. Usage + +You might use `if` to achieve this: + +```typst +#set heading(numbering: (..nums) => { + nums = nums.pos() + if nums.len() == 1 { + return "Appendix " + numbering("A.", ..nums) + } else if nums.len() == 2 { + return numbering("A.1.", ..nums) + } else { + return "Step " + numbering("1.", nums.last()) + } +}) + += Guide +== Installation +=== Download +=== Install +== Usage +``` + +But with `numbly`, you can do this more easily: + +```typst +#import "@preview/numbly:0.1.0": numbly +#set heading(numbering: numbly( + "Appendix {1:A}.", // use {level:format} to specify the format + "{1:A}.{2}.", // if format is not specified, arabic numbers will be used + "Step {3}.", // here, we only want the 3rd level +)) +``` diff --git a/render/vendor/typst-packages/preview/numbly/0.1.0/lib.typ b/render/vendor/typst-packages/preview/numbly/0.1.0/lib.typ new file mode 100644 index 0000000..baac794 --- /dev/null +++ b/render/vendor/typst-packages/preview/numbly/0.1.0/lib.typ @@ -0,0 +1,31 @@ +#let numbly(..arr, default: "1.") = (..nums) => { + let arr = arr.pos() + nums = nums.pos() + if nums.len() > arr.len() { + if default == none { + return none + } + if type(default) == function { + return default(..nums) + } + return numbering(default, ..nums) + } + let format = arr.at(nums.len() - 1) + if format == none { + return none + } + if type(format) == function { + return format(..nums) + } + format.replace( + regex("\{(\d)(:(.+?))?\}"), + m => { + let (a, b, c) = m.captures + if b != none { + numbering(c, nums.at(int(a) - 1)) + } else { + str(nums.at(int(a) - 1)) + } + }, + ) +} diff --git a/render/vendor/typst-packages/preview/numbly/0.1.0/typst.toml b/render/vendor/typst-packages/preview/numbly/0.1.0/typst.toml new file mode 100644 index 0000000..9ecc5e7 --- /dev/null +++ b/render/vendor/typst-packages/preview/numbly/0.1.0/typst.toml @@ -0,0 +1,10 @@ +[package] +name = "numbly" +version = "0.1.0" +entrypoint = "lib.typ" +authors = ["flaribbit <@flaribbit>"] +license = "MIT" +description = "A package that helps you to specify different numbering formats for different levels of headings." +categories = ["utility"] +keywords = ["numbering", "helper", "tool"] +repository = "https://github.com/flaribbit/numbly" \ No newline at end of file From bd1699a4c0734c739eb3e0092b467cc68c28f45e Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 08:47:13 +0800 Subject: [PATCH 17/29] feat(checker): align cph-typst + cph-check to export-as-build model (WU-D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate the WU-B (structured targets) and WU-C (numbly, config) changes. cph-typst: - World resolves any @preview/* package from the in-repo vendored dir (/vendor/typst-packages/preview///), fully offline — no typst-kit download. Proven: build_pdf_with_real_render compiles the real render package (which imports @preview/numbly:0.1.0) through the embedded engine, PDF out. - Driver threads each target's presentation config: looks up the TargetConfig by name, emits `config: (numbering: (heading: (...)))` (escaped) when an override is present, else `config: (:)` (render default). - target_precheck guards artifact/target: FileTree → blocking diagnostic (MVP is single-file, deferred per ADR-0009); --target not declared → blocking diagnostic; no-targets lesson still defaults through. cph-check: - Migrated to Lesson.targets: Vec (target_names() / target.name at the 6 sites). Pipeline unchanged (already matches ADR-0010 phases). Documented that !has_errors() implements Spec.Courseware.Legal (no error-level diagnostic). Workspace: fmt + clippy -D warnings + all tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/cph-check/src/lib.rs | 17 +++-- crates/cph-typst/src/driver.rs | 34 ++++++++++ crates/cph-typst/src/lib.rs | 53 ++++++++++++++- crates/cph-typst/src/world.rs | 29 +++++++- crates/cph-typst/tests/compile.rs | 66 +++++++++++++++++++ .../tests/fixtures/render-stub/lib.typ | 5 +- 6 files changed, 193 insertions(+), 11 deletions(-) diff --git a/crates/cph-check/src/lib.rs b/crates/cph-check/src/lib.rs index 5926a22..90e6dc3 100644 --- a/crates/cph-check/src/lib.rs +++ b/crates/cph-check/src/lib.rs @@ -60,6 +60,13 @@ impl CheckReport { } /// Whether any collected diagnostic is `Error`-severity. + /// + /// **Legality decision (spec alignment).** `!has_errors()` is the + /// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/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() @@ -247,7 +254,7 @@ fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> { if lesson.targets.is_empty() { vec![DEFAULT_TARGET] } else { - lesson.targets.iter().map(String::as_str).collect() + lesson.target_names() } } @@ -271,11 +278,11 @@ fn render_coverage(lesson: &cph_model::Lesson, known: &[&str]) -> Vec Vec(lesson: &'a Lesson, target: &str) -> Option<&'a cph_model::TargetConfig> { + lesson.targets.iter().find(|t| t.name == target) +} + /// In-memory directory the generated driver is mounted under (relative to the /// lesson root). Not written to disk. pub const DRIVER_DIR: &str = ".cph"; @@ -101,10 +106,39 @@ pub fn generate_driver(lesson: &Lesson, target: &str) -> String { // Trailing comma already present per element; ensure a 1-element array is // still an array (typst needs the trailing comma, which we always emit). s.push_str(" ),\n"); + s.push_str(&format!(" config: {},\n", config_dict(lesson, target))); s.push_str(")\n"); s } +/// The `config:` dict for `target` (ADR-0009 presentation overrides). +/// +/// Looks up the matching [`cph_model::TargetConfig`] by name and emits the +/// file-resident presentation slice the render package understands. For MVP +/// that is `numbering.heading` (an array of numbly pattern strings): +/// `config: (numbering: (heading: ("…", "…")))`. When the target declares no +/// `numbering.heading` override (or the target isn't declared at all), an empty +/// dict `(:)` is emitted so the render package applies its own correct default. +fn config_dict(lesson: &Lesson, target: &str) -> String { + let heading = target_config(lesson, target) + .and_then(|tc| tc.numbering.as_ref()) + .and_then(|n| n.heading.as_ref()); + + match heading { + Some(patterns) => { + let arr = patterns + .iter() + .map(|p| typst_str(p)) + .collect::>() + .join(", "); + // A 1-element typst array needs a trailing comma; always emitting + // one keeps any arity a valid array. + format!("(numbering: (heading: ({arr},)))") + } + None => "(:)".to_string(), + } +} + /// `(title: "…", author: "…")` — author key omitted when absent. fn info_dict(lesson: &Lesson) -> String { let mut parts = vec![format!("title: {}", typst_str(&lesson.info.title))]; diff --git a/crates/cph-typst/src/lib.rs b/crates/cph-typst/src/lib.rs index 7197673..69bd800 100644 --- a/crates/cph-typst/src/lib.rs +++ b/crates/cph-typst/src/lib.rs @@ -24,8 +24,8 @@ mod world; use std::path::PathBuf; -use cph_diag::Diagnostic; -use cph_model::Lesson; +use cph_diag::{DiagCode, Diagnostic}; +use cph_model::{ArtifactKind, Lesson}; use typst_kit::fonts::{self, FontStore}; use typst_layout::PagedDocument; use typst_pdf::PdfOptions; @@ -78,6 +78,9 @@ impl Engine { /// spans landing in the generated driver are re-pointed with a hint marking /// them as driver-internal (a driver-gen bug indicator) rather than dropped. pub fn compile_check(&self, lesson: &Lesson, target: &str) -> Vec { + if let Some(blocking) = target_precheck(lesson, target) { + return blocking; + } let world = self.world_for(lesson, target); let warned = typst::compile::(&world); @@ -93,6 +96,9 @@ impl Engine { /// bytes; on fatal errors returns the mapped diagnostics. Compile warnings /// are not surfaced here (use [`Engine::compile_check`] for those). pub fn build_pdf(&self, lesson: &Lesson, target: &str) -> Result, Vec> { + if let Some(blocking) = target_precheck(lesson, target) { + return Err(blocking); + } let world = self.world_for(lesson, target); let warned = typst::compile::(&world); let doc = match warned.output { @@ -120,6 +126,49 @@ impl Default for Engine { } } +/// Validate a `(lesson, target)` request before compiling. Returns +/// `Some(blocking_diagnostics)` when the request cannot be honored, or `None` +/// when it is fine to proceed: +/// +/// - **Unknown target** (the `--target` name isn't in `lesson.targets`): a +/// `SchemaViolation` error — a target must be declared in the manifest to be +/// built (ADR-0009: an export target is a declared build). +/// - **`FileTree` artifact**: deferred for MVP. ADR-0009 makes the artifact kind +/// decide assembly; only `SingleFile` (one bundled PDF) is implemented, so a +/// `FileTree` target returns a clear `SchemaViolation` rather than wrong +/// output. `SingleFile` proceeds to the normal one-PDF path. +/// +/// A target with **no** declared `[targets.*]` config at all is *not* an error +/// here: callers (e.g. `cph-check`) may compile-check a defaulted `"student"` +/// target that the lesson never declared. Only a name that *is* declared but is +/// `FileTree` is blocked; an undeclared name is the "unknown target" error. +fn target_precheck(lesson: &Lesson, target: &str) -> Option> { + match lesson.targets.iter().find(|t| t.name == target) { + None if lesson.targets.is_empty() => { + // Lesson declares no targets; the orchestrator compiles a defaulted + // target. Proceed with render-package defaults (SingleFile). + None + } + None => Some(vec![Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{target}' not declared in manifest"), + ) + .with_hint(format!( + "add a `[targets.{target}]` table to manifest.toml, or build a declared target" + ))]), + Some(tc) => match tc.artifact { + ArtifactKind::SingleFile => None, + ArtifactKind::FileTree => Some(vec![Diagnostic::error( + DiagCode::SchemaViolation, + "file-tree artifact not yet implemented (MVP supports single-file)", + ) + .with_hint(format!( + "set `[targets.{target}].artifact` to \"single-file\" for now" + ))]), + }, + } +} + /// Map a batch of typst diagnostics to `cph-diag` ones against `world`. fn map_all(world: &LessonWorld, diags: &[typst::diag::SourceDiagnostic]) -> Vec { diags diff --git a/crates/cph-typst/src/world.rs b/crates/cph-typst/src/world.rs index 8a2e3a8..e82431d 100644 --- a/crates/cph-typst/src/world.rs +++ b/crates/cph-typst/src/world.rs @@ -12,8 +12,15 @@ //! - **`@local/cph-render`**: a `FileId` whose root is the render package spec //! is read from `render_dir.join(vpath.realize)`. This avoids installing the //! package into the typst local-package directory (MVP: no environment -//! coupling). Any *other* `@package` id yields a `NotFound` error (the MVP -//! render package has no external `@preview` dependencies). +//! coupling). +//! - **`@preview/:`**: resolved offline from the in-repo vendored +//! preview tree at `/vendor/typst-packages/preview///` +//! (mirroring typst's preview-cache layout). The render package imports +//! `@preview/numbly:0.1.0` (per-level heading numbering); reading it from the +//! vendored dir keeps compilation network-free with no `typst-kit` package +//! download. This is general — any `@preview/*` resolves from the vendored +//! preview dir; numbly 0.1.0 is the only one currently present. +//! - Any *other* `@package` namespace yields a `NotFound` error. //! //! Sources are cached in a `Mutex>` so repeated //! accesses during one compilation are cheap, as the `World` contract expects. @@ -94,7 +101,8 @@ impl LessonWorld { } /// Map a `FileId` to its on-disk path, honoring the project / render-package - /// roots. Returns a `FileError` for any other package root. + /// / vendored-`@preview` roots. Returns a `FileError` for any other package + /// root. fn resolve(&self, id: FileId) -> FileResult { let vpath = id.vpath(); match id.root() { @@ -102,6 +110,21 @@ impl LessonWorld { VirtualRoot::Package(spec) if *spec == self.render_spec => { vpath.realize(&self.render_dir).map_err(Into::into) } + // `@preview/:` → vendored preview tree under the + // render dir. Offline, no download: mirrors typst's preview-cache + // layout so e.g. `@preview/numbly:0.1.0` (imported by the render + // package) reads from + // `/vendor/typst-packages/preview/numbly/0.1.0/`. + VirtualRoot::Package(spec) if spec.namespace == "preview" => { + let pkg_root = self + .render_dir + .join("vendor") + .join("typst-packages") + .join("preview") + .join(spec.name.as_str()) + .join(spec.version.to_string()); + vpath.realize(&pkg_root).map_err(Into::into) + } VirtualRoot::Package(spec) => Err(FileError::Package( typst::diag::PackageError::NotFound(spec.clone()), )), diff --git a/crates/cph-typst/tests/compile.rs b/crates/cph-typst/tests/compile.rs index 6f645c4..582dc10 100644 --- a/crates/cph-typst/tests/compile.rs +++ b/crates/cph-typst/tests/compile.rs @@ -78,6 +78,53 @@ fn optional_proof_included_when_present() { assert!(src.contains("_p1_proof = include")); } +/// The mini fixture's targets carry no `numbering.heading` override, so the +/// driver emits an empty `config: (:)` (render package applies its default). +#[test] +fn driver_emits_empty_config_when_no_override() { + let lesson = load_mini(); + let src = generate_driver(&lesson, "student"); + assert!( + src.contains("config: (:)"), + "expected empty config for a target with no numbering override:\n{src}" + ); +} + +/// CONFIG THREADING: a target declaring a `numbering.heading` override produces +/// a driver that passes `config: (numbering: (heading: (...)))` with the +/// patterns as escaped typst string literals, in order. +#[test] +fn driver_threads_numbering_heading_config() { + use cph_model::{ArtifactKind, Info, Lesson, NumberingConfig, Project, TargetConfig}; + use std::path::PathBuf; + + let lesson = Lesson { + project: Project { + id: "x".into(), + name: "x".into(), + }, + info: Info { + title: "t".into(), + author: None, + }, + parts: vec![], + targets: vec![TargetConfig { + name: "student".into(), + artifact: ArtifactKind::SingleFile, + numbering: Some(NumberingConfig { + heading: Some(vec!["{1:一}、".into(), "{1:1}.{2:1}".into()]), + }), + }], + root: PathBuf::from("/tmp/does-not-matter"), + }; + + let src = generate_driver(&lesson, "student"); + assert!( + src.contains(r#"config: (numbering: (heading: ("{1:一}、", "{1:1}.{2:1}",)))"#), + "expected threaded numbering config in driver:\n{src}" + ); +} + /// FONT-DEPENDENT: compile the mini lesson through the render-stub. Should /// produce zero Error-severity diagnostics. If this fails purely because the /// test host lacks fonts, the failure will be a typst font error — see the @@ -166,6 +213,13 @@ fn driver_internal_span_is_repointed() { /// targets. This proves the whole World → driver → render-package → PDF path, /// not just the stub. Requires system CJK fonts (present on dev + CI via /// fonts-noto-cjk). +/// +/// **This is also the offline-`@preview/numbly` proof (Task 1).** The real +/// render package's `src/style.typ` does `#import "@preview/numbly:0.1.0"`, so a +/// clean PDF here means the embedded `World` resolved numbly from the vendored +/// dir (`render/vendor/typst-packages/preview/numbly/0.1.0/`) with no network / +/// package download — had it failed, `build_pdf` would have returned a +/// package-not-found error and this test would panic. #[test] fn build_pdf_with_real_render() { let lesson = load_mini(); @@ -175,6 +229,14 @@ fn build_pdf_with_real_render() { "real render package missing at {}", render_dir.display() ); + // Sanity: the numbly the render package imports is actually vendored here. + assert!( + render_dir + .join("vendor/typst-packages/preview/numbly/0.1.0/lib.typ") + .is_file(), + "vendored numbly missing under {}", + render_dir.display() + ); let engine = Engine::with_render_dir(render_dir); for target in ["student", "teacher"] { @@ -183,6 +245,10 @@ fn build_pdf_with_real_render() { .unwrap_or_else(|d| panic!("{target} PDF build failed: {d:#?}")); assert!(pdf.starts_with(b"%PDF")); assert!(pdf.len() > 1024, "{target} PDF too small"); + eprintln!( + "build_pdf_with_real_render: {target} PDF = {} bytes (numbly resolved offline)", + pdf.len() + ); let out = std::env::temp_dir().join(format!("cph-typst-mini-real-{target}.pdf")); std::fs::write(&out, &pdf).expect("write pdf"); } diff --git a/crates/cph-typst/tests/fixtures/render-stub/lib.typ b/crates/cph-typst/tests/fixtures/render-stub/lib.typ index 1b9184b..9a56669 100644 --- a/crates/cph-typst/tests/fixtures/render-stub/lib.typ +++ b/crates/cph-typst/tests/fixtures/render-stub/lib.typ @@ -4,7 +4,10 @@ // It exercises the same call shape as the real entry: it reads `info`, walks // `parts` IN ORDER, and renders each part's content fields by `kind`. Content // values are already-evaluated content (the driver produced them via include). -#let display(info: (:), target: "student", parts: ()) = { +// +// `config` mirrors the real entry's ADR-0009 presentation-config parameter; the +// stub accepts it (the driver always passes `config:`) but ignores it. +#let display(info: (:), target: "student", parts: (), config: (:)) = { set document(title: info.at("title", default: "")) [= #info.at("title", default: "")] [target: #target] From c93503de50ec570b9798f8089fa5fe6394f26fb7 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 08:49:21 +0800 Subject: [PATCH 18/29] test(examples): TH-141 declares structured target build config (WU-E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acceptance fixture for the export-as-build model. TH-141's [targets.student] / [targets.teacher] now carry artifact = "single-file" + an explicit per-level heading numbering override (一、 / 1.1 / 1.1.1), exercising ADR-0009's file-resident override path end-to-end. Verified: `cph check` → 0 errors / 0 warnings; `cph build` both targets → PDFs (student 9pp, teacher 12pp) with correct numbering — zero "二、一" occurrences (the bug the override layer was missing to fix). numbly resolves offline through the embedded engine via the vendored package. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/TH-141/manifest.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/examples/TH-141/manifest.toml b/examples/TH-141/manifest.toml index 78199ee..2266562 100644 --- a/examples/TH-141/manifest.toml +++ b/examples/TH-141/manifest.toml @@ -162,5 +162,16 @@ path = "segments/各模型对水的预测对照" kind = "segment" path = "segments/算不准背后的真实物理" +# Export targets (ADR-0009): each is a build producing a typed artifact, with +# file-resident presentation config that overrides the framework defaults. +# Heading numbering is given explicitly per level (level-1 一、, level-2 1.1, +# level-3 1.1.1) — overriding the render package's default via the in-file path. [targets.student] +artifact = "single-file" +[targets.student.numbering] +heading = ["{1:一}、", "{1:1}.{2:1}", "{1:1}.{2:1}.{3:1}"] + [targets.teacher] +artifact = "single-file" +[targets.teacher.numbering] +heading = ["{1:一}、", "{1:1}.{2:1}", "{1:1}.{2:1}.{3:1}"] From 9b37914bfb26cf8a1dc417cecb6281d3e8f3d079 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 09:44:32 +0800 Subject: [PATCH 19/29] docs(adr): add ADR-0011 (export builds = typed steps over templates), correct ADR-0008 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refines ADR-0009 by fixing the shape of an artifact and a build; corrects a false premise that propagated into the implementation. ADR-0011: - Artifact is an ADT WITH fields: SingleFile{filepath} / FileTree{root, outputs:glob} — the product semantics (one file where, vs a tree of what) are pinned, not erased behind a bare tag. - A target = artifact + ordered typed steps: typstCompile{template} (typed because the framework wires the manifest in — not a raw shell line) | shell{run} (the hatch where ADR-0005 category-b medium-only builds land). - Presentation (numbly numbering, styling) lives in the template file (exports/.typ), NOT the manifest; manifest [targets.*.numbering] removed. - Correction (verified against typst source: typst-eval/src/import.rs:22,174; tests/suite/scripting/include.typ): include/import paths MAY be runtime-computed (only a bare `import expr` without `as` is restricted). So the framework does NOT generate a static-include driver — a template toml()-reads the manifest and includes each part via a computed path. Removes a whole generated layer. - RenderRule-as-payload retired; coverage downgraded to a declaration (which kinds a target renders) feeding the unchanged renderIgnored warning. ADR-0008: corrected the two clauses (the "generated driver / static #import" wording) that were residue of the false premise, pointing them at the template-injection model and citing ADR-0011. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...008-engineering-file-layout-conventions.md | 13 +- ...t-builds-are-typed-steps-over-templates.md | 142 ++++++++++++++++++ 2 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 docs/adr/0011-export-builds-are-typed-steps-over-templates.md diff --git a/docs/adr/0008-engineering-file-layout-conventions.md b/docs/adr/0008-engineering-file-layout-conventions.md index 25bd3d9..34c67c1 100644 --- a/docs/adr/0008-engineering-file-layout-conventions.md +++ b/docs/adr/0008-engineering-file-layout-conventions.md @@ -87,7 +87,9 @@ MVP kind set: `segment` ⇒ `textbook.typ`; `example` ⇒ `problem.typ`, `solution.typ`; `lemma` ⇒ `stmt.typ`, `proof.typ`; `sop` ⇒ `sop.typ`. This **drops the samples' per-element `main.typ`** (`#let textbook = include …`): that field-to-file wiring is now implied by the schema + naming convention and -materialized by the generated driver, not hand-authored. +materialized at build time by the export template, not hand-authored. (Originally +this said "the generated driver"; ADR-0011 supersedes that — there is no generated +driver, an export template reads the manifest and includes each part's content.) A `content` field's denotation remains ADR-0006's: the **module body content** of `F.typ`, not its `#let` exports. The file sits at a real relative path @@ -99,9 +101,12 @@ of `F.typ`, not its `#let` exports. The file sits at a real relative path - The checker recovers the full ordered lesson from `manifest.toml` + each `element.toml` without evaluating any typst — order and membership are plain data, diffable and greppable. -- A typst driver entrypoint (static `#import`s + the render call) becomes a - *generated artifact* the renderer produces from the manifest per target; it is - never the canonical lesson and is not hand-edited. +- A per-target **export template** (a real, editable `.typ` file, e.g. + `exports/student.typ`) reads the manifest and renders the lesson; it is the + build entrypoint, not the canonical lesson. (This clause originally described a + *generated* driver with static `#import`s, on a since-corrected belief that + typst forbids runtime-computed include paths — see ADR-0011's *Correction*. + Include/import paths may be computed, so no driver is generated.) - Element folders lose their per-element `main.typ`; `element.toml` + the schema's content-file convention replace it. Migrating the samples is a mechanical rewrite (the migration is part of the MVP). diff --git a/docs/adr/0011-export-builds-are-typed-steps-over-templates.md b/docs/adr/0011-export-builds-are-typed-steps-over-templates.md new file mode 100644 index 0000000..e58a765 --- /dev/null +++ b/docs/adr/0011-export-builds-are-typed-steps-over-templates.md @@ -0,0 +1,142 @@ +# ADR 0011: Export Builds Are Ordered Typed Steps Over Template Files + +## Status + +Accepted. **Refines ADR-0009** (which established target = a build producing a +typed artifact) by fixing the *shape* of a build and of an artifact. **Corrects +ADR-0008** on one point of fact (see *Correction* below). Some items deferred; +see *Open Questions*. + +## Context + +ADR-0009 said an export target is a build producing a typed `Artifact`, with a +map+reduce structure and a "declarative schema + shell hatch" form. The first +implementation of that idea (and its initial Lean encoding) got three things at +the wrong level of abstraction, surfaced in review: + +1. **The `Artifact` type was a bare enum** (`singleFile | fileTree`) with no + fields. But "what the product *is*" — a single file at some path, vs. a tree + of files under some root — is exactly the non-obvious semantics that must be + pinned. A bare enum erases it. +2. **A target was modeled as a loose config bag** (an artifact plus a per-kind + render map) with presentation knobs (heading numbering) stuffed into + `manifest.toml`. But a build is naturally **an ordered list of steps**, each a + typed operation; and presentation belongs in a **template file**, not the + manifest. +3. **A false premise about typst drove the design.** The build was going to have + the framework *generate* a typst entrypoint with **static** `#include`s of + every element, on the stated belief that "typst forbids a runtime-computed + include/import path." That belief is **wrong** (verified below). It is the + reason ADR-0008 mentions "the generated driver" and "static `#import`s" — that + wording is a residue of the false premise and is corrected here. + +## Correction: include/import paths may be runtime-computed + +Verified against the typst source (`/Users/sjfhsjfh/Typst/typst`, 0.15): + +- `#include` and `#import` parse an **arbitrary code expression**, not a string + literal (`typst-syntax` parser uses `code_expr` for both). +- The source operand is **evaluated at runtime** + (`typst-eval/src/import.rs:22` for import, `:174` for include); a + `Value::Str` is then resolved to a module via runtime path resolution + (`span.resolve_path(...)`). Path resolution is a **runtime** operation, so a + missing file is a runtime error, not a parse error. +- The test suite confirms it: `tests/suite/scripting/include.typ` contains + `#let chap2 = include "modu" + "les/chap" + "2.typ"` — a computed path. +- The **only** restriction: a *bare* `import expr` (no `as name`) rejects a + dynamic string source ("dynamic import requires an explicit name"); + `import expr as name` and `include expr` both accept dynamic paths. + +**Consequence:** the framework does **not** need to generate a static-include +driver. A template can `toml()`-read the manifest and `include` each part's +content via a **computed** path. This removes an entire generated-artifact layer. + +## Decision + +### `Artifact` is an ADT with fields + +``` +SingleFile { filepath } -- the product is one file, written at filepath +FileTree { root, outputs:glob } -- the product is the files under root matching outputs +``` + +`SingleFile.filepath` says where the one product lands (a 讲义/教案 PDF). +`FileTree.root` is the output directory; `FileTree.outputs` is a **glob** +describing which files this build produces (e.g. `**/*.{html,js,json}`) — lighter +than an explicit manifest of outputs, while still letting a consumer/checker know +what to expect and verify. These fields are the semantics; they go in the Lean +ADT with doc, not erased behind a bare tag. + +### A target is an artifact plus ordered, typed steps + +A target's build is `{ artifact, steps }`, where `steps` is an **ordered** list +and each `Step` is a **typed operation** (extensible): + +- `typstCompile { template }` — compile a **template file** (e.g. + `exports/student.typ`) to the artifact. It is *typed*, not a raw shell line, + precisely because the framework must **wire the manifest into it** — a bare + `typst compile` shell string cannot express that injection. +- `shell { run }` — an escape hatch for steps that resist declaration (this is + where ADR-0005's medium-only category (b): HTML interactive builds, npm, lands). + +MVP has exactly one step per target (a single `typstCompile`), but the structure +is a list because a `FileTree`/third-party build will need several. + +### Presentation lives in the template file, not the manifest + +The `typstCompile.template` (e.g. `exports/student.typ`) is a **real, editable +file** in the engineering file. Presentation — heading numbering (numbly), styling +— lives **there**, not in `manifest.toml`. The manifest's earlier +`[targets.*.numbering]` block is **removed**. (Framework defaults for these +templates are written into the engineering file at project-creation time; the +creation flow is out of this round's scope — the sample's templates are authored +by hand and noted as "should be seeded".) + +### The manifest is injected, not statically inlined + +A `typstCompile` step compiles its template **as the main file**, passing the +manifest in via `--input manifest=` (a path relative to the typst `--root`, +which is the engineering-file root). The template does +`toml(sys.inputs.manifest)` to read metadata (course title, author) and the +ordered `parts`, and `include`s each part's content via the part's path (a +**computed** path — legal per the *Correction*). No framework-generated driver. + +### Render coverage is a declaration, not a payload + +ADR-0009's per-target render *payload* (`RenderRule`) is **retired**: the "how" of +rendering now lives in the template/steps, not in a contract-level rule object. +What the contract keeps is a **coverage declaration** — *which kinds a target +renders* — used only by the seed diagnostic. ADR-0005's rule survives unchanged: +a used kind a target does **not** cover ⇒ a `renderIgnored` **warning** (non- +blocking). `Primitives.RenderRule` is removed from the model. + +## Consequences + +- The product type is self-describing: `SingleFile`'s path and `FileTree`'s + root+glob are in the contract; `cph build` and any consumer know exactly what a + target emits and where. +- A build is a list of typed steps — adding a non-typst step (npm, packaging) is + adding a `shell` step, not bending the typst path. +- Presentation is editable in one obvious place (the template file); the manifest + carries structure (parts, targets, artifacts, steps), not styling. +- Removing the generated-driver layer simplifies the engine: it compiles a real + template file as main (spans resolve to a real authored file, not a synthetic + one) and sets one `sys.inputs` value. +- ADR-0008's "generated driver / static `#import`s" wording is superseded by the + template-injection model here. + +## Open Questions / Deferred + +- **`FileTree` and `shell` step implementation** — specified here, not built this + round (no third-party target instance yet). MVP implements `SingleFile` + a + single `typstCompile` step; the rest is deferred with explicit diagnostics. +- **Project-creation seeding** — that default templates are written into a new + engineering file at creation time is asserted but not implemented here; the + sample's templates are hand-authored. +- **Template ↔ framework calling convention** — how exactly the template pulls + part content (a `cph-render` helper that takes a loader closure, vs. the + template doing its own `for`+`include`) is an implementation detail settled in + the render package, not pinned in Lean. +- **Multiple steps / step dependencies** — ordering is fixed as a list; whether + steps ever need richer dependency structure is deferred until a multi-step + target exists. From de87cddaf1f33f7275dee21435b6a12dba72de23 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 09:46:34 +0800 Subject: [PATCH 20/29] feat(spec): Artifact gets fields, target = artifact + typed steps, drop RenderRule (ADR-0011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the abstraction level review flagged. lake build green (20 jobs), no sorry. - Artifact.lean: bare enum → ADT with fields — singleFile(filepath) / fileTree(root, outputs:glob). The product semantics (one file where, vs a tree of what) are pinned in fields + doc, not erased behind empty constructors. Paths/globs carried as String (semantics in doc; no filesystem algebra). - Render.lean: Step inductive (typstCompile(template) | shell(run)) replaces the loose BuildStepForm. TargetSpec = artifact + steps:List Step + covers:Kind→Prop (the old renders:Kind→Option RenderRule is gone — coverage is now a plain declaration; the render "how" lives in the typstCompile template). covers / renderIgnored semantics continuous with ADR-0005. - Primitives.lean: RenderRule field removed (the per-target payload is retired per ADR-0011); a note records where the "how" now lives. - Diagnostic.lean unchanged in meaning (covers signature stable; Legal still quantifies over declared targets). Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/Spec/Courseware.lean | 10 +-- spec/Spec/Courseware/Artifact.lean | 35 ++++++----- spec/Spec/Courseware/Primitives.lean | 12 ++-- spec/Spec/Courseware/Render.lean | 92 ++++++++++++++-------------- 4 files changed, 77 insertions(+), 72 deletions(-) diff --git a/spec/Spec/Courseware.lean b/spec/Spec/Courseware.lean index ab5c799..ee76da3 100644 --- a/spec/Spec/Courseware.lean +++ b/spec/Spec/Courseware.lean @@ -13,16 +13,18 @@ import Spec.Courseware.Course # Courseware —— 产品层契约(课程工程文件) 护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005 / 0006 / 0007 / 0008 -/ 0009 / 0010。 +/ 0009 / 0010 / 0011。 已填实(PINNED): - `Primitives` —— 基元载体(表示留给实现;schema 形态见 ADR-0006)。 - `RichContent` —— 富内容 = 带虚拟路径的 typst 源(ADR-0006 的 prose 母本)。 -- `Artifact` —— export 产物形状(单文件 / 文件树),target 值的一部分(ADR-0009)。 +- `Artifact` —— export 产物(带字段 ADT:`singleFile filepath` / `fileTree root outputs`), + target 值的一部分(ADR-0009/0011)。 - `Element` —— kind 标签 + 依赖 schema 的数据。 - `Lesson` —— element 的有序序列(序载教学语义、不建模时长)。 -- `Render` —— export target = 一次 build(map + reduce):target-中心的 `TargetSpec` - 携产物形状 + per-kind map;`covers` 判定;build 形态结构锚点(ADR-0009,修订 ADR-0005)。 +- `Render` —— export target = artifact + 有序 typed steps:`TargetSpec` 携 `artifact` + + `steps : List Step`(`typstCompile template` / `shell run`)+ 覆盖声明 `covers`; + `RenderConfig.covers` 判定(ADR-0009/0011,修订 ADR-0005)。RenderRule 载荷已废止。 - `Diagnostic` —— Severity + 7 类诊断的含义与级别 + **合法 lesson = 无 error 级诊断**; 模型外设施诊断以抽象谓词 + `Oracle` 实现边界表示(ADR-0010)。 - `Pipeline` —— 检查管线的 5 阶段、执行序、compile 门控(ADR-0010)。 diff --git a/spec/Spec/Courseware/Artifact.lean b/spec/Spec/Courseware/Artifact.lean index 2f7ca52..4f6fe55 100644 --- a/spec/Spec/Courseware/Artifact.lean +++ b/spec/Spec/Courseware/Artifact.lean @@ -1,30 +1,35 @@ /-! -# Artifact —— export target 的产物形状 +# Artifact —— export target 的产物(ADR-0009 / 0011) ADR-0009:一个 export target 是**一次 build**,产出一个**有类型的产物**(artifact)。 -产物的**形状**是 target 值的一部分——"产出单个文件还是一棵文件树"是真分歧点,因为它 -改变 `cph build` 到底吐出什么、消费方期待什么,故进契约。 +ADR-0011 进一步钉死:产物不是一个光秃的标签,而是**带字段的 ADT**——"产物到底指什么" +(单个文件落在哪 / 一棵树产出哪些文件)是不好猜的领域语义,必须写进字段 + doc,而非 +抹成 `singleFile | fileTree` 两个空构造子。 -本模块只钉**形状**这一层。具体后端/格式(用哪个 PDF 引擎、哪种 markdown 方言)是 -ADR-0009 显式 OPEN 的,不在此承诺。 +路径/glob 在此用 `String` 承载并由 doc 赋义(它们就是文本:一个相对路径、一个 glob +模式),不复刻文件系统类型——契约只钉"这个字段是什么",不建模路径代数。 -/ namespace Spec.Courseware /-- -export 产物的形状(`PINNED`, ADR-0009)。 +export 产物(`PINNED` 带字段 ADT, ADR-0011)。 -- `singleFile` —— 一份打包文档(讲义 PDF、教案 PDF)。 -- `fileTree` —— 一棵文件树 + 索引(第三方平台 archive)。 +- `singleFile (filepath)` —— 产物是**单个文件**,落在 `filepath`(相对工程根的路径)。 + 讲义 PDF、教案 PDF 即此。`filepath` 说清"这一份产物写到哪"。 +- `fileTree (root) (outputs)` —— 产物是 `root` 目录下、匹配 `outputs` **glob** 的**一组 + 文件**(第三方平台 archive 即此)。用 glob 而非显式清单:比逐个枚举轻,又让消费方/ + checker 知道"这次 build 该产出哪些文件"、可据以校验产物完整性。 -**为什么这是分歧点而非实现细节**:产物是单件还是多件,决定 reduce/assemble 这一步 -怎么折叠(见 `Render`:`singleFile` 按 lesson 序拼成一份文档再编译——这也是交叉引用 -`@ref` 与 例题N 计数器能工作的前提;`fileTree` 每 part 一文件 + 索引),也决定 -`cph build` 写一个文件还是一个目录。后端/格式(typst→PDF、markdown 方言…)仍 OPEN, -故此处**只枚举形状**,不带后端标签。 +**为什么字段进契约**:产物形状决定 `cph build` 吐一个文件还是一个目录、消费方期待什么; +也决定 reduce/assemble 怎么折叠(`singleFile` 把有序片段拼成一份再编译——这也是交叉引用 +`@ref` 与 例题N 计数器能工作的前提;`fileTree` 每 part 落一文件)。后端/格式(哪个 PDF +引擎、哪种 markdown 方言)仍 OPEN(ADR-0009),不在字段里。 -/ inductive Artifact where - | singleFile - | fileTree + /-- 单文件产物,落在 `filepath`(相对工程根)。 -/ + | singleFile (filepath : String) + /-- 多文件产物:`root` 目录下匹配 `outputs` glob 的文件集。 -/ + | fileTree (root : String) (outputs : String) end Spec.Courseware diff --git a/spec/Spec/Courseware/Primitives.lean b/spec/Spec/Courseware/Primitives.lean index ff91b45..0c03121 100644 --- a/spec/Spec/Courseware/Primitives.lean +++ b/spec/Spec/Courseware/Primitives.lean @@ -40,15 +40,11 @@ structure Primitives where "存在一个由 kind 决定的合法数据类型",不复刻 JSON Schema / typst Content 的形状。 -/ ElementData : KindId → Type - /-- export target 的标识(`PINNED` 角色, ADR-0005;表示 `OPEN`)。一个 target 是对 lesson 的一种投影(讲义/教案/PPT/平台 archive…)。 -/ + /-- export target 的标识(`PINNED` 角色, ADR-0005;表示 `OPEN`)。一个 target 是一次 build,产出对 lesson 的一种投影(讲义/教案/PPT/平台 archive…),见 `Render`。 -/ TargetId : Type - /-- - 渲染规则的载荷(表示 `OPEN`, ADR-0005)。 - "某 kind 在某 target 下如何呈现"那条规则的**内容**。typst 是渲染后端之一(`PINNED`, - ADR-0005)、不是定义语言;但渲染管线/规则载荷的具体表示仍 `OPEN`。契约只关心规则 - **在不在**(见 `Render`),不碰它是什么。 - -/ - RenderRule : Type +-- 注:原有 `RenderRule : Type` 已随 ADR-0011 移除。渲染的"how"不再是契约层的 per-target +-- 载荷,而是 `Render.TargetSpec.steps` 里 `typstCompile` step 所引用的**模板文件**承载; +-- 契约只保留覆盖声明 `TargetSpec.covers`(该 target 渲染哪些 kind),供种子诊断用。 end Spec.Courseware diff --git a/spec/Spec/Courseware/Render.lean b/spec/Spec/Courseware/Render.lean index 5f50941..25a0d0c 100644 --- a/spec/Spec/Courseware/Render.lean +++ b/spec/Spec/Courseware/Render.lean @@ -2,22 +2,25 @@ import Spec.Courseware.Primitives import Spec.Courseware.Artifact /-! -# Render —— export target = 一次 build(ADR-0009,修订 ADR-0005) +# Render —— export target = artifact + 有序 typed steps(ADR-0009 / 0011) -ADR-0005 曾把渲染建成被动的 (kind × target) 查表矩阵。ADR-0009 **修订**之:一个 export -target 是**一次 build**,消费整节 lesson、产出一个有类型的 `Artifact`。一次 build = -**map + reduce**: +ADR-0009:export target 是**一次 build**,产出一个有类型的 `Artifact`。ADR-0011 钉死 +build 的**形状**:一个 target 是 `artifact` + 一串**有序的 typed step**。每个 step 是一个 +**有类型的操作**: -- **map(per-kind)**:每个 kind 如何变成中间片段——声明式、按 kind 索引;`RenderRule` - 是这张 map 的载荷。**字段可见性**(student 隐 `solution`/`proof`,teacher 全显)就住在 - 这张 per-kind map 里,不是独立概念、也不在 element 上。 -- **reduce/assemble**:有序片段如何折叠成产物——**由 `Artifact` 形状决定**(见 - `Courseware.Artifact`),框架按形状提供 assembler,build 只挑形状。 +- `typstCompile template` —— 把**模板文件**(如 `exports/student.typ`)编译成产物。它是 + *typed* 而非裸 shell,正因框架要把 **manifest 注入**模板(经 `--input manifest=…`), + 裸 `typst compile` 字符串表达不了这个 wiring。presentation(numbly 编号、样式)住在 + 模板里,**不**在 manifest。 +- `shell run` —— 逃生口,给难以声明的步骤(ADR-0005 的 (b) 类 medium-only:HTML 交互、 + npm build 落这)。 -本模块建 target-中心的结构与"某 kind 在某 target 下是否已配规则"的判定(`covers`, -`Diagnostic` 依赖它)。`RenderRule` 的载荷仍由 `Primitives` 抽象承载(ADR-0009 显式 -OPEN);build 形态(声明式 + shell 口子)以一个**结构锚点** `BuildStepForm` 体现——钉 -"该有的结构"而**不**复刻具体 schema(宪法第 5 条)。 +MVP 每个 target 只有一个 `typstCompile` step,但 `steps` 是 **list**,因为 `FileTree` / +第三方 build 会需要多步。 + +**渲染覆盖**:ADR-0011 废止了 per-target 的 `RenderRule` 载荷——渲染的"how"已移进模板。 +契约只保留**覆盖声明** `covers : KindId → Prop`(该 target 渲染哪些 kind),供种子诊断用; +模板是其实现。`RenderConfig.covers`/`renderIgnored` 据此,ADR-0005 种子规则不变。 -/ namespace Spec.Courseware @@ -25,62 +28,61 @@ namespace Spec.Courseware variable (P : Primitives) /-- -build 步骤的**形态**(结构锚点, ADR-0009;具体 schema 字段 `OPEN`)。 +一个 build **step**(`PINNED` typed, ADR-0011;可扩展)。 -ADR-0009 定 build 的表达形态 = **声明式 schema + 一个 `run`-shell 逃生口**(近似 -GitHub Actions、**非** Makefile)。这里只把这个二分立成一个具名结构,让"build 要么声明 -式、要么走 shell 口子"成为契约里可引用的事实: +- `typstCompile (template)` —— 编译模板文件(相对工程根的路径)成产物;框架把 manifest + 注入它。**typed** 的理由:注入这件事裸 shell 写不出。 +- `shell (run)` —— 逃生口,执行 `run` 命令(ADR-0005 的 (b) 类落这)。MVP 不实现,先建结构。 -- `declarative` —— 声明式步骤(per-kind map 的常规情形)。 -- `shell` —— shell 逃生口。ADR-0005 的 (b) 类 medium-only 信息(HTML 交互、npm build) - 正是落在这里——它属于 target 的 build,**不**上 element。 - -**刻意不**把 schema 的字段、shell 命令的内部结构写进来:那是实现细节(ADR-0009 OPEN), -此锚点只承诺"build 步骤分声明式与 shell 两形态"这一骨架。 +**刻意不**把模板内部、shell 命令的解析结构写进来:那是实现细节(ADR-0011 OPEN)。此处只钉 +"step 是有类型操作,目前两型,可扩展"这一骨架。 -/ -inductive BuildStepForm where - | declarative - | shell +inductive Step where + /-- 编译模板文件 `template`(相对工程根)成产物;框架注入 manifest。 -/ + | typstCompile (template : String) + /-- shell 逃生口:执行命令 `run`。 -/ + | shell (run : String) /-- -一个 export target 的 build 规格(`PINNED` target-中心结构, ADR-0009)。 +一个 export target 的 build 规格(`PINNED` artifact + 有序 steps, ADR-0011)。 -- `artifact` —— 产物形状(`Courseware.Artifact`)。它**决定** reduce/assemble 怎么折叠 - (单文件 vs 文件树),故是 target 值的一部分,而非事后的输出选项。 -- `renders` —— per-kind map:`renders k = none` 表示 kind `k` 在此 target 下**无渲染 - 规则**(该 element 会被该 target 忽略,见 `Diagnostic.renderIgnored`);`some _` 表示 - 有。框架默认与文件 override 最终坍缩成这一个映射(ADR-0009 的 default-at-creation + - 文件内 override);契约只看最终每格的有无,不区分"默认"与"改过"。 +- `artifact` —— 产物(`Courseware.Artifact`,带字段)。决定 build 折叠成单文件还是文件树。 +- `steps` —— **有序** step 列表。按序执行;MVP 仅一个 `typstCompile`。 +- `covers` —— **覆盖声明**:`covers k` 表示此 target **渲染** kind `k`。这是 ADR-0011 把 + 旧 `renders : KindId → Option RenderRule` 降级后的产物——契约只声明"渲染哪些 kind" + (种子诊断 `renderIgnored` 用),渲染的"how"由 `steps` 的模板实现,不再是契约层载荷。 -/ structure TargetSpec where - /-- 该 target 产物的形状(decides assemble, ADR-0009)。 -/ + /-- 产物(带字段, ADR-0011)。 -/ artifact : Artifact - /-- per-kind map:kind ↦ 该 kind 在此 target 下的渲染规则(无则 `none`)。 -/ - renders : P.KindId → Option P.RenderRule + /-- 有序 build steps。 -/ + steps : List Step + /-- 覆盖声明:此 target 是否渲染该 kind。 -/ + covers : P.KindId → Prop /-- -渲染配置(`PINNED` target-中心, ADR-0009;修订 ADR-0005 的矩阵)。 +渲染配置(`PINNED` target-中心, ADR-0009/0011)。 `spec t = none` 表示 target `t` **未声明**(此工程文件不导出该 target);`some s` 给出该 -target 的 build 规格 `s`。注意这比旧矩阵多了一层:旧矩阵默认每个 (kind,target) 格都在, -现在先要 target **存在**,其 build 规格再各自决定每个 kind 渲不渲。 +target 的 build 规格 `s`(artifact + steps + 覆盖声明)。 -/ structure RenderConfig where /-- target ↦ 该 target 的 build 规格(未声明则 `none`)。 -/ spec : P.TargetId → Option (TargetSpec P) /-- -kind `k` 在 target `t` 下**已配渲染规则**(`PINNED`, ADR-0009;承接 ADR-0005 `covers`)。 +kind `k` 在 target `t` 下**被渲染**(`PINNED`, ADR-0009/0011;承接 ADR-0005 `covers`)。 -成立 ⟺ target `t` 已声明(`spec t = some s`)**且**其 per-kind map 对 `k` 非空 -(`s.renders k = some _`)。`covers` 为假即"此 kind 在此 target 下无从渲染"——checker -据此报 warning(见 `Spec.Courseware.Diagnostic.renderIgnored`)。语义与 ADR-0005 的 -`covers` 连续,只是判定下沉到 target 的 build 规格里。`P` 隐式以便点记法。 +成立 ⟺ target `t` 已声明(`spec t = some s`)**且**其覆盖声明含 `k`(`s.covers k`)。 +`covers` 为假即"此 kind 在此 target 下不被渲染"——checker 据此报 warning(见 +`Spec.Courseware.Diagnostic.renderIgnored`)。语义与 ADR-0005 连续,只是判定基底从 +"有无 RenderRule"换成"覆盖声明是否含此 kind"(ADR-0011 废 RenderRule 载荷)。`P` 隐式 +以便点记法。 -/ def RenderConfig.covers {P : Primitives} (c : RenderConfig P) (k : P.KindId) (t : P.TargetId) : Prop := match c.spec t with | none => False - | some s => (s.renders k).isSome + | some s => s.covers k end Spec.Courseware From d5bce1bede886ee8aa175a4f9d09357cf07376b7 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 09:57:50 +0800 Subject: [PATCH 21/29] =?UTF-8?q?feat(checker):=20cph-model=20manifest=20v?= =?UTF-8?q?2=20=E2=80=94=20Artifact-with-fields=20+=20typed=20steps=20(WU-?= =?UTF-8?q?B',=20ADR-0011)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [targets.*] reworked: artifact is now an inline table with a type discriminator ({type="single-file",filepath} / {type="file-tree",root,outputs}) and steps is an ordered array-of-tables ({type="typst-compile",template} / {type="shell",run}). numbering removed entirely (presentation lives in the template per ADR-0011). TargetConfig { name, artifact: Artifact, steps: Vec }; Artifact and Step mirror Spec.Courseware.Artifact / Spec.Courseware.Step (doc-cited). Sensible defaults so a bare [targets.student] still works: single-file build/.pdf + one typst-compile exports/.typ. Malformed artifact/step type → non-fatal SchemaViolation with fallback. 8 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/cph-model/src/lib.rs | 502 +++++++++++++----- .../fixtures/bad-target-config/manifest.toml | 14 +- .../fixtures/target-configs/manifest.toml | 23 +- crates/cph-model/tests/load.rs | 124 +++-- 4 files changed, 473 insertions(+), 190 deletions(-) diff --git a/crates/cph-model/src/lib.rs b/crates/cph-model/src/lib.rs index 2b46004..8d9b6f4 100644 --- a/crates/cph-model/src/lib.rs +++ b/crates/cph-model/src/lib.rs @@ -36,9 +36,8 @@ pub struct Lesson { pub parts: Vec, /// Declared export targets, collected from the `[targets.]` tables. /// - /// Per ADR-0009 an export target is a *build* producing a typed artifact, - /// with a file-resident, overridable presentation config. This carries the - /// file-resident slice of that (artifact choice + presentation overrides), + /// Per ADR-0009/0011 an export target is a *build* producing a typed + /// [`Artifact`] via an ordered list of typed [`Step`]s. This carries those /// in declared (TOML document) order. See [`TargetConfig`]. pub targets: Vec, /// Engineering-file root (absolute), for resolving part paths. @@ -56,69 +55,134 @@ impl Lesson { } } -/// One declared export target's *file-resident* build config (ADR-0009). +/// One declared export target's build config (ADR-0009/0011). /// -/// An export target is a build producing a typed [`ArtifactKind`]; its defaults -/// are seeded at project creation and **overridable in the engineering file**. -/// This struct models the slice of that build config that lives in the file: -/// the artifact choice and presentation overrides (currently heading -/// numbering). The per-kind render *map* itself stays in the render package -/// (`Spec.Courseware.TargetSpec.renders`), not here. +/// An export target is a **build** producing a typed [`Artifact`] via an +/// **ordered** list of typed [`Step`]s. ADR-0011 fixed the *shape* of that +/// build: the artifact carries fields (where the product lands / which files it +/// is), and the build is a step list, each step a typed operation. Presentation +/// (heading numbering, styling) lives in the `typstCompile` template file, not +/// in the manifest — so there is no `numbering` field here anymore. +/// +/// ## Defaults for a minimal manifest +/// +/// - A target with **no `artifact`** key defaults to +/// [`Artifact::SingleFile`] with `filepath = "build/.pdf"`, so an +/// almost-empty `[targets.student]` still produces a usable build. +/// - A target with **no `steps`** defaults to a single +/// [`Step::TypstCompile`] with `template = "exports/.typ"` (the +/// framework's stock per-target template). #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct TargetConfig { /// Target name, e.g. `"student"` (the `[targets.]` table key). pub name: String, - /// The artifact shape this target produces. Defaults to - /// [`ArtifactKind::SingleFile`] when the `artifact` field is absent. - pub artifact: ArtifactKind, - /// Presentation override for this target. `None` when no presentation - /// config is given in the file (the render layer applies its defaults). - pub numbering: Option, + /// The artifact this target produces. Defaults to a [`Artifact::SingleFile`] + /// at `build/.pdf` when the `artifact` field is absent. + pub artifact: Artifact, + /// The ordered build steps. Defaults to a single [`Step::TypstCompile`] + /// with template `exports/.typ` when no `[[steps]]` are given. + pub steps: Vec, } -/// The shape of the artifact an export target produces (ADR-0009). +/// The artifact an export target produces (ADR-0009/0011). /// /// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master /// (`spec/Spec/Courseware/Artifact.lean`), whose definition is exactly: /// /// ```text /// inductive Artifact where -/// | singleFile -/// | fileTree +/// | singleFile (filepath : String) +/// | fileTree (root : String) (outputs : String) /// ``` /// -/// `SingleFile` is one bundled document (a 讲义 / 教案 PDF); `FileTree` is a -/// tree of files plus an index (a third-party-platform archive). The on-disk -/// `artifact` string maps `"single-file"` → [`ArtifactKind::SingleFile`] and -/// `"file-tree"` → [`ArtifactKind::FileTree`]. +/// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product +/// is" — one file at some path vs. a tree of files under some root matching a +/// glob — is the non-obvious domain semantics that must be in the type, not +/// erased behind a bare tag. `SingleFile` is one bundled document (a 讲义 / 教案 +/// PDF); `FileTree` is a tree of files (a third-party-platform archive). The +/// on-disk `artifact` inline table's `type` discriminator selects the variant: +/// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` → +/// [`Artifact::FileTree`]. /// /// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this /// alignment (see the repo constitution) — it is maintained by review, which is /// why the correspondence is documented here. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub enum ArtifactKind { - /// One bundled document. Mirrors Lean `Artifact.singleFile`. Default. - SingleFile, - /// A tree of files plus an index. Mirrors Lean `Artifact.fileTree`. - FileTree, -} - -impl ArtifactKind { - /// The default artifact shape when `artifact` is omitted: `SingleFile`. - const DEFAULT: ArtifactKind = ArtifactKind::SingleFile; -} - -/// File-resident presentation overrides for a target (ADR-0009). -/// -/// Each field is `Option` so **absence is distinguishable from a value**: when -/// a field is `None`, the render package applies its framework default; only an -/// explicitly-given value overrides it. This crate deliberately invents no -/// presentation defaults — that ownership stays in the render layer. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct NumberingConfig { - /// Per-heading-level numbering patterns (index 0 = level 1, etc.), e.g. - /// `["{1:一}、", "{1:1}.{2:1}"]`. `None` when `numbering.heading` is absent. - pub heading: Option>, +pub enum Artifact { + /// One bundled document landing at `filepath` (relative to the engineering + /// root). Mirrors Lean `Artifact.singleFile`. The default artifact shape. + SingleFile { + /// Where the single product is written (relative to the engineering + /// root), e.g. `build/student.pdf`. + filepath: PathBuf, + }, + /// A set of files under `root` matching the `outputs` glob. Mirrors Lean + /// `Artifact.fileTree`. + FileTree { + /// The output directory (relative to the engineering root). + root: PathBuf, + /// A **glob** describing which files this build produces, e.g. + /// `**/*.{html,js,json}` — kept as a string (the contract pins the + /// field, not a path/glob algebra). + outputs: String, + }, +} + +impl Artifact { + /// The default artifact when `artifact` is omitted: a single file at + /// `build/.pdf`. + fn default_for(name: &str) -> Artifact { + Artifact::SingleFile { + filepath: PathBuf::from(format!("build/{name}.pdf")), + } + } +} + +/// One typed build step (ADR-0011). +/// +/// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master +/// (`spec/Spec/Courseware/Render.lean`), whose definition is exactly: +/// +/// ```text +/// inductive Step where +/// | typstCompile (template : String) +/// | shell (run : String) +/// ``` +/// +/// A step is a *typed* operation (extensible): `TypstCompile` compiles a +/// template file — typed, not a raw shell line, because the framework must wire +/// the manifest into it (`--input manifest=…`), which a bare `typst compile` +/// string cannot express. `Shell` is the escape hatch for steps that resist +/// declaration (ADR-0005's medium-only category (b): HTML interactive / npm +/// builds). The on-disk `[[steps]]` entry's `type` discriminator selects the +/// variant: `"typst-compile"` → [`Step::TypstCompile`], `"shell"` → +/// [`Step::Shell`]. +/// +/// As with [`Artifact`], this alignment is maintained by review, not CI. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum Step { + /// Compile a template file (relative to the engineering root) into the + /// artifact; the framework injects the manifest. Mirrors Lean + /// `Step.typstCompile`. + TypstCompile { + /// The template file to compile as main, e.g. `exports/student.typ`. + template: PathBuf, + }, + /// Run a shell command — the escape hatch. Mirrors Lean `Step.shell`. + Shell { + /// The command line to run. + run: String, + }, +} + +impl Step { + /// The default step when no `[[steps]]` are given: compile the stock + /// per-target template `exports/.typ`. + fn default_for(name: &str) -> Step { + Step::TypstCompile { + template: PathBuf::from(format!("exports/{name}.typ")), + } + } } /// `[project]` table. @@ -296,7 +360,7 @@ pub fn load(root: &Path) -> (Option, Vec) { }; // Parse each [targets.] table into a structured build config - // (ADR-0009). Order is the TOML document order, as before. Malformed + // (ADR-0009/0011). Order is the TOML document order, as before. Malformed // target config is non-fatal: it is reported and the target is kept with // defaults so loading continues. let targets: Vec = raw @@ -485,13 +549,14 @@ fn load_descriptor( } } -/// Parse one `[targets.]` table into a [`TargetConfig`] (ADR-0009), +/// Parse one `[targets.]` table into a [`TargetConfig`] (ADR-0009/0011), /// collecting [`DiagCode::SchemaViolation`] diagnostics for malformed fields. /// -/// Malformed config is **non-fatal**: a bad `artifact` value falls back to the -/// default ([`ArtifactKind::SingleFile`]); a malformed `numbering.heading` -/// leaves the override absent — both reported, never dropping the target. An -/// empty `[targets.]` body is valid (all defaults, no diagnostics). +/// Malformed config is **non-fatal**: a bad/absent `artifact` falls back to the +/// default ([`Artifact::default_for`]); a bad/absent `steps` falls back to the +/// default single `typst-compile` step ([`Step::default_for`]) — all reported, +/// never dropping the target. An empty `[targets.]` body is valid (all +/// defaults, no diagnostics). fn parse_target(name: String, value: toml::Value, diags: &mut Vec) -> TargetConfig { // A target body must be a table; anything else (`student = 1`) is malformed. let mut table = match value { @@ -505,127 +570,286 @@ fn parse_target(name: String, value: toml::Value, diags: &mut Vec) - .with_hint("declare a target as a `[targets.]` table"), ); return TargetConfig { + artifact: Artifact::default_for(&name), + steps: vec![Step::default_for(&name)], name, - artifact: ArtifactKind::DEFAULT, - numbering: None, }; } }; let artifact = match table.remove("artifact") { - None => ArtifactKind::DEFAULT, - Some(toml::Value::String(s)) => match s.as_str() { - "single-file" => ArtifactKind::SingleFile, - "file-tree" => ArtifactKind::FileTree, - other => { - diags.push( - Diagnostic::error( - DiagCode::SchemaViolation, - format!( - "target '{name}' has an invalid artifact '{other}'; \ - valid values are \"single-file\", \"file-tree\"" - ), - ) - .with_hint("set `artifact` to \"single-file\" or \"file-tree\""), - ); - ArtifactKind::DEFAULT - } - }, - Some(_) => { - diags.push( - Diagnostic::error( - DiagCode::SchemaViolation, - format!( - "target '{name}' has a non-string `artifact`; \ - valid values are \"single-file\", \"file-tree\"" - ), - ) - .with_hint("set `artifact` to \"single-file\" or \"file-tree\""), - ); - ArtifactKind::DEFAULT - } + None => Artifact::default_for(&name), + Some(value) => parse_artifact(&name, value, diags), }; - let numbering = parse_numbering(&name, &mut table, diags); + let steps = match table.remove("steps") { + None => vec![Step::default_for(&name)], + Some(value) => parse_steps(&name, value, diags), + }; TargetConfig { name, artifact, - numbering, + steps, } } -/// Parse the optional `[targets..numbering]` sub-table into a -/// [`NumberingConfig`]. Returns `None` when no `numbering` table is present. -/// A malformed `heading` (not an array of strings) is reported as a -/// [`DiagCode::SchemaViolation`] and left absent (`None`). -fn parse_numbering( - name: &str, - table: &mut toml::Table, - diags: &mut Vec, -) -> Option { - let numbering = table.remove("numbering")?; - - let mut numbering_table = match numbering { +/// Parse the `artifact` inline table into an [`Artifact`]. The `type` +/// discriminator selects the variant and which fields are read. An unknown or +/// malformed `type`, a non-table value, or a missing required field is reported +/// as a [`DiagCode::SchemaViolation`]; in every error case a usable default +/// ([`Artifact::default_for`]) is returned so loading continues. +fn parse_artifact(name: &str, value: toml::Value, diags: &mut Vec) -> Artifact { + let mut table = match value { toml::Value::Table(t) => t, _ => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, - format!("target '{name}' has a non-table `numbering`"), + format!( + "target '{name}' has a non-table `artifact`; it must be an \ + inline table like `{{ type = \"single-file\", filepath = \"…\" }}`" + ), ) - .with_hint(format!("declare numbering as `[targets.{name}.numbering]`")), + .with_hint("set `artifact` to an inline table with a `type` discriminator"), ); - return Some(NumberingConfig { heading: None }); + return Artifact::default_for(name); } }; - let heading = match numbering_table.remove("heading") { - None => None, - Some(toml::Value::Array(items)) => { - let mut patterns = Vec::with_capacity(items.len()); - let mut malformed = false; - for item in items { - match item { - toml::Value::String(s) => patterns.push(s), - _ => { - malformed = true; - break; - } - } - } - if malformed { - diags.push( - Diagnostic::error( - DiagCode::SchemaViolation, - format!( - "target '{name}' has a `numbering.heading` that is not an array of strings" - ), - ) - .with_hint( - "`numbering.heading` must be an array of per-level pattern strings", - ), - ); - None - } else { - Some(patterns) - } - } + let kind = match table.remove("type") { + Some(toml::Value::String(s)) => s, Some(_) => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, - format!( - "target '{name}' has a `numbering.heading` that is not an array of strings" - ), + format!("target '{name}' has a non-string `artifact.type`"), ) - .with_hint("`numbering.heading` must be an array of per-level pattern strings"), + .with_hint("set `artifact.type` to \"single-file\" or \"file-tree\""), ); - None + return Artifact::default_for(name); + } + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{name}' has an `artifact` with no `type` discriminator"), + ) + .with_hint("add `type = \"single-file\"` (or `\"file-tree\"`) to `artifact`"), + ); + return Artifact::default_for(name); } }; - Some(NumberingConfig { heading }) + match kind.as_str() { + "single-file" => { + let filepath = match take_string_field(&mut table, "filepath") { + Some(s) => PathBuf::from(s), + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' single-file `artifact` is missing a string \ + `filepath`" + ), + ) + .with_hint("add `filepath = \"build/.pdf\"` to the artifact"), + ); + return Artifact::default_for(name); + } + }; + Artifact::SingleFile { filepath } + } + "file-tree" => { + let root = take_string_field(&mut table, "root"); + let outputs = take_string_field(&mut table, "outputs"); + match (root, outputs) { + (Some(root), Some(outputs)) => Artifact::FileTree { + root: PathBuf::from(root), + outputs, + }, + (root, outputs) => { + let mut missing = Vec::new(); + if root.is_none() { + missing.push("root"); + } + if outputs.is_none() { + missing.push("outputs"); + } + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' file-tree `artifact` is missing required \ + string field(s): {}", + missing.join(", ") + ), + ) + .with_hint( + "a file-tree artifact needs `root = \"\"` and \ + `outputs = \"\"`", + ), + ); + Artifact::default_for(name) + } + } + } + other => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' has an invalid `artifact.type` '{other}'; \ + valid values are \"single-file\", \"file-tree\"" + ), + ) + .with_hint("set `artifact.type` to \"single-file\" or \"file-tree\""), + ); + Artifact::default_for(name) + } + } +} + +/// Parse the `steps` array-of-tables into an ordered `Vec`, preserving +/// declared order. A non-array `steps`, or any individual step with an unknown +/// or malformed `type` / missing required field, is reported as a +/// [`DiagCode::SchemaViolation`]; malformed individual steps are skipped while +/// well-formed ones are kept. If `steps` is present but yields no usable steps, +/// the default step ([`Step::default_for`]) is substituted so the build stays +/// runnable. +fn parse_steps(name: &str, value: toml::Value, diags: &mut Vec) -> Vec { + let items = match value { + toml::Value::Array(items) => items, + _ => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' has a non-array `steps`; declare steps as \ + `[[targets.{name}.steps]]` entries" + ), + ) + .with_hint("each step is a `[[targets..steps]]` table with a `type`"), + ); + return vec![Step::default_for(name)]; + } + }; + + let mut steps = Vec::with_capacity(items.len()); + for item in items { + if let Some(step) = parse_step(name, item, diags) { + steps.push(step); + } + } + + if steps.is_empty() { + // `steps` was present but produced nothing usable: keep the build + // runnable with the default step (the malformed entries were reported). + vec![Step::default_for(name)] + } else { + steps + } +} + +/// Parse one `[[steps]]` entry into a [`Step`]. The `type` discriminator selects +/// the variant. Returns `None` (after reporting a [`DiagCode::SchemaViolation`]) +/// for a non-table entry, an unknown/malformed `type`, or a missing required +/// field. +fn parse_step(name: &str, value: toml::Value, diags: &mut Vec) -> Option { + let mut table = match value { + toml::Value::Table(t) => t, + _ => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{name}' has a step that is not a table"), + ) + .with_hint("each step is a `[[targets..steps]]` table with a `type`"), + ); + return None; + } + }; + + let kind = match table.remove("type") { + Some(toml::Value::String(s)) => s, + Some(_) => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{name}' has a step with a non-string `type`"), + ) + .with_hint("set the step `type` to \"typst-compile\" or \"shell\""), + ); + return None; + } + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{name}' has a step with no `type` discriminator"), + ) + .with_hint("add `type = \"typst-compile\"` (or `\"shell\"`) to the step"), + ); + return None; + } + }; + + match kind.as_str() { + "typst-compile" => match take_string_field(&mut table, "template") { + Some(template) => Some(Step::TypstCompile { + template: PathBuf::from(template), + }), + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' typst-compile step is missing a string `template`" + ), + ) + .with_hint("add `template = \"exports/.typ\"` to the step"), + ); + None + } + }, + "shell" => match take_string_field(&mut table, "run") { + Some(run) => Some(Step::Shell { run }), + None => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!("target '{name}' shell step is missing a string `run`"), + ) + .with_hint("add `run = \"\"` to the step"), + ); + None + } + }, + other => { + diags.push( + Diagnostic::error( + DiagCode::SchemaViolation, + format!( + "target '{name}' has a step with an invalid `type` '{other}'; \ + valid values are \"typst-compile\", \"shell\"" + ), + ) + .with_hint("set the step `type` to \"typst-compile\" or \"shell\""), + ); + None + } + } +} + +/// Remove `key` from `table` and return it if it is a string; otherwise `None` +/// (a missing key and a non-string value are both treated as absent — the +/// caller reports the missing-field diagnostic with the right context). +fn take_string_field(table: &mut toml::Table, key: &str) -> Option { + match table.remove(key) { + Some(toml::Value::String(s)) => Some(s), + _ => None, + } } /// True if `path` contains any `..` component (parent-dir traversal). diff --git a/crates/cph-model/tests/fixtures/bad-target-config/manifest.toml b/crates/cph-model/tests/fixtures/bad-target-config/manifest.toml index d47aa1b..cacd692 100644 --- a/crates/cph-model/tests/fixtures/bad-target-config/manifest.toml +++ b/crates/cph-model/tests/fixtures/bad-target-config/manifest.toml @@ -5,12 +5,14 @@ name = "bad-target-config" [info] title = "测试课:非法导出目标配置" -# Invalid artifact value → SchemaViolation, falls back to default (single-file). +# Invalid artifact `type` → SchemaViolation, falls back to default (single-file). [targets.student] -artifact = "pdf-thing" +artifact = { type = "pdf-thing", filepath = "build/student.pdf" } -# numbering.heading is not an array of strings → SchemaViolation, heading None. +# Invalid step `type` → SchemaViolation; the bad step is skipped and the build +# falls back to the default step. [targets.teacher] -artifact = "file-tree" -[targets.teacher.numbering] -heading = "not-an-array" +artifact = { type = "file-tree", root = "build/teacher", outputs = "**/*.html" } +[[targets.teacher.steps]] +type = "make-it-nice" +template = "exports/teacher.typ" diff --git a/crates/cph-model/tests/fixtures/target-configs/manifest.toml b/crates/cph-model/tests/fixtures/target-configs/manifest.toml index fe06232..48d0125 100644 --- a/crates/cph-model/tests/fixtures/target-configs/manifest.toml +++ b/crates/cph-model/tests/fixtures/target-configs/manifest.toml @@ -5,15 +5,24 @@ name = "target-configs" [info] title = "测试课:导出目标的结构化 build 配置" -# Full config: explicit artifact + numbering.heading override. +# Full v2 config: explicit single-file artifact + an ordered typst-compile step. [targets.student] -artifact = "single-file" -[targets.student.numbering] -heading = ["{1:一}、", "{1:1}.{2:1}", "{1:1}.{2:1}.{3:1}"] +artifact = { type = "single-file", filepath = "build/student.pdf" } +[[targets.student.steps]] +type = "typst-compile" +template = "exports/student.typ" -# file-tree artifact, no presentation override. +# file-tree artifact + multiple steps (typst-compile then a shell step), +# exercising both step types and order preservation. [targets.archive] -artifact = "file-tree" +artifact = { type = "file-tree", root = "build/archive", outputs = "**/*.{html,js,json}" } +[[targets.archive.steps]] +type = "typst-compile" +template = "exports/archive.typ" +[[targets.archive.steps]] +type = "shell" +run = "npm run build" -# Empty body → all defaults. +# Empty body → all defaults (single-file build/teacher.pdf, one typst-compile +# step exports/teacher.typ). [targets.teacher] diff --git a/crates/cph-model/tests/load.rs b/crates/cph-model/tests/load.rs index 511e53e..d26b8f2 100644 --- a/crates/cph-model/tests/load.rs +++ b/crates/cph-model/tests/load.rs @@ -48,10 +48,21 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() { // Targets are collected from [targets.*], in declared order. assert_eq!(lesson.target_names(), vec!["student", "teacher"]); - // Empty `[targets.x]` bodies → all-default build config, no diagnostics. + // Empty `[targets.x]` bodies → all-default build config, no diagnostics: + // single-file artifact at build/.pdf + one typst-compile step. for target in &lesson.targets { - assert_eq!(target.artifact, cph_model::ArtifactKind::SingleFile); - assert_eq!(target.numbering, None); + assert_eq!( + target.artifact, + cph_model::Artifact::SingleFile { + filepath: PathBuf::from(format!("build/{}.pdf", target.name)), + } + ); + assert_eq!( + target.steps, + vec![cph_model::Step::TypstCompile { + template: PathBuf::from(format!("exports/{}.typ", target.name)), + }] + ); } // Descriptor dir is absolute, anchored under the root. @@ -129,7 +140,7 @@ fn missing_manifest_is_a_hard_failure() { #[test] fn structured_target_configs_parse_into_typed_builds() { - use cph_model::ArtifactKind; + use cph_model::{Artifact, Step}; let (lesson, diags) = load(&fixture("target-configs")); let lesson = lesson.expect("target-configs fixture must produce a Lesson"); @@ -141,41 +152,66 @@ fn structured_target_configs_parse_into_typed_builds() { // Declared order is preserved. assert_eq!(lesson.target_names(), vec!["student", "archive", "teacher"]); - // student: explicit single-file + heading numbering override, in order. + // student: explicit single-file artifact (with filepath) + one + // typst-compile step (with template). let student = &lesson.targets[0]; assert_eq!(student.name, "student"); - assert_eq!(student.artifact, ArtifactKind::SingleFile); - let numbering = student - .numbering - .as_ref() - .expect("student declares a numbering override"); assert_eq!( - numbering.heading.as_deref(), - Some( - &[ - "{1:一}、".to_string(), - "{1:1}.{2:1}".to_string(), - "{1:1}.{2:1}.{3:1}".to_string(), - ][..] - ) + student.artifact, + Artifact::SingleFile { + filepath: PathBuf::from("build/student.pdf"), + } + ); + assert_eq!( + student.steps, + vec![Step::TypstCompile { + template: PathBuf::from("exports/student.typ"), + }] ); - // archive: file-tree artifact, no presentation override. + // archive: file-tree artifact (root + outputs glob) + two ordered steps, + // a typst-compile followed by a shell step (order preserved). let archive = &lesson.targets[1]; assert_eq!(archive.name, "archive"); - assert_eq!(archive.artifact, ArtifactKind::FileTree); - assert_eq!(archive.numbering, None); + assert_eq!( + archive.artifact, + Artifact::FileTree { + root: PathBuf::from("build/archive"), + outputs: "**/*.{html,js,json}".to_string(), + } + ); + assert_eq!( + archive.steps, + vec![ + Step::TypstCompile { + template: PathBuf::from("exports/archive.typ"), + }, + Step::Shell { + run: "npm run build".to_string(), + }, + ] + ); - // teacher: empty body → all defaults, numbering absent. + // teacher: empty body → all defaults. let teacher = &lesson.targets[2]; assert_eq!(teacher.name, "teacher"); - assert_eq!(teacher.artifact, ArtifactKind::SingleFile); - assert_eq!(teacher.numbering, None); + assert_eq!( + teacher.artifact, + Artifact::SingleFile { + filepath: PathBuf::from("build/teacher.pdf"), + } + ); + assert_eq!( + teacher.steps, + vec![Step::TypstCompile { + template: PathBuf::from("exports/teacher.typ"), + }] + ); } #[test] fn malformed_target_config_is_non_fatal_with_schema_violations() { - use cph_model::ArtifactKind; + use cph_model::{Artifact, Step}; let (lesson, diags) = load(&fixture("bad-target-config")); let lesson = lesson.expect("bad-target-config must still produce a Lesson"); @@ -183,19 +219,31 @@ fn malformed_target_config_is_non_fatal_with_schema_violations() { // Both targets survive despite their malformed fields. assert_eq!(lesson.target_names(), vec!["student", "teacher"]); - // Bad `artifact` value → SchemaViolation, target kept with default artifact. + // Bad `artifact.type` → SchemaViolation, target kept with default artifact. let student = &lesson.targets[0]; - assert_eq!(student.artifact, ArtifactKind::SingleFile); + assert_eq!( + student.artifact, + Artifact::SingleFile { + filepath: PathBuf::from("build/student.pdf"), + } + ); - // Bad `numbering.heading` (not an array of strings) → heading stays None, - // but the numbering table itself is present. + // Bad step `type` → the step is skipped; the file-tree artifact survives and + // the empty step list falls back to the default step. let teacher = &lesson.targets[1]; - assert_eq!(teacher.artifact, ArtifactKind::FileTree); - let teacher_numbering = teacher - .numbering - .as_ref() - .expect("teacher declares a numbering table"); - assert_eq!(teacher_numbering.heading, None); + assert_eq!( + teacher.artifact, + Artifact::FileTree { + root: PathBuf::from("build/teacher"), + outputs: "**/*.html".to_string(), + } + ); + assert_eq!( + teacher.steps, + vec![Step::TypstCompile { + template: PathBuf::from("exports/teacher.typ"), + }] + ); let violations: Vec<_> = diags .iter() @@ -208,12 +256,12 @@ fn malformed_target_config_is_non_fatal_with_schema_violations() { ); assert!( violations.iter().any(|d| d.message.contains("pdf-thing")), - "a diagnostic should name the bad artifact value, got: {diags:?}" + "a diagnostic should name the bad artifact type, got: {diags:?}" ); assert!( violations .iter() - .any(|d| d.message.contains("numbering.heading")), - "a diagnostic should name the malformed numbering.heading, got: {diags:?}" + .any(|d| d.message.contains("make-it-nice")), + "a diagnostic should name the bad step type, got: {diags:?}" ); } From 7e15cf34f6b38185d8e79c7000496ba8e4a00f43 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 09:57:51 +0800 Subject: [PATCH 22/29] feat(render): export templates + render-lesson entry; template owns include loop (WU-C', ADR-0011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the display(parts) entry (which a generated driver called) with the template model: real editable templates (render/templates/{student,teacher}.typ, the framework defaults copied into an engineering file's exports/) read the manifest via toml(sys.inputs.manifest) and assemble parts themselves. Key finding (drives the engine): a dynamic `include` inside a package resolves against the PACKAGE root, never --root — so the include loop must live in the TEMPLATE (which sits under --root): `include "/" + part.path + "/" + field + ".typ"` (root-relative absolute, runtime-computed — legal per ADR-0011's verified fact). cph-render exposes render-lesson(info, target, parts, heading-numbering) (content-in, never includes) + part-fields (kind→fields) + default-heading-numbering. Numbering (numbly per-level) is set in the template, editable per engineering file. Surfaced OPEN point closed by the engine (WU-D'): typst has no file-exists primitive, so optional content (lemma proof) presence comes from a per-part `fields` array the engine computes from disk. Manifest injected as root-relative absolute (--input manifest=/...). numbly stays vendored/offline. display removed. local-packages symlink (test-only @local resolution) gitignored — regenerable, self-referential; the embedded engine mounts cph-render directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + render/examples/smoke-config.typ | 14 --- .../examples/坐标数量积/element.toml | 1 + .../smoke-eng/examples/坐标数量积/problem.typ | 1 + .../examples/坐标数量积/solution.typ | 1 + .../smoke-eng/examples/求模长/element.toml | 1 + .../smoke-eng/examples/求模长/problem.typ | 1 + .../smoke-eng/examples/求模长/solution.typ | 1 + render/examples/smoke-eng/exports/student.typ | 79 +++++++++++++++++ render/examples/smoke-eng/exports/teacher.typ | 62 +++++++++++++ .../smoke-eng/lemmas/垂直判据/element.toml | 1 + .../smoke-eng/lemmas/垂直判据/stmt.typ | 1 + .../smoke-eng/lemmas/柯西不等式/element.toml | 1 + .../smoke-eng/lemmas/柯西不等式/proof.typ | 3 + .../smoke-eng/lemmas/柯西不等式/stmt.typ | 1 + render/examples/smoke-eng/manifest.toml | 64 ++++++++++++++ .../segments/向量数量积/textbook.typ | 14 +++ .../smoke-eng/sops/求夹角步骤/element.toml | 1 + .../smoke-eng/sops/求夹角步骤/sop.typ | 4 + render/examples/smoke-parts.typ | 83 ----------------- render/examples/smoke-student.typ | 6 -- render/examples/smoke-teacher.typ | 5 -- render/lib.typ | 88 +++++++++++++------ render/templates/README.md | 63 +++++++++++++ render/templates/student.typ | 79 +++++++++++++++++ render/templates/teacher.typ | 62 +++++++++++++ render/typst.toml | 2 +- 27 files changed, 506 insertions(+), 137 deletions(-) delete mode 100644 render/examples/smoke-config.typ create mode 100644 render/examples/smoke-eng/examples/坐标数量积/element.toml create mode 100644 render/examples/smoke-eng/examples/坐标数量积/problem.typ create mode 100644 render/examples/smoke-eng/examples/坐标数量积/solution.typ create mode 100644 render/examples/smoke-eng/examples/求模长/element.toml create mode 100644 render/examples/smoke-eng/examples/求模长/problem.typ create mode 100644 render/examples/smoke-eng/examples/求模长/solution.typ create mode 100644 render/examples/smoke-eng/exports/student.typ create mode 100644 render/examples/smoke-eng/exports/teacher.typ create mode 100644 render/examples/smoke-eng/lemmas/垂直判据/element.toml create mode 100644 render/examples/smoke-eng/lemmas/垂直判据/stmt.typ create mode 100644 render/examples/smoke-eng/lemmas/柯西不等式/element.toml create mode 100644 render/examples/smoke-eng/lemmas/柯西不等式/proof.typ create mode 100644 render/examples/smoke-eng/lemmas/柯西不等式/stmt.typ create mode 100644 render/examples/smoke-eng/manifest.toml create mode 100644 render/examples/smoke-eng/segments/向量数量积/textbook.typ create mode 100644 render/examples/smoke-eng/sops/求夹角步骤/element.toml create mode 100644 render/examples/smoke-eng/sops/求夹角步骤/sop.typ delete mode 100644 render/examples/smoke-parts.typ delete mode 100644 render/examples/smoke-student.typ delete mode 100644 render/examples/smoke-teacher.typ create mode 100644 render/templates/README.md create mode 100644 render/templates/student.typ create mode 100644 render/templates/teacher.typ diff --git a/.gitignore b/.gitignore index 3acbb49..a28e9e8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,9 @@ **/*.pdf .cph/ +# Test-only @local package resolution convenience (self-referential symlink); +# regenerable, not for VCS. The embedded engine mounts cph-render directly. +render/vendor/local-packages/ + # OS / editor .DS_Store diff --git a/render/examples/smoke-config.typ b/render/examples/smoke-config.typ deleted file mode 100644 index 9847344..0000000 --- a/render/examples/smoke-config.typ +++ /dev/null @@ -1,14 +0,0 @@ -// Smoke test — exercises the per-target presentation `config` override path -// (ADR-0009). Here the engineering-file-supplied `config.numbering.heading` -// overrides the framework-default heading numbering. The patterns happen to -// match the correct per-level scheme (level-1 `一、`, level-2 `1.1`), proving -// the override is wired AND yields correct (NOT `二、一、`) output. -#import "../lib.typ": display -#import "smoke-parts.typ": info, parts - -#display( - info: info, - target: "teacher", - parts: parts, - config: (numbering: (heading: ("{1:一}、", "{1:1}.{2:1}"))), -) diff --git a/render/examples/smoke-eng/examples/坐标数量积/element.toml b/render/examples/smoke-eng/examples/坐标数量积/element.toml new file mode 100644 index 0000000..1c09cee --- /dev/null +++ b/render/examples/smoke-eng/examples/坐标数量积/element.toml @@ -0,0 +1 @@ +source = "2024 高考甲卷" diff --git a/render/examples/smoke-eng/examples/坐标数量积/problem.typ b/render/examples/smoke-eng/examples/坐标数量积/problem.typ new file mode 100644 index 0000000..b12b104 --- /dev/null +++ b/render/examples/smoke-eng/examples/坐标数量积/problem.typ @@ -0,0 +1 @@ +已知 $arrow(a) = (1, 2)$,$arrow(b) = (3, -1)$,求 $arrow(a) dot arrow(b)$。 diff --git a/render/examples/smoke-eng/examples/坐标数量积/solution.typ b/render/examples/smoke-eng/examples/坐标数量积/solution.typ new file mode 100644 index 0000000..8dfdfe8 --- /dev/null +++ b/render/examples/smoke-eng/examples/坐标数量积/solution.typ @@ -0,0 +1 @@ +由坐标公式,$arrow(a) dot arrow(b) = 1 times 3 + 2 times (-1) = 3 - 2 = 1$。 diff --git a/render/examples/smoke-eng/examples/求模长/element.toml b/render/examples/smoke-eng/examples/求模长/element.toml new file mode 100644 index 0000000..e4311ae --- /dev/null +++ b/render/examples/smoke-eng/examples/求模长/element.toml @@ -0,0 +1 @@ +# no source scalar — exercises the absent-scalar path diff --git a/render/examples/smoke-eng/examples/求模长/problem.typ b/render/examples/smoke-eng/examples/求模长/problem.typ new file mode 100644 index 0000000..1bfe87e --- /dev/null +++ b/render/examples/smoke-eng/examples/求模长/problem.typ @@ -0,0 +1 @@ +求向量 $arrow(a) = (3, 4)$ 的模长 $|arrow(a)|$。 diff --git a/render/examples/smoke-eng/examples/求模长/solution.typ b/render/examples/smoke-eng/examples/求模长/solution.typ new file mode 100644 index 0000000..87e8a5f --- /dev/null +++ b/render/examples/smoke-eng/examples/求模长/solution.typ @@ -0,0 +1 @@ +$|arrow(a)| = sqrt(3^2 + 4^2) = sqrt(25) = 5$。 diff --git a/render/examples/smoke-eng/exports/student.typ b/render/examples/smoke-eng/exports/student.typ new file mode 100644 index 0000000..a33d25d --- /dev/null +++ b/render/examples/smoke-eng/exports/student.typ @@ -0,0 +1,79 @@ +// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011). +// +// This is a *real, editable* file that lives in an engineering file at +// `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the +// manifest injected: +// typst compile --root --input manifest= exports/student.typ +// +// It is intentionally self-contained (no shared helper import) so it can be +// copied verbatim into a new engineering file's `exports/`. Presentation — +// heading numbering — lives HERE (editable per engineering file), not in the +// manifest and not hardcoded in the cph-render package. +// +// WHY THE INCLUDE LOOP IS HERE AND NOT IN cph-render: typst resolves a dynamic +// `include` path relative to the file it lexically appears in, and a package has +// its own virtual root — an include inside cph-render would resolve against the +// PACKAGE, not the engineering root. A `//.typ` written HERE +// (this template lives under `--root`) resolves against `--root`. So the +// template loads content and hands cph-render an already-assembled `parts` array. +// +// OPEN CONTRACT POINT — optional-content presence. typst has no "does this file +// exist" primitive (a missing `include` is a hard compile error). So the +// template CANNOT probe disk the way the old Rust driver did for lemma `proof`. +// It relies on the manifest declaring which optional content fields are present, +// via a per-part `fields` array listing the content fields that exist on disk +// (the engine knows this — it walks the part dir). Required fields are loaded +// unconditionally; optional fields load only if listed in `fields`. If a part +// omits `fields`, optional content is skipped (conservative). The exact shape of +// this declaration is for the manifest/Rust contract to pin. + +#import "@local/cph-render:0.1.0": render-lesson, part-fields, default-heading-numbering + +// This template IS the student build, so the target is fixed. +#let target = "student" + +// Read the injected manifest (a path string relative to typst --root). +#let manifest = toml(sys.inputs.manifest) +#let info = manifest.at("info", default: (:)) +#let raw-parts = manifest.at("parts", default: ()) + +// Assemble each part: include its content fields (computed absolute paths, +// resolved against --root) and read scalar fields from /element.toml. +// `part-fields` (from cph-render) is the single source of truth for kind->fields. +#let parts = raw-parts.map(raw => { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let part = (kind: kind) + + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + part.insert(field, include "/" + path + "/" + field + ".typ") + } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + part.insert(field, include "/" + path + "/" + field + ".typ") + } + } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { part.insert(field, v) } + } + } + part +}) + +// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes +// from cph-render; override here per engineering file if desired. +#render-lesson( + info: info, + target: target, + parts: parts, + heading-numbering: default-heading-numbering, +) diff --git a/render/examples/smoke-eng/exports/teacher.typ b/render/examples/smoke-eng/exports/teacher.typ new file mode 100644 index 0000000..b976b1f --- /dev/null +++ b/render/examples/smoke-eng/exports/teacher.typ @@ -0,0 +1,62 @@ +// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011). +// +// Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with +// the manifest injected: +// typst compile --root --input manifest= exports/teacher.typ +// +// Self-contained (copyable into an engineering file). Identical to student.typ +// except the fixed target is "teacher" (so the (kind x target) render matrix in +// cph-render shows solutions and proofs). See student.typ for the full notes on +// why the include loop lives here (package virtual-root resolution) and on the +// OPEN optional-content (`fields`) contract point. + +#import "@local/cph-render:0.1.0": render-lesson, part-fields, default-heading-numbering + +// This template IS the teacher build, so the target is fixed. +#let target = "teacher" + +// Read the injected manifest (a path string relative to typst --root). +#let manifest = toml(sys.inputs.manifest) +#let info = manifest.at("info", default: (:)) +#let raw-parts = manifest.at("parts", default: ()) + +// Assemble each part: include its content fields (computed absolute paths, +// resolved against --root) and read scalar fields from /element.toml. +// `part-fields` (from cph-render) is the single source of truth for kind->fields. +#let parts = raw-parts.map(raw => { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let part = (kind: kind) + + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + part.insert(field, include "/" + path + "/" + field + ".typ") + } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + part.insert(field, include "/" + path + "/" + field + ".typ") + } + } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { part.insert(field, v) } + } + } + part +}) + +// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes +// from cph-render; override here per engineering file if desired. +#render-lesson( + info: info, + target: target, + parts: parts, + heading-numbering: default-heading-numbering, +) diff --git a/render/examples/smoke-eng/lemmas/垂直判据/element.toml b/render/examples/smoke-eng/lemmas/垂直判据/element.toml new file mode 100644 index 0000000..73f7af4 --- /dev/null +++ b/render/examples/smoke-eng/lemmas/垂直判据/element.toml @@ -0,0 +1 @@ +# lemma WITHOUT proof — proof.typ is absent on disk; manifest omits it from fields diff --git a/render/examples/smoke-eng/lemmas/垂直判据/stmt.typ b/render/examples/smoke-eng/lemmas/垂直判据/stmt.typ new file mode 100644 index 0000000..c5f0534 --- /dev/null +++ b/render/examples/smoke-eng/lemmas/垂直判据/stmt.typ @@ -0,0 +1 @@ +两个非零向量垂直当且仅当其数量积为零,即 $arrow(a) perp arrow(b) <==> arrow(a) dot arrow(b) = 0$。 diff --git a/render/examples/smoke-eng/lemmas/柯西不等式/element.toml b/render/examples/smoke-eng/lemmas/柯西不等式/element.toml new file mode 100644 index 0000000..5b79235 --- /dev/null +++ b/render/examples/smoke-eng/lemmas/柯西不等式/element.toml @@ -0,0 +1 @@ +# lemma scalars: none diff --git a/render/examples/smoke-eng/lemmas/柯西不等式/proof.typ b/render/examples/smoke-eng/lemmas/柯西不等式/proof.typ new file mode 100644 index 0000000..7248a39 --- /dev/null +++ b/render/examples/smoke-eng/lemmas/柯西不等式/proof.typ @@ -0,0 +1,3 @@ +由数量积定义 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$, +又 $|cos theta| <= 1$,故 $|arrow(a) dot arrow(b)| = |arrow(a)| |arrow(b)| |cos theta| <= |arrow(a)| |arrow(b)|$。 +$qed$ diff --git a/render/examples/smoke-eng/lemmas/柯西不等式/stmt.typ b/render/examples/smoke-eng/lemmas/柯西不等式/stmt.typ new file mode 100644 index 0000000..b397fda --- /dev/null +++ b/render/examples/smoke-eng/lemmas/柯西不等式/stmt.typ @@ -0,0 +1 @@ +对任意向量 $arrow(a)$、$arrow(b)$,有 $|arrow(a) dot arrow(b)| <= |arrow(a)| |arrow(b)|$。 diff --git a/render/examples/smoke-eng/manifest.toml b/render/examples/smoke-eng/manifest.toml new file mode 100644 index 0000000..bb64c9d --- /dev/null +++ b/render/examples/smoke-eng/manifest.toml @@ -0,0 +1,64 @@ +# Throwaway smoke engineering file for the cph-render template round (ADR-0011). +# Exercises all 4 kinds + nested headings (per-level numbering) + an example with +# a `source` scalar and one without + a lemma with proof and one without. +# +# The per-part `fields` array lists the content fields present ON DISK. The +# template uses it to decide whether to load OPTIONAL content (lemma `proof`), +# because typst has no file-exists primitive. (OPEN: the exact manifest shape for +# declaring optional-field presence is for the Rust/manifest contract to pin.) + +[project] +id = "smoke-eng" +name = "cph-render template smoke" + +[info] +title = "向量与几何 · 示例讲义" +author = ["张老师", "李老师"] + +# segment — nested headings exercise per-level numbering (一、 / 1.1 / 1.1.1). +[[parts]] +kind = "segment" +path = "segments/向量数量积" +fields = ["textbook"] + +# example WITH source scalar +[[parts]] +kind = "example" +path = "examples/坐标数量积" +fields = ["problem", "solution"] + +# example WITHOUT source scalar +[[parts]] +kind = "example" +path = "examples/求模长" +fields = ["problem", "solution"] + +# lemma WITH proof (proof.typ present on disk; declared in fields) +[[parts]] +kind = "lemma" +path = "lemmas/柯西不等式" +fields = ["stmt", "proof"] + +# lemma WITHOUT proof (proof.typ absent; fields omits it) +[[parts]] +kind = "lemma" +path = "lemmas/垂直判据" +fields = ["stmt"] + +# sop +[[parts]] +kind = "sop" +path = "sops/求夹角步骤" +fields = ["sop"] + +[targets.student] +artifact = "single-file" +[[targets.student.steps]] +type = "typst-compile" +template = "exports/student.typ" + +[targets.teacher] +artifact = "single-file" +[[targets.teacher.steps]] +type = "typst-compile" +template = "exports/teacher.typ" diff --git a/render/examples/smoke-eng/segments/向量数量积/textbook.typ b/render/examples/smoke-eng/segments/向量数量积/textbook.typ new file mode 100644 index 0000000..9ab574b --- /dev/null +++ b/render/examples/smoke-eng/segments/向量数量积/textbook.typ @@ -0,0 +1,14 @@ += 平面向量的数量积 + +本节研究平面向量的基本运算。设 $arrow(a)$、$arrow(b)$ 为平面内两个向量, +其数量积定义为 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$, +其中 $theta$ 为两向量的夹角。 + +== 坐标表示 + +在直角坐标系下,若 $arrow(a) = (x_1, y_1)$、$arrow(b) = (x_2, y_2)$,则 +$arrow(a) dot arrow(b) = x_1 x_2 + y_1 y_2$。 + +== 几何意义 + +数量积等于一个向量的模与另一向量在其方向上投影之积。 diff --git a/render/examples/smoke-eng/sops/求夹角步骤/element.toml b/render/examples/smoke-eng/sops/求夹角步骤/element.toml new file mode 100644 index 0000000..5d987cc --- /dev/null +++ b/render/examples/smoke-eng/sops/求夹角步骤/element.toml @@ -0,0 +1 @@ +# sop scalars: none diff --git a/render/examples/smoke-eng/sops/求夹角步骤/sop.typ b/render/examples/smoke-eng/sops/求夹角步骤/sop.typ new file mode 100644 index 0000000..2c9b75a --- /dev/null +++ b/render/examples/smoke-eng/sops/求夹角步骤/sop.typ @@ -0,0 +1,4 @@ +求两向量夹角的标准步骤: ++ 计算数量积 $arrow(a) dot arrow(b)$; ++ 分别计算模长 $|arrow(a)|$、$|arrow(b)|$; ++ 代入 $cos theta = (arrow(a) dot arrow(b)) / (|arrow(a)| |arrow(b)|)$ 求出 $theta$。 diff --git a/render/examples/smoke-parts.typ b/render/examples/smoke-parts.typ deleted file mode 100644 index 2a118c1..0000000 --- a/render/examples/smoke-parts.typ +++ /dev/null @@ -1,83 +0,0 @@ -// Shared hand-written sample lesson exercising all 4 kinds. -// Content field values are plain content blocks — exactly what the Rust driver -// would hand us via `include`. - -#let info = ( - title: "向量与几何 · 示例讲义", - author: ("张老师", "李老师"), -) - -#let parts = ( - // segment — with NESTED headings so per-level numbering is visible: - // level-1 `=` should render `一、`, level-2 `==` should render `1.1` - // (NOT the old buggy `二、一、`). - ( - kind: "segment", - textbook: [ - = 平面向量的数量积 - - 本节研究平面向量的基本运算。设 $arrow(a)$、$arrow(b)$ 为平面内两个向量, - 其数量积定义为 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$, - 其中 $theta$ 为两向量的夹角。 - - == 坐标表示 - - 在直角坐标系下,若 $arrow(a) = (x_1, y_1)$、$arrow(b) = (x_2, y_2)$,则 - $arrow(a) dot arrow(b) = x_1 x_2 + y_1 y_2$。 - - == 几何意义 - - 数量积等于一个向量的模与另一向量在其方向上投影之积。 - ], - ), - // example WITH source - ( - kind: "example", - source: "2024 高考甲卷", - problem: [ - 已知 $arrow(a) = (1, 2)$,$arrow(b) = (3, -1)$,求 $arrow(a) dot arrow(b)$。 - ], - solution: [ - 由坐标公式,$arrow(a) dot arrow(b) = 1 times 3 + 2 times (-1) = 3 - 2 = 1$。 - ], - ), - // example WITHOUT source - ( - kind: "example", - problem: [ - 求向量 $arrow(a) = (3, 4)$ 的模长 $|arrow(a)|$。 - ], - solution: [ - $|arrow(a)| = sqrt(3^2 + 4^2) = sqrt(25) = 5$。 - ], - ), - // lemma WITH proof - ( - kind: "lemma", - stmt: [ - 对任意向量 $arrow(a)$、$arrow(b)$,有 $|arrow(a) dot arrow(b)| <= |arrow(a)| |arrow(b)|$。 - ], - proof: [ - 由数量积定义 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$, - 又 $|cos theta| <= 1$,故 $|arrow(a) dot arrow(b)| = |arrow(a)| |arrow(b)| |cos theta| <= |arrow(a)| |arrow(b)|$。 - $qed$ - ], - ), - // lemma WITHOUT proof (proof key omitted entirely) - ( - kind: "lemma", - stmt: [ - 两个非零向量垂直当且仅当其数量积为零,即 $arrow(a) perp arrow(b) <==> arrow(a) dot arrow(b) = 0$。 - ], - ), - // sop - ( - kind: "sop", - sop: [ - 求两向量夹角的标准步骤: - + 计算数量积 $arrow(a) dot arrow(b)$; - + 分别计算模长 $|arrow(a)|$、$|arrow(b)|$; - + 代入 $cos theta = (arrow(a) dot arrow(b)) / (|arrow(a)| |arrow(b)|)$ 求出 $theta$。 - ], - ), -) diff --git a/render/examples/smoke-student.typ b/render/examples/smoke-student.typ deleted file mode 100644 index 37c6d68..0000000 --- a/render/examples/smoke-student.typ +++ /dev/null @@ -1,6 +0,0 @@ -// Smoke test — student target. Imports the package by RELATIVE path so it -// compiles in place (no @local install needed). -#import "../lib.typ": display -#import "smoke-parts.typ": info, parts - -#display(info: info, target: "student", parts: parts) diff --git a/render/examples/smoke-teacher.typ b/render/examples/smoke-teacher.typ deleted file mode 100644 index 47daac4..0000000 --- a/render/examples/smoke-teacher.typ +++ /dev/null @@ -1,5 +0,0 @@ -// Smoke test — teacher target. Imports the package by RELATIVE path. -#import "../lib.typ": display -#import "smoke-parts.typ": info, parts - -#display(info: info, target: "teacher", parts: parts) diff --git a/render/lib.typ b/render/lib.typ index ac45b1d..d809220 100644 --- a/render/lib.typ +++ b/render/lib.typ @@ -1,21 +1,40 @@ // cph-render — curriculum lesson render package. // -// Single public entry: `display(info, target, parts)`. +// PUBLIC ENTRY: `render-lesson(info, target, parts, heading-numbering)`. // -// A lesson is an ORDERED array of typed parts. Each part is a dict with a -// `kind` plus that kind's content/scalar fields. Content field VALUES are -// already-evaluated typst content (the driver produces them via `include`) — -// never modules or strings; we render them directly. +// MODEL (ADR-0011). A build compiles a *template file* (e.g. `exports/student.typ`) +// as the typst main, with the manifest injected via `--input manifest=`. +// The template reads the manifest, loads each part's content, and calls +// `render-lesson` here. Presentation (heading numbering) lives in the template, +// not the manifest, not hardcoded-unreachable in this package — we only provide +// the DEFAULT scheme (`default-heading-numbering`) for the template to use or +// override. // -// kind -> fields (MVP): +// WHY THE TEMPLATE LOADS CONTENT, NOT US (the include-resolution finding): +// typst resolves an `include`/`import` path relative to THE PACKAGE/FILE THE +// include LEXICALLY APPEARS IN, and a package (`@local/cph-render`) has its OWN +// virtual root. A dynamic `include` written inside this file resolves against +// the PACKAGE root — even an absolute `/segments/x.typ` lands in the package +// dir, NOT the engineering-file root (`--root`). Verified empirically. +// Therefore the dynamic-include LOOP must live in the TEMPLATE (which lives at +// `/exports/*.typ`, so `//.typ` resolves against +// `--root`). `render-lesson` is content-in: it takes an ALREADY-ASSEMBLED +// `parts` array of dicts and never includes anything itself. +// +// A part dict the template hands us: +// `kind` plus that kind's content/scalar fields. Content field VALUES are +// already-evaluated typst content (the template produced them via `include`). +// +// kind -> fields (MVP) — see `part-fields` below, which the template uses to +// know what to load: // segment : textbook (content, required) // example : problem (content, required), solution (content, required), -// source (string, optional) +// source (string scalar, optional, from /element.toml) // lemma : stmt (content, required), proof (content, optional) // sop : sop (content, required) // // target -> show/hide (the (kind x target) render matrix; defaults per -// ADR-0005/0008, derived internally — caller passes no flags): +// ADR-0005/0008, derived internally — template passes only the target string): // student : example problem only (hide solution); lemma stmt only (hide // proof); segment textbook; sop shown. // teacher : everything shown. @@ -30,6 +49,23 @@ #import "src/elements/sop.typ": display-sop #import "src/elements/common.typ": example-counter, lemma-counter +/// Per-kind field manifest, exported so a TEMPLATE knows exactly what to load +/// for each part without hardcoding the matrix. For each kind: +/// `content`: field names whose `/.typ` the template must +/// `include` (assembling them into the part dict). +/// `optional-content`: content fields that may be absent (template should +/// probe before including; absence is fine). +/// `scalars`: scalar field names the template reads from `/element.toml` +/// (currently only example `source`). +/// Keeping this here (not in the template) keeps kind->fields a single source of +/// truth in the package; the template iterates it generically. +#let part-fields = ( + segment: (content: ("textbook",), optional-content: (), scalars: ()), + example: (content: ("problem", "solution"), optional-content: (), scalars: ("source",)), + lemma: (content: ("stmt",), optional-content: ("proof",), scalars: ()), + sop: (content: ("sop",), optional-content: (), scalars: ()), +) + /// Resolve a target string to the set of show/hide booleans. /// Unknown targets fall back to the conservative (student-like) profile. #let _flags-for(target) = { @@ -70,32 +106,28 @@ } } -/// THE ENTRY POINT. +/// THE ENTRY POINT — called by a template (`exports/*.typ`). /// /// - `info`: dict, e.g. (title: "…", author: "…"). `author` may be absent. +/// Templates typically pass `manifest.at("info", default: (:))`. /// - `target`: string. MVP: "student" | "teacher". Unknown => conservative. -/// - `parts`: ordered array of part dicts (see file header). -/// - `config`: dict carrying the target's build/presentation overrides -/// (ADR-0009). Open/forward-compatible — read keys with -/// `.at(.., default: ..)`. Recognised keys (MVP): -/// `config.numbering.heading`: array of per-level numbly pattern -/// strings, e.g. `("{1:一}、", "{1:1}.{2:1}")`. Optional; when -/// absent the framework default (correct per-level scheme) is -/// used. Future presentation knobs slot in without touching -/// this signature. -#let display(info: (:), target: "student", parts: (), config: (:)) = { +/// Each template hardcodes its own target (student.typ => "student"). +/// - `parts`: ordered array of part dicts, ALREADY ASSEMBLED by the template +/// (content fields included, scalars read). See file header. +/// - `heading-numbering`: array of per-level numbly pattern strings, e.g. +/// `("{1:一}、", "{1:1}.{2:1}", "{1:1}.{2:1}.{3:1}")`. Presentation +/// lives in the template (ADR-0011); the template passes its chosen +/// scheme. Defaults to `default-heading-numbering` (the framework +/// default) when the template omits it. +#let render-lesson( + info: (:), + target: "student", + parts: (), + heading-numbering: default-heading-numbering, +) = { let title = info.at("title", default: []) let author = info.at("author", default: none) - // Resolve presentation config -> framework defaults, file may override. - // `config.numbering.heading` (array of numbly patterns) overrides the - // framework default per-level numbering when present. - let numbering-cfg = config.at("numbering", default: (:)) - let heading-numbering = numbering-cfg.at( - "heading", - default: default-heading-numbering, - ) - // `document` author wants a string/array; normalise the optional field. set document( title: title, diff --git a/render/templates/README.md b/render/templates/README.md new file mode 100644 index 0000000..c4cbd59 --- /dev/null +++ b/render/templates/README.md @@ -0,0 +1,63 @@ +# Default export templates (ADR-0011) + +`student.typ` / `teacher.typ` are the **framework default templates**. In a real +engineering file they live at `exports/student.typ` / `exports/teacher.typ`; the +project-creation flow seeds copies there (out of scope here — see ADR-0011 Open +Questions). They are kept here as the canonical, copyable defaults and for the +offline smoke test below. + +Each template: + +1. reads the injected manifest: `toml(sys.inputs.manifest)`; +2. loops `manifest.parts`, `include`-ing each content field via a computed + **root-relative absolute** path `//.typ`, and reading scalar + fields (example `source`) from `//element.toml`; +3. assembles a `parts` array and calls `cph-render`'s `render-lesson(...)`, + passing the per-level heading numbering (presentation lives in the template). + +## Why the include loop is in the template, not in cph-render + +typst resolves a dynamic `include`/`toml` path relative to **the file the call +lexically appears in**, and a package (`@local/cph-render`) has its **own virtual +root**. A dynamic `include` written inside the package resolves against the +*package* dir — even an absolute `/segments/x.typ` — never the engineering +`--root`. Verified empirically. The template lives under `--root`, so its +`//.typ` resolves against `--root`. Hence the template loads +content and hands `render-lesson` an already-assembled `parts` array; +`render-lesson` never includes anything. + +## Key path facts for the engine + +- **Template is the typst main file.** Compile it directly. +- **`--root` = engineering-file root.** Content `include`s use absolute + root-relative paths. +- **Manifest is injected as a root-relative ABSOLUTE path**: + `--input manifest=/manifest.toml`. `toml(sys.inputs.manifest)` resolves + relative to the *template's* location (`exports/`), so a bare + `manifest=manifest.toml` would look in `exports/`. Pass the leading `/`. +- **Optional content presence** (lemma `proof`): typst has no file-exists + primitive, so the template cannot probe disk. It reads a per-part `fields` + array from the manifest listing the content fields present on disk. *(OPEN: the + exact manifest shape for this is for the Rust/manifest contract to pin.)* + +## Offline smoke test + +`@local/cph-render` is resolved from a local-packages dir (a symlink to the +render package root — regenerable, test-only): + +```sh +cd render +# one-time: make @local/cph-render resolvable offline +mkdir -p vendor/local-packages/local/cph-render +ln -sfn "$(pwd)" vendor/local-packages/local/cph-render/0.1.0 + +# compile a template against the smoke engineering file +typst compile --root examples/smoke-eng \ + --package-path ./vendor/local-packages \ + --package-cache-path ./vendor/typst-packages \ + --input manifest=/manifest.toml \ + examples/smoke-eng/exports/student.typ /tmp/cph-tmpl-student.pdf +``` + +(`examples/smoke-eng/exports/{student,teacher}.typ` are copies of the defaults +here, mirroring how a real engineering file carries its own templates.) diff --git a/render/templates/student.typ b/render/templates/student.typ new file mode 100644 index 0000000..a33d25d --- /dev/null +++ b/render/templates/student.typ @@ -0,0 +1,79 @@ +// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011). +// +// This is a *real, editable* file that lives in an engineering file at +// `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the +// manifest injected: +// typst compile --root --input manifest= exports/student.typ +// +// It is intentionally self-contained (no shared helper import) so it can be +// copied verbatim into a new engineering file's `exports/`. Presentation — +// heading numbering — lives HERE (editable per engineering file), not in the +// manifest and not hardcoded in the cph-render package. +// +// WHY THE INCLUDE LOOP IS HERE AND NOT IN cph-render: typst resolves a dynamic +// `include` path relative to the file it lexically appears in, and a package has +// its own virtual root — an include inside cph-render would resolve against the +// PACKAGE, not the engineering root. A `//.typ` written HERE +// (this template lives under `--root`) resolves against `--root`. So the +// template loads content and hands cph-render an already-assembled `parts` array. +// +// OPEN CONTRACT POINT — optional-content presence. typst has no "does this file +// exist" primitive (a missing `include` is a hard compile error). So the +// template CANNOT probe disk the way the old Rust driver did for lemma `proof`. +// It relies on the manifest declaring which optional content fields are present, +// via a per-part `fields` array listing the content fields that exist on disk +// (the engine knows this — it walks the part dir). Required fields are loaded +// unconditionally; optional fields load only if listed in `fields`. If a part +// omits `fields`, optional content is skipped (conservative). The exact shape of +// this declaration is for the manifest/Rust contract to pin. + +#import "@local/cph-render:0.1.0": render-lesson, part-fields, default-heading-numbering + +// This template IS the student build, so the target is fixed. +#let target = "student" + +// Read the injected manifest (a path string relative to typst --root). +#let manifest = toml(sys.inputs.manifest) +#let info = manifest.at("info", default: (:)) +#let raw-parts = manifest.at("parts", default: ()) + +// Assemble each part: include its content fields (computed absolute paths, +// resolved against --root) and read scalar fields from /element.toml. +// `part-fields` (from cph-render) is the single source of truth for kind->fields. +#let parts = raw-parts.map(raw => { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let part = (kind: kind) + + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + part.insert(field, include "/" + path + "/" + field + ".typ") + } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + part.insert(field, include "/" + path + "/" + field + ".typ") + } + } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { part.insert(field, v) } + } + } + part +}) + +// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes +// from cph-render; override here per engineering file if desired. +#render-lesson( + info: info, + target: target, + parts: parts, + heading-numbering: default-heading-numbering, +) diff --git a/render/templates/teacher.typ b/render/templates/teacher.typ new file mode 100644 index 0000000..b976b1f --- /dev/null +++ b/render/templates/teacher.typ @@ -0,0 +1,62 @@ +// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011). +// +// Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with +// the manifest injected: +// typst compile --root --input manifest= exports/teacher.typ +// +// Self-contained (copyable into an engineering file). Identical to student.typ +// except the fixed target is "teacher" (so the (kind x target) render matrix in +// cph-render shows solutions and proofs). See student.typ for the full notes on +// why the include loop lives here (package virtual-root resolution) and on the +// OPEN optional-content (`fields`) contract point. + +#import "@local/cph-render:0.1.0": render-lesson, part-fields, default-heading-numbering + +// This template IS the teacher build, so the target is fixed. +#let target = "teacher" + +// Read the injected manifest (a path string relative to typst --root). +#let manifest = toml(sys.inputs.manifest) +#let info = manifest.at("info", default: (:)) +#let raw-parts = manifest.at("parts", default: ()) + +// Assemble each part: include its content fields (computed absolute paths, +// resolved against --root) and read scalar fields from /element.toml. +// `part-fields` (from cph-render) is the single source of truth for kind->fields. +#let parts = raw-parts.map(raw => { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let part = (kind: kind) + + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + part.insert(field, include "/" + path + "/" + field + ".typ") + } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + part.insert(field, include "/" + path + "/" + field + ".typ") + } + } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { part.insert(field, v) } + } + } + part +}) + +// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes +// from cph-render; override here per engineering file if desired. +#render-lesson( + info: info, + target: target, + parts: parts, + heading-numbering: default-heading-numbering, +) diff --git a/render/typst.toml b/render/typst.toml index b45f153..07429f9 100644 --- a/render/typst.toml +++ b/render/typst.toml @@ -5,7 +5,7 @@ entrypoint = "lib.typ" compiler = "0.15.0" authors = ["curriculum-project-hub"] license = "MIT" -description = "Curriculum lesson render package: single `display` entry over an ordered list of typed parts (segment/example/lemma/sop), targeting student/teacher handouts." +description = "Curriculum lesson render package: `render-lesson` entry over an ordered list of typed parts (segment/example/lemma/sop), called by export templates (ADR-0011), targeting student/teacher handouts." # External @preview dependencies (resolved via the typst package registry): # @preview/numbly:0.1.0 — per-level heading numbering (src/style.typ). From d76f9f9a54be884958aea018889d5a3389131eb0 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 10:05:03 +0800 Subject: [PATCH 23/29] feat(checker): compile template as main, augmented manifest closes optional-content (WU-D', ADR-0011) Replace generated-driver compilation with the template model. cph-typst: - driver.rs DELETED (generate_driver, static-include driver, numbering-threading). - New manifest.rs: build_augmented_manifest(&Lesson) emits a TOML doc with [info] + ordered [[parts]] each carrying a `fields` array = the kind's content fields whose //.typ exists on disk. Reuses cph-schema's content_field_names (new dep; no cycle). This closes the OPEN point WU-C' surfaced: typst has no file-exists primitive, so the engine (which has disk access) tells the template which optional content (lemma proof) is present. - World main = the real engineering-file template (/