forked from EduCraft/curriculum-project-hub
feat(checker): align cph-typst + cph-check to export-as-build model (WU-D)
Integrate the WU-B (structured targets) and WU-C (numbly, config) changes. cph-typst: - World resolves any @preview/* package from the in-repo vendored dir (<render_dir>/vendor/typst-packages/preview/<name>/<version>/), fully offline — no typst-kit download. Proven: build_pdf_with_real_render compiles the real render package (which imports @preview/numbly:0.1.0) through the embedded engine, PDF out. - Driver threads each target's presentation config: looks up the TargetConfig by name, emits `config: (numbering: (heading: (...)))` (escaped) when an override is present, else `config: (:)` (render default). - target_precheck guards artifact/target: FileTree → blocking diagnostic (MVP is single-file, deferred per ADR-0009); --target not declared → blocking diagnostic; no-targets lesson still defaults through. cph-check: - Migrated to Lesson.targets: Vec<TargetConfig> (target_names() / target.name at the 6 sites). Pipeline unchanged (already matches ADR-0010 phases). Documented that !has_errors() implements Spec.Courseware.Legal (no error-level diagnostic). 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:
@@ -60,6 +60,13 @@ impl CheckReport {
|
||||
}
|
||||
|
||||
/// Whether any collected diagnostic is `Error`-severity.
|
||||
///
|
||||
/// **Legality decision (spec alignment).** `!has_errors()` is the
|
||||
/// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Diagnostic.lean`):
|
||||
/// a lesson is *legal* iff its diagnostics contain no error-level diagnostic
|
||||
/// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI
|
||||
/// gate enforcing this alignment (repo constitution); it is kept greppable
|
||||
/// here so a reviewer can tie the orchestrator's gate to the Lean master.
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.diagnostics
|
||||
.iter()
|
||||
@@ -247,7 +254,7 @@ fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> {
|
||||
if lesson.targets.is_empty() {
|
||||
vec![DEFAULT_TARGET]
|
||||
} else {
|
||||
lesson.targets.iter().map(String::as_str).collect()
|
||||
lesson.target_names()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,11 +278,11 @@ fn render_coverage(lesson: &cph_model::Lesson, known: &[&str]) -> Vec<Diagnostic
|
||||
continue;
|
||||
}
|
||||
for target in &lesson.targets {
|
||||
let covered = COVERED_TARGETS.contains(&target.as_str());
|
||||
let covered = COVERED_TARGETS.contains(&target.name.as_str());
|
||||
if covered {
|
||||
continue;
|
||||
}
|
||||
let key = (part.kind.clone(), target.clone());
|
||||
let key = (part.kind.clone(), target.name.clone());
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
@@ -284,12 +291,12 @@ fn render_coverage(lesson: &cph_model::Lesson, known: &[&str]) -> Vec<Diagnostic
|
||||
DiagCode::RenderIgnored,
|
||||
format!(
|
||||
"kind '{}' has no render rule for target '{}'; it will be omitted from that export",
|
||||
part.kind, target
|
||||
part.kind, target.name
|
||||
),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"add a render rule for kind '{}' under target '{}', or remove the target",
|
||||
part.kind, target
|
||||
part.kind, target.name
|
||||
));
|
||||
// Pin the severity via the named contract const. `Diagnostic::warning`
|
||||
// already produces a warning, but assigning the const keeps the
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
|
||||
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";
|
||||
@@ -101,10 +106,39 @@ pub fn generate_driver(lesson: &Lesson, target: &str) -> String {
|
||||
// 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))];
|
||||
|
||||
@@ -24,8 +24,8 @@ mod world;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::Diagnostic;
|
||||
use cph_model::Lesson;
|
||||
use cph_diag::{DiagCode, Diagnostic};
|
||||
use cph_model::{ArtifactKind, Lesson};
|
||||
use typst_kit::fonts::{self, FontStore};
|
||||
use typst_layout::PagedDocument;
|
||||
use typst_pdf::PdfOptions;
|
||||
@@ -78,6 +78,9 @@ impl Engine {
|
||||
/// 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.
|
||||
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 warned = typst::compile::<PagedDocument>(&world);
|
||||
|
||||
@@ -93,6 +96,9 @@ impl Engine {
|
||||
/// bytes; on fatal errors returns the mapped diagnostics. Compile warnings
|
||||
/// are not surfaced here (use [`Engine::compile_check`] for those).
|
||||
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 warned = typst::compile::<PagedDocument>(&world);
|
||||
let doc = match warned.output {
|
||||
@@ -120,6 +126,49 @@ 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:
|
||||
///
|
||||
/// - **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() => {
|
||||
// Lesson declares no targets; the orchestrator compiles a defaulted
|
||||
// target. Proceed with render-package defaults (SingleFile).
|
||||
None
|
||||
}
|
||||
None => Some(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(
|
||||
DiagCode::SchemaViolation,
|
||||
"file-tree artifact not yet implemented (MVP supports single-file)",
|
||||
)
|
||||
.with_hint(format!(
|
||||
"set `[targets.{target}].artifact` to \"single-file\" for now"
|
||||
))]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a batch of typst diagnostics to `cph-diag` ones against `world`.
|
||||
fn map_all(world: &LessonWorld, diags: &[typst::diag::SourceDiagnostic]) -> Vec<Diagnostic> {
|
||||
diags
|
||||
|
||||
@@ -12,8 +12,15 @@
|
||||
//! - **`@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). Any *other* `@package` id yields a `NotFound` error (the MVP
|
||||
//! render package has no external `@preview` dependencies).
|
||||
//! coupling).
|
||||
//! - **`@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.
|
||||
//! - 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.
|
||||
@@ -94,7 +101,8 @@ impl LessonWorld {
|
||||
}
|
||||
|
||||
/// Map a `FileId` to its on-disk path, honoring the project / render-package
|
||||
/// roots. Returns a `FileError` for any other package root.
|
||||
/// / vendored-`@preview` roots. Returns a `FileError` for any other package
|
||||
/// root.
|
||||
fn resolve(&self, id: FileId) -> FileResult<PathBuf> {
|
||||
let vpath = id.vpath();
|
||||
match id.root() {
|
||||
@@ -102,6 +110,21 @@ impl LessonWorld {
|
||||
VirtualRoot::Package(spec) if *spec == self.render_spec => {
|
||||
vpath.realize(&self.render_dir).map_err(Into::into)
|
||||
}
|
||||
// `@preview/<name>:<version>` → vendored preview tree under the
|
||||
// render dir. Offline, no download: mirrors typst's preview-cache
|
||||
// layout so e.g. `@preview/numbly:0.1.0` (imported by the render
|
||||
// package) reads from
|
||||
// `<render_dir>/vendor/typst-packages/preview/numbly/0.1.0/`.
|
||||
VirtualRoot::Package(spec) if spec.namespace == "preview" => {
|
||||
let pkg_root = self
|
||||
.render_dir
|
||||
.join("vendor")
|
||||
.join("typst-packages")
|
||||
.join("preview")
|
||||
.join(spec.name.as_str())
|
||||
.join(spec.version.to_string());
|
||||
vpath.realize(&pkg_root).map_err(Into::into)
|
||||
}
|
||||
VirtualRoot::Package(spec) => Err(FileError::Package(
|
||||
typst::diag::PackageError::NotFound(spec.clone()),
|
||||
)),
|
||||
|
||||
@@ -78,6 +78,53 @@ fn optional_proof_included_when_present() {
|
||||
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"),
|
||||
};
|
||||
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -166,6 +213,13 @@ fn driver_internal_span_is_repointed() {
|
||||
/// 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() {
|
||||
let lesson = load_mini();
|
||||
@@ -175,6 +229,14 @@ 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")
|
||||
.is_file(),
|
||||
"vendored numbly missing under {}",
|
||||
render_dir.display()
|
||||
);
|
||||
let engine = Engine::with_render_dir(render_dir);
|
||||
|
||||
for target in ["student", "teacher"] {
|
||||
@@ -183,6 +245,10 @@ fn build_pdf_with_real_render() {
|
||||
.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)",
|
||||
pdf.len()
|
||||
);
|
||||
let out = std::env::temp_dir().join(format!("cph-typst-mini-real-{target}.pdf"));
|
||||
std::fs::write(&out, &pdf).expect("write pdf");
|
||||
}
|
||||
|
||||
+4
-1
@@ -4,7 +4,10 @@
|
||||
// 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).
|
||||
#let display(info: (:), target: "student", parts: ()) = {
|
||||
//
|
||||
// `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]
|
||||
|
||||
Reference in New Issue
Block a user