forked from EduCraft/curriculum-project-hub
feat(checker): compile template as main, augmented manifest closes optional-content (WU-D', ADR-0011)
Replace generated-driver compilation with the template model.
cph-typst:
- driver.rs DELETED (generate_driver, static-include driver, numbering-threading).
- New manifest.rs: build_augmented_manifest(&Lesson) emits a TOML doc with [info]
+ ordered [[parts]] each carrying a `fields` array = the kind's content fields
whose <root>/<path>/<field>.typ exists on disk. Reuses cph-schema's
content_field_names (new dep; no cycle). This closes the OPEN point WU-C'
surfaced: typst has no file-exists primitive, so the engine (which has disk
access) tells the template which optional content (lemma proof) is present.
- World main = the real engineering-file template (<root>/<template>); the
augmented manifest is served as an in-memory virtual file at /.cph/manifest.toml
(never written to the tree), injected via sys.inputs.manifest. render_dir kept
only for @local/cph-render + vendored @preview/numbly resolution.
- Artifact handling: SingleFile+TypstCompile compiles; FileTree, shell-only, and
undeclared-target are blocking "deferred/not-declared" diagnostics (ADR-0011).
- Engine public signatures unchanged; LessonWorld::new takes template+manifest_src.
cph-check: no source change needed (only uses lesson.targets/target.name +
unchanged Engine methods); pipeline + Legal alignment intact.
Fixtures: mini gets exports/{student,teacher}.typ (from render/templates/) + v2
manifest + a second proof-less lemma to exercise optional content. render-stub
retired (tests use the real render/). Through-template PDFs offline: student 44KB,
teacher 56KB; proof-less lemma compiles clean (optional-content confirmed).
Workspace: fmt + clippy -D warnings + all tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -436,6 +436,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"cph-diag",
|
||||
"cph-model",
|
||||
"cph-schema",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"typst",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/<target>.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) {
|
||||
|
||||
@@ -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 `<part.dir>/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 `<field>.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<String> = Vec::with_capacity(lesson.parts.len());
|
||||
|
||||
for (i, part) in lesson.parts.iter().enumerate() {
|
||||
let mut entries: Vec<String> = 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::<Vec<_>>()
|
||||
.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 `<field>.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 `<part.dir>/<field>.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<String> {
|
||||
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/开场对照导言");
|
||||
}
|
||||
}
|
||||
+122
-65
@@ -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-<target>.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 (`<root>/<template>`),
|
||||
/// found via the normal project-file mapping. `render_dir` only matters for
|
||||
/// package resolution.
|
||||
pub struct Engine {
|
||||
fonts: std::sync::Arc<FontStore>,
|
||||
render_dir: PathBuf,
|
||||
@@ -45,15 +67,16 @@ 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 `<repo>/render` resolved
|
||||
/// relative to this crate (`CARGO_MANIFEST_DIR/../../render`).
|
||||
/// package directory is taken from the `CPH_RENDER_DIR` environment variable
|
||||
/// when set, otherwise defaults to `<repo>/render` resolved relative to this
|
||||
/// crate (`CARGO_MANIFEST_DIR/../../render`).
|
||||
pub fn new() -> Self {
|
||||
Self::with_render_dir(default_render_dir())
|
||||
}
|
||||
|
||||
/// Construct an engine pointing at an explicit `cph-render` package
|
||||
/// directory (the folder containing `lib.typ` / `typst.toml`).
|
||||
/// directory (the folder containing `lib.typ` / `typst.toml`, and the
|
||||
/// vendored `@preview/*` tree under `vendor/`).
|
||||
pub fn with_render_dir(render_dir: PathBuf) -> Self {
|
||||
let mut store = FontStore::new();
|
||||
// System fonts first (so the host's CJK fonts are preferred), then the
|
||||
@@ -74,14 +97,17 @@ impl Engine {
|
||||
|
||||
/// Compile-check `lesson` for `target`, returning every typst diagnostic
|
||||
/// (errors **and** warnings) mapped to [`Diagnostic`]s. An empty `Vec` means
|
||||
/// a clean compile. Spans are resolved to files relative to `lesson.root`;
|
||||
/// spans landing in the generated driver are re-pointed with a hint marking
|
||||
/// them as driver-internal (a driver-gen bug indicator) rather than dropped.
|
||||
/// a clean compile. Spans are resolved to files relative to `lesson.root`.
|
||||
///
|
||||
/// A request the engine cannot honor (unknown target, or an MVP-unsupported
|
||||
/// artifact/step shape) short-circuits to a single blocking diagnostic; see
|
||||
/// [`target_precheck`].
|
||||
pub fn compile_check(&self, lesson: &Lesson, target: &str) -> Vec<Diagnostic> {
|
||||
if let Some(blocking) = target_precheck(lesson, target) {
|
||||
return blocking;
|
||||
}
|
||||
let world = self.world_for(lesson, target);
|
||||
let template = match self.world_for(lesson, target) {
|
||||
Ok(world) => world,
|
||||
Err(blocking) => return blocking,
|
||||
};
|
||||
let world = template;
|
||||
let warned = typst::compile::<PagedDocument>(&world);
|
||||
|
||||
let mut out = Vec::new();
|
||||
@@ -93,13 +119,16 @@ impl Engine {
|
||||
}
|
||||
|
||||
/// Build a PDF for `lesson` / `target`. On a clean compile returns the PDF
|
||||
/// bytes; on fatal errors returns the mapped diagnostics. Compile warnings
|
||||
/// are not surfaced here (use [`Engine::compile_check`] for those).
|
||||
/// bytes; on a blocking precheck failure or fatal compile errors returns the
|
||||
/// mapped diagnostics. Compile warnings are not surfaced here (use
|
||||
/// [`Engine::compile_check`] for those).
|
||||
///
|
||||
/// The bytes are returned, not written: for a [`Artifact::SingleFile`] the
|
||||
/// artifact's `filepath` is the *default* output location, but the caller
|
||||
/// (cph-check / the CLI) owns where the PDF lands (and may override with
|
||||
/// `-o`).
|
||||
pub fn build_pdf(&self, lesson: &Lesson, target: &str) -> Result<Vec<u8>, Vec<Diagnostic>> {
|
||||
if let Some(blocking) = target_precheck(lesson, target) {
|
||||
return Err(blocking);
|
||||
}
|
||||
let world = self.world_for(lesson, target);
|
||||
let world = self.world_for(lesson, target)?;
|
||||
let warned = typst::compile::<PagedDocument>(&world);
|
||||
let doc = match warned.output {
|
||||
Ok(doc) => doc,
|
||||
@@ -108,15 +137,18 @@ impl Engine {
|
||||
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors))
|
||||
}
|
||||
|
||||
fn world_for(&self, lesson: &Lesson, target: &str) -> LessonWorld {
|
||||
let driver_src = generate_driver(lesson, target);
|
||||
LessonWorld::new(
|
||||
/// Build the [`LessonWorld`] for `(lesson, target)`, or `Err(blocking)` when
|
||||
/// the request cannot be honored (see [`target_precheck`]).
|
||||
fn world_for(&self, lesson: &Lesson, target: &str) -> Result<LessonWorld, Vec<Diagnostic>> {
|
||||
let template = target_precheck(lesson, target)?;
|
||||
let manifest_src = build_augmented_manifest(lesson);
|
||||
Ok(LessonWorld::new(
|
||||
lesson.root.clone(),
|
||||
self.render_dir.clone(),
|
||||
target,
|
||||
driver_src,
|
||||
&template,
|
||||
manifest_src,
|
||||
self.fonts.clone(),
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,47 +158,72 @@ impl Default for Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a `(lesson, target)` request before compiling. Returns
|
||||
/// `Some(blocking_diagnostics)` when the request cannot be honored, or `None`
|
||||
/// when it is fine to proceed:
|
||||
/// Validate a `(lesson, target)` request and resolve the template path to
|
||||
/// compile. Returns `Ok(template_path)` (relative to the lesson root) when the
|
||||
/// request is buildable, or `Err(blocking_diagnostics)` when it is not:
|
||||
///
|
||||
/// - **Unknown target** (the `--target` name isn't in `lesson.targets`): a
|
||||
/// `SchemaViolation` error — a target must be declared in the manifest to be
|
||||
/// built (ADR-0009: an export target is a declared build).
|
||||
/// - **`FileTree` artifact**: deferred for MVP. ADR-0009 makes the artifact kind
|
||||
/// decide assembly; only `SingleFile` (one bundled PDF) is implemented, so a
|
||||
/// `FileTree` target returns a clear `SchemaViolation` rather than wrong
|
||||
/// output. `SingleFile` proceeds to the normal one-PDF path.
|
||||
///
|
||||
/// A target with **no** declared `[targets.*]` config at all is *not* an error
|
||||
/// here: callers (e.g. `cph-check`) may compile-check a defaulted `"student"`
|
||||
/// target that the lesson never declared. Only a name that *is* declared but is
|
||||
/// `FileTree` is blocked; an undeclared name is the "unknown target" error.
|
||||
fn target_precheck(lesson: &Lesson, target: &str) -> Option<Vec<Diagnostic>> {
|
||||
match lesson.targets.iter().find(|t| t.name == target) {
|
||||
None if lesson.targets.is_empty() => {
|
||||
/// - **Unknown target** (the `--target` name isn't in `lesson.targets`, and the
|
||||
/// lesson declares at least one target): a `SchemaViolation` error — a target
|
||||
/// must be declared in the manifest to be built (ADR-0009).
|
||||
/// - **No declared targets at all**: not an error — callers (e.g. `cph-check`)
|
||||
/// may compile-check a defaulted `"student"` target the lesson never declared.
|
||||
/// The stock template path `exports/<target>.typ` is used (the framework
|
||||
/// default; ADR-0011 [`cph_model::Step::default_for`]).
|
||||
/// - **MVP-unsupported shape**: per ADR-0011 only a [`Artifact::SingleFile`] with
|
||||
/// a [`Step::TypstCompile`] step is implemented this round. A
|
||||
/// [`Artifact::FileTree`] artifact, or a target whose (only) step is a
|
||||
/// [`Step::Shell`], returns a clear "not yet implemented" `SchemaViolation`
|
||||
/// rather than wrong output. The template is taken from the **first**
|
||||
/// `TypstCompile` step (MVP: one step per target).
|
||||
fn target_precheck(lesson: &Lesson, target: &str) -> Result<PathBuf, Vec<Diagnostic>> {
|
||||
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else {
|
||||
if lesson.targets.is_empty() {
|
||||
// Lesson declares no targets; the orchestrator compiles a defaulted
|
||||
// target. Proceed with render-package defaults (SingleFile).
|
||||
None
|
||||
// target. Use the stock template path (matches cph-model's default).
|
||||
return Ok(PathBuf::from(format!("exports/{target}.typ")));
|
||||
}
|
||||
None => Some(vec![Diagnostic::error(
|
||||
return Err(vec![Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
format!("target '{target}' not declared in manifest"),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"add a `[targets.{target}]` table to manifest.toml, or build a declared target"
|
||||
))]),
|
||||
Some(tc) => match tc.artifact {
|
||||
ArtifactKind::SingleFile => None,
|
||||
ArtifactKind::FileTree => Some(vec![Diagnostic::error(
|
||||
))]);
|
||||
};
|
||||
|
||||
// MVP supports a single-file artifact only.
|
||||
if let Artifact::FileTree { .. } = tc.artifact {
|
||||
return Err(vec![Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
"file-tree artifact not yet implemented (MVP supports single-file)",
|
||||
"file-tree artifact not yet implemented (MVP supports single-file typst-compile)",
|
||||
)
|
||||
.with_hint(format!(
|
||||
"set `[targets.{target}].artifact` to \"single-file\" for now"
|
||||
))]),
|
||||
},
|
||||
"set `[targets.{target}].artifact` to a single-file artifact for now"
|
||||
))]);
|
||||
}
|
||||
|
||||
template_step(tc).ok_or_else(|| {
|
||||
vec![Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
format!(
|
||||
"target '{target}' has no typst-compile step; only single-file \
|
||||
typst-compile builds are implemented (MVP)"
|
||||
),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"add a `typst-compile` step with `template = \"exports/{target}.typ\"`, \
|
||||
or defer this target (shell/file-tree builds are not yet implemented)"
|
||||
))]
|
||||
})
|
||||
}
|
||||
|
||||
/// The template of the first [`Step::TypstCompile`] in `tc`, if any (MVP: a
|
||||
/// target has one step; a `Shell`-only target yields `None`).
|
||||
fn template_step(tc: &TargetConfig) -> Option<PathBuf> {
|
||||
tc.steps.iter().find_map(|s| match s {
|
||||
Step::TypstCompile { template } => Some(template.clone()),
|
||||
Step::Shell { .. } => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Map a batch of typst diagnostics to `cph-diag` ones against `world`.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Augmented-manifest construction (ADR-0011).
|
||||
//!
|
||||
//! The template (`exports/<target>.typ`) reads the manifest via
|
||||
//! `toml(sys.inputs.manifest)`, then for each part `include`s its content fields
|
||||
//! by a **computed** path and reads scalar fields from `<path>/element.toml`.
|
||||
//! For *optional* content fields the template must know whether the file exists
|
||||
//! on disk — typst has no file-exists primitive and a missing `include` is a
|
||||
//! hard error (see the OPEN contract point in `render/templates/student.typ`).
|
||||
//!
|
||||
//! The ENGINE has filesystem access, so it closes that gap: it builds an
|
||||
//! **augmented manifest** = the lesson's `[info]` + ordered `[[parts]]`, with a
|
||||
//! per-part **`fields` array** listing the content fields whose `<field>.typ`
|
||||
//! actually exists under the lesson root. The augmented manifest is served as an
|
||||
//! in-memory virtual file in the [`crate::world::LessonWorld`] (it is **never**
|
||||
//! written to the user's tree), and injected via `sys.inputs.manifest`.
|
||||
//!
|
||||
//! ## `fields` is computed from `cph-schema`
|
||||
//!
|
||||
//! `cph-schema` already encodes each kind's content fields
|
||||
//! ([`cph_schema::KindSchema::content_field_names`]) — the same knowledge the
|
||||
//! render package exposes as `part-fields`. We reuse it here rather than
|
||||
//! re-deriving a kind→fields map, so the engine and the template agree on what a
|
||||
//! kind's content fields are. For each part, a content field is listed in
|
||||
//! `fields` iff `<root>/<part.path>/<field>.typ` is a real file.
|
||||
|
||||
use cph_model::Lesson;
|
||||
|
||||
/// Build the augmented-manifest TOML source for `lesson`.
|
||||
///
|
||||
/// The result is a self-contained TOML document the template's
|
||||
/// `toml(sys.inputs.manifest)` reads. It carries `[info]` (title + optional
|
||||
/// author) and the ordered `[[parts]]`, each with `kind`, `path`, and a
|
||||
/// `fields = [...]` array of the content fields present on disk (per
|
||||
/// [`present_fields`]). It does **not** reproduce `[project]` or `[targets.*]`
|
||||
/// — the template only consumes `info` and `parts`.
|
||||
pub fn build_augmented_manifest(lesson: &Lesson) -> String {
|
||||
let mut doc = toml::Table::new();
|
||||
|
||||
// [info]
|
||||
let mut info = toml::Table::new();
|
||||
info.insert(
|
||||
"title".to_string(),
|
||||
toml::Value::String(lesson.info.title.clone()),
|
||||
);
|
||||
if let Some(author) = &lesson.info.author {
|
||||
info.insert("author".to_string(), toml::Value::String(author.clone()));
|
||||
}
|
||||
doc.insert("info".to_string(), toml::Value::Table(info));
|
||||
|
||||
// [[parts]] — preserve declared order; attach the on-disk `fields` array.
|
||||
let parts: Vec<toml::Value> = lesson
|
||||
.parts
|
||||
.iter()
|
||||
.map(|part| {
|
||||
let mut entry = toml::Table::new();
|
||||
entry.insert("kind".to_string(), toml::Value::String(part.kind.clone()));
|
||||
entry.insert(
|
||||
"path".to_string(),
|
||||
toml::Value::String(path_to_forward_slash(&part.path)),
|
||||
);
|
||||
let fields = present_fields(lesson, part)
|
||||
.into_iter()
|
||||
.map(toml::Value::String)
|
||||
.collect();
|
||||
entry.insert("fields".to_string(), toml::Value::Array(fields));
|
||||
toml::Value::Table(entry)
|
||||
})
|
||||
.collect();
|
||||
doc.insert("parts".to_string(), toml::Value::Array(parts));
|
||||
|
||||
toml::to_string(&doc).expect("augmented manifest serializes")
|
||||
}
|
||||
|
||||
/// The content fields of `part`'s kind whose `<root>/<part.path>/<field>.typ`
|
||||
/// file exists on disk, in schema order.
|
||||
///
|
||||
/// The kind→content-fields knowledge is reused from `cph-schema`
|
||||
/// ([`cph_schema::schema_for`]); an unknown kind has no schema and yields an
|
||||
/// empty list (the render package surfaces an unknown kind on its own). Both
|
||||
/// required and optional content fields are probed — listing a *required* field
|
||||
/// here is harmless (the template includes required fields unconditionally), and
|
||||
/// it keeps `fields` a faithful "what exists on disk" record.
|
||||
fn present_fields(lesson: &Lesson, part: &cph_model::Part) -> Vec<String> {
|
||||
let Some(schema) = cph_schema::schema_for(&part.kind) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let part_dir = lesson.root.join(&part.path);
|
||||
schema
|
||||
.content_field_names()
|
||||
.into_iter()
|
||||
.filter(|field| part_dir.join(format!("{field}.typ")).is_file())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render a relative `PathBuf` as a forward-slash string, dropping any leading
|
||||
/// `./` or `/` and ignoring `..` (already rejected by the cph-model loader).
|
||||
/// Element folder names are UTF-8 (e.g. Chinese) and kept verbatim. The template
|
||||
/// rebuilds an absolute root-relative include path as `"/" + path + "/" + field`,
|
||||
/// so `path` must be a clean relative slash path.
|
||||
fn path_to_forward_slash(path: &std::path::Path) -> String {
|
||||
use std::path::Component;
|
||||
let mut out = String::new();
|
||||
for comp in path.components() {
|
||||
if let Component::Normal(s) = comp {
|
||||
if !out.is_empty() {
|
||||
out.push('/');
|
||||
}
|
||||
out.push_str(&s.to_string_lossy());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn forward_slash_path_keeps_unicode() {
|
||||
let p = std::path::Path::new("segments/开场对照导言");
|
||||
assert_eq!(path_to_forward_slash(p), "segments/开场对照导言");
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,45 @@
|
||||
//! [`LessonWorld`] — a typst [`World`] over an engineering-file directory tree
|
||||
//! plus an in-memory generated driver and a disk-mounted `cph-render` package.
|
||||
//! plus an in-memory augmented manifest and a disk-mounted `cph-render` package.
|
||||
//!
|
||||
//! ## Model (ADR-0011)
|
||||
//!
|
||||
//! The `main` file is the target's **template** (e.g. `<root>/exports/student.typ`),
|
||||
//! a *real* file under the lesson root — no framework-generated driver. The
|
||||
//! template `toml()`-reads an injected manifest and `include`s each part's
|
||||
//! content via computed root-relative paths. The engine injects the manifest as
|
||||
//! a virtual file (see below) and sets `sys.inputs.manifest` to its vpath.
|
||||
//!
|
||||
//! ## File resolution
|
||||
//!
|
||||
//! - **The driver** (`main()`): mounted in-memory at `VirtualRoot::Project` +
|
||||
//! vpath `/.cph/driver-<target>.typ`. `source()` returns it from the field;
|
||||
//! it is never written to disk. Its root-relative `include "/segments/…"`
|
||||
//! paths therefore anchor at the lesson root.
|
||||
//! - **Project files**: any `FileId` with `VirtualRoot::Project` is read from
|
||||
//! `root.join(vpath.realize)`. Used for content `.typ`, images, `toml()`, etc.
|
||||
//! - **`@local/cph-render`**: a `FileId` whose root is the render package spec
|
||||
//! is read from `render_dir.join(vpath.realize)`. This avoids installing the
|
||||
//! package into the typst local-package directory (MVP: no environment
|
||||
//! coupling).
|
||||
//! - **The template** (`main()`): a `FileId` with `VirtualRoot::Project` whose
|
||||
//! vpath is the template's path under the lesson root (e.g. `/exports/student.typ`).
|
||||
//! It is read from disk like any other project file; its `include "/<part>/…"`
|
||||
//! paths anchor at the lesson root (`--root`).
|
||||
//! - **The augmented manifest**: served in-memory at `VirtualRoot::Project` +
|
||||
//! vpath [`MANIFEST_VPATH`] (`/.cph/manifest.toml`). It is **never** written to
|
||||
//! disk — the engine builds it (original manifest + a per-part `fields` array)
|
||||
//! and the template reads it via `toml(sys.inputs.manifest)`.
|
||||
//! - **Project files**: any other `FileId` with `VirtualRoot::Project` is read
|
||||
//! from `root.join(vpath.realize)`. Used for content `.typ`, images,
|
||||
//! `element.toml` `toml()`, etc.
|
||||
//! - **`@local/cph-render`**: a `FileId` whose root is the render package spec is
|
||||
//! read from `render_dir.join(vpath.realize)`, avoiding installing the package
|
||||
//! into the typst local-package directory.
|
||||
//! - **`@preview/<name>:<version>`**: resolved offline from the in-repo vendored
|
||||
//! preview tree at `<render_dir>/vendor/typst-packages/preview/<name>/<version>/`
|
||||
//! (mirroring typst's preview-cache layout). The render package imports
|
||||
//! `@preview/numbly:0.1.0` (per-level heading numbering); reading it from the
|
||||
//! vendored dir keeps compilation network-free with no `typst-kit` package
|
||||
//! download. This is general — any `@preview/*` resolves from the vendored
|
||||
//! preview dir; numbly 0.1.0 is the only one currently present.
|
||||
//! preview tree at `<render_dir>/vendor/typst-packages/preview/<name>/<version>/`.
|
||||
//! The render package imports `@preview/numbly:0.1.0`; reading it from the
|
||||
//! vendored dir keeps compilation network-free.
|
||||
//! - Any *other* `@package` namespace yields a `NotFound` error.
|
||||
//!
|
||||
//! Sources are cached in a `Mutex<HashMap<FileId, Source>>` so repeated
|
||||
//! accesses during one compilation are cheap, as the `World` contract expects.
|
||||
//! Sources are cached in a `Mutex<HashMap<FileId, Source>>` so repeated accesses
|
||||
//! during one compilation are cheap, as the `World` contract expects.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use typst::diag::{FileError, FileResult};
|
||||
use typst::foundations::{Bytes, Datetime, Duration};
|
||||
use typst::foundations::{Bytes, Datetime, Dict, Duration, Value};
|
||||
use typst::syntax::package::{PackageSpec, PackageVersion};
|
||||
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
|
||||
use typst::text::{Font, FontBook};
|
||||
@@ -38,9 +47,15 @@ use typst::utils::LazyHash;
|
||||
use typst::{Library, LibraryExt, World};
|
||||
use typst_kit::fonts::FontStore;
|
||||
|
||||
use crate::driver::DRIVER_DIR;
|
||||
/// Root-relative vpath the augmented manifest is served at (in-memory only).
|
||||
///
|
||||
/// A **leading slash** is essential: the template lives under `exports/`, and
|
||||
/// `toml(sys.inputs.manifest)` resolves a relative path against the template's
|
||||
/// own directory — a bare name would miss. A root-relative absolute path anchors
|
||||
/// at `--root` (the lesson root) regardless of where the template sits.
|
||||
pub const MANIFEST_VPATH: &str = "/.cph/manifest.toml";
|
||||
|
||||
/// The package spec the driver imports and the World mounts from `render_dir`.
|
||||
/// The package spec the template imports and the World mounts from `render_dir`.
|
||||
pub fn render_package_spec() -> PackageSpec {
|
||||
PackageSpec {
|
||||
namespace: "local".into(),
|
||||
@@ -61,11 +76,13 @@ pub struct LessonWorld {
|
||||
render_dir: PathBuf,
|
||||
/// The render package spec (`@local/cph-render:0.1.0`).
|
||||
render_spec: PackageSpec,
|
||||
/// FileId of the in-memory driver entrypoint.
|
||||
/// FileId of the template entrypoint (a real file under `root`).
|
||||
main: FileId,
|
||||
/// The driver source, parsed once.
|
||||
driver_source: Source,
|
||||
/// Standard library (default configuration).
|
||||
/// FileId of the in-memory augmented manifest.
|
||||
manifest_id: FileId,
|
||||
/// The augmented-manifest source (in-memory; never on disk).
|
||||
manifest_source: Source,
|
||||
/// Standard library, with `sys.inputs.manifest` set.
|
||||
library: LazyHash<Library>,
|
||||
/// Shared font store (book + lazily-loaded fonts).
|
||||
fonts: Arc<FontStore>,
|
||||
@@ -74,27 +91,42 @@ pub struct LessonWorld {
|
||||
}
|
||||
|
||||
impl LessonWorld {
|
||||
/// Build a world. `driver_src` is the generated entrypoint text; it is
|
||||
/// mounted in-memory at `/.cph/driver-<target>.typ` under the project root.
|
||||
/// Build a world whose `main` is the template at `<root>/<template>` and
|
||||
/// whose injected manifest is `manifest_src` (served virtually at
|
||||
/// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there).
|
||||
///
|
||||
/// `template` is the lesson-root-relative template path (e.g.
|
||||
/// `exports/student.typ`), taken from the target's `Step::TypstCompile`.
|
||||
pub fn new(
|
||||
root: PathBuf,
|
||||
render_dir: PathBuf,
|
||||
target: &str,
|
||||
driver_src: String,
|
||||
template: &Path,
|
||||
manifest_src: String,
|
||||
fonts: Arc<FontStore>,
|
||||
) -> Self {
|
||||
let driver_vpath = VirtualPath::new(format!("/{DRIVER_DIR}/driver-{target}.typ"))
|
||||
.expect("driver vpath is a valid virtual path");
|
||||
let main = FileId::new(RootedPath::new(VirtualRoot::Project, driver_vpath));
|
||||
let driver_source = Source::new(main, driver_src);
|
||||
let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template)))
|
||||
.expect("template vpath is a valid virtual path");
|
||||
let main = FileId::new(RootedPath::new(VirtualRoot::Project, main_vpath));
|
||||
|
||||
let manifest_vpath =
|
||||
VirtualPath::new(MANIFEST_VPATH).expect("manifest vpath is a valid virtual path");
|
||||
let manifest_id = FileId::new(RootedPath::new(VirtualRoot::Project, manifest_vpath));
|
||||
let manifest_source = Source::new(manifest_id, manifest_src);
|
||||
|
||||
// Inject `sys.inputs.manifest = "/.cph/manifest.toml"` so the template's
|
||||
// `toml(sys.inputs.manifest)` reads the augmented manifest.
|
||||
let mut inputs = Dict::new();
|
||||
inputs.insert("manifest".into(), Value::Str(MANIFEST_VPATH.into()));
|
||||
let library = Library::builder().with_inputs(inputs).build();
|
||||
|
||||
Self {
|
||||
root,
|
||||
render_dir,
|
||||
render_spec: render_package_spec(),
|
||||
main,
|
||||
driver_source,
|
||||
library: LazyHash::new(Library::default()),
|
||||
manifest_id,
|
||||
manifest_source,
|
||||
library: LazyHash::new(library),
|
||||
fonts,
|
||||
sources: Mutex::new(HashMap::new()),
|
||||
}
|
||||
@@ -131,7 +163,8 @@ impl LessonWorld {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read raw bytes for `id` from disk (driver excluded — handled by callers).
|
||||
/// Read raw bytes for `id` from disk (virtual files excluded — handled by
|
||||
/// callers).
|
||||
fn read_bytes(&self, id: FileId) -> FileResult<Vec<u8>> {
|
||||
let path = self.resolve(id)?;
|
||||
std::fs::read(&path).map_err(|e| FileError::from_io(e, &path))
|
||||
@@ -152,8 +185,8 @@ impl World for LessonWorld {
|
||||
}
|
||||
|
||||
fn source(&self, id: FileId) -> FileResult<Source> {
|
||||
if id == self.main {
|
||||
return Ok(self.driver_source.clone());
|
||||
if id == self.manifest_id {
|
||||
return Ok(self.manifest_source.clone());
|
||||
}
|
||||
// Cache hit?
|
||||
if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) {
|
||||
@@ -170,8 +203,8 @@ impl World for LessonWorld {
|
||||
}
|
||||
|
||||
fn file(&self, id: FileId) -> FileResult<Bytes> {
|
||||
if id == self.main {
|
||||
return Ok(Bytes::from_string(self.driver_source.text().to_string()));
|
||||
if id == self.manifest_id {
|
||||
return Ok(Bytes::from_string(self.manifest_source.text().to_string()));
|
||||
}
|
||||
let bytes = self.read_bytes(id)?;
|
||||
Ok(Bytes::new(bytes))
|
||||
@@ -186,3 +219,18 @@ impl World for LessonWorld {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a relative `Path` as a forward-slash string, dropping any leading
|
||||
/// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim.
|
||||
fn path_to_forward_slash(path: &Path) -> String {
|
||||
let mut out = String::new();
|
||||
for comp in path.components() {
|
||||
if let Component::Normal(s) = comp {
|
||||
if !out.is_empty() {
|
||||
out.push('/');
|
||||
}
|
||||
out.push_str(&s.to_string_lossy());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
+188
-181
@@ -1,24 +1,25 @@
|
||||
//! Integration tests for `cph-typst`: driver generation, World, compile-check,
|
||||
//! and PDF export over the `tests/fixtures/mini` lesson.
|
||||
//! Integration tests for `cph-typst` under the ADR-0011 model: the engine
|
||||
//! compiles a **template file** (`exports/<target>.typ`) as main, injecting an
|
||||
//! **augmented manifest** (per-part `fields` array of on-disk content fields)
|
||||
//! served as a virtual file, and resolving `@local/cph-render` + vendored
|
||||
//! `@preview/numbly` from the repo `render/` directory.
|
||||
//!
|
||||
//! Render-package dependency: most tests point the [`Engine`] at the bundled
|
||||
//! **render-stub** (`tests/fixtures/render-stub`) so the World/compile/PDF path
|
||||
//! is proven independently of WU-2. One `#[ignore]`d test points at the real
|
||||
//! repo `render/` package for when it is ready / for manual runs.
|
||||
//! There is no render-stub any more: the stock templates import the real
|
||||
//! `cph-render` package's full API (`render-lesson`, `part-fields`,
|
||||
//! `default-heading-numbering`), so the tests point the [`Engine`] at the real
|
||||
//! repo `render/` package. These tests require system CJK fonts (present on dev
|
||||
//! and CI via fonts-noto-cjk) and the vendored numbly (committed under
|
||||
//! `render/vendor/`), so they run fully offline.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::Severity;
|
||||
use cph_typst::{generate_driver, Engine};
|
||||
use cph_typst::{build_augmented_manifest, Engine};
|
||||
|
||||
fn fixture_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mini")
|
||||
}
|
||||
|
||||
fn stub_render_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/render-stub")
|
||||
}
|
||||
|
||||
fn real_render_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
@@ -37,102 +38,89 @@ fn load_mini() -> cph_model::Lesson {
|
||||
lesson
|
||||
}
|
||||
|
||||
/// PURE UNIT TEST (no fonts, no render package): the generated driver string
|
||||
/// has the expected shape, order, includes, and scalar.
|
||||
/// PURE UNIT TEST (no fonts, no render package): the augmented manifest carries
|
||||
/// `[info]`, the ordered `[[parts]]`, and a per-part `fields` array listing the
|
||||
/// content fields present on disk.
|
||||
#[test]
|
||||
fn driver_gen_shape_and_order() {
|
||||
fn augmented_manifest_has_per_part_fields() {
|
||||
let lesson = load_mini();
|
||||
let src = generate_driver(&lesson, "student");
|
||||
let src = build_augmented_manifest(&lesson);
|
||||
|
||||
// Imports the render package and calls display.
|
||||
assert!(src.contains("#import \"@local/cph-render:0.1.0\": display"));
|
||||
assert!(src.contains("#display("));
|
||||
assert!(src.contains("target: \"student\""));
|
||||
assert!(src.contains("title: \"迷你示例课时\""));
|
||||
assert!(src.contains("author: \"测试作者\""));
|
||||
// Info round-trips.
|
||||
assert!(src.contains("迷你示例课时"), "info.title present:\n{src}");
|
||||
assert!(src.contains("测试作者"), "info.author present:\n{src}");
|
||||
|
||||
// Root-relative includes with UTF-8 folder names, one per content field.
|
||||
assert!(src.contains("include \"/segments/开场对照导言/textbook.typ\""));
|
||||
assert!(src.contains("include \"/lemmas/量纲分析估计/stmt.typ\""));
|
||||
assert!(src.contains("include \"/lemmas/量纲分析估计/proof.typ\""));
|
||||
assert!(src.contains("include \"/examples/自由落体/problem.typ\""));
|
||||
assert!(src.contains("include \"/examples/自由落体/solution.typ\""));
|
||||
// Parse it back to inspect the per-part fields precisely.
|
||||
let doc: toml::Value = toml::from_str(&src).expect("augmented manifest is valid TOML");
|
||||
let parts = doc
|
||||
.get("parts")
|
||||
.and_then(|p| p.as_array())
|
||||
.expect("parts array present");
|
||||
assert_eq!(parts.len(), 4, "four parts in declared order:\n{src}");
|
||||
|
||||
// Example scalar `source` is emitted from element.toml.
|
||||
assert!(src.contains("source: \"自拟\""));
|
||||
|
||||
// Parts appear in manifest order: segment, then lemma, then example.
|
||||
let seg = src.find("kind: \"segment\"").expect("segment present");
|
||||
let lem = src.find("kind: \"lemma\"").expect("lemma present");
|
||||
let exa = src.find("kind: \"example\"").expect("example present");
|
||||
assert!(seg < lem && lem < exa, "parts must keep manifest order");
|
||||
}
|
||||
|
||||
/// Optional `proof.typ` is only included when it exists on disk. (It exists in
|
||||
/// the mini fixture, so we assert presence; absence is covered by construction
|
||||
/// — a kind without the file would simply omit the binding.)
|
||||
#[test]
|
||||
fn optional_proof_included_when_present() {
|
||||
let lesson = load_mini();
|
||||
let src = generate_driver(&lesson, "teacher");
|
||||
assert!(src.contains("_p1_proof = include"));
|
||||
}
|
||||
|
||||
/// The mini fixture's targets carry no `numbering.heading` override, so the
|
||||
/// driver emits an empty `config: (:)` (render package applies its default).
|
||||
#[test]
|
||||
fn driver_emits_empty_config_when_no_override() {
|
||||
let lesson = load_mini();
|
||||
let src = generate_driver(&lesson, "student");
|
||||
assert!(
|
||||
src.contains("config: (:)"),
|
||||
"expected empty config for a target with no numbering override:\n{src}"
|
||||
);
|
||||
}
|
||||
|
||||
/// CONFIG THREADING: a target declaring a `numbering.heading` override produces
|
||||
/// a driver that passes `config: (numbering: (heading: (...)))` with the
|
||||
/// patterns as escaped typst string literals, in order.
|
||||
#[test]
|
||||
fn driver_threads_numbering_heading_config() {
|
||||
use cph_model::{ArtifactKind, Info, Lesson, NumberingConfig, Project, TargetConfig};
|
||||
use std::path::PathBuf;
|
||||
|
||||
let lesson = Lesson {
|
||||
project: Project {
|
||||
id: "x".into(),
|
||||
name: "x".into(),
|
||||
},
|
||||
info: Info {
|
||||
title: "t".into(),
|
||||
author: None,
|
||||
},
|
||||
parts: vec![],
|
||||
targets: vec![TargetConfig {
|
||||
name: "student".into(),
|
||||
artifact: ArtifactKind::SingleFile,
|
||||
numbering: Some(NumberingConfig {
|
||||
heading: Some(vec!["{1:一}、".into(), "{1:1}.{2:1}".into()]),
|
||||
}),
|
||||
}],
|
||||
root: PathBuf::from("/tmp/does-not-matter"),
|
||||
// `fields` is a presence SET (the template tests membership), so order is
|
||||
// not load-bearing; sort for a stable assertion.
|
||||
let fields_of = |idx: usize| -> Vec<String> {
|
||||
let mut v: Vec<String> = parts[idx]
|
||||
.get("fields")
|
||||
.and_then(|f| f.as_array())
|
||||
.expect("part has a fields array")
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let path_of = |idx: usize| {
|
||||
parts[idx]
|
||||
.get("path")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
let kind_of = |idx: usize| {
|
||||
parts[idx]
|
||||
.get("kind")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let src = generate_driver(&lesson, "student");
|
||||
assert!(
|
||||
src.contains(r#"config: (numbering: (heading: ("{1:一}、", "{1:1}.{2:1}",)))"#),
|
||||
"expected threaded numbering config in driver:\n{src}"
|
||||
// Order preserved: segment, lemma (w/ proof), lemma (no proof), example.
|
||||
assert_eq!(kind_of(0), "segment");
|
||||
assert_eq!(kind_of(1), "lemma");
|
||||
assert_eq!(kind_of(2), "lemma");
|
||||
assert_eq!(kind_of(3), "example");
|
||||
|
||||
// Paths kept as forward-slash UTF-8.
|
||||
assert_eq!(path_of(0), "segments/开场对照导言");
|
||||
assert_eq!(path_of(2), "lemmas/无证明引理");
|
||||
|
||||
// segment: only `textbook` exists.
|
||||
assert_eq!(fields_of(0), vec!["textbook"]);
|
||||
// lemma WITH proof.typ: both stmt + proof present (sorted).
|
||||
assert_eq!(fields_of(1), vec!["proof", "stmt"]);
|
||||
// lemma WITHOUT proof.typ: only stmt present (the OPTIONAL-content path).
|
||||
assert_eq!(
|
||||
fields_of(2),
|
||||
vec!["stmt"],
|
||||
"proof must be omitted when absent"
|
||||
);
|
||||
// example: problem + solution present (source is a scalar, not a content field).
|
||||
assert_eq!(fields_of(3), vec!["problem", "solution"]);
|
||||
}
|
||||
|
||||
/// FONT-DEPENDENT: compile the mini lesson through the render-stub. Should
|
||||
/// produce zero Error-severity diagnostics. If this fails purely because the
|
||||
/// test host lacks fonts, the failure will be a typst font error — see the
|
||||
/// note in the crate docs.
|
||||
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the
|
||||
/// student template as main with the injected augmented manifest is clean (zero
|
||||
/// Error-severity diagnostics). This proves the template → manifest → include
|
||||
/// loop → cph-render path, including the optional-content gate (the second lemma
|
||||
/// has no proof.typ and must NOT cause a missing-include error).
|
||||
#[test]
|
||||
fn compile_check_clean_with_stub() {
|
||||
fn compile_check_clean_through_template() {
|
||||
let lesson = load_mini();
|
||||
let engine = Engine::with_render_dir(stub_render_dir());
|
||||
let engine = Engine::with_render_dir(real_render_dir());
|
||||
let diags = engine.compile_check(&lesson, "student");
|
||||
let errors: Vec<_> = diags
|
||||
.iter()
|
||||
@@ -141,87 +129,13 @@ fn compile_check_clean_with_stub() {
|
||||
assert!(errors.is_empty(), "unexpected compile errors: {errors:#?}");
|
||||
}
|
||||
|
||||
/// FONT-DEPENDENT: a real PDF is produced through the render-stub and is
|
||||
/// non-trivial in size.
|
||||
/// THROUGH-TEMPLATE PDF export, fully offline (vendored numbly + @local/cph-render):
|
||||
/// a non-trivial PDF is produced for both targets through the real templates.
|
||||
/// This is also the offline-`@preview/numbly` proof — `render/src/style.typ`
|
||||
/// imports `@preview/numbly:0.1.0`, resolved from the vendored dir with no
|
||||
/// network access; a failure there would surface as a package-not-found error.
|
||||
#[test]
|
||||
fn build_pdf_with_stub() {
|
||||
let lesson = load_mini();
|
||||
let engine = Engine::with_render_dir(stub_render_dir());
|
||||
let pdf = engine
|
||||
.build_pdf(&lesson, "student")
|
||||
.unwrap_or_else(|d| panic!("PDF build failed: {d:#?}"));
|
||||
assert!(pdf.starts_with(b"%PDF"), "output is a PDF");
|
||||
assert!(
|
||||
pdf.len() > 1024,
|
||||
"PDF is non-trivial (got {} bytes)",
|
||||
pdf.len()
|
||||
);
|
||||
|
||||
let out = std::env::temp_dir().join("cph-typst-mini-student.pdf");
|
||||
std::fs::write(&out, &pdf).expect("write pdf");
|
||||
assert!(std::fs::metadata(&out).unwrap().len() > 1024);
|
||||
}
|
||||
|
||||
/// A diagnostic that lands inside the generated driver is re-pointed at the
|
||||
/// driver path with a "generated driver" hint, not dropped. We force this by
|
||||
/// compiling against a render dir whose `display` does not exist, so the
|
||||
/// driver's `#display(...)` call is unknown — the error span is in the driver.
|
||||
#[test]
|
||||
fn driver_internal_span_is_repointed() {
|
||||
let lesson = load_mini();
|
||||
// Point at a render dir with an EMPTY lib.typ (no `display`), so calling
|
||||
// `#display(...)` errors inside the driver.
|
||||
let empty = std::env::temp_dir().join("cph-typst-empty-render");
|
||||
std::fs::create_dir_all(&empty).unwrap();
|
||||
std::fs::write(
|
||||
empty.join("typst.toml"),
|
||||
"[package]\nname=\"cph-render\"\nversion=\"0.1.0\"\nentrypoint=\"lib.typ\"\n\
|
||||
authors=[\"t\"]\nlicense=\"MIT\"\ndescription=\"empty\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(empty.join("lib.typ"), "// no display defined\n").unwrap();
|
||||
|
||||
let engine = Engine::with_render_dir(empty);
|
||||
let diags = engine.compile_check(&lesson, "student");
|
||||
let errors: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.severity == Severity::Error)
|
||||
.collect();
|
||||
assert!(!errors.is_empty(), "expected an unknown-`display` error");
|
||||
// At least one error should point at the driver and carry the driver hint.
|
||||
let driver_err = errors.iter().find(|d| {
|
||||
d.span
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.file.to_string_lossy().contains(".cph/driver-"))
|
||||
});
|
||||
assert!(
|
||||
driver_err.is_some(),
|
||||
"an error span should be re-pointed at the generated driver: {errors:#?}"
|
||||
);
|
||||
assert!(
|
||||
driver_err
|
||||
.unwrap()
|
||||
.hint
|
||||
.as_deref()
|
||||
.is_some_and(|h| h.contains("generated driver")),
|
||||
"driver-internal error should carry the generated-driver hint"
|
||||
);
|
||||
}
|
||||
|
||||
/// Full end-to-end against the REAL repo `render/` package: load the mini
|
||||
/// fixture, compile through `cph-render`'s `display`, export a PDF for both
|
||||
/// targets. This proves the whole World → driver → render-package → PDF path,
|
||||
/// not just the stub. Requires system CJK fonts (present on dev + CI via
|
||||
/// fonts-noto-cjk).
|
||||
///
|
||||
/// **This is also the offline-`@preview/numbly` proof (Task 1).** The real
|
||||
/// render package's `src/style.typ` does `#import "@preview/numbly:0.1.0"`, so a
|
||||
/// clean PDF here means the embedded `World` resolved numbly from the vendored
|
||||
/// dir (`render/vendor/typst-packages/preview/numbly/0.1.0/`) with no network /
|
||||
/// package download — had it failed, `build_pdf` would have returned a
|
||||
/// package-not-found error and this test would panic.
|
||||
#[test]
|
||||
fn build_pdf_with_real_render() {
|
||||
fn build_pdf_through_template_offline() {
|
||||
let lesson = load_mini();
|
||||
let render_dir = real_render_dir();
|
||||
assert!(
|
||||
@@ -229,7 +143,6 @@ fn build_pdf_with_real_render() {
|
||||
"real render package missing at {}",
|
||||
render_dir.display()
|
||||
);
|
||||
// Sanity: the numbly the render package imports is actually vendored here.
|
||||
assert!(
|
||||
render_dir
|
||||
.join("vendor/typst-packages/preview/numbly/0.1.0/lib.typ")
|
||||
@@ -243,13 +156,107 @@ fn build_pdf_with_real_render() {
|
||||
let pdf = engine
|
||||
.build_pdf(&lesson, target)
|
||||
.unwrap_or_else(|d| panic!("{target} PDF build failed: {d:#?}"));
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
assert!(pdf.len() > 1024, "{target} PDF too small");
|
||||
eprintln!(
|
||||
"build_pdf_with_real_render: {target} PDF = {} bytes (numbly resolved offline)",
|
||||
assert!(pdf.starts_with(b"%PDF"), "{target} output is a PDF");
|
||||
assert!(
|
||||
pdf.len() > 1024,
|
||||
"{target} PDF is non-trivial (got {} bytes)",
|
||||
pdf.len()
|
||||
);
|
||||
let out = std::env::temp_dir().join(format!("cph-typst-mini-real-{target}.pdf"));
|
||||
eprintln!(
|
||||
"build_pdf_through_template_offline: {target} PDF = {} bytes (numbly resolved offline)",
|
||||
pdf.len()
|
||||
);
|
||||
let out = std::env::temp_dir().join(format!("cph-typst-mini-{target}.pdf"));
|
||||
std::fs::write(&out, &pdf).expect("write pdf");
|
||||
}
|
||||
}
|
||||
|
||||
/// An undeclared target name (when the lesson declares at least one target) is a
|
||||
/// blocking `SchemaViolation`, not a compile attempt.
|
||||
#[test]
|
||||
fn unknown_target_is_blocking() {
|
||||
let lesson = load_mini();
|
||||
let engine = Engine::with_render_dir(real_render_dir());
|
||||
let diags = engine.compile_check(&lesson, "nonexistent");
|
||||
assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}");
|
||||
assert_eq!(diags[0].severity, Severity::Error);
|
||||
assert!(
|
||||
diags[0].message.contains("not declared"),
|
||||
"expected an undeclared-target error: {diags:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `file-tree` artifact is deferred for MVP: a clear "not yet implemented"
|
||||
/// blocking diagnostic rather than a wrong build.
|
||||
#[test]
|
||||
fn file_tree_artifact_is_deferred() {
|
||||
use cph_model::{Artifact, Info, Lesson, Project, Step, TargetConfig};
|
||||
|
||||
let lesson = Lesson {
|
||||
project: Project {
|
||||
id: "x".into(),
|
||||
name: "x".into(),
|
||||
},
|
||||
info: Info {
|
||||
title: "t".into(),
|
||||
author: None,
|
||||
},
|
||||
parts: vec![],
|
||||
targets: vec![TargetConfig {
|
||||
name: "web".into(),
|
||||
artifact: Artifact::FileTree {
|
||||
root: PathBuf::from("build/web"),
|
||||
outputs: "**/*.html".into(),
|
||||
},
|
||||
steps: vec![Step::TypstCompile {
|
||||
template: PathBuf::from("exports/web.typ"),
|
||||
}],
|
||||
}],
|
||||
root: fixture_root(),
|
||||
};
|
||||
|
||||
let engine = Engine::with_render_dir(real_render_dir());
|
||||
let diags = engine.compile_check(&lesson, "web");
|
||||
assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}");
|
||||
assert!(
|
||||
diags[0].message.contains("file-tree"),
|
||||
"expected a file-tree deferral: {diags:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A target whose only step is a `shell` step (no typst-compile) is deferred for
|
||||
/// MVP with a clear blocking diagnostic.
|
||||
#[test]
|
||||
fn shell_only_target_is_deferred() {
|
||||
use cph_model::{Artifact, Info, Lesson, Project, Step, TargetConfig};
|
||||
|
||||
let lesson = Lesson {
|
||||
project: Project {
|
||||
id: "x".into(),
|
||||
name: "x".into(),
|
||||
},
|
||||
info: Info {
|
||||
title: "t".into(),
|
||||
author: None,
|
||||
},
|
||||
parts: vec![],
|
||||
targets: vec![TargetConfig {
|
||||
name: "packaged".into(),
|
||||
artifact: Artifact::SingleFile {
|
||||
filepath: PathBuf::from("build/packaged.zip"),
|
||||
},
|
||||
steps: vec![Step::Shell {
|
||||
run: "echo hi".into(),
|
||||
}],
|
||||
}],
|
||||
root: fixture_root(),
|
||||
};
|
||||
|
||||
let engine = Engine::with_render_dir(real_render_dir());
|
||||
let diags = engine.compile_check(&lesson, "packaged");
|
||||
assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}");
|
||||
assert!(
|
||||
diags[0].message.contains("no typst-compile step"),
|
||||
"expected a no-typst-compile-step deferral: {diags:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011).
|
||||
//
|
||||
// This is a *real, editable* file that lives in an engineering file at
|
||||
// `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the
|
||||
// manifest injected:
|
||||
// typst compile --root <eng-root> --input manifest=<path-rel-to-root> exports/student.typ <out>
|
||||
//
|
||||
// It is intentionally self-contained (no shared helper import) so it can be
|
||||
// copied verbatim into a new engineering file's `exports/`. Presentation —
|
||||
// heading numbering — lives HERE (editable per engineering file), not in the
|
||||
// manifest and not hardcoded in the cph-render package.
|
||||
//
|
||||
// WHY THE INCLUDE LOOP IS HERE AND NOT IN cph-render: typst resolves a dynamic
|
||||
// `include` path relative to the file it lexically appears in, and a package has
|
||||
// its own virtual root — an include inside cph-render would resolve against the
|
||||
// PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE
|
||||
// (this template lives under `--root`) resolves against `--root`. So the
|
||||
// template loads content and hands cph-render an already-assembled `parts` array.
|
||||
//
|
||||
// OPEN CONTRACT POINT — optional-content presence. typst has no "does this file
|
||||
// exist" primitive (a missing `include` is a hard compile error). So the
|
||||
// template CANNOT probe disk the way the old Rust driver did for lemma `proof`.
|
||||
// It relies on the manifest declaring which optional content fields are present,
|
||||
// via a per-part `fields` array listing the content fields that exist on disk
|
||||
// (the engine knows this — it walks the part dir). Required fields are loaded
|
||||
// unconditionally; optional fields load only if listed in `fields`. If a part
|
||||
// omits `fields`, optional content is skipped (conservative). The exact shape of
|
||||
// this declaration is for the manifest/Rust contract to pin.
|
||||
|
||||
#import "@local/cph-render:0.1.0": render-lesson, part-fields, default-heading-numbering
|
||||
|
||||
// This template IS the student build, so the target is fixed.
|
||||
#let target = "student"
|
||||
|
||||
// Read the injected manifest (a path string relative to typst --root).
|
||||
#let manifest = toml(sys.inputs.manifest)
|
||||
#let info = manifest.at("info", default: (:))
|
||||
#let raw-parts = manifest.at("parts", default: ())
|
||||
|
||||
// Assemble each part: include its content fields (computed absolute paths,
|
||||
// resolved against --root) and read scalar fields from <path>/element.toml.
|
||||
// `part-fields` (from cph-render) is the single source of truth for kind->fields.
|
||||
#let parts = raw-parts.map(raw => {
|
||||
let kind = raw.at("kind", default: none)
|
||||
let path = raw.at("path", default: none)
|
||||
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
|
||||
// Which optional content fields are present on disk (manifest-declared).
|
||||
let present = raw.at("fields", default: ())
|
||||
let part = (kind: kind)
|
||||
|
||||
// Required content fields: <path>/<field>.typ (absolute, root-relative).
|
||||
for field in spec.content {
|
||||
part.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
}
|
||||
// Optional content fields: only when the manifest says the file exists.
|
||||
for field in spec.optional-content {
|
||||
if field in present {
|
||||
part.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
}
|
||||
}
|
||||
// Scalar fields come from <path>/element.toml.
|
||||
if spec.scalars.len() > 0 {
|
||||
let element = toml("/" + path + "/element.toml")
|
||||
for field in spec.scalars {
|
||||
let v = element.at(field, default: none)
|
||||
if v != none and v != "" { part.insert(field, v) }
|
||||
}
|
||||
}
|
||||
part
|
||||
})
|
||||
|
||||
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
|
||||
// from cph-render; override here per engineering file if desired.
|
||||
#render-lesson(
|
||||
info: info,
|
||||
target: target,
|
||||
parts: parts,
|
||||
heading-numbering: default-heading-numbering,
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011).
|
||||
//
|
||||
// Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with
|
||||
// the manifest injected:
|
||||
// typst compile --root <eng-root> --input manifest=<path-rel-to-root> exports/teacher.typ <out>
|
||||
//
|
||||
// Self-contained (copyable into an engineering file). Identical to student.typ
|
||||
// except the fixed target is "teacher" (so the (kind x target) render matrix in
|
||||
// cph-render shows solutions and proofs). See student.typ for the full notes on
|
||||
// why the include loop lives here (package virtual-root resolution) and on the
|
||||
// OPEN optional-content (`fields`) contract point.
|
||||
|
||||
#import "@local/cph-render:0.1.0": render-lesson, part-fields, default-heading-numbering
|
||||
|
||||
// This template IS the teacher build, so the target is fixed.
|
||||
#let target = "teacher"
|
||||
|
||||
// Read the injected manifest (a path string relative to typst --root).
|
||||
#let manifest = toml(sys.inputs.manifest)
|
||||
#let info = manifest.at("info", default: (:))
|
||||
#let raw-parts = manifest.at("parts", default: ())
|
||||
|
||||
// Assemble each part: include its content fields (computed absolute paths,
|
||||
// resolved against --root) and read scalar fields from <path>/element.toml.
|
||||
// `part-fields` (from cph-render) is the single source of truth for kind->fields.
|
||||
#let parts = raw-parts.map(raw => {
|
||||
let kind = raw.at("kind", default: none)
|
||||
let path = raw.at("path", default: none)
|
||||
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
|
||||
// Which optional content fields are present on disk (manifest-declared).
|
||||
let present = raw.at("fields", default: ())
|
||||
let part = (kind: kind)
|
||||
|
||||
// Required content fields: <path>/<field>.typ (absolute, root-relative).
|
||||
for field in spec.content {
|
||||
part.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
}
|
||||
// Optional content fields: only when the manifest says the file exists.
|
||||
for field in spec.optional-content {
|
||||
if field in present {
|
||||
part.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
}
|
||||
}
|
||||
// Scalar fields come from <path>/element.toml.
|
||||
if spec.scalars.len() > 0 {
|
||||
let element = toml("/" + path + "/element.toml")
|
||||
for field in spec.scalars {
|
||||
let v = element.at(field, default: none)
|
||||
if v != none and v != "" { part.insert(field, v) }
|
||||
}
|
||||
}
|
||||
part
|
||||
})
|
||||
|
||||
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
|
||||
// from cph-render; override here per engineering file if desired.
|
||||
#render-lesson(
|
||||
info: info,
|
||||
target: target,
|
||||
parts: parts,
|
||||
heading-numbering: default-heading-numbering,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
kind = "lemma"
|
||||
@@ -0,0 +1 @@
|
||||
设 $f$ 在闭区间上连续,则 $f$ 有最大值与最小值。
|
||||
@@ -14,10 +14,25 @@ path = "segments/开场对照导言"
|
||||
kind = "lemma"
|
||||
path = "lemmas/量纲分析估计"
|
||||
|
||||
[[parts]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/无证明引理"
|
||||
|
||||
[[parts]]
|
||||
kind = "example"
|
||||
path = "examples/自由落体"
|
||||
|
||||
# v2 target shape (ADR-0011): a typed artifact + an ordered list of typed steps.
|
||||
# Each target's single `typst-compile` step names the template file to compile
|
||||
# as main; the framework injects the augmented manifest into it.
|
||||
[targets.student]
|
||||
artifact = { type = "single-file", filepath = "build/student.pdf" }
|
||||
[[targets.student.steps]]
|
||||
type = "typst-compile"
|
||||
template = "exports/student.typ"
|
||||
|
||||
[targets.teacher]
|
||||
artifact = { type = "single-file", filepath = "build/teacher.pdf" }
|
||||
[[targets.teacher.steps]]
|
||||
type = "typst-compile"
|
||||
template = "exports/teacher.typ"
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// Minimal stand-in for the real `cph-render` package, used so cph-typst's
|
||||
// World / compile / PDF path can be proven independently of WU-2.
|
||||
//
|
||||
// It exercises the same call shape as the real entry: it reads `info`, walks
|
||||
// `parts` IN ORDER, and renders each part's content fields by `kind`. Content
|
||||
// values are already-evaluated content (the driver produced them via include).
|
||||
//
|
||||
// `config` mirrors the real entry's ADR-0009 presentation-config parameter; the
|
||||
// stub accepts it (the driver always passes `config:`) but ignores it.
|
||||
#let display(info: (:), target: "student", parts: (), config: (:)) = {
|
||||
set document(title: info.at("title", default: ""))
|
||||
[= #info.at("title", default: "")]
|
||||
[target: #target]
|
||||
for p in parts {
|
||||
let kind = p.at("kind", default: none)
|
||||
[== #kind]
|
||||
if kind == "segment" {
|
||||
p.textbook
|
||||
} else if kind == "lemma" {
|
||||
p.stmt
|
||||
let proof = p.at("proof", default: none)
|
||||
if proof != none { proof }
|
||||
} else if kind == "example" {
|
||||
p.problem
|
||||
p.solution
|
||||
let src = p.at("source", default: none)
|
||||
if src != none [来源:#src]
|
||||
} else if kind == "sop" {
|
||||
p.sop
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
[package]
|
||||
name = "cph-render"
|
||||
version = "0.1.0"
|
||||
entrypoint = "lib.typ"
|
||||
authors = ["cph-typst tests"]
|
||||
license = "MIT"
|
||||
description = "Minimal stand-in for cph-render used by cph-typst tests."
|
||||
Reference in New Issue
Block a user