feat(spec+checker): slides/逐字稿 markdown 内容面 + assembleMarkdown step(ADR-0015)

给课程加两个 markdown 内容面:slides(大纲:h2 part → h3 小节 → ![]() 图 +
:::widget 教具)与逐字稿(口播)。直接 markdown+KaTeX 撰写,绕开 typst→md
公式转换(等于在 slides 面选 ADR-0014 的 R2)。

spec:
- RichContent.lean: content leaf 带 format(typst|markdown),新增 ContentFormat
  归纳,RichContentRef 加 format 字段
- Export/Render.lean: Step 加 assembleMarkdown (field),钉执行语义
  (同 shell 三边界 + 注入课程 h1 + 自包含收集引用图)
- Courseware.lean: ADR 区间 →0015

实现:
- cph-schema: x-cph-content 支持 true→.typ / 字符串→该扩展名(.md);
  ContentField 带 ext;4 个 kind schema 各加可选 slides/transcript
- cph-model: Step::AssembleMarkdown{field} + manifest "assemble-markdown" 解析
- cph-check: run_markdown_assemble_target / target_is_markdown_assemble
  (照 run_shell_target 形状:check 门控、注入 h1、按 [[parts]] 序拼、写盘后
  收集 ![](rel) 引用图进 build 根使产物自包含、引用图缺失属 build-过程错误
  不入 6 类诊断);compile_targets 已 filter TypstCompile,纯 assemble 自然排除
- cph-cli: run_markdown_assemble_build 路由
- cph-typst: template_step 补 AssembleMarkdown => None 分支

测试:全 workspace 49 passed(新增 6 个 markdown 装配测试含图收集);
lake build 25 jobs 绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:55:55 +08:00
parent 9590e15236
commit 144e2d8c80
14 changed files with 1053 additions and 42 deletions
@@ -0,0 +1,177 @@
# ADR 0015: Slides & Transcript As Per-Element Markdown Content Surfaces
## Status
Accepted — and to be implemented. Adds two new **markdown** content surfaces
(`slides`, `transcript`) as per-element optional content fields, plus a typed
`Step.assembleMarkdown` that concatenates them in parts order into a single-file
markdown deliverable.
Builds on **ADR-0005** ((a)-class fields like 逐字稿 live inside a kind's
`ElementData`; (b)-class medium lives in the target build), **ADR-0006**
(`content` leaves in a kind schema), **ADR-0011** (a target is an artifact +
ordered typed steps; presentation lives in the template/content not the
manifest), **ADR-0013** (non-typst targets ride a build step; failures are
build-process errors outside the 6-class taxonomy), and **ADR-0014** (the
markdown/HTML export direction — R1 shared typst-content vs. R2 target-specific
content; math must survive as markup not images).
## Context
The KenKen lesson (and curriculum generally) needs a **slides** deliverable —
an **outline-grade** projection (titles + bullet points; detail belongs in the
讲义/教案 or the 逐字稿), aimed at a third-party platform whose interchange
format is a **specific markdown dialect**. It also needs a **逐字稿**
(transcript) — the spoken text, read aloud, no typography needed.
Two prior facts made the design:
1. **slides content ≠ 讲义 content.** ADR-0014 already recorded that a
markdown/HTML target's content does not fully overlap the PDF 讲义/教案.
Forcing slides to be a projection of the same typst source (ADR-0014's R1)
drags in the hard **typst→markdown math conversion** (typlite renders math
as images; 0.15 MathML is accessibility-oriented and hard to turn into KaTeX;
`mitex` is LaTeX→typst, the reverse direction — ADR-0014 §Context).
2. **slides/逐字稿 can be authored directly in markdown.** If `slides` and
`transcript` are authored as **markdown + KaTeX from the start**, there is
*no* typst→markdown conversion at all — the math is KaTeX source (`$…$`) on
the day it is written. The math principle ("markup, not images", ADR-0014)
is satisfied for free, not by building a fragile converter.
So for the slides/transcript surfaces this ADR picks **ADR-0014's R2**
(target-specific content) deliberately: it **sidesteps** the typst→md converter
entirely. The converter (R1) remains an open problem only for the *讲义* surface,
which is independent and out of scope here.
The spec already pins where (a)-class fields live: `Element.lean` says
"逐字稿、重点圈划 落在具体 kind 的 `ElementData` 内". So `slides`/`transcript`
are modeled as **per-kind optional `content` leaves** (declared in each kind's
JSON schema), consistent with the existing `textbook`/`problem`/`solution`
content fields — except their **format is markdown, not typst**. This is a real
extension to the `content` concept: a content leaf now carries a *format*
(typst | markdown). `RichContent.lean` gains a `ContentFormat` and the content
ref gains a `format` field.
## Decision
### Two new markdown content fields on every kind
Each stdlib kind schema (`segment`, `example`, `lemma`, `sop`) gains two
**optional** `content` fields — `slides` and `transcript` — whose format is
**markdown** (`x-cph-content: "md"`, the new extension-bearing form of the
marker; the old `x-cph-content: true` stays `.typ` for backward compat).
Optional ⇒ a kind instance without them is legal (like a lemma's `proof`).
Convention: `slides.md` is **outline-grade** (title + bullets; no worked
detail — that lives in 讲义/逐字稿); `transcript.md` is the **spoken** text.
### A new typed step `Step.assembleMarkdown field`
A target like `[targets.slides]` carries `[[…steps]] type = "assemble-markdown"
field = "slides"` over a `single-file` artifact. The framework **owns** the
assembly (not a `cat`): it walks the manifest `[[parts]]` **in order**, reads
each element's `<field>.md` if present (skips if absent), joins them with a
blank-line separator, and writes the single file to the artifact's `filepath`
(relative to the engineering root). It does **not** inject headings — each
`slides.md`/`transcript.md` authors its own `# …` (presentation lives in the
content, ADR-0011).
This is **typed**, not `shell`, because ordered-read + skip-missing +
write-to-artifact-path is cleaner owned by the framework than expressed as a
shell pipeline, and it stays deterministic/testable.
### Self-contained build dir: the assembler collects referenced images
The assembled `slides.md` is written under the build root (the artifact
`filepath`'s parent, e.g. `build/`). For that directory to be a **standalone
deliverable** — uploadable to the platform on its own — its `![](rel)` image
references must resolve *within* it, not back into the engineering root. So the
assembler, after writing the markdown, scans it for local `![](rel)` references
and copies each referenced image from the engineering root (`<eng-root>/<rel>`)
into the build root (`<build-root>/<rel>`), preserving the relative path. This
is part of the `assembleMarkdown` step's owned responsibility (one step, not a
separate copy step), keeping a slides target a single typed step.
Conventions that fall out of this:
- **Image references are authored relative to the engineering root** (`assets/images/…`),
which is *also* their form relative to the build root after the copy — so the
same path string is correct both at authoring time (the source image exists at
`<eng-root>/assets/images/…`) and in the assembled output.
- **Widget references are build-relative** (`interactives/ex1.html`): widgets are
*build artifacts* produced by a separate target (the `interactives` shell
target writes `build/interactives/`), not engineering-root sources, so they are
referenced but **not** copied by the assembler. A fully self-contained `build/`
therefore requires running the `interactives` target first — a documented
prerequisite, like `kendoku` reachability (ADR-0013).
- **External URLs** (`http://`/`https://`/`data:`) are left untouched.
- A referenced image **missing** at the engineering root is a build-process error
(the build dir would be broken), reported by the build layer — it does **not**
enter the 6-class taxonomy.
### Course-level h1 is injected
The assembler prepends the course title (from manifest `[info] title`) as the
document's `#` h1. This is course metadata, not per-element content; each
element's `<field>.md` then contributes its `##` part and `###` 小节. This is
the one piece of presentation the assembler owns at the document level (ADR-0011's
"presentation lives in the content" governs *per-element* presentation).
### Same three boundaries as the shell step (ADR-0013)
`assembleMarkdown` is a **non-typst target**, identical in boundary to the
shell step:
1. **opt-in by construction** — runs only on an explicit
`cph build --target <name>`, never in `check`. `check` validates lesson
*structure*, not the outcome of assembling markdown.
2. **failure is a build-process error, not a lesson diagnostic** — an assembly
failure (e.g. write error) does **not** enter the 6-class diagnostic
taxonomy; it is reported by the build layer. We do **not** add an
`AssembleMarkdown` diagnostic code (taxonomy stays 6, ADR-0010/0012/0013).
3. **non-typst targets skip typst compile**`check`'s compile phase already
filters to `TypstCompile`-bearing targets, so a pure `assembleMarkdown`
target is naturally excluded.
### Single-file artifact now; structured FileTree deferred
The artifact is a **single-file** markdown. The user reasoned that until parts
are organized as an **ordered tree** (rather than the current flat ordered
list), single-file is unavoidable — a loose FileTree of per-element `.md`
files would lose the ordering/structure the manifest encodes. So: single-file
now; a structured multi-file FileTree artifact is deferred until the parts
tree-restructure lands (it is out of scope here and recorded as OPEN).
## Consequences
- The framework gains two orthogonal content surfaces (outline slides, spoken
transcript) authored directly in markdown+KaTeX, with **no** typst→md math
conversion in their path. The math problem (ADR-0014) is *not* solved
generally — it is *avoided* for these surfaces by choosing R2.
- A third typed `Step` variant (`assembleMarkdown`) joins `typstCompile` and
`shell`; the step universe is extensible by design (ADR-0011).
- The `content` leaf concept broadens: it now carries a *format*. The
`x-cph-content` marker accepts an extension (`"md"`), backward-compatible
with the boolean form (`.typ`). `RichContent.lean` pins `ContentFormat`.
- The 6-class diagnostic taxonomy is unchanged.
- The 恒一小奥 KenKen lesson gains `slides`/`transcript` targets alongside its
existing `student`/`teacher`/`interactives` targets — all from one
engineering file through one CLI.
## Open Questions / Deferred
- **Third-party markdown dialect.** Baseline CommonMark + KaTeX (`$…$`/`$$…$$`)
ships now; the platform's specific dialect (page-break markers, callout /
fenced-div conventions, heading-depth rules, math-delimiter quirks) is a
transform layer to be added once the lesson's output is fed to the platform
and the real requirements are observed.
- **Structured FileTree artifact.** A multi-file markdown deliverable organized
by the parts tree — deferred until the parts tree-restructure (itself a
separate OPEN / future ADR; touches the manifest schema, render assembly,
and all examples).
- **讲义 (typst) → markdown.** Still ADR-0014's open R1/converter problem,
*unaffected* by this ADR. Whether the 讲义 surface ever needs a markdown
export is an independent decision.
- **Universal vs. per-kind markdown surfaces.** `slides`/`transcript` are
declared per-kind (4 schemas, duplicated) to match ADR-0005's "(a)-class
lives in kind `ElementData`". Whether they should become a cross-cutting
universal content surface is reconsidered alongside the parts tree-restructure.