//! Loading a Typst file and splicing generated data into it. //! //! The previous version of this module built a document with `format!`. That made //! every layout decision a code change, which is the wrong place for a decision //! about where the points label sits. So the tool no longer writes documents; it //! writes *data into* a document you own. //! //! ## Markers //! //! A template marks its injection points with Typst line comments, which means a //! template is a valid `.typ` file that compiles on its own and can be styled //! without this tool in the loop: //! //! ```typst //! // coursebank:begin questions //! #render-question((number: 1, stem: [Sample.], options: ())) //! // coursebank:end questions //! ``` //! //! Everything between the `begin` and `end` lines is replaced; the marker lines //! themselves survive. That has two consequences worth stating plainly: //! //! * The bundled templates ship with sample data inside their regions, so //! `typst compile templates/exam.typ` works before any export has happened. //! * An exported document is itself a valid template. Re-exporting into a file you //! have since restyled replaces the questions and leaves your edits alone, which //! is the difference between a generator you can use twice and one you copy out //! of once. //! //! A bare `// coursebank:questions` with no region also works. It is rewritten //! into a region on output, so the second export behaves like every subsequent one. //! //! ## Slots //! //! | Slot | Injected | //! |:--|:--| //! | `meta` | `#let cb-meta = (...)` — course, assessment, form, totals | //! | `questions` | one `#render-question((...))` call per printed item | //! | `data` | `#let cb-data = (...)` — metadata and questions together | //! //! `questions` unrolls the loop with the record's own numbering. `data` hands you //! the array and gets out of the way. Templates are free to use either, both, or //! neither; only slots the template actually contains are rendered, so nothing //! costs anything until it is asked for. //! //! ## Lookup order //! //! 1. an explicit `--template` path, or `template:` in the render config //! 2. `templates/-.typ`, for a one-off layout //! 3. `templates/.typ`, the course's own default //! 4. the template compiled into this binary //! //! `coursebank template dump` writes step 4 into step 3 so that customizing means //! editing a file rather than reading this documentation. use std::path::{Path, PathBuf}; use crate::Layout; use crate::error::{Error, Result}; use super::config::Variant; /// The prefix every marker comment carries. const MARKER: &str = "coursebank:"; /// The bundled exam paper template. const EMBEDDED_EXAM: &str = include_str!("templates/exam.typ"); /// The bundled answer key template. const EMBEDDED_KEY: &str = include_str!("templates/key.typ"); /// The bundled answer sheet template. const EMBEDDED_ANSWER_SHEET: &str = include_str!("templates/answer-sheet.typ"); /// An injection point a template can declare. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Slot { /// Course, assessment, form, and totals, as a `#let` binding. Meta, /// One call per printed item. Questions, /// Metadata and questions together, as a `#let` binding. Data, } impl Slot { /// Every slot. pub const ALL: [Slot; 3] = [Slot::Meta, Slot::Questions, Slot::Data]; /// The name used in a marker comment. pub fn as_str(self) -> &'static str { match self { Slot::Meta => "meta", Slot::Questions => "questions", Slot::Data => "data", } } /// The slot for a marker name. fn parse(s: &str) -> Option { Slot::ALL.iter().copied().find(|slot| slot.as_str() == s) } /// A comma-separated list of every slot name, for error messages. fn names() -> String { Slot::ALL .iter() .map(|s| s.as_str()) .collect::>() .join(", ") } } /// Where a template came from. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Origin { /// Compiled into the binary. Embedded, /// Read from disk. File(PathBuf), } impl std::fmt::Display for Origin { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Origin::Embedded => f.write_str("built-in"), Origin::File(path) => write!(f, "{}", path.display()), } } } /// A loaded template, with its markers already located. #[derive(Debug, Clone)] pub struct Template { /// Which document this template produces. pub variant: Variant, /// Where it was loaded from. pub origin: Origin, /// The full source. pub source: String, /// The regions found, in the order they appear. regions: Vec, } /// One located marker, as a half-open line range to replace. #[derive(Debug, Clone)] struct Region { /// Which slot. slot: Slot, /// The marker line's leading whitespace, reapplied to every injected line so /// data nested inside a Typst block stays readable. indent: String, /// First line index of the marker, which is the `begin` line for a region. start: usize, /// One past the `end` line index, or one past a bare point marker. end: usize, } /// The source of the template compiled in for a variant. /// /// # Arguments /// /// * `variant` - which document. /// /// # Returns /// /// The bundled template source. pub fn embedded(variant: Variant) -> &'static str { match variant { Variant::Exam => EMBEDDED_EXAM, Variant::Key => EMBEDDED_KEY, Variant::AnswerSheet => EMBEDDED_ANSWER_SHEET, } } /// The directory holding a course's template overrides. /// /// # Arguments /// /// * `layout` - the course layout. /// /// # Returns /// /// The `templates/` directory, which need not exist. pub fn dir(layout: &Layout) -> PathBuf { layout.templates() } /// The paths that are consulted for a variant, in order. /// /// Exposed so `coursebank template list` can show where a template would be found /// and why, rather than leaving the lookup order to be inferred. /// /// # Arguments /// /// * `layout` - the course layout. /// * `variant` - which document. /// * `assessment_id` - the assessment being exported, if one is in hand. /// /// # Returns /// /// Candidate paths, most specific first. pub fn candidates(layout: &Layout, variant: Variant, assessment_id: Option<&str>) -> Vec { let base = dir(layout); let mut paths = Vec::new(); if let Some(id) = assessment_id { paths.push(base.join(format!("{id}-{}", variant.template_file()))); } paths.push(base.join(variant.template_file())); paths } /// Loads the template for a variant. /// /// # Arguments /// /// * `layout` - the course layout. /// * `variant` - which document. /// * `assessment_id` - the assessment being exported, if one is in hand. /// * `explicit` - a path that overrides the lookup entirely. /// /// # Returns /// /// The loaded template. /// /// # Errors /// /// Returns [`Error::Io`] when an explicitly requested template cannot be read, and /// [`Error::Invalid`] when the template's markers are malformed. A missing file in /// the lookup chain is not an error; it just falls through to the next candidate. pub fn load( layout: &Layout, variant: Variant, assessment_id: Option<&str>, explicit: Option<&Path>, ) -> Result