forked from EduCraft/curriculum-project-hub
9590e15236
三件事,服务于"把恒一小奥 KenKen 课作者化为合法 native 工程文件"这条主线
(工程文件本身在 repo 外的 sibling playground,不入此提交):
1. cph-typst: 把 render/ 编译期嵌入二进制,`cargo install` 后开箱即用
- build.rs 把 render/ 拷进 OUT_DIR,剔除 dev 自指符号链接 vendor/local-packages
(否则 include_dir! 无限递归)
- embedded.rs 用 include_dir! 嵌入,运行时按版本解压到 per-user cache dir
(CPH_RENDER_DIR 仍优先作 dev override)
- default_render_dir 不再用编译期 CARGO_MANIFEST_DIR 写死路径
2. cph-check + cph-cli: 实现 Step::Shell 执行器(此前仅占位)
- run_shell_target: 跑 check 门控 → 以工程根为 cwd 执行 shell step,首非零退出即停
- target_is_shell + CLI run_shell_build 路由;`check` 不跑 shell(结构校验只看 lesson)
- compile_targets 只保留含 TypstCompile 的 target,纯 shell target 不走 typst 引擎
- 语义:失败属 build-过程错误,不入 6 类诊断(taxonomy 保持 6,显式拒绝加 ShellStep 码)
3. spec + ADR: 在 Export/Render.lean 钉 shell step 执行语义 prose;
ADR-0013(已实现)、ADR-0014(md/HTML 导出后端方向,Proposed/设计先行,未实现)
测试:全 workspace 42 passed(新增 4 个 shell 执行器测试);lake build 25 jobs 绿。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
250 lines
11 KiB
Rust
250 lines
11 KiB
Rust
//! `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`] for a target (collect typst
|
|
//! errors+warnings as [`cph_diag::Diagnostic`]s), and
|
|
//! - **export a PDF** for a render target.
|
|
//!
|
|
//! ## 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
|
|
//!
|
|
//! Pinned to the `typst` family `=0.15.0`. The 0.15 path/file API differs from
|
|
//! 0.14: `FileId::new(RootedPath)` where `RootedPath::new(VirtualRoot,
|
|
//! VirtualPath)`, and a diagnostic span is a `DiagSpan` (not a bare `Span`).
|
|
|
|
mod diag;
|
|
mod embedded;
|
|
mod manifest;
|
|
mod world;
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use cph_diag::{DiagCode, Diagnostic};
|
|
use cph_model::{Artifact, Lesson, Step, TargetConfig};
|
|
use typst_kit::fonts::{self, FontStore};
|
|
use typst_layout::PagedDocument;
|
|
use typst_pdf::PdfOptions;
|
|
|
|
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,
|
|
}
|
|
|
|
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 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())
|
|
}
|
|
|
|
/// Construct an engine pointing at an explicit `cph-render` package
|
|
/// 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
|
|
// embedded fallbacks. `scan-fonts` / `embedded-fonts` features gate
|
|
// these iterators.
|
|
store.extend(fonts::system());
|
|
store.extend(fonts::embedded());
|
|
Self {
|
|
fonts: std::sync::Arc::new(store),
|
|
render_dir,
|
|
}
|
|
}
|
|
|
|
/// The directory this engine resolves `@local/cph-render` from.
|
|
pub fn render_dir(&self) -> &std::path::Path {
|
|
&self.render_dir
|
|
}
|
|
|
|
/// 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`.
|
|
///
|
|
/// 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> {
|
|
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();
|
|
if let Err(errors) = &warned.output {
|
|
out.extend(map_all(&world, errors));
|
|
}
|
|
out.extend(map_all(&world, &warned.warnings));
|
|
out
|
|
}
|
|
|
|
/// Build a PDF for `lesson` / `target`. On a clean compile returns the PDF
|
|
/// 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>> {
|
|
let world = self.world_for(lesson, target)?;
|
|
let warned = typst::compile::<PagedDocument>(&world);
|
|
let doc = match warned.output {
|
|
Ok(doc) => doc,
|
|
Err(errors) => return Err(map_all(&world, &errors)),
|
|
};
|
|
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors))
|
|
}
|
|
|
|
/// 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(),
|
|
&template,
|
|
manifest_src,
|
|
self.fonts.clone(),
|
|
))
|
|
}
|
|
}
|
|
|
|
impl Default for Engine {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// 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`, 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. Use the stock template path (matches cph-model's default).
|
|
return Ok(PathBuf::from(format!("exports/{target}.typ")));
|
|
}
|
|
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"
|
|
))]);
|
|
};
|
|
|
|
// 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 typst-compile)",
|
|
)
|
|
.with_hint(format!(
|
|
"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`.
|
|
fn map_all(world: &LessonWorld, diags: &[typst::diag::SourceDiagnostic]) -> Vec<Diagnostic> {
|
|
diags
|
|
.iter()
|
|
.map(|d| diag::map_diagnostic(world, d))
|
|
.collect()
|
|
}
|
|
|
|
/// 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 {
|
|
embedded::resolve_render_dir()
|
|
}
|