forked from EduCraft/curriculum-project-hub
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]] 序拼、写盘后
收集  引用图进 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:
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user