diff --git a/crates/cph-check/src/lib.rs b/crates/cph-check/src/lib.rs index 7e5a491..d547846 100644 --- a/crates/cph-check/src/lib.rs +++ b/crates/cph-check/src/lib.rs @@ -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' `.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, + /// `None` on success; an error message if reading a required file or writing + /// the artifact failed. + pub error: Option, +} + +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, + /// `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 `.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 `/.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 `, +/// 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 = 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 `.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 `.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/`) into the build root + // (`build_root/`), 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 = 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 diff --git a/crates/cph-check/tests/pipeline.rs b/crates/cph-check/tests/pipeline.rs index 12e51bc..a834da2 100644 --- a/crates/cph-check/tests/pipeline.rs +++ b/crates/cph-check/tests/pipeline.rs @@ -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 { diff --git a/crates/cph-cli/src/main.rs b/crates/cph-cli/src/main.rs index 730427f..10a0936 100644 --- a/crates/cph-cli/src/main.rs +++ b/crates/cph-cli/src/main.rs @@ -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 `.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 `.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 `, +/// 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 + } +} diff --git a/crates/cph-model/src/lib.rs b/crates/cph-model/src/lib.rs index 7b89748..b3c4d10 100644 --- a/crates/cph-model/src/lib.rs +++ b/crates/cph-model/src/lib.rs @@ -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) -> 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) -> 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) -> 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 } diff --git a/crates/cph-schema/schemas/example.json b/crates/cph-schema/schemas/example.json index 01103d7..ee10cb0 100644 --- a/crates/cph-schema/schemas/example.json +++ b/crates/cph-schema/schemas/example.json @@ -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"], diff --git a/crates/cph-schema/schemas/lemma.json b/crates/cph-schema/schemas/lemma.json index e21433f..e5cbd6c 100644 --- a/crates/cph-schema/schemas/lemma.json +++ b/crates/cph-schema/schemas/lemma.json @@ -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 diff --git a/crates/cph-schema/schemas/segment.json b/crates/cph-schema/schemas/segment.json index 91f8a37..ea5d53f 100644 --- a/crates/cph-schema/schemas/segment.json +++ b/crates/cph-schema/schemas/segment.json @@ -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 diff --git a/crates/cph-schema/schemas/sop.json b/crates/cph-schema/schemas/sop.json index db6d944..8da118a 100644 --- a/crates/cph-schema/schemas/sop.json +++ b/crates/cph-schema/schemas/sop.json @@ -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 diff --git a/crates/cph-schema/src/lib.rs b/crates/cph-schema/src/lib.rs index 968db7b..1932b49 100644 --- a/crates/cph-schema/src/lib.rs +++ b/crates/cph-schema/src/lib.rs @@ -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 `.typ` file exists in -/// the element directory. +/// For each required content field, check that its `.` 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); + } } diff --git a/crates/cph-typst/src/lib.rs b/crates/cph-typst/src/lib.rs index a904693..e1bf25f 100644 --- a/crates/cph-typst/src/lib.rs +++ b/crates/cph-typst/src/lib.rs @@ -220,11 +220,13 @@ fn target_precheck(lesson: &Lesson, target: &str) -> Result Option { tc.steps.iter().find_map(|s| match s { Step::TypstCompile { template } => Some(template.clone()), Step::Shell { .. } => None, + Step::AssembleMarkdown { .. } => None, }) } diff --git a/docs/adr/0015-slides-and-transcript-markdown-surfaces.md b/docs/adr/0015-slides-and-transcript-markdown-surfaces.md new file mode 100644 index 0000000..14fb1e0 --- /dev/null +++ b/docs/adr/0015-slides-and-transcript-markdown-surfaces.md @@ -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 `.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 `![](rel)` image +references must resolve *within* it, not back into the engineering root. So the +assembler, after writing the markdown, scans it for local `![](rel)` references +and copies each referenced image from the engineering root (`/`) +into the build root (`/`), 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 + `/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 `.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 `, 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. diff --git a/spec/Spec/Courseware.lean b/spec/Spec/Courseware.lean index 6b5ade8..253a5b9 100644 --- a/spec/Spec/Courseware.lean +++ b/spec/Spec/Courseware.lean @@ -6,7 +6,7 @@ import Spec.Courseware.Open /-! # Courseware —— 产品层契约(课程工程文件) -护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005..0014。按四组组织: +护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005..0015。按四组组织: - **`Model`** —— 工程文件的内容模型:基元 `Primitives`、富内容 `RichContent`、原子 单位 `Element`、单节课 `Lesson`(element 的有序序列)。 diff --git a/spec/Spec/Courseware/Export/Render.lean b/spec/Spec/Courseware/Export/Render.lean index 866a6e0..ed71d5c 100644 --- a/spec/Spec/Courseware/Export/Render.lean +++ b/spec/Spec/Courseware/Export/Render.lean @@ -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 的 `.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]]` 顺序拼接(分隔符空行)。**自包含**:装配器扫描产物里的 `![](rel)` 图引用,把引用到的 +本地图从工程根按原相对路径复制进产物所在 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** 产物,并收集 `![](rel)` 引用的本地图进 + 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 diff --git a/spec/Spec/Courseware/Model/RichContent.lean b/spec/Spec/Courseware/Model/RichContent.lean index 05589bd..d1a8899 100644 --- a/spec/Spec/Courseware/Model/RichContent.lean +++ b/spec/Spec/Courseware/Model/RichContent.lean @@ -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