feat: improve typst handling

This commit is contained in:
2026-08-06 16:45:50 -04:00
parent 09c3222f8d
commit 07e3131f17
19 changed files with 4148 additions and 416 deletions
+743
View File
@@ -0,0 +1,743 @@
//! 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/<assessment-id>-<variant>.typ`, for a one-off layout
//! 3. `templates/<variant>.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::course::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> {
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::<Vec<_>>()
.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<Region>,
}
/// 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<PathBuf> {
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<Template> {
if let Some(path) = explicit {
let source = std::fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
return Template::parse(variant, Origin::File(path.to_path_buf()), source);
}
for path in candidates(layout, variant, assessment_id) {
if path.is_file() {
let source = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
return Template::parse(variant, Origin::File(path), source);
}
}
Template::parse(variant, Origin::Embedded, embedded(variant).to_string())
}
impl Template {
/// Parses a template, locating its markers.
///
/// # Arguments
///
/// * `variant` - which document.
/// * `origin` - where the source came from.
/// * `source` - the template source.
///
/// # Returns
///
/// The parsed template.
///
/// # Errors
///
/// Returns [`Error::Invalid`] listing every marker problem at once: an unknown
/// slot name, a `begin` with no `end`, an `end` with no `begin`, a nested
/// region, or the same slot claimed twice.
pub fn parse(variant: Variant, origin: Origin, source: String) -> Result<Template> {
let lines: Vec<&str> = source.lines().collect();
let mut regions: Vec<Region> = Vec::new();
let mut issues: Vec<String> = Vec::new();
let mut open: Option<(Slot, String, usize)> = None;
for (index, line) in lines.iter().enumerate() {
let Some(marker) = parse_marker(line) else {
continue;
};
let human = index + 1;
match marker.kind {
MarkerKind::Begin => {
if let Some((slot, _, at)) = &open {
issues.push(format!(
"line {human}: `begin {}` opens inside the region `{}` opened on line \
{}; regions cannot nest",
marker.name,
slot.as_str(),
at + 1
));
continue;
}
match Slot::parse(&marker.name) {
Some(slot) => open = Some((slot, marker.indent, index)),
None => issues.push(unknown_slot(human, &marker.name)),
}
}
MarkerKind::End => match open.take() {
Some((slot, indent, start)) => {
if slot.as_str() != marker.name {
issues.push(format!(
"line {human}: `end {}` closes the region `{}` opened on line \
{}",
marker.name,
slot.as_str(),
start + 1
));
}
regions.push(Region {
slot,
indent,
start,
end: index + 1,
});
}
None => issues.push(format!(
"line {human}: `end {}` has no matching `begin`",
marker.name
)),
},
MarkerKind::Point => {
if open.is_some() {
// A point marker inside a region would be overwritten by
// the region's own injection, so it is a mistake worth
// naming rather than silently dropping.
issues.push(format!(
"line {human}: the marker `{}` sits inside an open region and would be \
overwritten",
marker.name
));
continue;
}
match Slot::parse(&marker.name) {
Some(slot) => regions.push(Region {
slot,
indent: marker.indent,
start: index,
end: index + 1,
}),
None => issues.push(unknown_slot(human, &marker.name)),
}
}
}
}
if let Some((slot, _, start)) = open {
issues.push(format!(
"line {}: the region `{}` is never closed; add `// coursebank:end {}`",
start + 1,
slot.as_str(),
slot.as_str()
));
}
for slot in Slot::ALL {
let count = regions.iter().filter(|r| r.slot == slot).count();
if count > 1 {
issues.push(format!(
"the slot `{}` appears {count} times; each slot may be filled once",
slot.as_str()
));
}
}
if !issues.is_empty() {
issues.insert(0, format!("in the Typst template {origin}:"));
return Err(Error::Invalid(issues));
}
Ok(Template {
variant,
origin,
source,
regions,
})
}
/// Whether the template asks for a slot.
///
/// # Arguments
///
/// * `slot` - the slot to look for.
pub fn wants(&self, slot: Slot) -> bool {
self.regions.iter().any(|r| r.slot == slot)
}
/// The slots this template declares, in the order they appear.
pub fn slots(&self) -> Vec<Slot> {
self.regions.iter().map(|r| r.slot).collect()
}
/// Whether the template declares no markers at all.
pub fn is_inert(&self) -> bool {
self.regions.is_empty()
}
/// Renders the template with the given slot bodies.
///
/// # Arguments
///
/// * `bodies` - the Typst source to inject, by slot. A slot the template does
/// not declare is ignored; a slot the template declares but that has no body
/// is emitted as an empty region.
///
/// # Returns
///
/// The finished document. Every injected region is delimited by `begin`/`end`
/// markers, including regions that came from a bare point marker, so the
/// output can be used as the template for the next export.
pub fn render(&self, bodies: &[(Slot, String)]) -> String {
let lines: Vec<&str> = self.source.lines().collect();
let mut out = String::with_capacity(self.source.len() + 4096);
let mut cursor = 0usize;
// `parse` produced regions in source order and rejected overlaps, so a
// single forward pass is enough.
for region in &self.regions {
for line in &lines[cursor..region.start] {
out.push_str(line);
out.push('\n');
}
let body = bodies
.iter()
.find(|(slot, _)| *slot == region.slot)
.map(|(_, body)| body.as_str())
.unwrap_or("");
let name = region.slot.as_str();
let indent = region.indent.as_str();
out.push_str(indent);
out.push_str("// ");
out.push_str(MARKER);
out.push_str("begin ");
out.push_str(name);
out.push('\n');
for line in body.lines() {
if line.trim().is_empty() {
out.push('\n');
} else {
out.push_str(indent);
out.push_str(line);
out.push('\n');
}
}
out.push_str(indent);
out.push_str("// ");
out.push_str(MARKER);
out.push_str("end ");
out.push_str(name);
out.push('\n');
// Everything from the opening marker through the closing one has now
// been rewritten, so resume after it.
cursor = region.end;
}
for line in &lines[cursor..] {
out.push_str(line);
out.push('\n');
}
out
}
}
/// Writes the bundled templates into a directory.
///
/// # Arguments
///
/// * `dir` - the destination directory, created if absent.
/// * `variants` - which templates to write.
/// * `force` - whether to overwrite files that already exist.
///
/// # Returns
///
/// The paths written, and the paths skipped because they already existed.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory or a file cannot be written.
pub fn dump(dir: &Path, variants: &[Variant], force: bool) -> Result<(Vec<PathBuf>, Vec<PathBuf>)> {
std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
let mut written = Vec::new();
let mut skipped = Vec::new();
for variant in variants {
let path = dir.join(variant.template_file());
if path.exists() && !force {
// These are hand-edited files. Clobbering one is the sort of thing you
// discover after you have already lost the edit.
skipped.push(path);
continue;
}
crate::yaml::write_text(&path, embedded(*variant))?;
written.push(path);
}
Ok((written, skipped))
}
/// A marker comment, parsed.
struct Marker {
kind: MarkerKind,
name: String,
indent: String,
}
/// What a marker comment does.
enum MarkerKind {
/// Opens a replaceable region.
Begin,
/// Closes one.
End,
/// A standalone insertion point.
Point,
}
/// Parses one line as a marker comment, if it is one.
///
/// Recognized forms, with any leading whitespace and any run of `/` accepted:
///
/// ```text
/// // coursebank:questions
/// // coursebank:begin questions
/// // coursebank:end questions
/// ```
///
/// `begin`/`end` may also be spelled with a colon (`coursebank:begin:questions`),
/// because that is how people guess it.
fn parse_marker(line: &str) -> Option<Marker> {
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
let rest = line[indent.len()..].trim_end();
let rest = rest
.strip_prefix("//")?
.trim_start_matches('/')
.trim_start();
let rest = rest.strip_prefix(MARKER)?.trim();
if rest.is_empty() {
return None;
}
let (kind, name) = if let Some(name) = strip_word(rest, "begin") {
(MarkerKind::Begin, name)
} else if let Some(name) = strip_word(rest, "end") {
(MarkerKind::End, name)
} else {
(MarkerKind::Point, rest)
};
Some(Marker {
kind,
name: name.trim().to_ascii_lowercase(),
indent,
})
}
/// Strips a leading `begin`/`end` keyword, separated by whitespace or a colon.
fn strip_word<'a>(s: &'a str, word: &str) -> Option<&'a str> {
let rest = s.strip_prefix(word)?;
match rest.chars().next() {
Some(c) if c.is_whitespace() || c == ':' => Some(rest[c.len_utf8()..].trim_start()),
// `begin` alone, with no slot named.
None => Some(""),
// `beginning`, which is a slot name that happens to start with `begin`.
Some(_) => None,
}
}
/// The message for a marker naming a slot that does not exist.
fn unknown_slot(line: usize, name: &str) -> String {
format!(
"line {line}: unknown slot `{name}`; expected one of {}",
Slot::names()
)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(source: &str) -> Result<Template> {
Template::parse(Variant::Exam, Origin::Embedded, source.to_string())
}
#[test]
fn a_point_marker_is_replaced_and_becomes_a_region() {
// The second export has to behave like every one after it, so a point
// marker is rewritten into a region on the way out.
let template = parse("before\n// coursebank:questions\nafter\n").unwrap();
let out = template.render(&[(Slot::Questions, "#q(1)".to_string())]);
assert!(out.contains("before\n"));
assert!(out.contains("// coursebank:begin questions\n#q(1)\n"));
assert!(out.contains("// coursebank:end questions\n"));
assert!(out.contains("after\n"));
assert!(!out.contains("#q(1)\n#q(1)"));
}
#[test]
fn a_region_replaces_its_body_and_keeps_its_markers() {
let template = parse(
"head\n// coursebank:begin questions\nstale sample data\nmore stale\n// \
coursebank:end questions\ntail\n",
)
.unwrap();
let out = template.render(&[(Slot::Questions, "#q(1)".to_string())]);
assert!(!out.contains("stale"), "old body survived: {out}");
assert!(out.contains("#q(1)"));
assert!(out.contains("head\n"));
assert!(out.contains("tail\n"));
}
#[test]
fn rendering_is_idempotent() {
// Exporting into a file that was itself exported must replace the
// questions and leave the surrounding edits alone. This is the property
// that makes "restyle the output, then re-export" a workable habit.
let template = parse("// coursebank:questions\n").unwrap();
let first = template.render(&[(Slot::Questions, "#q(1)".to_string())]);
let again = parse(&first).unwrap();
let second = again.render(&[(Slot::Questions, "#q(2)".to_string())]);
assert!(second.contains("#q(2)"));
assert!(!second.contains("#q(1)"));
assert_eq!(second.matches("coursebank:begin questions").count(), 1);
assert_eq!(second.matches("coursebank:end questions").count(), 1);
}
#[test]
fn indentation_is_reapplied_to_injected_lines() {
let template = parse("#block[\n // coursebank:questions\n]\n").unwrap();
let out = template.render(&[(Slot::Questions, "#q(\n 1,\n)".to_string())]);
assert!(out.contains(" // coursebank:begin questions"), "{out}");
assert!(out.contains(" #q("), "{out}");
assert!(out.contains(" 1,"), "{out}");
}
#[test]
fn only_declared_slots_are_reported() {
let template = parse("// coursebank:meta\n// coursebank:questions\n").unwrap();
assert!(template.wants(Slot::Meta));
assert!(template.wants(Slot::Questions));
assert!(!template.wants(Slot::Data));
assert_eq!(template.slots(), vec![Slot::Meta, Slot::Questions]);
}
#[test]
fn a_template_with_no_markers_is_reported_as_inert() {
// Not an error: someone may want a fully hand-written paper. But the CLI
// warns, because silently writing a document with no questions in it is
// not a good afternoon.
let template = parse("#set page(paper: \"us-letter\")\n").unwrap();
assert!(template.is_inert());
}
#[test]
fn an_unknown_slot_names_the_valid_ones() {
let err = parse("// coursebank:qustions\n").unwrap_err();
let message = err.to_string();
assert!(message.contains("unknown slot `qustions`"), "{message}");
assert!(message.contains("questions"), "{message}");
}
#[test]
fn unbalanced_regions_are_reported_with_line_numbers() {
let err = parse("a\n// coursebank:begin questions\nb\n").unwrap_err();
assert!(err.to_string().contains("never closed"));
let err = parse("// coursebank:end questions\n").unwrap_err();
assert!(err.to_string().contains("no matching `begin`"));
}
#[test]
fn a_duplicated_slot_is_an_error() {
let err = parse("// coursebank:questions\n// coursebank:questions\n").unwrap_err();
assert!(err.to_string().contains("appears 2 times"));
}
#[test]
fn mismatched_region_names_are_reported() {
let err = parse("// coursebank:begin questions\n// coursebank:end meta\n").unwrap_err();
assert!(err.to_string().contains("closes the region"));
}
#[test]
fn every_problem_is_reported_in_one_pass() {
let err = parse("// coursebank:nope\n// coursebank:end data\n").unwrap_err();
let message = err.to_string();
assert!(message.contains("unknown slot"), "{message}");
assert!(message.contains("no matching `begin`"), "{message}");
}
#[test]
fn marker_spelling_is_forgiving() {
assert!(parse("// coursebank:begin:questions\n// coursebank:end:questions\n").is_ok());
assert!(parse(" // coursebank: questions\n").is_ok());
assert!(parse("/// coursebank:questions\n").is_ok());
assert!(parse("// coursebank:QUESTIONS\n").is_ok());
}
#[test]
fn ordinary_comments_are_left_alone() {
assert!(parse("// nothing to see\n// coursebank\n")
.unwrap()
.is_inert());
// A word that merely starts with `begin` is a slot name, not a keyword.
let err = parse("// coursebank:beginning\n").unwrap_err();
assert!(err.to_string().contains("unknown slot `beginning`"));
}
#[test]
fn a_missing_body_leaves_an_empty_region() {
let template = parse("// coursebank:questions\n").unwrap();
let out = template.render(&[]);
assert!(out.contains("// coursebank:begin questions"));
assert!(out.contains("// coursebank:end questions"));
}
#[test]
fn every_bundled_template_parses_and_declares_slots() {
for variant in Variant::ALL {
let template =
Template::parse(variant, Origin::Embedded, embedded(variant).to_string())
.unwrap_or_else(|e| panic!("bundled {} template: {e}", variant.as_str()));
assert!(
!template.is_inert(),
"the bundled {} template declares no slots",
variant.as_str()
);
}
}
#[test]
fn the_lookup_order_is_most_specific_first() {
let layout = Layout::new("/course");
let paths = candidates(&layout, Variant::Key, Some("exam-2"));
assert_eq!(paths.len(), 2);
assert!(paths[0].ends_with("templates/exam-2-key.typ"));
assert!(paths[1].ends_with("templates/key.typ"));
}
}