diff --git a/Cargo.lock b/Cargo.lock index d27621d..316f6b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,6 +436,7 @@ version = "0.1.0" dependencies = [ "cph-diag", "cph-model", + "cph-schema", "tempfile", "toml", "typst", diff --git a/crates/cph-typst/Cargo.toml b/crates/cph-typst/Cargo.toml index cf7f3e7..f51acb6 100644 --- a/crates/cph-typst/Cargo.toml +++ b/crates/cph-typst/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] cph-diag = { workspace = true } cph-model = { workspace = true } +cph-schema = { path = "../cph-schema" } toml = { workspace = true } typst = "=0.15.0" diff --git a/crates/cph-typst/src/diag.rs b/crates/cph-typst/src/diag.rs index f954227..447c9a5 100644 --- a/crates/cph-typst/src/diag.rs +++ b/crates/cph-typst/src/diag.rs @@ -12,11 +12,13 @@ //! 3. compute 1-based line/col from the file's [`typst::syntax::Source`] via //! `lines().byte_to_line_column` (0-based ⇒ `+1`). //! -//! A span whose `FileId` is the generated **driver** is re-pointed: its `file` -//! is set to the driver vpath and a hint flags it as driver-internal (a -//! driver-gen bug indicator) rather than dropped or mis-attributed to a user -//! file. Spans in the `cph-render` package are reported with an -//! `@local/cph-render:…` pseudo-path. +//! The `main` file is now the real **template** (`exports/.typ`), so a +//! span there resolves naturally to an authored file — no re-pointing needed. A +//! span landing in the **virtual augmented manifest** ([`crate::MANIFEST_VPATH`], +//! served in-memory, not on disk) is reported with its vpath plus a hint marking +//! it as the framework-synthesized manifest (a manifest-generation bug +//! indicator) rather than mis-attributed to a user file. Spans in the +//! `cph-render` package are reported with an `@local/cph-render:…` pseudo-path. use std::path::PathBuf; @@ -55,10 +57,11 @@ pub fn map_diagnostic(world: &LessonWorld, d: &SourceDiagnostic) -> Diagnostic { if let Some(id) = d.span.id() { if let Some(file) = file_for(world, id) { - if id == world.main() { + if is_virtual_manifest(id) { hints.push( - "this span is inside the generated driver (.cph/driver-*.typ), \ - not a lesson source file — likely a driver-generation issue" + "this span is inside the framework-synthesized manifest \ + (/.cph/manifest.toml), not a lesson source file — likely a \ + manifest-generation issue" .to_string(), ); } @@ -80,6 +83,13 @@ pub fn map_diagnostic(world: &LessonWorld, d: &SourceDiagnostic) -> Diagnostic { diag } +/// Whether `id` is the in-memory augmented-manifest virtual file +/// ([`crate::MANIFEST_VPATH`]) the engine injects (not a real lesson file). +fn is_virtual_manifest(id: FileId) -> bool { + matches!(id.root(), VirtualRoot::Project) + && id.vpath().get_with_slash() == crate::MANIFEST_VPATH +} + /// 1-based (line, col) for a byte offset in file `id`, defaulting to `(1, 1)` /// when the source can't be read or the offset is out of range. fn line_col(world: &LessonWorld, id: FileId, byte: usize) -> (u32, u32) { diff --git a/crates/cph-typst/src/driver.rs b/crates/cph-typst/src/driver.rs deleted file mode 100644 index 0695053..0000000 --- a/crates/cph-typst/src/driver.rs +++ /dev/null @@ -1,235 +0,0 @@ -//! Driver generation: turn a [`Lesson`] + target into a typst entrypoint that -//! `include`s each part's content files and calls `cph-render`'s `display`. -//! -//! ## Decisions -//! -//! - **Include-path style: root-relative (`/segments/…`).** The lesson root is -//! mounted as the typst project root ([`crate::world::LessonWorld`] uses -//! `VirtualRoot::Project`), so a leading-`/` path resolves against the root -//! regardless of where the driver itself sits. This keeps include paths -//! identical to the part `path`s recorded in the manifest, independent of the -//! driver's `.cph/` location. -//! - **Content via `include`, not `import`** (ADR-0006): a `content` field's -//! value is the module *body*, which `include` yields (`import` would expose -//! only `#let` exports). -//! - **`@local/cph-render`** is referenced by the standard package import; the -//! World resolves it from the render directory (see `crate::world`). -//! - **Optional `proof`** (lemma) is emitted only when `/proof.typ` -//! exists on disk. -//! - **Scalars** (example `source`) come from `part.descriptor.scalars` and are -//! emitted as escaped typst string literals. - -use cph_model::{Lesson, Part}; - -/// Locate the [`cph_model::TargetConfig`] for `target` by name, if declared. -fn target_config<'a>(lesson: &'a Lesson, target: &str) -> Option<&'a cph_model::TargetConfig> { - lesson.targets.iter().find(|t| t.name == target) -} - -/// In-memory directory the generated driver is mounted under (relative to the -/// lesson root). Not written to disk. -pub const DRIVER_DIR: &str = ".cph"; - -/// The package import line the driver opens with. -const RENDER_IMPORT: &str = "#import \"@local/cph-render:0.1.0\": display"; - -/// The content fields of each kind, in render-contract order. The `bool` is -/// whether the field is optional (only emitted if its `.typ` exists). -fn content_fields(kind: &str) -> &'static [(&'static str, bool)] { - match kind { - "segment" => &[("textbook", false)], - "example" => &[("problem", false), ("solution", false)], - "lemma" => &[("stmt", false), ("proof", true)], - "sop" => &[("sop", false)], - // Unknown kind: emit nothing for it; the render package surfaces the - // unknown kind visibly, and cph-model/cph-schema report it upstream. - _ => &[], - } -} - -/// The optional **scalar** string fields per kind (read from `element.toml`). -fn scalar_fields(kind: &str) -> &'static [&'static str] { - match kind { - "example" => &["source"], - _ => &[], - } -} - -/// Generate the driver source for `lesson` under `target`. -/// -/// The result is deterministic and order-preserving (parts emitted in -/// `lesson.parts` order, matching the render contract). It is pure text -/// generation — it touches the filesystem only to test for optional content -/// files (e.g. lemma `proof.typ`). -pub fn generate_driver(lesson: &Lesson, target: &str) -> String { - let mut s = String::new(); - s.push_str(RENDER_IMPORT); - s.push('\n'); - - // One `#let _pN_field = include "…"` per content file, then the parts array - // referencing those bindings. - let mut part_dicts: Vec = Vec::with_capacity(lesson.parts.len()); - - for (i, part) in lesson.parts.iter().enumerate() { - let mut entries: Vec = Vec::new(); - entries.push(format!("kind: {}", typst_str(&part.kind))); - - for &(field, optional) in content_fields(&part.kind) { - if optional && !content_file_exists(part, field) { - continue; - } - let binding = format!("_p{i}_{field}"); - let inc_path = include_path(part, field); - s.push_str(&format!( - "#let {binding} = include {}\n", - typst_str(&inc_path) - )); - entries.push(format!("{field}: {binding}")); - } - - for &field in scalar_fields(&part.kind) { - if let Some(value) = scalar_string(part, field) { - entries.push(format!("{field}: {}", typst_str(&value))); - } - } - - part_dicts.push(format!("({})", entries.join(", "))); - } - - s.push_str("#display(\n"); - s.push_str(&format!(" info: {},\n", info_dict(lesson))); - s.push_str(&format!(" target: {},\n", typst_str(target))); - s.push_str(" parts: (\n"); - for dict in &part_dicts { - s.push_str(&format!(" {dict},\n")); - } - // Trailing comma already present per element; ensure a 1-element array is - // still an array (typst needs the trailing comma, which we always emit). - s.push_str(" ),\n"); - s.push_str(&format!(" config: {},\n", config_dict(lesson, target))); - s.push_str(")\n"); - s -} - -/// The `config:` dict for `target` (ADR-0009 presentation overrides). -/// -/// Looks up the matching [`cph_model::TargetConfig`] by name and emits the -/// file-resident presentation slice the render package understands. For MVP -/// that is `numbering.heading` (an array of numbly pattern strings): -/// `config: (numbering: (heading: ("…", "…")))`. When the target declares no -/// `numbering.heading` override (or the target isn't declared at all), an empty -/// dict `(:)` is emitted so the render package applies its own correct default. -fn config_dict(lesson: &Lesson, target: &str) -> String { - let heading = target_config(lesson, target) - .and_then(|tc| tc.numbering.as_ref()) - .and_then(|n| n.heading.as_ref()); - - match heading { - Some(patterns) => { - let arr = patterns - .iter() - .map(|p| typst_str(p)) - .collect::>() - .join(", "); - // A 1-element typst array needs a trailing comma; always emitting - // one keeps any arity a valid array. - format!("(numbering: (heading: ({arr},)))") - } - None => "(:)".to_string(), - } -} - -/// `(title: "…", author: "…")` — author key omitted when absent. -fn info_dict(lesson: &Lesson) -> String { - let mut parts = vec![format!("title: {}", typst_str(&lesson.info.title))]; - if let Some(author) = &lesson.info.author { - parts.push(format!("author: {}", typst_str(author))); - } - format!("({})", parts.join(", ")) -} - -/// Root-relative include path for `part`'s `field` content file, e.g. -/// `/segments/开场对照导言/textbook.typ`. Always forward-slashed and -/// leading-`/` so it resolves against the typst project root. -fn include_path(part: &Part, field: &str) -> String { - let mut p = String::from("/"); - // `part.path` is the manifest-relative folder; join the `.typ`. - p.push_str(&path_to_forward_slash(&part.path)); - if !p.ends_with('/') { - p.push('/'); - } - p.push_str(field); - p.push_str(".typ"); - p -} - -/// Does `/.typ` exist on disk? Used to gate optional content. -fn content_file_exists(part: &Part, field: &str) -> bool { - part.descriptor.dir.join(format!("{field}.typ")).is_file() -} - -/// Pull a scalar string field out of the element descriptor, if present and a -/// string (and non-empty). -fn scalar_string(part: &Part, field: &str) -> Option { - match part.descriptor.scalars.get(field) { - Some(toml::Value::String(s)) if !s.is_empty() => Some(s.clone()), - _ => None, - } -} - -/// Render a `PathBuf` (folder) as a forward-slash string, dropping any leading -/// `./` or `/`. Element folder names are UTF-8 (Chinese) and kept verbatim. -fn path_to_forward_slash(path: &std::path::Path) -> String { - use std::path::Component; - let mut out = String::new(); - for comp in path.components() { - // Only `Normal` segments contribute; RootDir / CurDir / Prefix / - // ParentDir are ignored. `..` is already rejected by cph-model's loader, - // and the part path is relative. - if let Component::Normal(s) = comp { - if !out.is_empty() { - out.push('/'); - } - out.push_str(&s.to_string_lossy()); - } - } - out -} - -/// Quote `value` as a typst string literal, escaping `\` and `"`. Unicode is -/// passed through as UTF-8 (typst sources are UTF-8). Control chars that would -/// break a single-line literal (newline, CR, tab) are escaped too. -fn typst_str(value: &str) -> String { - let mut out = String::with_capacity(value.len() + 2); - out.push('"'); - for c in value.chars() { - match c { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - _ => out.push(c), - } - } - out.push('"'); - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn escapes_quotes_and_backslashes() { - assert_eq!(typst_str(r#"a"b\c"#), r#""a\"b\\c""#); - // Unicode passes through. - assert_eq!(typst_str("自拟"), "\"自拟\""); - } - - #[test] - fn forward_slash_path_keeps_unicode() { - let p = std::path::Path::new("segments/开场对照导言"); - assert_eq!(path_to_forward_slash(p), "segments/开场对照导言"); - } -} diff --git a/crates/cph-typst/src/lib.rs b/crates/cph-typst/src/lib.rs index 69bd800..0af8ced 100644 --- a/crates/cph-typst/src/lib.rs +++ b/crates/cph-typst/src/lib.rs @@ -1,16 +1,32 @@ -//! `cph-typst` — typst `World`, driver generation, compile, PDF, span mapping. +//! `cph-typst` — typst `World`, augmented-manifest injection, compile, PDF, +//! span mapping. //! //! Owned by **WU-4**. Embeds the typst 0.15 compiler as a library to: -//! - **compile-check** a [`cph_model::Lesson`] (collect typst errors+warnings as -//! [`cph_diag::Diagnostic`]s), and +//! - **compile-check** a [`cph_model::Lesson`] for a target (collect typst +//! errors+warnings as [`cph_diag::Diagnostic`]s), and //! - **export a PDF** for a render target. //! -//! The lesson's engineering-file directory tree (ADR-0007) is mounted as the -//! typst project root; a generated *driver* entrypoint (one per target) lives -//! in-memory under `.cph/driver-.typ` and `include`s each part's -//! content files (ADR-0006: a `content` value denotes the module **body**, so -//! the driver uses `include`, never `import`). The driver hands an ordered -//! `parts` array to the `@local/cph-render` package's `display` entry. +//! ## Model (ADR-0011): compile a template, inject a manifest +//! +//! There is **no framework-generated driver** any more. An export target is a +//! build with a typed [`cph_model::Step::TypstCompile`] naming a **template +//! file** (e.g. `exports/student.typ`) that lives in the engineering file. The +//! engine: +//! +//! 1. mounts the lesson's engineering-file tree (ADR-0007) as the typst project +//! root (`--root`); +//! 2. sets the World's `main` to that **real template file** under the root, so +//! diagnostic spans resolve to an authored file; +//! 3. builds an **augmented manifest** (the lesson's `info` + ordered `parts`, +//! each with a `fields` array of the content fields present on disk — see +//! [`manifest`]) and serves it as a virtual in-memory file at +//! [`world::MANIFEST_VPATH`], injecting `sys.inputs.manifest` to point there; +//! 4. lets the template `toml()`-read that manifest and `include` each part's +//! content via computed root-relative paths (legal per ADR-0011). +//! +//! The augmented manifest closes the OPEN optional-content point: typst has no +//! file-exists primitive, so the engine (which has filesystem access) tells the +//! template which optional content fields exist via the per-part `fields` array. //! //! ## typst version //! @@ -19,25 +35,31 @@ //! VirtualPath)`, and a diagnostic span is a `DiagSpan` (not a bare `Span`). mod diag; -mod driver; +mod manifest; mod world; use std::path::PathBuf; use cph_diag::{DiagCode, Diagnostic}; -use cph_model::{ArtifactKind, Lesson}; +use cph_model::{Artifact, Lesson, Step, TargetConfig}; use typst_kit::fonts::{self, FontStore}; use typst_layout::PagedDocument; use typst_pdf::PdfOptions; -pub use driver::{generate_driver, DRIVER_DIR}; -pub use world::{render_package_spec, LessonWorld}; +pub use manifest::build_augmented_manifest; +pub use world::{render_package_spec, LessonWorld, MANIFEST_VPATH}; /// The compile/PDF engine: holds the shared font store and the on-disk location /// of the `cph-render` package. /// /// Fonts are discovered once (system + embedded) and shared across every /// compilation via an [`std::sync::Arc`] inside the per-compile [`LessonWorld`]. +/// +/// `render_dir` is the directory the World resolves `@local/cph-render` (and the +/// vendored `@preview/*`) from. It is **not** where the compiled template comes +/// from: the template is a real file under the lesson root (`/