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
+379
View File
@@ -304,6 +304,7 @@ pub fn run_shell_target(root: &Path, engine: &Engine, target: &str) -> ShellRunR
.filter_map(|s| match s {
cph_model::Step::Shell { run } => Some(run.as_str()),
cph_model::Step::TypstCompile { .. } => None,
cph_model::Step::AssembleMarkdown { .. } => None,
})
.collect();
@@ -390,6 +391,384 @@ fn run_one_shell(cwd: &Path, run: &str) -> ShellStepOutcome {
}
}
/// One assemble-markdown step's outcome (for [`run_markdown_assemble_target`]).
#[derive(Debug, Clone, PartialEq)]
pub struct MarkdownAssembleOutcome {
/// The field name that was assembled (verbatim from the step's `field`).
pub field: String,
/// How many elements' `<field>.md` files were read and concatenated.
pub parts_read: usize,
/// The assembled markdown body (empty if nothing was read).
pub body: String,
/// Where the assembled file was written (relative to the engineering root);
/// `None` if the write failed or was skipped.
pub written: Option<String>,
/// `None` on success; an error message if reading a required file or writing
/// the artifact failed.
pub error: Option<String>,
}
impl MarkdownAssembleOutcome {
/// Whether the step succeeded (wrote the artifact with no error).
pub fn ok(&self) -> bool {
self.error.is_none() && self.written.is_some()
}
}
/// The result of [`run_markdown_assemble_target`].
#[derive(Debug, Clone, PartialEq)]
pub struct MarkdownAssembleReport {
/// The check phases' report (load → structural → schema). Assembly runs only
/// when this has no errors.
pub check: CheckReport,
/// Each assemble-markdown step's outcome, in declared order. Empty if the
/// build was refused (check errors, unknown target, no assemble steps).
pub outcomes: Vec<MarkdownAssembleOutcome>,
/// `true` if the target was found, check passed, and every assemble step wrote
/// its artifact without error.
pub ok: bool,
}
/// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each
/// element's `<field>.md` markdown content file in `[[parts]]` order into the
/// target's single-file artifact. This is the **third typed step**: unlike
/// [`build`] (typst template → PDF) the framework owns the read/concatenate/write
/// itself (not a typst compile, not an external tool like [`run_shell_target`]).
///
/// Contract / safety (same three boundaries as the shell step, ADR-0013/0015):
/// - Runs the check phases first (load → structural → schema). Any error refuses
/// the run (`ok == false`, nothing written) — assembling a broken lesson is
/// not attempted.
/// - Walks `lesson.parts` **in declared order**, reading `<part.path>/<field>.md`
/// when it exists (skipping absent ones — these fields are optional), joining
/// them with a blank line, and writing to the artifact's single-file
/// `filepath` (relative to the engineering root). Each `slides.md`/etc.
/// authors its own heading; the assembler injects nothing (presentation in the
/// content, ADR-0011).
/// - **Opt-in by construction**: only reached from `cph build --target <name>`,
/// never from `check`. A non-typst target, so `check`'s compile phase skips it.
/// - A write error is a **build-process error**, not a lesson diagnostic — it is
/// reported in `MarkdownAssembleOutcome::error`, not the 6-class taxonomy.
pub fn run_markdown_assemble_target(
root: &Path,
engine: &Engine,
target: &str,
) -> MarkdownAssembleReport {
let mut diags = Vec::new();
let (lesson, load_diags) = cph_model::load(root);
diags.extend(load_diags);
let Some(lesson) = lesson else {
return MarkdownAssembleReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: false,
},
outcomes: Vec::new(),
ok: false,
};
};
let known = cph_schema::known_kinds();
run_structural_and_schema(&lesson, known, &mut diags);
// Signature uniform with `build`/`run_shell_target`; the engine is not used
// to assemble markdown (no typst compile), but callers pass it for API
// consistency.
let _ = engine;
let has_error = diags.iter().any(|d| d.severity == Severity::Error);
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{target}' not declared in manifest"),
)
.with_hint(format!(
"add a `[targets.{target}]` table, or run a declared target"
)),
);
return MarkdownAssembleReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
outcomes: Vec::new(),
ok: false,
};
};
let assemble_steps: Vec<&str> = tc
.steps
.iter()
.filter_map(|s| match s {
cph_model::Step::AssembleMarkdown { field } => Some(field.as_str()),
_ => None,
})
.collect();
if has_error || assemble_steps.is_empty() {
return MarkdownAssembleReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
outcomes: Vec::new(),
ok: false,
};
}
// Run each assemble-markdown step. First failure stops the sequence.
let mut outcomes = Vec::new();
let mut all_ok = true;
for field in assemble_steps {
let outcome = assemble_one(&lesson, field);
let ok = outcome.ok();
outcomes.push(outcome);
if !ok {
all_ok = false;
break;
}
}
MarkdownAssembleReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
outcomes,
ok: all_ok,
}
}
/// Assemble one markdown `field` across all parts in order, writing the
/// single-file artifact.
fn assemble_one(lesson: &cph_model::Lesson, field: &str) -> MarkdownAssembleOutcome {
let mut chunks: Vec<String> = Vec::new();
let mut parts_read = 0usize;
// Inject the course-level title as the document's h1. The title is course
// metadata (from `[info]` in the manifest), not per-element content, so the
// assembler prepends it; each element's `<field>.md` then contributes its
// `##` part heading and `###` 小节. (ADR-0011's "presentation lives in the
// content" is about per-element presentation; the single course title is a
// document-level fact the assembler owns.)
if !lesson.info.title.trim().is_empty() {
chunks.push(format!("# {}", lesson.info.title.trim()));
}
for part in &lesson.parts {
if !part.descriptor.dir.is_dir() {
continue;
}
let md = part.descriptor.dir.join(format!("{field}.md"));
if md.is_file() {
match std::fs::read_to_string(&md) {
Ok(body) => {
chunks.push(body);
parts_read += 1;
}
Err(e) => {
return MarkdownAssembleOutcome {
field: field.to_string(),
parts_read,
body: String::new(),
written: None,
error: Some(format!("failed to read '{}': {e}", md.display())),
};
}
}
}
// Absent `<field>.md` is fine — the field is optional (ADR-0015).
}
let body = chunks.join("\n\n");
// Resolve the target's single-file artifact path. (The assemble target is a
// single-file artifact by construction; a file-tree target has no single
// filepath to write to, which is a manifest error surfaced here.)
let tc = lesson.targets.iter().find(|t| {
t.steps
.iter()
.any(|s| matches!(s, cph_model::Step::AssembleMarkdown { field: f } if f == field))
});
let Some(tc) = tc else {
return MarkdownAssembleOutcome {
field: field.to_string(),
parts_read,
body,
written: None,
error: Some("no target carries this assemble-markdown step".into()),
};
};
let cph_model::Artifact::SingleFile { filepath } = &tc.artifact else {
return MarkdownAssembleOutcome {
field: field.to_string(),
parts_read,
body,
written: None,
error: Some(format!(
"target '{}' has a non-single-file artifact; assemble-markdown writes a single file",
tc.name
)),
};
};
let out_path = lesson.root.join(filepath);
if let Some(parent) = out_path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
return MarkdownAssembleOutcome {
field: field.to_string(),
parts_read,
body,
written: None,
error: Some(format!("failed to create '{}': {e}", parent.display())),
};
}
}
match std::fs::write(&out_path, &body) {
Ok(()) => {
// Collect referenced image assets so the build dir is self-contained.
// The assembled `body` is written under `build_root` (the artifact
// filepath's parent); its `![](rel)` image references resolve against
// that build root. So each referenced local image is copied from the
// engineering root (`lesson.root/<rel>`) into the build root
// (`build_root/<rel>`), preserving its relative path. A referenced
// image that is missing at the engineering root is a build-process
// error (a self-contained build with a dangling ref is broken) — it
// does not enter the 6-class diagnostic taxonomy (ADR-0015).
if let Some(build_root) = out_path.parent() {
if let Err(e) = collect_image_assets(&lesson.root, build_root, &body) {
return MarkdownAssembleOutcome {
field: field.to_string(),
parts_read,
body,
written: Some(filepath.display().to_string()),
error: Some(e),
};
}
}
MarkdownAssembleOutcome {
field: field.to_string(),
parts_read,
body,
written: Some(filepath.display().to_string()),
error: None,
}
}
Err(e) => MarkdownAssembleOutcome {
field: field.to_string(),
parts_read,
body,
written: None,
error: Some(format!("failed to write '{}': {e}", out_path.display())),
},
}
}
/// Copy each local markdown image reference in `body` from the engineering root
/// `eng_root` into the build root `build_root`, preserving the reference's
/// relative path. Returns `Err(message)` on the first referenced image that is
/// missing at `eng_root` (a build-process error, ADR-0015).
///
/// References are `![alt](target)` markdown images; `target` is taken up to the
/// first whitespace (a possible ` "title"` suffix is stripped). Absolute URLs
/// (`http://`, `https://`, `data:`) are left untouched — they are not local
/// assets to collect. Duplicate references copy once.
fn collect_image_assets(eng_root: &Path, build_root: &Path, body: &str) -> Result<(), String> {
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for target in image_refs(body) {
if target.is_empty()
|| target.starts_with("http://")
|| target.starts_with("https://")
|| target.starts_with("data:")
{
continue;
}
if !seen.insert(target.to_string()) {
continue;
}
let src = eng_root.join(target);
if !src.is_file() {
return Err(format!(
"slides references image '{}' which is missing from the engineering root",
src.display()
));
}
let dst = build_root.join(target);
if let Some(parent) = dst.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
return Err(format!("failed to create '{}': {e}", parent.display()));
}
}
if let Err(e) = std::fs::copy(&src, &dst) {
return Err(format!("failed to copy '{}' → '{}': {e}", src.display(), dst.display()));
}
}
Ok(())
}
/// Scan `body` for markdown image references `![alt](target)` and yield each
/// `target` (whitespace-trimmed of a trailing ` "title"`). Hand-rolled to avoid
/// pulling a regex dependency for a single, simple pattern.
fn image_refs(body: &str) -> Vec<&str> {
let bytes = body.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i + 1 < bytes.len() {
// Look for the `![` opener.
if bytes[i] == b'!' && bytes[i + 1] == b'[' {
// Find the matching `]`.
if let Some(bracket_close) = body[i + 2..].find(']') {
let after_bracket = i + 2 + bracket_close + 1;
if after_bracket < bytes.len() && bytes[after_bracket] == b'(' {
// Find the closing `)` of the link target.
if let Some(paren_close) = body[after_bracket + 1..].find(')') {
let target_start = after_bracket + 1;
let target_end = target_start + paren_close;
let target = &body[target_start..target_end];
// Trim a possible ` "title"` suffix: take up to first space.
let target = target.split_whitespace().next().unwrap_or("");
out.push(target);
i = target_end + 1;
continue;
}
}
}
}
i += 1;
}
out
}
/// Whether the named target is an **assemble-markdown** target (its steps
/// contain at least one `Step::AssembleMarkdown` and no `Step::TypstCompile`) —
/// i.e. a markdown deliverable that [`run_markdown_assemble_target`] builds
/// rather than [`build`] compiling to PDF.
///
/// Loads the lesson read-only (ignoring diagnostics); an unloadable lesson or an
/// unknown target returns `false`, so the caller falls through to the normal
/// (typst) build path, which then reports the real error.
pub fn target_is_markdown_assemble(root: &Path, target: &str) -> bool {
let (Some(lesson), _) = cph_model::load(root) else {
return false;
};
lesson
.targets
.iter()
.find(|t| t.name == target)
.is_some_and(|t| {
t.steps
.iter()
.any(|s| matches!(s, cph_model::Step::AssembleMarkdown { .. }))
&& !t.steps
.iter()
.any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
})
}
/// 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
+277
View File
@@ -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![one](assets/img/one.png)\n\n![two](assets/img/two.png)\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![ghost](assets/img/ghost.png)\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 {
+47
View File
@@ -110,6 +110,13 @@ fn run_build(
return run_shell_build(path, engine, target);
}
// A target whose steps assemble markdown (ADR-0015: slides outline / 逐字稿
// transcript surfaces) is built by concatenating per-element `<field>.md`
// files in parts order, not by compiling a typst template.
if cph_check::target_is_markdown_assemble(path, target) {
return run_markdown_assemble_build(path, engine, target);
}
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
let (pdf, report) = cph_check::build(path, engine, target);
@@ -182,3 +189,43 @@ fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> Exi
ExitCode::FAILURE
}
}
/// Run an assemble-markdown target: concatenate each element's `<field>.md`
/// (in parts order) and write the single-file artifact (ADR-0015). Like the
/// shell path, this is opt-in — only on an explicit `cph build --target <name>`,
/// and never in `check`.
fn run_markdown_assemble_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode {
eprintln!("assembling markdown target '{target}' (reading parts under {})", path.display());
let report = cph_check::run_markdown_assemble_target(path, engine, target);
print_diagnostics(&report.check);
if report.outcomes.is_empty() && !report.ok {
if report.check.has_errors() {
eprintln!("build refused: {} errors (fix the lesson first)", report.check.error_count());
} else {
eprintln!("build failed: target '{target}' has no assemble-markdown steps to run");
}
return ExitCode::FAILURE;
}
for outcome in &report.outcomes {
eprintln!(
"assembled field '{}' from {} part(s)",
outcome.field, outcome.parts_read
);
if let Some(out_rel) = &outcome.written {
println!("wrote {} ({} bytes)", path.join(out_rel).display(), outcome.body.len());
}
if let Some(err) = &outcome.error {
eprintln!("assemble failed: {err}");
}
}
if report.ok {
println!("assemble-markdown target '{target}' completed: {} step(s) ok", report.outcomes.len());
ExitCode::SUCCESS
} else {
eprintln!("assemble-markdown target '{target}' failed");
ExitCode::FAILURE
}
}
+42 -7
View File
@@ -163,6 +163,7 @@ impl Artifact {
/// inductive Step where
/// | typstCompile (template : String)
/// | shell (run : String)
/// | assembleMarkdown (field : String)
/// ```
///
/// A step is a *typed* operation (extensible): `TypstCompile` compiles a
@@ -170,9 +171,13 @@ impl Artifact {
/// 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`].
/// builds). `AssembleMarkdown` concatenates a per-element markdown content
/// field into a single-file markdown deliverable (ADR-0015: the slides outline
/// and 逐字稿 transcript surfaces, authored directly in markdown+KaTeX to
/// sidestep the typst→md conversion). The on-disk `[[steps]]` entry's `type`
/// discriminator selects the variant: `"typst-compile"` →
/// [`Step::TypstCompile`], `"shell"` → [`Step::Shell`], `"assemble-markdown"`
/// → [`Step::AssembleMarkdown`].
///
/// As with [`Artifact`], this alignment is maintained by review, not CI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -189,6 +194,15 @@ pub enum Step {
/// The command line to run.
run: String,
},
/// Assemble a single-file markdown deliverable by concatenating each
/// element's `field` markdown content file in `[[parts]]` order. Mirrors
/// Lean `Step.assembleMarkdown` (ADR-0015). Not a typst build — the
/// framework owns the read/concatenate/write itself.
AssembleMarkdown {
/// The per-element markdown content field to assemble (e.g. `slides`,
/// `transcript`).
field: String,
},
}
impl Step {
@@ -901,7 +915,9 @@ fn parse_step(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Op
DiagCode::SchemaViolation,
format!("target '{name}' has a step with a non-string `type`"),
)
.with_hint("set the step `type` to \"typst-compile\" or \"shell\""),
.with_hint(
"set the step `type` to \"typst-compile\", \"shell\", or \"assemble-markdown\"",
),
);
return None;
}
@@ -911,7 +927,9 @@ fn parse_step(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Op
DiagCode::SchemaViolation,
format!("target '{name}' has a step with no `type` discriminator"),
)
.with_hint("add `type = \"typst-compile\"` (or `\"shell\"`) to the step"),
.with_hint(
"add `type = \"typst-compile\"` (or `\"shell\"`, `\"assemble-markdown\"`) to the step",
),
);
return None;
}
@@ -948,16 +966,33 @@ fn parse_step(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Op
None
}
},
"assemble-markdown" => match take_string_field(&mut table, "field") {
Some(field) => Some(Step::AssembleMarkdown { field }),
None => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' assemble-markdown step is missing a string `field`"
),
)
.with_hint("add `field = \"slides\"` (or `\"transcript\"`) 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\""
valid values are \"typst-compile\", \"shell\", \"assemble-markdown\""
),
)
.with_hint("set the step `type` to \"typst-compile\" or \"shell\""),
.with_hint(
"set the step `type` to \"typst-compile\", \"shell\", or \"assemble-markdown\"",
),
);
None
}
+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);
}
}
+3 -1
View File
@@ -220,11 +220,13 @@ fn target_precheck(lesson: &Lesson, target: &str) -> Result<PathBuf, Vec<Diagnos
}
/// The template of the first [`Step::TypstCompile`] in `tc`, if any (MVP: a
/// target has one step; a `Shell`-only target yields `None`).
/// target has one step; a `Shell`-only or `AssembleMarkdown`-only target yields
/// `None`).
fn template_step(tc: &TargetConfig) -> Option<PathBuf> {
tc.steps.iter().find_map(|s| match s {
Step::TypstCompile { template } => Some(template.clone()),
Step::Shell { .. } => None,
Step::AssembleMarkdown { .. } => None,
})
}