diff --git a/Cargo.lock b/Cargo.lock index 316f6b2..5ef80d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -437,6 +437,8 @@ dependencies = [ "cph-diag", "cph-model", "cph-schema", + "dirs", + "include_dir", "tempfile", "toml", "typst", @@ -1191,6 +1193,25 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.14.0" diff --git a/crates/cph-check/src/lib.rs b/crates/cph-check/src/lib.rs index 80680c8..7e5a491 100644 --- a/crates/cph-check/src/lib.rs +++ b/crates/cph-check/src/lib.rs @@ -198,6 +198,198 @@ pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option>, Ch } } +/// One shell step's execution outcome (for [`run_shell_target`]). +#[derive(Debug, Clone, PartialEq)] +pub struct ShellStepOutcome { + /// The command line that was run (verbatim from the manifest `run` field). + pub run: String, + /// Process exit code (`None` if the process was killed by a signal). + pub status: Option, + /// Captured stdout. + pub stdout: String, + /// Captured stderr. + pub stderr: String, +} + +impl ShellStepOutcome { + /// Whether the step exited successfully (status 0). + pub fn ok(&self) -> bool { + self.status == Some(0) + } +} + +/// The result of [`run_shell_target`]. +#[derive(Debug, Clone, PartialEq)] +pub struct ShellRunReport { + /// The check phases' report (load → structural → schema). Shell steps run + /// only when this has no errors. + pub check: CheckReport, + /// Each shell step's outcome, in declared order. Empty if the build was + /// refused (check errors, unknown target, or the target has no shell steps). + pub outcomes: Vec, + /// `true` if the target was found, check passed, and every shell step exited 0. + pub ok: bool, +} + +/// Execute the `Shell` steps of a `file-tree`/tool-generated target (ADR-0009 +/// category (b); e.g. the KenKen interactives produced by the `kendoku` CLI). +/// +/// This is the **escape-hatch executor**: unlike [`build`] (which compiles a +/// typst template to a PDF), a shell target hands off to an external tool that +/// writes files itself. cph only *runs the declared command* in the engineering +/// root and reports what happened — it assembles nothing. +/// +/// Contract / safety: +/// - Runs the check phases first (load → structural → schema). Any error refuses +/// the run (`ok == false`, no command executed) — a shell build of a broken +/// lesson is not attempted. +/// - Each `Step::Shell { run }` is executed via the platform shell +/// (`sh -c ` / `cmd /C `) with the **engineering root as the working +/// directory**, so a manifest can use root-relative paths. +/// - **Arbitrary command execution is opt-in by construction**: this function is +/// only reached from `cph build --target `, never from `check`. Callers +/// (the CLI) surface the command before running it. +/// - `TypstCompile` steps inside the same target are skipped here (a shell target +/// is not a typst build); a target with no shell steps yields `ok == false`. +pub fn run_shell_target(root: &Path, engine: &Engine, target: &str) -> ShellRunReport { + let mut diags = Vec::new(); + + let (lesson, load_diags) = cph_model::load(root); + diags.extend(load_diags); + + let Some(lesson) = lesson else { + return ShellRunReport { + 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); + + // Suppress the unused-parameter warning while keeping the signature uniform + // with `build`/`check` (the engine is not needed to run shell steps, but + // callers pass it so the API is consistent and future steps may use it). + 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 ShellRunReport { + check: CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + outcomes: Vec::new(), + ok: false, + }; + }; + + let shell_steps: Vec<&str> = tc + .steps + .iter() + .filter_map(|s| match s { + cph_model::Step::Shell { run } => Some(run.as_str()), + cph_model::Step::TypstCompile { .. } => None, + }) + .collect(); + + if has_error || shell_steps.is_empty() { + return ShellRunReport { + check: CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + outcomes: Vec::new(), + ok: false, + }; + } + + // Run each shell step in the engineering root. First non-zero exit stops the + // sequence (a failed step's successors likely depend on it). + let mut outcomes = Vec::new(); + let mut all_ok = true; + for run in shell_steps { + let outcome = run_one_shell(&lesson.root, run); + let ok = outcome.ok(); + outcomes.push(outcome); + if !ok { + all_ok = false; + break; + } + } + + ShellRunReport { + check: CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + outcomes, + ok: all_ok, + } +} + +/// Whether the named target is a **shell-step** target (its steps contain at +/// least one `Step::Shell` and no `Step::TypstCompile`) — i.e. a tool-generated +/// asset bundle that [`run_shell_target`] executes 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_shell(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::Shell { .. })) + && !t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. })) + }) +} + +/// Run one shell command line in `cwd` via the platform shell, capturing output. +fn run_one_shell(cwd: &Path, run: &str) -> ShellStepOutcome { + use std::process::Command; + + let output = if cfg!(target_os = "windows") { + Command::new("cmd").arg("/C").arg(run).current_dir(cwd).output() + } else { + Command::new("sh").arg("-c").arg(run).current_dir(cwd).output() + }; + + match output { + Ok(out) => ShellStepOutcome { + run: run.to_string(), + status: out.status.code(), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }, + Err(e) => ShellStepOutcome { + run: run.to_string(), + status: None, + stdout: String::new(), + stderr: format!("failed to spawn command: {e}"), + }, + } +} + /// 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 @@ -245,14 +437,26 @@ fn run_structural_and_schema( } } -/// The targets to compile-check for [`check`]: every declared target, or -/// `["student"]` if the lesson declares none. +/// The targets to compile-check for [`check`]: every declared **typst-compile** +/// target, or `["student"]` if the lesson declares none. +/// +/// A target whose steps are all `Shell` (e.g. a tool-generated asset bundle — +/// ADR-0005 category (b), like the KenKen interactives produced by `kendoku`) is +/// **not** typst-compiled, so it is excluded here: compile-checking it would +/// wrongly route a non-typst build through the typst engine. Such targets are +/// executed only on an explicit `cph build --target ` (see +/// [`run_shell_target`]); `check` validates the lesson structure, not the +/// outcome of running an external tool. fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> { if lesson.targets.is_empty() { - vec![DEFAULT_TARGET] - } else { - lesson.target_names() + return vec![DEFAULT_TARGET]; } + lesson + .targets + .iter() + .filter(|t| t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))) + .map(|t| t.name.as_str()) + .collect() } /// Phase (e): for each `(part.kind, target)` over the lesson's declared targets, diff --git a/crates/cph-check/tests/pipeline.rs b/crates/cph-check/tests/pipeline.rs index a80efaf..12e51bc 100644 --- a/crates/cph-check/tests/pipeline.rs +++ b/crates/cph-check/tests/pipeline.rs @@ -262,6 +262,118 @@ template = "exports/handout.typ" cleanup(&tmp); } +/// Write a one-segment lesson with a `file-tree` shell target whose `run` is the +/// given command. Returns nothing; the caller runs `run_shell_target`. +fn write_shell_target_lesson(tmp: &PathBuf, run: &str) { + std::fs::write( + tmp.join("manifest.toml"), + format!( + r#" +[project] +id = "sh" +name = "sh" + +[info] +title = "sh" + +[[parts]] +kind = "segment" +path = "segments/intro" + +[targets.assets] +artifact = {{ type = "file-tree", root = "build/assets", outputs = "**/*" }} +[[targets.assets.steps]] +type = "shell" +run = "{run}" +"# + ), + ) + .unwrap(); + let part_dir = tmp.join("segments").join("intro"); + std::fs::create_dir_all(&part_dir).unwrap(); + std::fs::write(part_dir.join("element.toml"), "kind = \"segment\"\n").unwrap(); + std::fs::write(part_dir.join("textbook.typ"), "Hi.\n").unwrap(); +} + +#[test] +fn target_is_shell_detects_shape() { + let tmp = tempdir(); + write_shell_target_lesson(&tmp, "true"); + assert!(cph_check::target_is_shell(&tmp, "assets")); + assert!(!cph_check::target_is_shell(&tmp, "nonexistent")); + cleanup(&tmp); +} + +#[test] +fn run_shell_target_executes_in_engineering_root() { + // The command writes a file under the engineering root; cwd must be the root. + let tmp = tempdir(); + write_shell_target_lesson( + &tmp, + "mkdir -p build/assets && printf ok > build/assets/marker.txt", + ); + + let report = cph_check::run_shell_target(&tmp, &engine(), "assets"); + assert!(report.ok, "shell target should succeed: {:#?}", report); + assert_eq!(report.outcomes.len(), 1); + assert!(report.outcomes[0].ok()); + + // The side effect landed in the engineering root. + let marker = tmp.join("build").join("assets").join("marker.txt"); + assert_eq!(std::fs::read_to_string(&marker).unwrap(), "ok"); + cleanup(&tmp); +} + +#[test] +fn run_shell_target_reports_nonzero_exit() { + let tmp = tempdir(); + write_shell_target_lesson(&tmp, "exit 3"); + + let report = cph_check::run_shell_target(&tmp, &engine(), "assets"); + assert!(!report.ok, "non-zero exit must fail the run"); + assert_eq!(report.outcomes.len(), 1); + assert_eq!(report.outcomes[0].status, Some(3)); + cleanup(&tmp); +} + +#[test] +fn run_shell_target_refuses_broken_lesson() { + // A part path that doesn't exist → check error → shell steps never run. + let tmp = tempdir(); + std::fs::write( + tmp.join("manifest.toml"), + r#" +[project] +id = "sh" +name = "sh" + +[info] +title = "sh" + +[[parts]] +kind = "segment" +path = "segments/missing" + +[targets.assets] +artifact = { type = "file-tree", root = "build/assets", outputs = "**/*" } +[[targets.assets.steps]] +type = "shell" +run = "printf SHOULD_NOT_RUN > build/ran.txt" +"#, + ) + .unwrap(); + + let report = cph_check::run_shell_target(&tmp, &engine(), "assets"); + assert!(!report.ok); + assert!(report.outcomes.is_empty(), "no command should run on a broken lesson"); + assert!(report.check.has_errors()); + assert!( + !tmp.join("build").join("ran.txt").exists(), + "the command must not have executed" + ); + 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 b564458..730427f 100644 --- a/crates/cph-cli/src/main.rs +++ b/crates/cph-cli/src/main.rs @@ -102,6 +102,14 @@ fn run_build( target: &str, out: Option, ) -> ExitCode { + // A target whose steps are shell commands (a tool-generated asset bundle, + // ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by + // executing those commands, not by compiling a typst template. Detect that + // shape up front and route accordingly. + if cph_check::target_is_shell(path, target) { + return run_shell_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); @@ -131,3 +139,46 @@ fn run_build( } } } + +/// Run a shell-step target: execute its declared commands in the engineering +/// root. Arbitrary command execution is **opt-in** — it only happens on an +/// explicit `cph build --target `, and each command is printed before it +/// runs so the user sees exactly what is executed. +fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode { + eprintln!("running shell-step target '{target}' (commands execute in {})", path.display()); + let report = cph_check::run_shell_target(path, engine, target); + print_diagnostics(&report.check); + + if report.outcomes.is_empty() && !report.ok { + // Refused before running anything (check errors / unknown target / no + // shell steps): the diagnostics above explain why. + 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 shell steps to run"); + } + return ExitCode::FAILURE; + } + + for outcome in &report.outcomes { + eprintln!("$ {}", outcome.run); + if !outcome.stdout.trim().is_empty() { + print!("{}", outcome.stdout); + } + if !outcome.ok() { + eprint!("{}", outcome.stderr); + eprintln!( + "step failed (exit {})", + outcome.status.map(|c| c.to_string()).unwrap_or_else(|| "signal".into()) + ); + } + } + + if report.ok { + println!("shell-step target '{target}' completed: {} step(s) ok", report.outcomes.len()); + ExitCode::SUCCESS + } else { + eprintln!("shell-step target '{target}' failed"); + ExitCode::FAILURE + } +} diff --git a/crates/cph-typst/Cargo.toml b/crates/cph-typst/Cargo.toml index f51acb6..08bab88 100644 --- a/crates/cph-typst/Cargo.toml +++ b/crates/cph-typst/Cargo.toml @@ -10,6 +10,13 @@ cph-model = { workspace = true } cph-schema = { path = "../cph-schema" } toml = { workspace = true } +# Embed the `render/` package (cph-render + vendored @preview/numbly) INTO the +# binary so an installed `cph` is self-contained — no reliance on a source +# checkout living at a compile-time path. `dirs` locates a per-user cache dir to +# extract the embedded tree to (typst's World reads render files from disk). +include_dir = "0.7" +dirs = "6" + typst = "=0.15.0" typst-layout = "=0.15.0" typst-pdf = "=0.15.0" diff --git a/crates/cph-typst/build.rs b/crates/cph-typst/build.rs new file mode 100644 index 0000000..c0e215c --- /dev/null +++ b/crates/cph-typst/build.rs @@ -0,0 +1,64 @@ +//! Build script: stage a clean copy of the repo `render/` package into `OUT_DIR` +//! so `src/embedded.rs` can `include_dir!` it without tripping over the dev-only +//! self-referential symlink at `render/vendor/local-packages/`. +//! +//! Why this exists: `include_dir!` walks a directory at compile time and follows +//! symlinks. The repo keeps `render/vendor/local-packages/local/cph-render/0.1.0` +//! as a symlink pointing back at `render/` (a gitignored dev convenience for +//! typst's local-package resolution). Embedding `render/` directly would make +//! `include_dir!` recurse into that link forever. The runtime never needs that +//! link (the engine resolves `@local/cph-render` from the render dir directly, +//! and `@preview/*` from `vendor/typst-packages/`), so we copy everything EXCEPT +//! `vendor/local-packages/` into `OUT_DIR/render` and embed that staged tree. + +use std::path::{Path, PathBuf}; + +fn main() { + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let render_src = manifest_dir.join("..").join("..").join("render"); + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let staged = out_dir.join("render"); + + // Rebuild the staged tree from scratch each run (cheap — ~140 KiB). + if staged.exists() { + let _ = std::fs::remove_dir_all(&staged); + } + copy_tree(&render_src, &staged).expect("stage render/ for embedding"); + + // Re-run when the render package changes. + println!("cargo:rerun-if-changed={}", render_src.display()); + // Expose the staged path to the crate via an env var include_dir! can read. + println!("cargo:rustc-env=CPH_STAGED_RENDER_DIR={}", staged.display()); +} + +/// Recursively copy `src` → `dst`, skipping the dev-only `vendor/local-packages` +/// subtree and never following symlinks into it. +fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let name = entry.file_name(); + let from = entry.path(); + let to = dst.join(&name); + + // Skip the self-referential dev symlink tree. + if from.ends_with("vendor/local-packages") || name == *"local-packages" { + continue; + } + + // Resolve symlinks/metadata without following into a cycle: use + // `symlink_metadata` so a symlinked dir is treated by its link, not its + // (recursive) target. We only copy real files and real directories. + let meta = std::fs::symlink_metadata(&from)?; + let ft = meta.file_type(); + if ft.is_symlink() { + // Skip any symlink (the only one in the tree is the dev convenience). + continue; + } else if ft.is_dir() { + copy_tree(&from, &to)?; + } else if ft.is_file() { + std::fs::copy(&from, &to)?; + } + } + Ok(()) +} diff --git a/crates/cph-typst/src/embedded.rs b/crates/cph-typst/src/embedded.rs new file mode 100644 index 0000000..47fc00e --- /dev/null +++ b/crates/cph-typst/src/embedded.rs @@ -0,0 +1,104 @@ +//! Embedded `cph-render` package — makes an installed `cph` self-contained. +//! +//! The typst [`crate::world::LessonWorld`] reads the render package +//! (`@local/cph-render` + the vendored `@preview/numbly`) **from disk**, by +//! `render_dir.join(vpath.realize)`. In a source checkout that dir is the repo's +//! `render/`. But a `cargo install`-ed binary has no repo around it — the old +//! `env!("CARGO_MANIFEST_DIR")/../../render` path points into the build sandbox +//! and does not exist at runtime. +//! +//! So we **embed the whole `render/` tree into the binary** at compile time +//! (`include_dir!`) and, on first use, **extract it to a per-user cache dir** +//! keyed by the crate version. `default_render_dir` returns that extracted path. +//! The tree is tiny (~140 KiB, ~33 files), so embedding is cheap and extraction +//! is a one-time cost per version. +//! +//! `render/vendor/local-packages/` (a dev-only self-referential symlink, +//! gitignored) is excluded by the build script when it stages the tree for +//! embedding — see `build.rs`. The embedded copy never contains it, which is +//! correct: the World resolves `@local/cph-render` from `render_dir` directly, +//! and `@preview/*` from `vendor/typst-packages/`, neither via that symlink. + +use std::path::{Path, PathBuf}; + +use include_dir::{include_dir, Dir}; + +/// The `render/` tree, embedded at compile time from the **staged** copy the +/// build script wrote to `OUT_DIR/render` (which omits the dev-only symlink that +/// would otherwise make `include_dir!` recurse forever). `CPH_STAGED_RENDER_DIR` +/// is set by `build.rs`. +static RENDER_DIR: Dir<'_> = include_dir!("$CPH_STAGED_RENDER_DIR"); + +/// Resolve the directory the typst World should read the render package from, +/// extracting the embedded copy to a per-user cache dir if needed. +/// +/// Resolution order: +/// 1. `CPH_RENDER_DIR` env var — an explicit override (dev convenience: point +/// at the live repo `render/`). +/// 2. The extracted embedded copy under the user cache dir +/// (`/cph/render-/`). Extracted once per crate version; +/// subsequent runs reuse it. +/// +/// On any failure to locate a cache dir or extract, falls back to a temp-dir +/// location so the engine still works (just re-extracting per process). +pub fn resolve_render_dir() -> PathBuf { + if let Ok(dir) = std::env::var("CPH_RENDER_DIR") { + return PathBuf::from(dir); + } + ensure_extracted().unwrap_or_else(|_| { + // Last-resort: extract under the OS temp dir. Still correct, just not + // cached across processes. + let fallback = std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION"))); + let _ = extract_to(&fallback); + fallback + }) +} + +/// The version-keyed cache location and a guarantee the embedded tree is present +/// there. Returns the directory the World should use. +fn ensure_extracted() -> std::io::Result { + let base = dirs::cache_dir() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?; + let dest = base + .join("cph") + .join(format!("render-{}", env!("CARGO_PKG_VERSION"))); + + // A sentinel marks a complete extraction; if present, reuse as-is. (Keyed by + // version, so a new `cph` version re-extracts into a fresh dir.) + let sentinel = dest.join(".extracted"); + if sentinel.is_file() { + return Ok(dest); + } + + extract_to(&dest)?; + std::fs::write(&sentinel, env!("CARGO_PKG_VERSION"))?; + Ok(dest) +} + +/// Write the embedded `render/` tree under `dest` (creating dirs as needed). +/// +/// Each embedded entry's `.path()` is already relative to the embed root (e.g. +/// `src/elements/segment.typ`), so we join the **full** path under `dest` and +/// create parents — no per-level mirroring needed. +fn extract_to(dest: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dest)?; + write_dir(&RENDER_DIR, dest) +} + +/// Recursively write an embedded [`Dir`]'s files under `dest`, preserving the +/// full relative path of each file. +fn write_dir(dir: &Dir<'_>, dest: &Path) -> std::io::Result<()> { + for entry in dir.entries() { + match entry { + include_dir::DirEntry::Dir(sub) => write_dir(sub, dest)?, + include_dir::DirEntry::File(file) => { + let file_dest = dest.join(file.path()); + if let Some(parent) = file_dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&file_dest, file.contents())?; + } + } + } + Ok(()) +} diff --git a/crates/cph-typst/src/lib.rs b/crates/cph-typst/src/lib.rs index 0af8ced..a904693 100644 --- a/crates/cph-typst/src/lib.rs +++ b/crates/cph-typst/src/lib.rs @@ -35,6 +35,7 @@ //! VirtualPath)`, and a diagnostic span is a `DiagSpan` (not a bare `Span`). mod diag; +mod embedded; mod manifest; mod world; @@ -68,8 +69,9 @@ pub struct Engine { impl Engine { /// Construct an engine, discovering system + embedded fonts. The render /// package directory is taken from the `CPH_RENDER_DIR` environment variable - /// when set, otherwise defaults to `/render` resolved relative to this - /// crate (`CARGO_MANIFEST_DIR/../../render`). + /// when set, otherwise the **embedded** render package is extracted to a + /// per-user cache dir and used (see [`default_render_dir`]). This keeps an + /// installed `cph` self-contained — no source checkout required at runtime. pub fn new() -> Self { Self::with_render_dir(default_render_dir()) } @@ -234,14 +236,14 @@ fn map_all(world: &LessonWorld, diags: &[typst::diag::SourceDiagnostic]) -> Vec< .collect() } -/// `/../../render` — the repo's render package, used when `CPH_RENDER_DIR` -/// is unset. +/// The render-package directory the engine resolves `@local/cph-render` (and the +/// vendored `@preview/*`) from when no explicit dir is given. +/// +/// Honors `CPH_RENDER_DIR` first (dev override → live repo `render/`); otherwise +/// returns a per-user cache dir holding the **embedded** render tree, extracted +/// on demand (see [`embedded`]). This is what makes a `cargo install`-ed `cph` +/// self-contained: it no longer depends on a source checkout living at a +/// compile-time path. fn default_render_dir() -> PathBuf { - if let Ok(dir) = std::env::var("CPH_RENDER_DIR") { - return PathBuf::from(dir); - } - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("render") + embedded::resolve_render_dir() } diff --git a/docs/adr/0013-shell-step-execution-for-tool-generated-assets.md b/docs/adr/0013-shell-step-execution-for-tool-generated-assets.md new file mode 100644 index 0000000..4d29b49 --- /dev/null +++ b/docs/adr/0013-shell-step-execution-for-tool-generated-assets.md @@ -0,0 +1,106 @@ +# ADR 0013: Shell-Step Execution For Tool-Generated Assets + +## Status + +Accepted — and implemented. The `Step::Shell` escape hatch (introduced +structurally in ADR-0011 but left unimplemented) now has an executor in +`cph-check`, wired into `cph build --target `. First real use: the +KenKen interactive boards of the 恒一小奥 lesson, generated by the external +`kendoku` CLI. + +Builds on **ADR-0005** (category (b): medium-only information — HTML interactive +behavior, build scripts, npm deps — belongs to the target's build, not the +element) and **ADR-0009/0011** (a target is an `Artifact` produced by an ordered +list of typed `Step`s; `Shell` is the escape hatch for steps that resist +declaration). It does not amend them; it fixes the **execution semantics** of +`Shell`, which those ADRs left open ("MVP 不实现,先建结构"). + +## Context + +Authoring the 恒一小奥 KenKen lesson as a native engineering file surfaced a +concrete category-(b) need. The lesson's interactive teaching apparatus — a +clickable 4×4 KenKen board per problem — is produced by a **separate tool** +(`kendoku`, a TypeScript CLI living in the playground: `kendoku export + -o `). The structured source of truth is the puzzle JSON +(cages / solution / cell-to-cage); the self-contained interactive HTML is a +**generated build artifact**, exactly ADR-0005's category (b) and ADR-0009's +"third-party archive that runs an external build". + +The contract already named this (`Step::Shell`, `Artifact::FileTree`), and +`cph-model` already parsed both, but the engine refused them: there was **no step +executor**, and `cph build` only knew how to compile a typst template to a single +PDF. So the lesson's apparatus could be *described* but not *produced* through the +same framework. + +Three questions about *how* a shell step runs were genuinely undecided, and +divergent answers would matter (safety, what `check` means, where failures go). + +## Decision + +### A shell step executes the command, in the engineering root, on explicit build + +`cph build --target ` for a target whose steps are `Shell` runs each `run` +string through the platform shell (`sh -c` / `cmd /C`) with the **engineering +root as the working directory**, so manifests use root-relative paths. cph +**executes** the declared command and reports the outcome; it does **not** +assemble or post-process the tool's output — the external tool writes the files +(this is the difference from a `TypstCompile` step, where the framework owns the +compile). + +### Arbitrary command execution is opt-in by construction + +A shell step is arbitrary code execution, so it must never be a surprise: + +- It runs **only** on an explicit `cph build --target `. +- `cph check` **never** runs shell steps — `check` validates lesson *structure* + (is the lesson legal?), not the outcome of running an external tool. A + shell/file-tree target is also skipped by the check pipeline's typst-compile + phase (it is not a typst build). +- The CLI prints each command before running it, and streams the tool's output. + +### A shell step failure is a build-process error, not a lesson diagnostic + +A non-zero exit is a failure of the *build process*, categorically distinct from +the six lesson-legality diagnostics (`PartPathMissing`, `UnknownKind`, +`MissingContentFile`, `SchemaViolation`, `TypstCompile`, `RenderIgnored`). Those +six describe defects *in the lesson*; a tool that fails to run says nothing about +the lesson's legality. + +**We deliberately do NOT add a `ShellStep` diagnostic code.** The closed, +spec-pinned taxonomy (`Check/Diagnostic.lean`, ADR-0010/0012) stays at six. Shell +outcomes are reported by the build-execution layer (exit status + captured +stdout/stderr surfaced by the CLI), keeping the diagnostic vocabulary about +lesson legality only. This is the lighter, constitution-respecting choice +(taxonomy is a pinned divergence point; not every error category belongs in it). + +### `kendoku` reachability is left open + +How the external tool is invoked (a relative `node …/dist/cli.js` path, `npx +kendoku` after `npm link`, or an installed binary on PATH) is **not** fixed by +the contract — it is a property of a given engineering file's `run` string and +its host. The 恒一小奥 manifest uses a relative `node` path as an MVP and its +README documents the prerequisite; a portable convention is left open. + +## Consequences + +- The same framework now *produces* the apparatus it could previously only + describe: `cph build --target interactives` runs `kendoku` and writes the 10 + interactive boards under the target's `FileTree` root. The lesson's PDFs + (`student`/`teacher`) and its interactives all come from one engineering file + through one CLI. +- `Step::Shell` is no longer a placeholder; its execution semantics are pinned in + `Export/Render.lean`. +- The diagnostic taxonomy is unchanged (still six). Build-process failures live + outside it. +- `TypstCompile` targets are unaffected: the PDF path is exactly as before. + +## Open Questions / Deferred + +- **Portable tool reachability.** A cross-machine convention for locating an + external generator (`npx`/PATH/vendored) rather than a host-specific path. +- **`FileTree` output validation.** Whether the checker should verify a shell + target actually produced the files its `Artifact::FileTree` `outputs` glob + declares (currently the tool's exit status is the only signal). +- **Step animation (GIF) pipeline.** `kendoku steps` → headless-Chrome + screenshots → ffmpeg is a multi-tool chain; this lesson uses pre-generated + static step PNGs instead. Wiring that chain as shell steps is deferred. diff --git a/docs/adr/0014-markdown-html-export-backend-direction.md b/docs/adr/0014-markdown-html-export-backend-direction.md new file mode 100644 index 0000000..0aa6106 --- /dev/null +++ b/docs/adr/0014-markdown-html-export-backend-direction.md @@ -0,0 +1,107 @@ +# ADR 0014: Direction For A Markdown / HTML Export Backend + +## Status + +Proposed — **design-only**. This ADR fixes the *direction* and the key open +decision for a future markdown/HTML export target; it does **not** implement a +backend. No code or schema changes accompany it. (The 恒一小奥 lesson ships with +`student`/`teacher` PDF targets and a `kendoku`-generated `interactives` target; +a markdown/HTML analog is future work gated on the decision below.) + +Builds on **ADR-0009** (export targets are builds producing typed artifacts; +"which markdown dialect … stays open") and **ADR-0011** (presentation lives in +the template; the manifest is injected). Relates to **ADR-0013** (a non-typst +backend could ride a shell step or earn a typed step — still open). + +## Context + +The third-party 恒一小奥 deliverable was a slide-deck markdown bundle +(`lesson_slides.md`). A recurring goal is to export an analog of such +markdown/HTML from the same engineering file. The hard part is **math**: a +curriculum is formula-heavy, and a markdown/HTML export that turns formulas into +images (losing selectability, accessibility, and editability) is a regression. +Three facts were established (web research + reading the pinned deps), and a +fourth is the user's framing: + +1. **typlite (tinymist's markdown export)** renders math **as images** and loses + cross-references and styles — it goes through HTML as an intermediary. This is + exactly the failure mode to avoid for a math-rich curriculum. +2. **typst 0.15 native HTML export** (the version this repo pins; `typst-html + 0.15` is already in `Cargo.lock`) emits equations as **MathML**. MathML is + *accessibility-oriented*: selectable and screen-readable, but **not** trivially + convertible to KaTeX/LaTeX, and rendering fidelity varies by browser. +3. **`mitex`** converts **LaTeX → typst** (an authoring-time import direction), + **not** typst → LaTeX. So it is not, by itself, the typst-source → markdown + lever; the user's instinct that "keep the formula source span and convert it" + is right about *what* we want, but `mitex`'s direction is the reverse of what a + typst→markdown path needs. +4. **The user's framing**: the content a markdown/HTML target needs and the + content the PDF 讲义/教案 need **do not fully overlap** — so whether a + markdown/HTML export must reuse the *same* `typst-content` source, or instead + draw on target-specific content, is **genuinely open** and is the pivotal + design decision here. + +## Decision (direction, not implementation) + +### The pivotal open decision: shared source vs. target-specific content + +Before any backend is built, decide: **does the markdown/HTML target render the +same per-element `typst-content` the PDF targets render, or does it consume +target-specific content?** This ADR does **not** pre-pick it; it names it as the +gating decision and frames the two routes so the next round can choose +deliberately (constitution rule 2 — surface, do not assume): + +- **(R1) Shared typst-content.** One source of truth; markdown/HTML is another + projection of the same `.typ`. Requires a real typst-source → markdown/HTML + converter that preserves math as editable markup. Highest reuse, hardest math + story. +- **(R2) Target-specific content.** The element carries (or the target supplies) + content authored for the web medium. Decouples the hard typst→markdown math + problem from the PDF path, at the cost of a second content surface to maintain. + +### Preferred math direction (whichever route is chosen) + +Math must survive as **markup, not images**. The favored approach is to obtain +each equation's **source span** from the typst AST and convert that span's source +to LaTeX/KaTeX, rather than accept typlite's image rendering or 0.15's +accessibility-MathML as the final form. Concretely, the next round should +evaluate: + +- harvesting equation source spans via `typst-syntax` and emitting + `$…$`/`$$…$$` (KaTeX-compatible) from the typst math source, and +- studying tinymist's own conversion crate (typlite / `cmarker`) for reusable + pieces — adopting what helps, but **not** its math-as-image fallback. + +A small **proof-of-concept** scoped to *this* lesson (whose math is trivial — +`8×`, `2-`, `12×`) is the right first experiment: it exercises the pipeline +end-to-end while sidestepping the hardest formula cases, and shows whether +equations come out as text/KaTeX rather than images. + +### Backend mechanics, once the route is chosen + +- typst 0.15 HTML export is reachable today (`typst-html 0.15` is a transitive + dep; promotable to a direct dep) — usable for R1's HTML side. +- A markdown (rather than HTML) artifact may post-process that HTML, or emit + markdown directly from the AST — open, decided with the route. +- Per ADR-0013, the backend may run as a typed step or a shell step; whether the + non-typst backend earns its own typed `Step` constructor is still open. + +## Consequences + +- The export-backend work has an explicit gating decision (R1 vs. R2) and a + pinned math principle (markup, not images), so the next round starts from a + decision rather than a blank page. +- No implementation, schema, or Lean change lands now — the contract is + unchanged; this is a recorded design direction. +- The PDF and interactives paths are unaffected and remain the lesson's shipping + targets. + +## Open Questions / Deferred + +- **R1 vs. R2** — the pivotal decision above; unresolved by design. +- **Concrete math conversion** — span-harvest → KaTeX vs. reusing a tinymist + crate; to be settled by the PoC. +- **Markdown dialect** — which dialect/fenced-div conventions the markdown + artifact targets (ADR-0009 left this open; still open). +- **Typed non-typst step** — whether the backend rides `Shell` or earns a typed + `Step` (ADR-0013 open question). diff --git a/spec/Spec/Courseware.lean b/spec/Spec/Courseware.lean index 5a3610e..6b5ade8 100644 --- a/spec/Spec/Courseware.lean +++ b/spec/Spec/Courseware.lean @@ -6,7 +6,7 @@ import Spec.Courseware.Open /-! # Courseware —— 产品层契约(课程工程文件) -护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005..0011。按四组组织: +护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005..0014。按四组组织: - **`Model`** —— 工程文件的内容模型:基元 `Primitives`、富内容 `RichContent`、原子 单位 `Element`、单节课 `Lesson`(element 的有序序列)。 diff --git a/spec/Spec/Courseware/Export/Render.lean b/spec/Spec/Courseware/Export/Render.lean index 183da8b..866a6e0 100644 --- a/spec/Spec/Courseware/Export/Render.lean +++ b/spec/Spec/Courseware/Export/Render.lean @@ -14,6 +14,17 @@ ADR-0009:export target 是一次 build,产出一个有类型的 `Artifact`。ADR **渲染覆盖**:ADR-0011 废止了 per-target `RenderRule` 载荷——渲染的"how"已移进模板。 契约只保留覆盖声明 `covers`(该 target 渲染哪些 kind),供种子诊断用。 + +**shell step 的执行语义(ADR-0013)。** `shell` 不再只是占位:它**会被执行**,语义是把 +`run` 交给平台 shell、以**工程根为工作目录**运行,产物由被调外部工具自己写出(框架不装配 +内容)。三条边界是真分歧点,故钉契约: +1. **opt-in by construction** —— 任意命令执行只在用户**显式** build 一个 shell target 时发生, + 绝不在 `check` 里跑。`check` 只校验结构(lesson 是否合法),不执行外部工具、不验其产物。 +2. **失败归属** —— shell step 退出非零是一次 **build-过程失败**,不是 lesson 的合法性缺陷; + 因此它**不**进 `Diagnostic` 的 6 类(那 6 类是 lesson 自身的诊断,见 `Check/Diagnostic.lean`), + 而由 build 执行层报告。诊断分类保持 6 类不变(ADR-0013 显式拒绝新增 `ShellStep` 诊断码)。 +3. **非-typst target 不过 typst 编译** —— 一个只含 `shell` step 的 target(教具包即此)由 + 执行器跑命令,而非走 typst 引擎;`check` 的 compile 阶段跳过它。 -/ namespace Spec.Courseware @@ -27,7 +38,9 @@ inductive Step where /-- 编译模板文件 `template`(相对工程根)成产物;框架注入 manifest。typed 的理由: 注入这件事裸 shell 写不出。 -/ | typstCompile (template : String) - /-- shell 逃生口:执行命令 `run`(ADR-0005 (b) 类落这)。MVP 不实现,先建结构。 -/ + /-- shell 逃生口:执行命令 `run`(ADR-0005 (b) 类落这)。**已实现**(ADR-0013):以工程根 + 为 cwd 执行,opt-in(只在显式 build 该 target 时跑,`check` 不跑),失败属 build-过程错误 + 而非 lesson 诊断。教具包(如 KenKen 交互 HTML 由外部 `kendoku` 生成)即走此 step。 -/ | shell (run : String) /-- 一个 export target 的 build 规格(`PINNED` artifact + 有序 steps, ADR-0011)。 -/