forked from bai/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:
@@ -374,6 +374,283 @@ run = "printf SHOULD_NOT_RUN > build/ran.txt"
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
// --- assemble-markdown target (ADR-0015) ---------------------------------------
|
||||
|
||||
/// Write a two-segment lesson where each segment carries a `slides.md` markdown
|
||||
/// content file, plus a `slides` target that assembles them. The `which` slice
|
||||
/// controls which segments get a `slides.md` (so the "skip absent" path can be
|
||||
/// exercised). Returns nothing; the caller runs `run_markdown_assemble_target`.
|
||||
fn write_markdown_assemble_target_lesson(tmp: &PathBuf, slides: &[(&str, &str)]) {
|
||||
let mut parts = String::new();
|
||||
for (i, (name, _)) in slides.iter().enumerate() {
|
||||
if i > 0 {
|
||||
parts.push('\n');
|
||||
}
|
||||
parts.push_str(&format!(
|
||||
"[[parts]]\nkind = \"segment\"\npath = \"segments/{name}\"\n"
|
||||
));
|
||||
}
|
||||
std::fs::write(
|
||||
tmp.join("manifest.toml"),
|
||||
format!(
|
||||
r#"
|
||||
[project]
|
||||
id = "md"
|
||||
name = "md"
|
||||
|
||||
[info]
|
||||
title = "md"
|
||||
|
||||
{parts}
|
||||
|
||||
[targets.slides]
|
||||
artifact = {{ type = "single-file", filepath = "build/slides.md" }}
|
||||
[[targets.slides.steps]]
|
||||
type = "assemble-markdown"
|
||||
field = "slides"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
for (name, body) in slides {
|
||||
let dir = tmp.join("segments").join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap();
|
||||
std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap();
|
||||
std::fs::write(dir.join("slides.md"), body).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_is_markdown_assemble_detects_shape() {
|
||||
let tmp = tempdir();
|
||||
write_markdown_assemble_target_lesson(
|
||||
&tmp,
|
||||
&[("a", "# A"), ("b", "# B")],
|
||||
);
|
||||
assert!(cph_check::target_is_markdown_assemble(&tmp, "slides"));
|
||||
// The student target doesn't exist here → falls through to typst path → false.
|
||||
assert!(!cph_check::target_is_markdown_assemble(&tmp, "student"));
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_markdown_assemble_target_concatenates_in_parts_order() {
|
||||
let tmp = tempdir();
|
||||
write_markdown_assemble_target_lesson(
|
||||
&tmp,
|
||||
&[("intro", "# 规则一\n- 范围"), ("rule2", "# 规则二\n$8\\times$")],
|
||||
);
|
||||
|
||||
let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides");
|
||||
assert!(report.ok, "assembly should succeed: {:#?}", report);
|
||||
assert_eq!(report.outcomes.len(), 1);
|
||||
let o = &report.outcomes[0];
|
||||
assert!(o.ok());
|
||||
assert_eq!(o.parts_read, 2);
|
||||
assert_eq!(o.field, "slides");
|
||||
|
||||
let out = tmp.join("build").join("slides.md");
|
||||
let body = std::fs::read_to_string(&out).unwrap();
|
||||
// Both parts present, in declared order, joined by a blank line.
|
||||
let intro_idx = body.find("规则一").unwrap();
|
||||
let rule2_idx = body.find("规则二").unwrap();
|
||||
assert!(intro_idx < rule2_idx, "parts must keep manifest order");
|
||||
assert!(body.contains("$8\\times$"), "KaTeX source survives as text");
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_markdown_assemble_target_skips_parts_without_the_field() {
|
||||
// Only the first segment has a slides.md; the second is skipped (optional).
|
||||
let tmp = tempdir();
|
||||
let mut parts = String::new();
|
||||
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n\n");
|
||||
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/b\"\n");
|
||||
std::fs::write(
|
||||
tmp.join("manifest.toml"),
|
||||
format!(
|
||||
r#"
|
||||
[project]
|
||||
id = "md"
|
||||
name = "md"
|
||||
|
||||
[info]
|
||||
title = "md"
|
||||
|
||||
{parts}
|
||||
|
||||
[targets.slides]
|
||||
artifact = {{ type = "single-file", filepath = "build/slides.md" }}
|
||||
[[targets.slides.steps]]
|
||||
type = "assemble-markdown"
|
||||
field = "slides"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
for name in ["a", "b"] {
|
||||
let dir = tmp.join("segments").join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap();
|
||||
std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap();
|
||||
}
|
||||
// Only `a` gets a slides.md.
|
||||
std::fs::write(tmp.join("segments/a/slides.md"), "# A only\n").unwrap();
|
||||
|
||||
let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides");
|
||||
assert!(report.ok, "{:#?}", report);
|
||||
assert_eq!(report.outcomes[0].parts_read, 1);
|
||||
let body = std::fs::read_to_string(tmp.join("build/slides.md")).unwrap();
|
||||
// The assembler prepends the course title (info.title = "md") as an h1,
|
||||
// then the single part's slides.md.
|
||||
assert_eq!(body, "# md\n\n# A only\n");
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_markdown_assemble_target_copies_referenced_images() {
|
||||
// A slides.md that references two local images; both must be copied from
|
||||
// the engineering root into the build root (same relative path) so the
|
||||
// build dir is self-contained (ADR-0015).
|
||||
let tmp = tempdir();
|
||||
std::fs::write(
|
||||
tmp.join("manifest.toml"),
|
||||
r#"
|
||||
[project]
|
||||
id = "md"
|
||||
name = "md"
|
||||
|
||||
[info]
|
||||
title = "md"
|
||||
|
||||
[[parts]]
|
||||
kind = "segment"
|
||||
path = "segments/a"
|
||||
|
||||
[targets.slides]
|
||||
artifact = { type = "single-file", filepath = "build/slides.md" }
|
||||
[[targets.slides.steps]]
|
||||
type = "assemble-markdown"
|
||||
field = "slides"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let dir = tmp.join("segments").join("a");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap();
|
||||
std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap();
|
||||
std::fs::write(
|
||||
dir.join("slides.md"),
|
||||
"## A\n\n\n\n\n",
|
||||
)
|
||||
.unwrap();
|
||||
// The two referenced images exist at the engineering root.
|
||||
std::fs::create_dir_all(tmp.join("assets/img")).unwrap();
|
||||
std::fs::write(tmp.join("assets/img/one.png"), b"PNG1").unwrap();
|
||||
std::fs::write(tmp.join("assets/img/two.png"), b"PNG2").unwrap();
|
||||
|
||||
let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides");
|
||||
assert!(report.ok, "{:#?}", report);
|
||||
|
||||
// Both images copied into the build root, preserving the assets/img/ path.
|
||||
assert_eq!(
|
||||
std::fs::read(tmp.join("build/assets/img/one.png")).unwrap(),
|
||||
b"PNG1"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(tmp.join("build/assets/img/two.png")).unwrap(),
|
||||
b"PNG2"
|
||||
);
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_markdown_assemble_target_fails_on_missing_referenced_image() {
|
||||
// A slides.md references an image that does NOT exist at the engineering
|
||||
// root → a build-process error (self-contained build would be broken),
|
||||
// not a lesson diagnostic.
|
||||
let tmp = tempdir();
|
||||
std::fs::write(
|
||||
tmp.join("manifest.toml"),
|
||||
r#"
|
||||
[project]
|
||||
id = "md"
|
||||
name = "md"
|
||||
|
||||
[info]
|
||||
title = "md"
|
||||
|
||||
[[parts]]
|
||||
kind = "segment"
|
||||
path = "segments/a"
|
||||
|
||||
[targets.slides]
|
||||
artifact = { type = "single-file", filepath = "build/slides.md" }
|
||||
[[targets.slides.steps]]
|
||||
type = "assemble-markdown"
|
||||
field = "slides"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let dir = tmp.join("segments").join("a");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("element.toml"), "kind = \"segment\"\n").unwrap();
|
||||
std::fs::write(dir.join("textbook.typ"), "text.\n").unwrap();
|
||||
std::fs::write(
|
||||
dir.join("slides.md"),
|
||||
"## A\n\n\n",
|
||||
)
|
||||
.unwrap();
|
||||
// No image created → reference is dangling.
|
||||
|
||||
let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides");
|
||||
assert!(!report.ok, "a missing referenced image must fail the build");
|
||||
let o = &report.outcomes[0];
|
||||
assert!(o.error.as_deref().unwrap_or("").contains("ghost.png"));
|
||||
// The markdown was still written, but the image was not (it doesn't exist).
|
||||
assert!(tmp.join("build/slides.md").is_file());
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_markdown_assemble_target_refuses_broken_lesson() {
|
||||
// A part path that doesn't exist → check error → assembly never runs.
|
||||
let tmp = tempdir();
|
||||
std::fs::write(
|
||||
tmp.join("manifest.toml"),
|
||||
r#"
|
||||
[project]
|
||||
id = "md"
|
||||
name = "md"
|
||||
|
||||
[info]
|
||||
title = "md"
|
||||
|
||||
[[parts]]
|
||||
kind = "segment"
|
||||
path = "segments/missing"
|
||||
|
||||
[targets.slides]
|
||||
artifact = { type = "single-file", filepath = "build/slides.md" }
|
||||
[[targets.slides.steps]]
|
||||
type = "assemble-markdown"
|
||||
field = "slides"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides");
|
||||
assert!(!report.ok);
|
||||
assert!(report.outcomes.is_empty(), "no assembly on a broken lesson");
|
||||
assert!(report.check.has_errors());
|
||||
assert!(
|
||||
!tmp.join("build").join("slides.md").exists(),
|
||||
"nothing should have been written"
|
||||
);
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
// --- tiny temp-dir helpers (no external dev-dep) ------------------------------
|
||||
|
||||
fn tempdir() -> PathBuf {
|
||||
|
||||
Reference in New Issue
Block a user