forked from EduCraft/curriculum-project-hub
feat(cph): implement nested outline manifest and batch/combined export
ADR-0029 — nested outline manifest, supersedes ADR-0008's flat [[parts]]:
- cph-model: recursive loader over manifest.toml containers / element.toml
leaves; Lesson.parts (pure elements, DFS order) + Lesson.outline (elements
interleaved with section headings at their DFS-open position); rejects
ambiguous/incomplete folders and root-vs-container table misplacement
- cph-diag: new DiagCode::ManifestMalformed for carrier-document structure
errors (discharges an existing TODO)
- cph-typst: augmented manifest now serializes the outline (element/section
entries) instead of a flat parts array
- render/lib.typ: render-lesson renders section headings at their depth
- examples/TH-141 migrated to 5 nested section containers + 3 root segments,
byte-identical element order; smoke-verified via cph check/build + pdftotext
ADR-0030 — batch & combined export, extends ADR-0009/0011:
- cph build with no --target batches every declared target (repeatable
--target for an explicit subset); any target failure => non-zero exit,
per-target ledger, independent per-target execution
- cph-model: bundle.toml loader (directory + [info]/[targets.*]/ordered
lessons with per-lesson target overrides)
- cph-typst: augmented bundle manifest (path-prefixed member outlines),
Engine::{compile_check_bundle,build_bundle_pdf}
- render/lib.typ: render-bundle assembles member lessons under per-lesson
headings, depth-shifts their own section headings, resets example/lemma
counters at each lesson boundary by default
- cph-cli: `cph bundle <path> --target <name>` subcommand, same batching
contract as `cph build`
- new bundle fixtures/tests (cph-model unit + cph-typst through-template PDF
compile), smoke-verified via a real 2-lesson merged PDF
Verification: cargo fmt/clippy/test clean across the workspace (68 tests);
real cph check/build/bundle runs against TH-141 and a bundle fixture, PDF
content inspected via pdftotext.
This commit is contained in:
+70
-13
@@ -42,12 +42,12 @@ mod world;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::{DiagCode, Diagnostic};
|
||||
use cph_model::{Artifact, Lesson, Step, TargetConfig};
|
||||
use cph_model::{Artifact, Bundle, 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 manifest::{build_augmented_bundle_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
|
||||
@@ -142,7 +142,7 @@ impl Engine {
|
||||
/// 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 template = target_precheck(target, &lesson.targets)?;
|
||||
let manifest_src = build_augmented_manifest(lesson);
|
||||
Ok(LessonWorld::new(
|
||||
lesson.root.clone(),
|
||||
@@ -152,6 +152,60 @@ impl Engine {
|
||||
self.fonts.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Compile-check `bundle` for `target` (ADR-0030) — same contract as
|
||||
/// [`Engine::compile_check`], but over a [`Bundle`]'s own declared targets
|
||||
/// and the augmented **bundle** manifest (each member lesson's outline,
|
||||
/// path-prefixed to resolve against the bundle root).
|
||||
pub fn compile_check_bundle(&self, bundle: &Bundle, target: &str) -> Vec<Diagnostic> {
|
||||
let world = match self.world_for_bundle(bundle, target) {
|
||||
Ok(world) => world,
|
||||
Err(blocking) => return blocking,
|
||||
};
|
||||
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 `bundle` / `target` (ADR-0030) — same contract as
|
||||
/// [`Engine::build_pdf`], over a [`Bundle`]'s own declared targets.
|
||||
pub fn build_bundle_pdf(
|
||||
&self,
|
||||
bundle: &Bundle,
|
||||
target: &str,
|
||||
) -> Result<Vec<u8>, Vec<Diagnostic>> {
|
||||
let world = self.world_for_bundle(bundle, 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 `(bundle, target)`: same shape as
|
||||
/// [`Engine::world_for`], main file resolved under the **bundle** root and
|
||||
/// the injected manifest built by [`build_augmented_bundle_manifest`].
|
||||
fn world_for_bundle(
|
||||
&self,
|
||||
bundle: &Bundle,
|
||||
target: &str,
|
||||
) -> Result<LessonWorld, Vec<Diagnostic>> {
|
||||
let template = target_precheck(target, &bundle.targets)?;
|
||||
let manifest_src = build_augmented_bundle_manifest(bundle);
|
||||
Ok(LessonWorld::new(
|
||||
bundle.root.clone(),
|
||||
self.render_dir.clone(),
|
||||
&template,
|
||||
manifest_src,
|
||||
self.fonts.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Engine {
|
||||
@@ -160,13 +214,16 @@ impl Default for Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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:
|
||||
/// Validate a `(targets, target)` request and resolve the template path to
|
||||
/// compile. Shared by [`Engine::world_for`] (a lesson's `targets`) and
|
||||
/// [`Engine::world_for_bundle`] (a bundle's own `targets` — ADR-0030 gives a
|
||||
/// bundle target the exact same build/artifact/step shape). Returns
|
||||
/// `Ok(template_path)` (relative to the lesson/bundle 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).
|
||||
/// - **Unknown target** (the `--target` name isn't in `targets`, and `targets`
|
||||
/// is non-empty): 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
|
||||
@@ -177,10 +234,10 @@ impl Default for Engine {
|
||||
/// [`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
|
||||
fn target_precheck(target: &str, targets: &[TargetConfig]) -> Result<PathBuf, Vec<Diagnostic>> {
|
||||
let Some(tc) = targets.iter().find(|t| t.name == target) else {
|
||||
if targets.is_empty() {
|
||||
// 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")));
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
//! Augmented-manifest construction (ADR-0011).
|
||||
//! Augmented-manifest construction (ADR-0011, outline shape per ADR-0029).
|
||||
//!
|
||||
//! 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`).
|
||||
//! `toml(sys.inputs.manifest)`, then for each **element** outline entry
|
||||
//! `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`.
|
||||
//! **augmented manifest** = the lesson's `[info]` + the ordered `[[outline]]`
|
||||
//! (ADR-0029's depth-first rendering order — elements interleaved with section
|
||||
//! headings at their DFS-open position). Each `[[outline]]` entry carries a
|
||||
//! `type` discriminator (`"element"` | `"section"`):
|
||||
//!
|
||||
//! - `type = "element"`: `kind`, `path`, and a per-part **`fields` array**
|
||||
//! listing the content fields whose `<field>.typ` actually exists under the
|
||||
//! lesson root (same contract as before ADR-0029).
|
||||
//! - `type = "section"`: `kind`, `title`, `depth`, `path` — a section heading;
|
||||
//! the template renders it without touching any content file.
|
||||
//!
|
||||
//! 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`
|
||||
//!
|
||||
@@ -20,23 +30,104 @@
|
||||
//! ([`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
|
||||
//! kind's content fields are. For each element, a content field is listed in
|
||||
//! `fields` iff `<root>/<part.path>/<field>.typ` is a real file.
|
||||
|
||||
use cph_model::Lesson;
|
||||
use std::path::Path;
|
||||
|
||||
use cph_model::{Bundle, BundleLesson, Lesson, OutlineEntry};
|
||||
|
||||
/// 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`.
|
||||
/// author) and the ordered `[[outline]]` (ADR-0029's depth-first rendering
|
||||
/// order), each entry typed `"element"` or `"section"` per the module docs. It
|
||||
/// does **not** reproduce `[project]` or `[targets.*]` — the template only
|
||||
/// consumes `info` and `outline`.
|
||||
pub fn build_augmented_manifest(lesson: &Lesson) -> String {
|
||||
let mut doc = toml::Table::new();
|
||||
|
||||
// [info]
|
||||
doc.insert("info".to_string(), toml::Value::Table(info_table(lesson)));
|
||||
|
||||
// [[outline]] — ADR-0029's depth-first rendering order: elements
|
||||
// interleaved with section headings at their DFS-open position.
|
||||
let outline: Vec<toml::Value> = lesson
|
||||
.outline
|
||||
.iter()
|
||||
.map(|entry| toml::Value::Table(outline_entry_table(lesson, entry, None)))
|
||||
.collect();
|
||||
doc.insert("outline".to_string(), toml::Value::Array(outline));
|
||||
|
||||
toml::to_string(&doc).expect("augmented manifest serializes")
|
||||
}
|
||||
|
||||
/// Build the augmented **bundle** manifest TOML source for `bundle` (ADR-0030).
|
||||
///
|
||||
/// The bundle template (`exports/<target>.typ` under the `bundle.toml` root)
|
||||
/// reads it via `toml(sys.inputs.manifest)`. It carries `[info]` (the bundle's
|
||||
/// own title/author) and the ordered `[[lessons]]`, each a
|
||||
/// `(info, target, outline)` table — the same shape a single-lesson template
|
||||
/// would assemble, except every outline entry's `path` is **prefixed with that
|
||||
/// lesson's own bundle-root-relative directory** (`BundleLesson::path`), since
|
||||
/// the bundle template's computed include paths resolve against the *bundle*
|
||||
/// root, not each lesson's own root (ADR-0030: combination reads
|
||||
/// already-authored lessons at export time; each lesson's `path` bookkeeping
|
||||
/// stays correct because the prefix is applied only here, in the manifest the
|
||||
/// template consumes — never inside a lesson's own authored content).
|
||||
pub fn build_augmented_bundle_manifest(bundle: &Bundle) -> String {
|
||||
let mut doc = toml::Table::new();
|
||||
|
||||
let mut info = toml::Table::new();
|
||||
info.insert(
|
||||
"title".to_string(),
|
||||
toml::Value::String(bundle.info.title.clone()),
|
||||
);
|
||||
if !bundle.info.authors.is_empty() {
|
||||
let authors = bundle
|
||||
.info
|
||||
.authors
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(toml::Value::String)
|
||||
.collect();
|
||||
info.insert("author".to_string(), toml::Value::Array(authors));
|
||||
}
|
||||
doc.insert("info".to_string(), toml::Value::Table(info));
|
||||
|
||||
let lessons: Vec<toml::Value> = bundle
|
||||
.lessons
|
||||
.iter()
|
||||
.map(|bl| toml::Value::Table(bundle_lesson_table(bl)))
|
||||
.collect();
|
||||
doc.insert("lessons".to_string(), toml::Value::Array(lessons));
|
||||
|
||||
toml::to_string(&doc).expect("augmented bundle manifest serializes")
|
||||
}
|
||||
|
||||
/// Build one `[[lessons]]` entry's table: that member lesson's own `info`,
|
||||
/// its selected `target`, and its outline with every entry's `path` prefixed
|
||||
/// by the lesson's bundle-relative directory.
|
||||
fn bundle_lesson_table(bl: &BundleLesson) -> toml::Table {
|
||||
let mut t = toml::Table::new();
|
||||
t.insert(
|
||||
"info".to_string(),
|
||||
toml::Value::Table(info_table(&bl.lesson)),
|
||||
);
|
||||
t.insert("target".to_string(), toml::Value::String(bl.target.clone()));
|
||||
let outline: Vec<toml::Value> = bl
|
||||
.lesson
|
||||
.outline
|
||||
.iter()
|
||||
.map(|entry| toml::Value::Table(outline_entry_table(&bl.lesson, entry, Some(&bl.path))))
|
||||
.collect();
|
||||
t.insert("outline".to_string(), toml::Value::Array(outline));
|
||||
t
|
||||
}
|
||||
|
||||
/// Build the `[info]` table shared by a single-lesson manifest and a bundle
|
||||
/// member's `info` entry.
|
||||
fn info_table(lesson: &Lesson) -> toml::Table {
|
||||
let mut info = toml::Table::new();
|
||||
info.insert(
|
||||
"title".to_string(),
|
||||
@@ -52,30 +143,60 @@ pub fn build_augmented_manifest(lesson: &Lesson) -> String {
|
||||
.collect();
|
||||
info.insert("author".to_string(), toml::Value::Array(authors));
|
||||
}
|
||||
doc.insert("info".to_string(), toml::Value::Table(info));
|
||||
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(
|
||||
/// Build one `[[outline]]` entry's table for either variant of
|
||||
/// [`OutlineEntry`]. `bundle_prefix`, when set (ADR-0030's bundle case), is
|
||||
/// joined onto the emitted `path` so the bundle template's computed include
|
||||
/// resolves against the bundle root rather than the lesson's own root.
|
||||
fn outline_entry_table(
|
||||
lesson: &Lesson,
|
||||
entry: &OutlineEntry,
|
||||
bundle_prefix: Option<&Path>,
|
||||
) -> toml::Table {
|
||||
let mut e = toml::Table::new();
|
||||
match entry {
|
||||
OutlineEntry::Element { part_index } => {
|
||||
let part = &lesson.parts[*part_index];
|
||||
e.insert("type".to_string(), toml::Value::String("element".into()));
|
||||
e.insert("kind".to_string(), toml::Value::String(part.kind.clone()));
|
||||
e.insert(
|
||||
"path".to_string(),
|
||||
toml::Value::String(path_to_forward_slash(&part.path)),
|
||||
toml::Value::String(prefixed_forward_slash(bundle_prefix, &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));
|
||||
e.insert("fields".to_string(), toml::Value::Array(fields));
|
||||
}
|
||||
OutlineEntry::Section {
|
||||
kind,
|
||||
title,
|
||||
depth,
|
||||
path,
|
||||
} => {
|
||||
e.insert("type".to_string(), toml::Value::String("section".into()));
|
||||
e.insert("kind".to_string(), toml::Value::String(kind.clone()));
|
||||
e.insert("title".to_string(), toml::Value::String(title.clone()));
|
||||
e.insert("depth".to_string(), toml::Value::Integer(i64::from(*depth)));
|
||||
e.insert(
|
||||
"path".to_string(),
|
||||
toml::Value::String(prefixed_forward_slash(bundle_prefix, path)),
|
||||
);
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
toml::to_string(&doc).expect("augmented manifest serializes")
|
||||
/// [`path_to_forward_slash`], with `prefix` (a bundle member's own
|
||||
/// bundle-relative directory) joined in front when present.
|
||||
fn prefixed_forward_slash(prefix: Option<&Path>, path: &Path) -> String {
|
||||
match prefix {
|
||||
Some(p) => path_to_forward_slash(&p.join(path)),
|
||||
None => path_to_forward_slash(path),
|
||||
}
|
||||
}
|
||||
|
||||
/// The content fields of `part`'s kind whose `<root>/<part.path>/<field>.typ`
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Integration tests for the bundle build path (ADR-0030): compiling a bundle
|
||||
//! target's template (`exports/<target>.typ` under a `bundle.toml` root) as
|
||||
//! main, injecting the augmented **bundle** manifest, against the real
|
||||
//! `render/` package.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::Severity;
|
||||
use cph_typst::{build_augmented_bundle_manifest, Engine};
|
||||
|
||||
fn fixture_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bundle")
|
||||
}
|
||||
|
||||
fn real_render_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("render")
|
||||
}
|
||||
|
||||
fn load_bundle() -> cph_model::Bundle {
|
||||
let (bundle, diags) = cph_model::load_bundle(&fixture_root());
|
||||
let bundle = bundle.expect("bundle fixture loads into a Bundle");
|
||||
let errors: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.severity == Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "fixture has loader errors: {errors:?}");
|
||||
bundle
|
||||
}
|
||||
|
||||
/// PURE UNIT TEST (no fonts, no render package): the augmented bundle manifest
|
||||
/// carries the bundle's own `[info]` and an ordered `[[lessons]]`, each with
|
||||
/// that member's own `info`/`target` and a `path`-prefixed outline (ADR-0030).
|
||||
#[test]
|
||||
fn augmented_bundle_manifest_prefixes_member_paths() {
|
||||
let bundle = load_bundle();
|
||||
let src = build_augmented_bundle_manifest(&bundle);
|
||||
|
||||
assert!(
|
||||
src.contains("测试合集"),
|
||||
"bundle info.title present:\n{src}"
|
||||
);
|
||||
|
||||
let doc: toml::Value = toml::from_str(&src).expect("augmented bundle manifest is valid TOML");
|
||||
let lessons = doc
|
||||
.get("lessons")
|
||||
.and_then(|l| l.as_array())
|
||||
.expect("lessons array present");
|
||||
assert_eq!(lessons.len(), 2, "two member lessons:\n{src}");
|
||||
|
||||
// lesson-a: target defaults to its own first declared target ("student"),
|
||||
// outline paths are prefixed with "lesson-a/".
|
||||
let a_target = lessons[0].get("target").unwrap().as_str().unwrap();
|
||||
assert_eq!(a_target, "student");
|
||||
let a_outline = lessons[0].get("outline").unwrap().as_array().unwrap();
|
||||
// segment + section heading + lemma = 3 outline entries.
|
||||
assert_eq!(a_outline.len(), 3);
|
||||
let a_seg_path = a_outline[0].get("path").unwrap().as_str().unwrap();
|
||||
assert_eq!(a_seg_path, "lesson-a/segments/a");
|
||||
let a_section_path = a_outline[1].get("path").unwrap().as_str().unwrap();
|
||||
assert_eq!(a_section_path, "lesson-a/小节");
|
||||
let a_lemma_path = a_outline[2].get("path").unwrap().as_str().unwrap();
|
||||
assert_eq!(a_lemma_path, "lesson-a/小节/lemmas/引理甲");
|
||||
|
||||
// lesson-b: bundle.toml overrides `target = "teacher"`.
|
||||
let b_target = lessons[1].get("target").unwrap().as_str().unwrap();
|
||||
assert_eq!(b_target, "teacher");
|
||||
let b_outline = lessons[1].get("outline").unwrap().as_array().unwrap();
|
||||
assert_eq!(b_outline.len(), 1);
|
||||
let b_seg_path = b_outline[0].get("path").unwrap().as_str().unwrap();
|
||||
assert_eq!(b_seg_path, "lesson-b/segments/b");
|
||||
}
|
||||
|
||||
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling
|
||||
/// the bundle's `merged` target as main with the injected augmented bundle
|
||||
/// manifest is clean.
|
||||
#[test]
|
||||
fn compile_check_clean_through_bundle_template() {
|
||||
let bundle = load_bundle();
|
||||
let engine = Engine::with_render_dir(real_render_dir());
|
||||
let diags = engine.compile_check_bundle(&bundle, "merged");
|
||||
let errors: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.severity == Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "unexpected compile errors: {errors:#?}");
|
||||
}
|
||||
|
||||
/// THROUGH-TEMPLATE PDF export, fully offline: a non-trivial combined PDF is
|
||||
/// produced from the two member lessons through the real bundle template.
|
||||
#[test]
|
||||
fn build_bundle_pdf_through_template_offline() {
|
||||
let bundle = load_bundle();
|
||||
let engine = Engine::with_render_dir(real_render_dir());
|
||||
|
||||
let pdf = engine
|
||||
.build_bundle_pdf(&bundle, "merged")
|
||||
.unwrap_or_else(|d| panic!("bundle PDF build failed: {d:#?}"));
|
||||
assert!(pdf.starts_with(b"%PDF"), "output is a PDF");
|
||||
assert!(
|
||||
pdf.len() > 1024,
|
||||
"bundle PDF is non-trivial (got {} bytes)",
|
||||
pdf.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// An undeclared bundle target name is a blocking `SchemaViolation`, exactly
|
||||
/// like a lesson's own unknown-target path.
|
||||
#[test]
|
||||
fn unknown_bundle_target_is_blocking() {
|
||||
let bundle = load_bundle();
|
||||
let engine = Engine::with_render_dir(real_render_dir());
|
||||
let diags = engine.compile_check_bundle(&bundle, "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:#?}"
|
||||
);
|
||||
}
|
||||
@@ -39,10 +39,12 @@ fn load_mini() -> cph_model::Lesson {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// `[info]` and the ordered `[[outline]]` (ADR-0029) — elements (with a
|
||||
/// per-element `fields` array of the content fields present on disk)
|
||||
/// interleaved with the section heading the mini fixture nests its two lemmas
|
||||
/// under.
|
||||
#[test]
|
||||
fn augmented_manifest_has_per_part_fields() {
|
||||
fn augmented_manifest_has_outline_with_section_and_fields() {
|
||||
let lesson = load_mini();
|
||||
let src = build_augmented_manifest(&lesson);
|
||||
|
||||
@@ -50,66 +52,86 @@ fn augmented_manifest_has_per_part_fields() {
|
||||
assert!(src.contains("迷你示例课时"), "info.title present:\n{src}");
|
||||
assert!(src.contains("测试作者"), "info.author present:\n{src}");
|
||||
|
||||
// Parse it back to inspect the per-part fields precisely.
|
||||
// Parse it back to inspect the outline entries precisely.
|
||||
let doc: toml::Value = toml::from_str(&src).expect("augmented manifest is valid TOML");
|
||||
let parts = doc
|
||||
.get("parts")
|
||||
let outline = doc
|
||||
.get("outline")
|
||||
.and_then(|p| p.as_array())
|
||||
.expect("parts array present");
|
||||
assert_eq!(parts.len(), 4, "four parts in declared order:\n{src}");
|
||||
.expect("outline array present");
|
||||
// segment, section, lemma, lemma, example — 5 entries (ADR-0029: the
|
||||
// section contributes a heading entry, not an element).
|
||||
assert_eq!(outline.len(), 5, "five outline entries:\n{src}");
|
||||
|
||||
// `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")
|
||||
let entry_type = |idx: usize| {
|
||||
outline[idx]
|
||||
.get("type")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
let kind_of = |idx: usize| {
|
||||
parts[idx]
|
||||
outline[idx]
|
||||
.get("kind")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
let path_of = |idx: usize| {
|
||||
outline[idx]
|
||||
.get("path")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
// `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> = outline[idx]
|
||||
.get("fields")
|
||||
.and_then(|f| f.as_array())
|
||||
.expect("element entry has a fields array")
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
|
||||
// Order preserved: segment, lemma (w/ proof), lemma (no proof), example.
|
||||
// Order preserved: segment, section (引理组), lemma (w/ proof), lemma (no
|
||||
// proof), example.
|
||||
assert_eq!(entry_type(0), "element");
|
||||
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"]);
|
||||
|
||||
assert_eq!(entry_type(1), "section");
|
||||
assert_eq!(outline[1].get("title").unwrap().as_str().unwrap(), "引理组");
|
||||
assert_eq!(outline[1].get("depth").unwrap().as_integer().unwrap(), 1);
|
||||
assert_eq!(path_of(1), "引理组");
|
||||
|
||||
assert_eq!(entry_type(2), "element");
|
||||
assert_eq!(kind_of(2), "lemma");
|
||||
assert_eq!(path_of(2), "引理组/lemmas/量纲分析估计");
|
||||
// lemma WITH proof.typ: both stmt + proof present (sorted).
|
||||
assert_eq!(fields_of(1), vec!["proof", "stmt"]);
|
||||
assert_eq!(fields_of(2), vec!["proof", "stmt"]);
|
||||
|
||||
assert_eq!(entry_type(3), "element");
|
||||
assert_eq!(kind_of(3), "lemma");
|
||||
assert_eq!(path_of(3), "引理组/lemmas/无证明引理");
|
||||
// lemma WITHOUT proof.typ: only stmt present (the OPTIONAL-content path).
|
||||
assert_eq!(
|
||||
fields_of(2),
|
||||
fields_of(3),
|
||||
vec!["stmt"],
|
||||
"proof must be omitted when absent"
|
||||
);
|
||||
|
||||
assert_eq!(entry_type(4), "element");
|
||||
assert_eq!(kind_of(4), "example");
|
||||
// example: problem + solution present (source is a scalar, not a content field).
|
||||
assert_eq!(fields_of(3), vec!["problem", "solution"]);
|
||||
assert_eq!(fields_of(4), vec!["problem", "solution"]);
|
||||
}
|
||||
|
||||
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the
|
||||
@@ -202,6 +224,7 @@ fn file_tree_artifact_is_deferred() {
|
||||
authors: vec![],
|
||||
},
|
||||
parts: vec![],
|
||||
outline: vec![],
|
||||
targets: vec![TargetConfig {
|
||||
name: "web".into(),
|
||||
artifact: Artifact::FileTree {
|
||||
@@ -241,6 +264,7 @@ fn shell_only_target_is_deferred() {
|
||||
authors: vec![],
|
||||
},
|
||||
parts: vec![],
|
||||
outline: vec![],
|
||||
targets: vec![TargetConfig {
|
||||
name: "packaged".into(),
|
||||
artifact: Artifact::SingleFile {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[info]
|
||||
title = "测试合集"
|
||||
author = "测试作者"
|
||||
|
||||
[[lessons]]
|
||||
path = "lesson-a"
|
||||
|
||||
[[lessons]]
|
||||
path = "lesson-b"
|
||||
target = "teacher"
|
||||
|
||||
[targets.merged]
|
||||
artifact = { type = "single-file", filepath = "build/merged.pdf" }
|
||||
[[targets.merged.steps]]
|
||||
type = "typst-compile"
|
||||
template = "exports/merged.typ"
|
||||
@@ -0,0 +1,76 @@
|
||||
// DEFAULT BUNDLE TEMPLATE (ADR-0030, outline shape ADR-0029).
|
||||
//
|
||||
// Lives in a bundle at `<bundle-root>/exports/<target>.typ`, e.g.
|
||||
// `exports/merged.typ`. Compiled AS MAIN with the augmented BUNDLE manifest
|
||||
// injected:
|
||||
// typst compile --root <bundle-root> --input manifest=<path-rel-to-root> exports/merged.typ <out>
|
||||
//
|
||||
// Structurally identical to the single-lesson `student.typ`/`teacher.typ`
|
||||
// templates (see their notes on why the include loop lives in the template,
|
||||
// not in cph-render), except it reads `manifest.lessons` (an ordered array of
|
||||
// per-lesson `(info, target, outline)` tables — see
|
||||
// `cph_typst::build_augmented_bundle_manifest`) instead of a single
|
||||
// `manifest.outline`, and calls `render-bundle` instead of `render-lesson`.
|
||||
//
|
||||
// Every outline entry's `path` in a bundle manifest is ALREADY prefixed with
|
||||
// that lesson's own bundle-root-relative directory (done by the Rust engine),
|
||||
// so the same `include "/" + path + "/" + field + ".typ"` computation used by
|
||||
// a single-lesson template resolves correctly here too — no special-casing
|
||||
// needed in this loop.
|
||||
|
||||
#import "@local/cph-render:0.1.0": render-bundle, part-fields, default-heading-numbering
|
||||
|
||||
#let manifest = toml(sys.inputs.manifest)
|
||||
#let info = manifest.at("info", default: (:))
|
||||
#let raw-lessons = manifest.at("lessons", default: ())
|
||||
|
||||
// Assemble one outline entry exactly as a single-lesson template would.
|
||||
#let assemble-entry(raw) = {
|
||||
if raw.at("type", default: "element") == "section" {
|
||||
(
|
||||
entry-type: "section",
|
||||
kind: raw.at("kind", default: none),
|
||||
title: raw.at("title", default: ""),
|
||||
depth: raw.at("depth", default: 1),
|
||||
)
|
||||
} else {
|
||||
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: ()))
|
||||
let present = raw.at("fields", default: ())
|
||||
let entry = (entry-type: "element", kind: kind)
|
||||
|
||||
for field in spec.content {
|
||||
entry.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
}
|
||||
for field in spec.optional-content {
|
||||
if field in present {
|
||||
entry.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
}
|
||||
}
|
||||
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 != "" { entry.insert(field, v) }
|
||||
}
|
||||
}
|
||||
entry
|
||||
}
|
||||
}
|
||||
|
||||
#let lessons = raw-lessons.map(raw => (
|
||||
info: raw.at("info", default: (:)),
|
||||
target: raw.at("target", default: "student"),
|
||||
outline: raw.at("outline", default: ()).map(assemble-entry),
|
||||
))
|
||||
|
||||
// Presentation: shared per-level heading numbering across the whole bundle,
|
||||
// and the ADR-0030 recommended default of resetting auto-counters at each
|
||||
// lesson boundary (override `reset-counters: false` for continuous numbering).
|
||||
#render-bundle(
|
||||
info: info,
|
||||
lessons: lessons,
|
||||
heading-numbering: default-heading-numbering,
|
||||
reset-counters: true,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
[project]
|
||||
id = "lesson-a"
|
||||
name = "lesson-a"
|
||||
|
||||
[info]
|
||||
title = "课时A"
|
||||
author = "作者A"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/a"
|
||||
|
||||
[[children]]
|
||||
kind = "section"
|
||||
path = "小节"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
= 课时A导言
|
||||
@@ -0,0 +1 @@
|
||||
引理甲陈述。
|
||||
@@ -0,0 +1,6 @@
|
||||
[group]
|
||||
title = "小节"
|
||||
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/引理甲"
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "lesson-b"
|
||||
name = "lesson-b"
|
||||
|
||||
[info]
|
||||
title = "课时B"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/b"
|
||||
|
||||
[targets.teacher]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
= 课时B导言
|
||||
+46
-32
@@ -1,4 +1,4 @@
|
||||
// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011).
|
||||
// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0029).
|
||||
//
|
||||
// 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
|
||||
@@ -15,15 +15,16 @@
|
||||
// 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.
|
||||
// template loads content and hands cph-render an already-assembled `outline`
|
||||
// array (elements interleaved with section headings, ADR-0029).
|
||||
//
|
||||
// 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
|
||||
// via a per-element `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
|
||||
// unconditionally; optional fields load only if listed in `fields`. If an element
|
||||
// omits `fields`, optional content is skipped (conservative). The exact shape of
|
||||
// this declaration is for the manifest/Rust contract to pin.
|
||||
|
||||
@@ -35,38 +36,51 @@
|
||||
// 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: ())
|
||||
#let raw-outline = manifest.at("outline", 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)
|
||||
// Assemble each outline entry:
|
||||
// - an "element" entry: 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.
|
||||
// - a "section" entry (ADR-0029): pass its title/depth straight through — no
|
||||
// content to load, it is a heading.
|
||||
#let outline = raw-outline.map(raw => {
|
||||
if raw.at("type", default: "element") == "section" {
|
||||
(
|
||||
entry-type: "section",
|
||||
kind: raw.at("kind", default: none),
|
||||
title: raw.at("title", default: ""),
|
||||
depth: raw.at("depth", default: 1),
|
||||
)
|
||||
} else {
|
||||
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 entry = (entry-type: "element", 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")
|
||||
// Required content fields: <path>/<field>.typ (absolute, root-relative).
|
||||
for field in spec.content {
|
||||
entry.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) }
|
||||
// Optional content fields: only when the manifest says the file exists.
|
||||
for field in spec.optional-content {
|
||||
if field in present {
|
||||
entry.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 != "" { entry.insert(field, v) }
|
||||
}
|
||||
}
|
||||
entry
|
||||
}
|
||||
part
|
||||
})
|
||||
|
||||
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
|
||||
@@ -74,6 +88,6 @@
|
||||
#render-lesson(
|
||||
info: info,
|
||||
target: target,
|
||||
parts: parts,
|
||||
outline: outline,
|
||||
heading-numbering: default-heading-numbering,
|
||||
)
|
||||
|
||||
+38
-29
@@ -1,4 +1,4 @@
|
||||
// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011).
|
||||
// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0029).
|
||||
//
|
||||
// Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with
|
||||
// the manifest injected:
|
||||
@@ -18,38 +18,47 @@
|
||||
// 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: ())
|
||||
#let raw-outline = manifest.at("outline", 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)
|
||||
// Assemble each outline entry: an "element" entry includes its content fields
|
||||
// and reads scalars from element.toml; a "section" entry (ADR-0029) passes
|
||||
// title/depth straight through as a heading, no content to load.
|
||||
#let outline = raw-outline.map(raw => {
|
||||
if raw.at("type", default: "element") == "section" {
|
||||
(
|
||||
entry-type: "section",
|
||||
kind: raw.at("kind", default: none),
|
||||
title: raw.at("title", default: ""),
|
||||
depth: raw.at("depth", default: 1),
|
||||
)
|
||||
} else {
|
||||
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 entry = (entry-type: "element", 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")
|
||||
// Required content fields: <path>/<field>.typ (absolute, root-relative).
|
||||
for field in spec.content {
|
||||
entry.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) }
|
||||
// Optional content fields: only when the manifest says the file exists.
|
||||
for field in spec.optional-content {
|
||||
if field in present {
|
||||
entry.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 != "" { entry.insert(field, v) }
|
||||
}
|
||||
}
|
||||
entry
|
||||
}
|
||||
part
|
||||
})
|
||||
|
||||
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
|
||||
@@ -57,6 +66,6 @@
|
||||
#render-lesson(
|
||||
info: info,
|
||||
target: target,
|
||||
parts: parts,
|
||||
outline: outline,
|
||||
heading-numbering: default-heading-numbering,
|
||||
)
|
||||
|
||||
+5
-9
@@ -6,19 +6,15 @@ name = "迷你课时"
|
||||
title = "迷你示例课时"
|
||||
author = "测试作者"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/开场对照导言"
|
||||
|
||||
[[parts]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/量纲分析估计"
|
||||
[[children]]
|
||||
kind = "section"
|
||||
path = "引理组"
|
||||
|
||||
[[parts]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/无证明引理"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "example"
|
||||
path = "examples/自由落体"
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
kind = "lemma"
|
||||
@@ -0,0 +1,10 @@
|
||||
[group]
|
||||
title = "引理组"
|
||||
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/量纲分析估计"
|
||||
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/无证明引理"
|
||||
Reference in New Issue
Block a user