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
+2
View File
@@ -6,6 +6,8 @@
"properties": {
"problem": { "type": "string", "x-cph-content": true },
"solution": { "type": "string", "x-cph-content": true },
"slides": { "type": "string", "x-cph-content": "md" },
"transcript": { "type": "string", "x-cph-content": "md" },
"source": { "type": "string" }
},
"required": ["problem", "solution"],
+3 -1
View File
@@ -5,7 +5,9 @@
"type": "object",
"properties": {
"stmt": { "type": "string", "x-cph-content": true },
"proof": { "type": "string", "x-cph-content": true }
"proof": { "type": "string", "x-cph-content": true },
"slides": { "type": "string", "x-cph-content": "md" },
"transcript": { "type": "string", "x-cph-content": "md" }
},
"required": ["stmt"],
"additionalProperties": false
+3 -1
View File
@@ -4,7 +4,9 @@
"title": "segment",
"type": "object",
"properties": {
"textbook": { "type": "string", "x-cph-content": true }
"textbook": { "type": "string", "x-cph-content": true },
"slides": { "type": "string", "x-cph-content": "md" },
"transcript": { "type": "string", "x-cph-content": "md" }
},
"required": ["textbook"],
"additionalProperties": false
+3 -1
View File
@@ -4,7 +4,9 @@
"title": "sop",
"type": "object",
"properties": {
"sop": { "type": "string", "x-cph-content": true }
"sop": { "type": "string", "x-cph-content": true },
"slides": { "type": "string", "x-cph-content": "md" },
"transcript": { "type": "string", "x-cph-content": "md" }
},
"required": ["sop"],
"additionalProperties": false
+69 -20
View File
@@ -116,12 +116,16 @@ struct ScalarField {
required: bool,
}
/// One content property of a kind (a field whose value is a sibling `.typ`
/// file, marked with `x-cph-content`).
/// One content property of a kind (a field whose value is a sibling content
/// file, marked with `x-cph-content`). The file extension is determined by the
/// marker's value: `true` (or `"typ"`) → `.typ`; any other string → that
/// extension (e.g. `"md"` → `.md`, ADR-0015 markdown content surfaces).
#[derive(Debug, Clone)]
struct ContentField {
name: String,
required: bool,
/// The content file's extension without the dot (e.g. `"typ"`, `"md"`).
ext: String,
}
/// A parsed kind schema: the content fields (which drive `.typ` existence
@@ -190,15 +194,22 @@ impl KindSchema {
.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);
// The `x-cph-content` marker selects a content field and fixes its
// file extension. `true` (the legacy form) and the string `"typ"`
// both mean a `.typ` file; any other string is taken as the
// extension (e.g. `"md"` → `.md` for markdown content surfaces,
// ADR-0015). Absent ⇒ a scalar field.
let ext = match prop.get(CONTENT_MARKER) {
Some(Json::Bool(true)) => Some("typ".to_string()),
Some(Json::String(s)) => Some(s.clone()),
_ => None,
};
if is_content {
if let Some(ext) = ext {
content_fields.push(ContentField {
name: name.clone(),
required: is_required(name),
ext,
});
} else {
let ty = prop
@@ -307,14 +318,8 @@ fn validate_scalars(schema: &KindSchema, desc: &ElementDescriptor, diags: &mut V
// 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()) {
if let Some(field) = schema.content_fields.iter().find(|f| f.name == *key) {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
@@ -324,7 +329,8 @@ fn validate_scalars(schema: &KindSchema, desc: &ElementDescriptor, diags: &mut V
),
)
.with_hint(format!(
"remove '{key}' from element.toml; put its content in the sibling file '{key}.typ'"
"remove '{key}' from element.toml; put its content in the sibling file '{key}.{}'",
field.ext
)),
);
continue;
@@ -390,8 +396,9 @@ fn validate_scalars(schema: &KindSchema, desc: &ElementDescriptor, diags: &mut V
}
}
/// For each required content field, check that its `<field>.typ` file exists in
/// the element directory.
/// For each required content field, check that its `<field>.<ext>` file exists
/// in the element directory (`.typ` for typst content, `.md` for markdown
/// content surfaces — ADR-0015).
fn validate_content_files(
schema: &KindSchema,
desc: &ElementDescriptor,
@@ -402,7 +409,7 @@ fn validate_content_files(
// Optional content (e.g. lemma's `proof`): absence is fine.
continue;
}
let file_name = format!("{}.typ", field.name);
let file_name = format!("{}.{}", field.name, field.ext);
let path = desc.dir.join(&file_name);
if !path.is_file() {
diags.push(
@@ -415,7 +422,8 @@ fn validate_content_files(
),
)
.with_hint(format!(
"create '{file_name}' in the element folder with the typst source for '{}'",
"create '{file_name}' in the element folder with the {} source for '{}'",
ext_label(&field.ext),
field.name
)),
);
@@ -423,6 +431,15 @@ fn validate_content_files(
}
}
/// A human label for a content-file extension, used in diagnostic hints.
fn ext_label(ext: &str) -> &'static str {
match ext {
"md" => "markdown",
"typ" => "typst",
_ => "content",
}
}
/// Convert a `toml::Value` scalar into the equivalent `serde_json::Value` for
/// type checking. Composite TOML values (arrays / tables) are converted
/// structurally too, though the MVP stdlib schemas only use scalars.
@@ -484,7 +501,14 @@ mod tests {
#[test]
fn example_field_split() {
let s = schema_for("example").unwrap();
assert_eq!(s.content_field_names(), vec!["problem", "solution"]);
// `problem`/`solution` are typst content; `slides`/`transcript` are the
// optional markdown content surfaces (ADR-0015); `source` is a scalar.
// (serde_json's Map is a BTreeMap without the `preserve_order` feature,
// so content field names come back alphabetically sorted.)
assert_eq!(
s.content_field_names(),
vec!["problem", "slides", "solution", "transcript"]
);
assert_eq!(s.scalar_field_names(), vec!["source"]);
}
@@ -496,4 +520,29 @@ mod tests {
let stmt = s.content_fields.iter().find(|f| f.name == "stmt").unwrap();
assert!(stmt.required);
}
#[test]
fn markdown_content_fields_carry_md_ext() {
// ADR-0015: `slides`/`transcript` are markdown content fields (`x-cph-content: "md"`),
// distinct from the `.typ` content fields.
let s = schema_for("segment").unwrap();
let slides = s.content_fields.iter().find(|f| f.name == "slides").unwrap();
assert_eq!(slides.ext, "md");
assert!(!slides.required);
let transcript = s
.content_fields
.iter()
.find(|f| f.name == "transcript")
.unwrap();
assert_eq!(transcript.ext, "md");
assert!(!transcript.required);
// Legacy typst content still resolves to `.typ`.
let textbook = s
.content_fields
.iter()
.find(|f| f.name == "textbook")
.unwrap();
assert_eq!(textbook.ext, "typ");
assert!(textbook.required);
}
}