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:
@@ -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 `` 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 `` 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 `` 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 `` image
|
||||
references must resolve *within* it, not back into the engineering root. So the
|
||||
assembler, after writing the markdown, scans it for local `` 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.
|
||||
@@ -6,7 +6,7 @@ import Spec.Courseware.Open
|
||||
/-!
|
||||
# Courseware —— 产品层契约(课程工程文件)
|
||||
|
||||
护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005..0014。按四组组织:
|
||||
护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005..0015。按四组组织:
|
||||
|
||||
- **`Model`** —— 工程文件的内容模型:基元 `Primitives`、富内容 `RichContent`、原子
|
||||
单位 `Element`、单节课 `Lesson`(element 的有序序列)。
|
||||
|
||||
@@ -11,6 +11,9 @@ ADR-0009:export target 是一次 build,产出一个有类型的 `Artifact`。ADR
|
||||
*typed* 而非裸 shell,正因框架要把 **manifest 注入**模板(经 `--input manifest=…`),
|
||||
裸字符串表达不了这个 wiring。presentation(编号、样式)住模板里,不在 manifest。
|
||||
- `shell run` —— 逃生口,给难以声明的步骤(ADR-0005 的 (b) 类 medium-only)。
|
||||
- `assembleMarkdown field` —— 按 parts 顺序把每个 element 的 `field`(markdown content 叶子,
|
||||
ADR-0015)拼成**单文件 markdown** 产物。typed 而非 `cat`,因为按 manifest `[[parts]]` 顺序读取 +
|
||||
跳过缺失项 + 写到单文件产物路径这件事,框架 own 比裸 shell 稳。
|
||||
|
||||
**渲染覆盖**:ADR-0011 废止了 per-target `RenderRule` 载荷——渲染的"how"已移进模板。
|
||||
契约只保留覆盖声明 `covers`(该 target 渲染哪些 kind),供种子诊断用。
|
||||
@@ -25,6 +28,17 @@ ADR-0009:export target 是一次 build,产出一个有类型的 `Artifact`。ADR
|
||||
而由 build 执行层报告。诊断分类保持 6 类不变(ADR-0013 显式拒绝新增 `ShellStep` 诊断码)。
|
||||
3. **非-typst target 不过 typst 编译** —— 一个只含 `shell` step 的 target(教具包即此)由
|
||||
执行器跑命令,而非走 typst 引擎;`check` 的 compile 阶段跳过它。
|
||||
|
||||
**assembleMarkdown step 的执行语义(ADR-0015)。** 与 `shell` 同属"非-typst target":框架 own
|
||||
读取每个 element 的 `<field>.md`、按 `[[parts]]` 顺序拼接、写到单文件产物路径(产物是 markdown,
|
||||
不经 typst 引擎)。同 shell 的三条边界:① 只在显式 `build --target` 跑,`check` 不跑;② 装配失败
|
||||
(如写盘失败、引用图缺失)是 build-过程错误,不入 6 类诊断;③ 非-typst target,`check` 的 compile
|
||||
阶段跳过它。**单文件产物**(现):装配器注入课程级标题为文档 h1(课程元数据,非 element 内容),
|
||||
每份 `slides.md`/`transcript.md` 贡献其 `##` part 与 `###` 小节(presentation 在内容里, ADR-0011),
|
||||
按 `[[parts]]` 顺序拼接(分隔符空行)。**自包含**:装配器扫描产物里的 `` 图引用,把引用到的
|
||||
本地图从工程根按原相对路径复制进产物所在 build 根,使该 build 目录可独立交付;引用图在工程根缺失属
|
||||
build-过程错误(不入 6 类)。外部 URL(`http(s)://`/`data:`)不复制。结构化 FileTree 产物延后
|
||||
(待 parts 树形重组, ADR-0015 OPEN)。
|
||||
-/
|
||||
|
||||
namespace Spec.Courseware
|
||||
@@ -42,6 +56,13 @@ inductive Step where
|
||||
为 cwd 执行,opt-in(只在显式 build 该 target 时跑,`check` 不跑),失败属 build-过程错误
|
||||
而非 lesson 诊断。教具包(如 KenKen 交互 HTML 由外部 `kendoku` 生成)即走此 step。 -/
|
||||
| shell (run : String)
|
||||
/-- 装配 markdown:按 `[[parts]]` 顺序读取每个 element 的 `field`(markdown content 叶子,
|
||||
ADR-0015),注入课程 h1 后拼接成**单文件 markdown** 产物,并收集 `` 引用的本地图进
|
||||
build 根使产物自包含。typed 而非 `cat`:按序读取+跳过缺失+注入标题+写盘+收集图由框架 own。
|
||||
**已实现**(ADR-0015):非-typst target(`check` 不跑,失败属 build-过程错误不入 6 类诊断)。
|
||||
slides 大纲面 / 逐字稿口播面即走此 step(直接 markdown+KaTeX 撰写,绕开 typst→md 公式转换,
|
||||
ADR-0014 R2)。 -/
|
||||
| assembleMarkdown (field : String)
|
||||
|
||||
/-- 一个 export target 的 build 规格(`PINNED` artifact + 有序 steps, ADR-0011)。 -/
|
||||
structure TargetSpec where
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
/-!
|
||||
# RichContent —— 富内容(ADR-0006 的 prose 母本)
|
||||
|
||||
ADR-0006:element schema 的"叶子"可以是 `content` 类型,其值是**一段 typst 源**,
|
||||
语义取该源作为 module 求值后的 body content。关键约束(均 ADR-0006,源自 typst 源码
|
||||
事实):富内容**不可无主**——typst 的源必须有 `FileId`,否则 span 脱锚、相对 import
|
||||
报"cannot access file system from here"。故每段富内容是 World 里的一等文件,坐落在
|
||||
一个**虚拟路径**上;相对 import 限本工程路径结构内 + `@package`(不跨工程)。
|
||||
ADR-0006:element schema 的"叶子"可以是 `content` 类型,其值是一段**源文本**,
|
||||
按其 **format** 决定语义(ADR-0015)。两种 format:
|
||||
- **typst** —— 一段 typst 源,语义取该源作为 module 求值后的 body content(讲义/教案面)。
|
||||
- **markdown** —— 一段**原样**的 markdown + KaTeX 源,**不经 typst 求值**(slides 大纲面 / 逐字稿口播面;
|
||||
ADR-0015)。直接以 markdown 撰写**绕开** typst→markdown 的公式转换难题(ADR-0014 R2):公式一开始就是
|
||||
KaTeX 源(`$…$`),没有"把 typst 公式转成 md"这一步。
|
||||
|
||||
本模块只立 prose 锚点 + 最小抽象签名:typst 的 `Content`/`Module` 内部结构、JSON
|
||||
Schema 形状属实现细节,不进 Lean,只承诺"富内容由一个虚拟路径定位"这一条关系。
|
||||
关键约束(均 ADR-0006,源自 typst 源码事实):**typst** format 的富内容**不可无主**——typst 的源必须有
|
||||
`FileId`,否则 span 脱锚、相对 import 报"cannot access file system from here"。故每段 typst 富内容是 World 里
|
||||
的一等文件,坐落在一个**虚拟路径**上;相对 import 限本工程路径结构内 + `@package`(不跨工程)。markdown format
|
||||
的富内容不参与 typst 求值,但同样由一个虚拟路径定位(供 markdown 装配 step 按序读取,见 `Export/Render`)。
|
||||
|
||||
本模块只立 prose 锚点 + 最小抽象签名:typst 的 `Content`/`Module` 内部结构、JSON Schema 形状、format 的
|
||||
具体判别属实现细节,不进 Lean,只承诺"富内容由一个虚拟路径定位"+"叶子带 format"这两条关系。
|
||||
-/
|
||||
|
||||
namespace Spec.Courseware
|
||||
@@ -18,11 +24,21 @@ namespace Spec.Courseware
|
||||
(ADR-0007),不在本层承诺,故 opaque。 -/
|
||||
opaque VPath : Type
|
||||
|
||||
/-- 对一段富内容的**引用**:它坐落在某个虚拟路径上(`PINNED` 关系, ADR-0006)。
|
||||
刻意**不**建模源文本、不建模求值出的 `Content`(那是实现侧的事);只钉"富内容经由一个
|
||||
`VPath` 定位",作为 `Primitives.ElementData` 里 `content` 叶子的语义锚点。 -/
|
||||
/-- 富内容的 **format**(`PINNED`, ADR-0015)。`content` 叶子带 format:typst 叶子被 typst 求值;
|
||||
markdown 叶子原样保留(markdown+KaTeX 源,不经求值)。 -/
|
||||
inductive ContentFormat where
|
||||
/-- typst 源:求值为 typst `Content`(讲义/教案面)。 -/
|
||||
| typst
|
||||
/-- markdown + KaTeX 源:原样保留,不经 typst 求值(slides 大纲面 / 逐字稿口播面;ADR-0015)。 -/
|
||||
| markdown
|
||||
|
||||
/-- 对一段富内容的**引用**:它坐落在某个虚拟路径上(`PINNED` 关系, ADR-0006),并带一个
|
||||
**format**(`PINNED`, ADR-0015)。刻意**不**建模源文本、不建模求值出的 `Content`(那是实现侧的事);
|
||||
只钉"富内容经由一个 `VPath` 定位 + 带 format",作为 `Primitives.ElementData` 里 `content` 叶子的语义锚点。 -/
|
||||
structure RichContentRef where
|
||||
/-- 该富内容所在的虚拟路径(ADR-0006;落盘后为真实相对路径, ADR-0007)。 -/
|
||||
vpath : VPath
|
||||
/-- 该富内容的 format(ADR-0015)。 -/
|
||||
format : ContentFormat
|
||||
|
||||
end Spec.Courseware
|
||||
|
||||
Reference in New Issue
Block a user