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
+3 -3
View File
@@ -18,14 +18,14 @@ pkg-config = "*"
build = "cargo build --release"
debug = "cargo build"
tests = "cargo test"
fmt = "cargo fmt"
format = "cargo fmt"
lint = { cmd = "cargo clippy --all-targets -- -D warnings", depends-on = [
"fmt",
"format",
] }
doc = "cargo doc --no-deps --open"
clean = "cargo clean"
install = "cargo install --path . --locked"
check = { depends-on = ["fmt", "lint", "tests"] }
check = { depends-on = ["format", "lint", "tests"] }
build-lean = "cargo build --release --no-default-features"
[tasks.cb]
+1 -1
View File
@@ -17,7 +17,7 @@
//! contain those routinely.
//!
//! [`students`] turns item statistics into per-objective standing, and is careful
//! about what three questions can honestly support it classifies on the observed
//! about what three questions can honestly support: it classifies on the observed
//! rate and reports confidence from the Wilson interval separately.
//!
//! [`calibrate`] is the arrow back to authoring, and the reason the system
+20 -6
View File
@@ -881,6 +881,16 @@ mod tests {
}
/// Twelve students; item 1 discriminates, item 2 is keyed backwards.
///
/// Item 2 is only *partly* reversed, and that is load-bearing rather than
/// sloppy. The corrected point-biserial scores each item against the total of
/// the *other* items, and with four items — one of them unanimous — item 2 is
/// most of item 1's rest score. Make item 2 an exact complement of item 1 and
/// `item1 + item2 == 1` for every student, so the total collapses to
/// `2 + item4`, carries no ability signal at all, and both items come out at
/// r = -0.71. The fixture then contradicts itself: the item it calls good is
/// flagged for negative discrimination, and the reversed key is negative only
/// because it mirrors item 1 rather than because it is miskeyed.
fn sample() -> ResponseSet {
let mut set = ResponseSet::new();
for i in 0..12 {
@@ -893,21 +903,25 @@ mod tests {
if strong { "A" } else { "B" },
if strong { 1.0 } else { 0.0 },
));
// Item 2: reversed, which is what a keying error looks like.
// Item 2: weaker students do better on it, which is what a keying
// error looks like. Half the strong group and two thirds of the weak
// group get it, so it is reversed without mirroring item 1.
let missed = if strong { i < 3 } else { i < 8 };
set.rows.push(resp(
&s,
2,
if strong { "C" } else { "D" },
if strong { 0.0 } else { 1.0 },
if missed { "C" } else { "D" },
if missed { 0.0 } else { 1.0 },
));
// Item 3: everyone correct.
set.rows.push(resp(&s, 3, "A", 1.0));
// Item 4: gives the totals some spread.
// Item 4: a second discriminating item, so that removing any one item
// still leaves a rest score that tracks ability.
set.rows.push(resp(
&s,
4,
if i % 2 == 0 { "A" } else { "B" },
if i % 2 == 0 { 1.0 } else { 0.0 },
if strong { "A" } else { "B" },
if strong { 1.0 } else { 0.0 },
));
}
set
+1 -1
View File
@@ -1220,7 +1220,7 @@ stem: Which mechanism best explains the sigmoidal binding curve?
options:
- { id: A, text: Ligand binding shifts the tetramer to a higher-affinity state, correct: true }
- { id: B, text: Each subunit binds with the same fixed affinity throughout }
- { id: C, text: Ligand is consumed as it binds, depleting the available pool }
- { id: C, text: "Ligand is consumed as it binds, depleting the available pool" }
- { id: D, text: The heme iron changes oxidation state upon binding }
"#,
);
+283 -353
View File
@@ -5,40 +5,78 @@
//! iterate on. `pixi run -e docs typst compile` turns the output of this module
//! into a PDF.
//!
//! The emitted document is deliberately plain and self-contained: no imports, no
//! template packages, nothing that can break because a package version moved. It
//! is also meant to be edited. A generated exam is a starting point, and the
//! output is formatted so that a human can reasonably open it and adjust spacing
//! before printing.
//! ## What changed, and why
//!
//! Every export takes a form, so form B's answer key is generated from the same
//! permutation that produced form B's question paper. Keeping the key and the
//! paper in one code path is the only way to be sure they agree — a mismatch is
//! discovered by twenty-five students at once.
//! This module used to build a document with `format!`. That made the position of
//! the points label a Rust change, put a `#grid` call in a match arm, and meant
//! the only way to move something on the page was to fork the crate. It also made
//! a generated exam a dead end: you could edit the output, but the next export
//! overwrote the edit.
//!
//! So the tool no longer writes documents. It loads a Typst file that you own,
//! finds the markers in it, and injects data:
//!
//! ```typst
//! // coursebank:begin questions
//! #render-question((number: 1, stem: [Sample.], options: ()))
//! // coursebank:end questions
//! ```
//!
//! `render-question` is defined in your template. What the payload contains is
//! governed by [`config::RenderConfig`]; where it lands and how it looks is
//! governed by the template. The default templates are compiled into the binary,
//! and `coursebank template dump` writes them into `templates/` so that
//! customizing means editing a file. See [`template`] for the marker syntax, the
//! slot list, and the lookup order.
//!
//! ## What is still enforced here
//!
//! Two invariants survived the rewrite, because both are the kind of mistake a
//! room full of students discovers simultaneously.
//!
//! **A form's key is derived from the same permutation as its paper.** Option order
//! comes from [`select::option_order`](crate::select::option_order) against the
//! form's recorded seed, never from anything stored, so every export of form B
//! agrees with every other. The paper, the key, and the answer sheet are all built
//! from one [`payload::Payload`].
//!
//! **The paper's payload does not contain the answer.** Not `correct: false`, not a
//! flag to check — the field is absent. See [`config::Reveal`]. A template cannot
//! leak what it was never given, and that stays true through every future edit of
//! the template by someone who has not read this comment.
pub mod config;
pub mod payload;
pub mod template;
pub mod value;
use std::path::{Path, PathBuf};
pub use config::{
ConfigFile, ContentMode, Fields, LetterStyle, Overrides, RenderConfig, Reveal, StimulusMode,
Variant, CONFIG_FILE, CONFIG_TEMPLATE,
};
pub use payload::Payload;
pub use template::{Origin, Slot, Template};
pub use value::Value;
use crate::assessment::{AssessmentFile, Form};
use crate::catalog::Catalog;
use crate::course::CourseFile;
use crate::course::Layout;
use crate::error::{Error, Result};
use crate::markup;
use crate::select;
/// Options for a printed exam.
/// What to render.
#[derive(Debug, Clone)]
pub struct Options {
/// Which form to render.
/// Which form.
pub form: Form,
/// Whether to leave a name and student-id block at the top.
pub name_block: bool,
/// Whether to show the points each question is worth.
pub show_points: bool,
/// Whether to start each question on a new page. Occasionally worth it for an
/// exam with long stimuli.
pub page_per_item: bool,
/// Paper size, as Typst names it.
pub paper: String,
/// Base font size.
pub font_size: String,
/// Which document.
pub variant: Variant,
/// A template path that overrides the usual lookup. Takes precedence over
/// `template` in the render config.
pub template: Option<PathBuf>,
/// What to put in the payload, and how.
pub config: RenderConfig,
}
impl Default for Options {
@@ -50,22 +88,195 @@ impl Default for Options {
shuffle_items: false,
shuffle_options: false,
},
name_block: true,
show_points: true,
page_per_item: false,
paper: "us-letter".to_string(),
font_size: "11pt".to_string(),
variant: Variant::Exam,
template: None,
config: RenderConfig::for_variant(Variant::Exam),
}
}
}
/// Renders the question paper.
impl Options {
/// Options for one variant, with that variant's default configuration.
///
/// # Arguments
///
/// * `variant` - which document.
/// * `form` - the form to render.
///
/// # Returns
///
/// The options.
pub fn new(variant: Variant, form: Form) -> Options {
Options {
form,
variant,
template: None,
config: RenderConfig::for_variant(variant),
}
}
/// The template path to use, preferring the explicit one.
fn template_path(&self) -> Option<&Path> {
self.template.as_deref().or(self.config.template.as_deref())
}
}
/// A finished document, with everything a caller needs to explain it.
#[derive(Debug, Clone)]
pub struct Rendered {
/// Which document this is.
pub variant: Variant,
/// Where the template came from.
pub origin: Origin,
/// Which slots the template declared and this render filled.
pub slots: Vec<Slot>,
/// The Typst source.
pub text: String,
/// The payload, kept so a caller can also write it as JSON without rebuilding.
pub payload: Payload,
/// Advisory problems: markup that will probably confuse the Typst compiler, and
/// a template that declared no markers at all.
pub warnings: Vec<String>,
}
/// Renders one document.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `opts` - rendering options.
/// * `opts` - what to render.
///
/// # Returns
///
/// The finished document.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when a placement references a missing item,
/// [`Error::Io`] when an explicitly requested template cannot be read, and
/// [`Error::Invalid`] when the template's markers are malformed or every placement
/// is marked dropped.
pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<Rendered> {
let template = template::load(
&catalog.layout,
opts.variant,
Some(record.assessment.id.as_str()),
opts.template_path(),
)?;
let payload = payload::build(catalog, record, &opts.form, &opts.config)?;
if payload.questions.is_empty() {
return Err(Error::Invalid(vec![
"this assessment has no printable items; every placement is marked dropped".to_string(),
]));
}
// Only build what the template asked for. A paper template that never mentions
// `data` should not pay for a second copy of every stem.
let mut bodies = Vec::new();
if template.wants(Slot::Meta) {
bodies.push((Slot::Meta, payload::meta_body(&payload, &opts.config)));
}
if template.wants(Slot::Questions) {
bodies.push((
Slot::Questions,
payload::questions_body(&payload, &opts.config),
));
}
if template.wants(Slot::Data) {
bodies.push((Slot::Data, payload::data_body(&payload, &opts.config)));
}
let mut warnings = payload::check(&payload, &opts.config);
if template.is_inert() {
warnings.push(format!(
"the template {} declares no coursebank markers, so no questions were injected; add \
`// coursebank:questions` where they belong",
template.origin
));
}
Ok(Rendered {
variant: opts.variant,
origin: template.origin.clone(),
slots: template.slots(),
text: template.render(&bodies),
payload,
warnings,
})
}
/// Builds the payload without rendering a template.
///
/// For writing the questions out as JSON, which is what a hand-written Typst exam
/// that already calls `json("questions.json")` wants.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `opts` - what to render; only the form and config are consulted.
///
/// # Returns
///
/// The payload.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when a placement references a missing item.
pub fn build_payload(
catalog: &Catalog,
record: &AssessmentFile,
opts: &Options,
) -> Result<Payload> {
payload::build(catalog, record, &opts.form, &opts.config)
}
/// The path a course's Typst configuration lives at.
///
/// # Arguments
///
/// * `layout` - the course layout.
pub fn config_path(layout: &Layout) -> PathBuf {
template::dir(layout).join(CONFIG_FILE)
}
/// Loads a course's Typst configuration.
///
/// # Arguments
///
/// * `layout` - the course layout.
///
/// # Returns
///
/// The parsed config file, or an empty one when `templates/typst.yaml` is absent.
/// Absence is not an error: a course that has never customized anything should
/// export without being told to write a config file first.
///
/// # Errors
///
/// Returns [`Error::Yaml`] when the file exists but does not parse.
pub fn load_config(layout: &Layout) -> Result<ConfigFile> {
let path = config_path(layout);
if path.is_file() {
crate::yaml::read(&path)
} else {
Ok(ConfigFile::default())
}
}
/// Renders the question paper.
///
/// Kept so existing callers keep working. New code should use [`render`], which
/// also reports which template was used and what it warned about.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `opts` - rendering options; the variant is forced to [`Variant::Exam`].
///
/// # Returns
///
@@ -73,120 +284,9 @@ impl Default for Options {
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when a placement references a missing item.
/// As [`render`].
pub fn exam(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
let course = &catalog.course;
let mut out = String::new();
out.push_str(&preamble(course, record, opts));
if opts.name_block {
out.push_str(
"#block(above: 1em, below: 1.5em)[\n \
#grid(columns: (auto, 1fr, auto, 1fr), gutter: 0.6em,\n \
[*Name*], [#box(width: 100%, repeat[.])],\n \
[*Student ID*], [#box(width: 100%, repeat[.])],\n )\n]\n\n",
);
}
if let Some(instructions) = &record.assessment.instructions {
out.push_str(&format!(
"#block(fill: luma(245), inset: 8pt, radius: 3pt, width: 100%)[\n {}\n]\n\n",
markup::to_typst(instructions)
));
}
let default_points = course.policy.points_per_item;
let mut printed = 0usize;
for placement in select::layout(record, &opts.form) {
if placement.dropped {
continue;
}
let entry = catalog.require(&placement.item)?;
let item = &entry.item;
printed += 1;
if opts.page_per_item && printed > 1 {
out.push_str("#pagebreak()\n\n");
}
// A stimulus shared by several items is printed with each of them. That
// repeats material, but a student should never have to flip pages to find
// the passage a question refers to.
if let Some(id) = &item.stimulus {
if let Some(stimulus) = course.stimuli.get(id) {
out.push_str(&format!(
"#block(stroke: 0.5pt + luma(180), inset: 8pt, radius: 3pt, width: 100%)[\n \
{}\n]\n",
markup::to_typst(&stimulus.body)
));
if let Some(caption) = &stimulus.caption {
out.push_str(&format!(
"#block(above: 0.3em)[#text(size: 0.85em, style: \"italic\")[{}]]\n",
markup::to_typst(caption)
));
}
}
}
let points = placement
.points
.unwrap_or_else(|| item.points(default_points));
let label = if placement.bonus {
if opts.show_points {
format!(
" #text(fill: rgb(\"#666666\"))[(bonus, {})]",
plural_points(points)
)
} else {
" #text(fill: rgb(\"#666666\"))[(bonus)]".to_string()
}
} else if opts.show_points {
format!(
" #text(fill: rgb(\"#666666\"))[({})]",
plural_points(points)
)
} else {
String::new()
};
// The question number is the recorded one, not the printed position, so a
// scanned answer sheet still joins to the assessment record.
out.push_str(&format!(
"#block(above: 1.2em, below: 0.5em)[*{}.*{label} {}]\n",
placement.number,
markup::to_typst(&item.stem)
));
if item.is_multi_key() {
out.push_str(
"#block(below: 0.4em)[#text(size: 0.9em, style: \"italic\")[Select all that \
apply.]]\n",
);
}
let order = select::option_order(&opts.form, &placement.item, item.options.len());
out.push_str("#block(inset: (left: 1.2em))[\n");
for (position, source_index) in order.iter().enumerate() {
let choice = &item.options[*source_index];
// Options are relabeled by printed position, so a shuffled form still
// reads A, B, C, D.
let letter = (b'A' + position as u8) as char;
out.push_str(&format!(
" #grid(columns: (1.4em, 1fr), gutter: 0.2em)[{letter}.][{}]\n",
markup::to_typst(&choice.text)
));
}
out.push_str("]\n\n");
}
if printed == 0 {
return Err(Error::Invalid(vec![
"this assessment has no printable items; every placement is marked dropped".to_string(),
]));
}
Ok(out)
render_variant(catalog, record, opts, Variant::Exam)
}
/// Renders the answer key.
@@ -195,7 +295,7 @@ pub fn exam(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Resul
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `opts` - rendering options, whose form determines the letters.
/// * `opts` - rendering options; the variant is forced to [`Variant::Key`].
///
/// # Returns
///
@@ -203,107 +303,18 @@ pub fn exam(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Resul
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when a placement references a missing item.
/// As [`render`].
pub fn answer_key(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
let mut out = String::new();
out.push_str(&format!(
"#set page(paper: \"{}\", margin: 2cm)\n#set text(size: 10pt)\n\n\
= {} — answer key (form {})\n\n",
opts.paper,
escape(&record.assessment.title),
opts.form.id
));
out.push_str(
"#text(size: 0.9em, style: \"italic\")[Letters below are the letters *as printed on this \
form*. Do not use this key on another form.]\n\n",
);
out.push_str(
"#table(\n columns: (auto, auto, auto, 1fr),\n align: (right, center, center, left),\n \
table.header([*\\#*], [*Key*], [*Level*], [*Objectives*]),\n",
);
for placement in select::layout(record, &opts.form) {
if placement.dropped {
continue;
}
let entry = catalog.require(&placement.item)?;
let item = &entry.item;
let order = select::option_order(&opts.form, &placement.item, item.options.len());
// Map the source option index to the letter it was printed as.
let mut printed_letters = Vec::new();
for (position, source_index) in order.iter().enumerate() {
if item.options[*source_index].correct {
printed_letters.push(((b'A' + position as u8) as char).to_string());
}
}
let objectives = if placement.learning_objectives.is_empty() {
item.learning_objectives.join(", ")
} else {
placement.learning_objectives.join(", ")
};
out.push_str(&format!(
" [{}], [*{}*], [{}], [{}],\n",
placement.number,
printed_letters.join(""),
placement
.level
.map(|l| l.code().to_string())
.unwrap_or_else(|| "-".into()),
escape(&objectives)
));
}
out.push_str(")\n\n");
// Partial credit decisions belong on the key, where the grader will see them.
let overrides: Vec<String> = record
.items
.iter()
.filter(|p| !p.credit_overrides.is_empty())
.map(|p| {
let list: Vec<String> = p
.credit_overrides
.iter()
.map(|(letter, credit)| format!("{letter} = {:.0}%", credit * 100.0))
.collect();
format!("Question {}: {}", p.number, list.join(", "))
})
.collect();
if !overrides.is_empty() {
out.push_str("== Partial credit\n\n");
for line in overrides {
out.push_str(&format!("- {}\n", escape(&line)));
}
out.push('\n');
}
let dropped: Vec<String> = record
.items
.iter()
.filter(|p| p.dropped)
.map(|p| p.number.to_string())
.collect();
if !dropped.is_empty() {
out.push_str(&format!(
"== Dropped\n\nQuestion(s) {} were dropped and are not printed.\n\n",
dropped.join(", ")
));
}
Ok(out)
render_variant(catalog, record, opts, Variant::Key)
}
/// Renders a bubble sheet matching the form.
///
/// # Arguments
///
/// * `catalog` - the loaded course, for option counts.
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `opts` - rendering options.
/// * `opts` - rendering options; the variant is forced to [`Variant::AnswerSheet`].
///
/// # Returns
///
@@ -311,132 +322,51 @@ pub fn answer_key(catalog: &Catalog, record: &AssessmentFile, opts: &Options) ->
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when a placement references a missing item.
/// As [`render`].
pub fn bubble_sheet(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
let mut out = format!(
"#set page(paper: \"{}\", margin: 1.5cm)\n#set text(size: 10pt)\n\n\
= {} — answer sheet (form {})\n\n\
#grid(columns: (auto, 1fr, auto, 1fr), gutter: 0.6em,\n \
[*Name*], [#box(width: 100%, repeat[.])],\n \
[*Student ID*], [#box(width: 100%, repeat[.])],\n)\n\n\
#v(1em)\n",
opts.paper,
escape(&record.assessment.title),
opts.form.id
);
render_variant(catalog, record, opts, Variant::AnswerSheet)
}
out.push_str("#columns(2)[\n");
for placement in select::layout(record, &opts.form) {
if placement.dropped {
continue;
}
let entry = catalog.require(&placement.item)?;
let count = entry.item.options.len();
let bubbles: Vec<String> = (0..count)
.map(|i| {
let letter = (b'A' + i as u8) as char;
format!("#circle(radius: 0.42em, stroke: 0.5pt)[#align(center + horizon)[#text(size: 0.7em)[{letter}]]]")
})
.collect();
out.push_str(&format!(
" #block(below: 0.45em)[#box(width: 2em)[{}.] {}]\n",
placement.number,
bubbles.join(" ")
));
/// Renders one variant, substituting that variant's default config when the caller
/// passed the config for a different one.
fn render_variant(
catalog: &Catalog,
record: &AssessmentFile,
opts: &Options,
variant: Variant,
) -> Result<String> {
let mut opts = opts.clone();
if opts.variant != variant {
// The caller asked for a different document than the config describes, so
// the config's `reveal` almost certainly belongs to the other one. Taking
// the variant's own default is the safe reading, and the direction that
// matters is the paper: never inherit a key's `reveal`.
opts.config = RenderConfig::for_variant(variant);
opts.variant = variant;
}
out.push_str("]\n");
Ok(out)
}
/// Builds the document preamble.
///
/// # Arguments
///
/// * `course` - the course.
/// * `record` - the assessment record.
/// * `opts` - rendering options.
///
/// # Returns
///
/// Typst setup and a title block.
fn preamble(course: &CourseFile, record: &AssessmentFile, opts: &Options) -> String {
let form_note = if record.forms.len() > 1 {
format!(" · Form {}", opts.form.id)
} else {
String::new()
};
let date = record
.assessment
.date
.map(|d| d.to_string())
.unwrap_or_default();
let minutes = record
.assessment
.minutes_allowed
.map(|m| format!(" · {m:.0} minutes"))
.unwrap_or_default();
format!(
"#set page(\n paper: \"{paper}\",\n margin: 2cm,\n \
header: [#text(size: 0.85em)[{code} · {title}{form_note}]],\n \
footer: context [#text(size: 0.85em)[Page #counter(page).display() of \
#counter(page).final().first()]],\n)\n\
#set text(size: {size})\n\
#set par(justify: false, leading: 0.65em)\n\n\
#align(center)[\n #text(size: 1.4em, weight: \"bold\")[{title}]\n \\\n \
#text(size: 0.95em)[{code}{course_title} · {term}]\n \\\n \
#text(size: 0.9em)[{date}{minutes}]\n]\n\n",
paper = opts.paper,
size = opts.font_size,
code = escape(&course.course.code),
course_title = escape(&course.course.title),
term = escape(
record
.assessment
.term
.as_deref()
.unwrap_or(&course.course.term)
),
title = escape(&record.assessment.title),
form_note = form_note,
date = date,
minutes = minutes,
)
}
/// Formats a point value with the right plural.
fn plural_points(points: f64) -> String {
if (points - 1.0).abs() < 1e-9 {
"1 point".to_string()
} else if (points.fract()).abs() < 1e-9 {
format!("{points:.0} points")
} else {
format!("{points} points")
}
}
/// Escapes text for Typst content mode.
fn escape(s: &str) -> String {
markup::to_typst(s)
Ok(render(catalog, record, &opts)?.text)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assessment::Placement;
use crate::select;
#[test]
fn point_labels_are_pluralized() {
assert_eq!(plural_points(1.0), "1 point");
assert_eq!(plural_points(2.0), "2 points");
assert_eq!(plural_points(1.5), "1.5 points");
fn options_default_to_the_paper_and_withhold_the_key() {
let opts = Options::default();
assert_eq!(opts.variant, Variant::Exam);
assert!(!opts.config.reveal.shows_key());
}
#[test]
fn escaping_protects_typst_syntax() {
assert_eq!(escape("email me @ home"), "email me \\@ home");
assert_eq!(escape("a < b"), "a \\< b");
fn an_explicit_template_beats_the_config() {
let mut opts = Options::default();
opts.config.template = Some(PathBuf::from("from-config.typ"));
assert_eq!(opts.template_path(), Some(Path::new("from-config.typ")));
opts.template = Some(PathBuf::from("from-cli.typ"));
assert_eq!(opts.template_path(), Some(Path::new("from-cli.typ")));
}
#[test]
@@ -452,8 +382,8 @@ mod tests {
let order = select::option_order(&form, "bank::q-1", 4);
let correct_source = 0usize;
let printed_position = order.iter().position(|i| *i == correct_source).unwrap();
let letter = (b'A' + printed_position as u8) as char;
assert!(('A'..='D').contains(&letter));
let letter = LetterStyle::Upper.label(printed_position);
assert!(["A", "B", "C", "D"].contains(&letter.as_str()));
// And it is reproducible.
let again = select::option_order(&form, "bank::q-1", 4);
assert_eq!(order, again);
+746
View File
@@ -0,0 +1,746 @@
//! What gets emitted, and in what shape.
//!
//! This module holds the knobs that used to be `format!` calls. The split is
//! deliberate: a template decides how a question *looks*, and this config decides
//! what the template is *told*. Anything you can express by rearranging boxes
//! belongs in the template, not here.
//!
//! The one setting that is not cosmetic is [`Reveal`]. It controls whether the
//! payload contains the answer at all, and the reason it is a config value rather
//! than a template concern is that a template cannot be trusted with it. If the
//! exam paper's payload carries `correct: true`, then every future edit to that
//! template is one `if` statement away from printing the key, and the failure mode
//! is discovered by a room full of students. Withholding the field is the only
//! version of this that stays correct under editing.
//!
//! Config is resolved in three layers, each overriding the last: the built-in
//! defaults for the variant, the `defaults:` block of `templates/typst.yaml`, and
//! that file's `variants:` block. `coursebank template config` writes the whole
//! resolved thing out so there is no guessing about what applied.
use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
/// Which document is being produced.
///
/// A variant is not a style. It is a different set of facts: the paper withholds
/// the key, the key withholds the questions, and the answer sheet needs only the
/// option counts. Each has its own template and its own default [`Reveal`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Variant {
/// The question paper a student writes on.
Exam,
/// The grader's answer key.
Key,
/// A bubble sheet matching the form.
AnswerSheet,
}
impl Variant {
/// Every variant, in the order `export` writes them.
pub const ALL: [Variant; 3] = [Variant::Exam, Variant::Key, Variant::AnswerSheet];
/// The token used on the command line, in config keys, and in file names.
pub fn as_str(self) -> &'static str {
match self {
Variant::Exam => "exam",
Variant::Key => "key",
Variant::AnswerSheet => "answer-sheet",
}
}
/// The variant for a token.
///
/// # Arguments
///
/// * `s` - the token, e.g. `"answer-sheet"`.
///
/// # Returns
///
/// The variant.
///
/// # Errors
///
/// Returns [`Error::Usage`] naming the valid tokens.
pub fn parse(s: &str) -> Result<Variant> {
let normalized = s.trim().to_ascii_lowercase().replace('_', "-");
Variant::ALL
.iter()
.copied()
.find(|v| v.as_str() == normalized)
.ok_or_else(|| {
Error::usage(format!(
"unknown template variant `{s}`; expected one of {}",
Variant::ALL
.iter()
.map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
))
})
}
/// The file name this variant's template is looked up under.
pub fn template_file(self) -> String {
format!("{}.typ", self.as_str())
}
/// The suffix appended to an exported file's stem.
///
/// The paper gets no suffix because it is the thing you print most often and
/// `exam-2-A.typ` reads better than `exam-2-A-exam.typ`.
pub fn suffix(self) -> &'static str {
match self {
Variant::Exam => "",
Variant::Key => "-key",
Variant::AnswerSheet => "-answer-sheet",
}
}
}
/// How much of the answer side of an item reaches the payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Reveal {
/// Nothing. No `correct`, no `credit`, no keyed letters, no rationale. What a
/// student form must use.
Nothing,
/// Which options are correct, what credit each earns, and any recorded
/// partial-credit overrides. Enough to grade with.
Key,
/// Everything, including instructor rationale, targeted misconceptions, and
/// the record's private notes. For a review copy that never leaves your desk.
Everything,
}
impl Reveal {
/// Whether keyed letters, `correct`, and `credit` are emitted.
pub fn shows_key(self) -> bool {
self != Reveal::Nothing
}
/// Whether rationale, misconceptions, and private notes are emitted.
pub fn shows_rationale(self) -> bool {
self == Reveal::Everything
}
}
/// How option labels are generated.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LetterStyle {
/// `A`, `B`, `C`.
Upper,
/// `a`, `b`, `c`.
Lower,
/// `1`, `2`, `3`.
Numeric,
/// `i`, `ii`, `iii`.
Roman,
/// No label; the template supplies its own, usually via `numbering`.
Nothing,
}
impl LetterStyle {
/// The label for a zero-based printed position.
///
/// # Arguments
///
/// * `position` - the printed position, counting from zero.
///
/// # Returns
///
/// The label, or an empty string for [`LetterStyle::Nothing`].
pub fn label(self, position: usize) -> String {
match self {
LetterStyle::Upper => alphabetic(position, true),
LetterStyle::Lower => alphabetic(position, false),
LetterStyle::Numeric => (position + 1).to_string(),
LetterStyle::Roman => roman(position + 1),
LetterStyle::Nothing => String::new(),
}
}
}
/// A spreadsheet-style label: `A`..`Z`, then `AA`.
///
/// Eight options is the schema's ceiling, so the second character is unreachable
/// in practice. It is here so that raising that ceiling does not silently produce
/// `[` as an option label, which is what `b'A' + 26` gives you.
fn alphabetic(position: usize, upper: bool) -> String {
let base = if upper { b'A' } else { b'a' };
let mut n = position;
let mut letters = Vec::new();
loop {
letters.push((base + (n % 26) as u8) as char);
if n < 26 {
break;
}
n = n / 26 - 1;
}
letters.iter().rev().collect()
}
/// A lowercase Roman numeral for a one-based position.
fn roman(mut n: usize) -> String {
const TABLE: [(usize, &str); 13] = [
(1000, "m"),
(900, "cm"),
(500, "d"),
(400, "cd"),
(100, "c"),
(90, "xc"),
(50, "l"),
(40, "xl"),
(10, "x"),
(9, "ix"),
(5, "v"),
(4, "iv"),
(1, "i"),
];
let mut out = String::new();
for (value, numeral) in TABLE {
while n >= value {
out.push_str(numeral);
n -= value;
}
}
out
}
/// How markup-bearing fields are emitted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ContentMode {
/// As content blocks, `[...]`, which Typst parses at compile time. Errors
/// point at the generated file, and no `eval` is needed.
Content,
/// As quoted strings, which a template evaluates with
/// `eval(q.stem, mode: "markup")`. Required if the same payload is also
/// consumed as JSON, since JSON has no content type.
Str,
}
impl ContentMode {
/// Whether markup becomes a content block rather than a string.
pub fn is_content(self) -> bool {
self == ContentMode::Content
}
}
/// Whether a shared stimulus travels with each item that uses it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StimulusMode {
/// Repeat the body with every item that references it. Wasteful on paper, but
/// a student should never have to turn a page to find the passage a question
/// is about.
Inline,
/// Emit only the id on the item, and the bodies once in the metadata under
/// `stimuli`. For a template that prints a testlet header above its group.
Shared,
/// Leave stimuli out.
Omit,
}
/// Which optional per-question fields are emitted.
///
/// Everything here defaults on except the two heavy ones. Turning a field off is
/// about payload noise, not secrecy — [`Reveal`] is what governs secrecy.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Fields {
/// The item's global id, `bank::item`. Useful printed in small grey type on a
/// review copy; never wanted on a student form.
#[serde(default = "yes")]
pub uid: bool,
/// The item's short title.
#[serde(default = "yes")]
pub title: bool,
/// Point value.
#[serde(default = "yes")]
pub points: bool,
/// Cognitive level, as both a number and a name.
#[serde(default = "yes")]
pub level: bool,
/// Learning objective ids.
#[serde(default = "yes")]
pub objectives: bool,
/// Topic tags.
#[serde(default = "yes")]
pub topics: bool,
/// Figures and data files attached to the item.
#[serde(default = "yes")]
pub assets: bool,
/// The letter the option carries in the bank, before shuffling. On a key this
/// is what lets you find the option in the YAML.
#[serde(default = "yes")]
pub source_letters: bool,
/// The authored predictions in the item's `design` block.
#[serde(default = "no")]
pub design: bool,
/// Pooled statistics from previous administrations.
#[serde(default = "no")]
pub calibration: bool,
}
impl Default for Fields {
fn default() -> Fields {
Fields {
uid: true,
title: true,
points: true,
level: true,
objectives: true,
topics: true,
assets: true,
source_letters: true,
design: false,
calibration: false,
}
}
}
/// The resolved configuration for rendering one variant.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RenderConfig {
/// A template path that overrides the usual lookup.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template: Option<PathBuf>,
/// The Typst function the `questions` slot calls, once per item, with a
/// single dictionary argument. The template defines it.
#[serde(default = "default_question_fn")]
pub question_fn: String,
/// The binding the `meta` slot declares.
#[serde(default = "default_meta_binding")]
pub meta_binding: String,
/// The binding the `data` slot declares.
#[serde(default = "default_data_binding")]
pub data_binding: String,
/// How much of the answer side to include.
#[serde(default = "default_reveal")]
pub reveal: Reveal,
/// How option labels are generated.
#[serde(default = "default_letters")]
pub letters: LetterStyle,
/// How markup is emitted.
#[serde(default = "default_content")]
pub content: ContentMode,
/// How shared stimuli are handled.
#[serde(default = "default_stimulus")]
pub stimulus: StimulusMode,
/// Which optional fields to include.
#[serde(default)]
pub fields: Fields,
/// Whether questions carry the number recorded in the assessment record
/// rather than their printed position.
///
/// Keep this on. The recorded number is the join key to every grading export
/// and response row; renumbering after a drop breaks that join silently, and
/// the symptom is item statistics attributed to the wrong question.
#[serde(default = "yes")]
pub number_from_record: bool,
/// Anything else you want the template to see, carried through untouched.
///
/// This is the escape hatch that keeps the crate out of your layout
/// decisions. Tier colours, a `show-solutions` flag, a font stack, a watermark
/// string: put it here and read it from `extra` in the template.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, serde_yaml_ng::Value>,
}
impl RenderConfig {
/// The built-in configuration for a variant.
///
/// # Arguments
///
/// * `variant` - which document.
///
/// # Returns
///
/// A configuration matching what the bundled template for that variant
/// expects.
pub fn for_variant(variant: Variant) -> RenderConfig {
let mut config = RenderConfig {
template: None,
question_fn: default_question_fn(),
meta_binding: default_meta_binding(),
data_binding: default_data_binding(),
reveal: Reveal::Nothing,
letters: LetterStyle::Upper,
content: ContentMode::Content,
stimulus: StimulusMode::Inline,
fields: Fields::default(),
number_from_record: true,
extra: BTreeMap::new(),
};
match variant {
Variant::Exam => {
// The paper must not carry the key in any form.
config.reveal = Reveal::Nothing;
config.fields.uid = false;
config.fields.source_letters = false;
config.fields.objectives = false;
}
Variant::Key => {
config.reveal = Reveal::Everything;
config.stimulus = StimulusMode::Omit;
}
Variant::AnswerSheet => {
config.reveal = Reveal::Nothing;
config.stimulus = StimulusMode::Omit;
config.fields = Fields {
uid: false,
title: false,
points: true,
level: false,
objectives: false,
topics: false,
assets: false,
source_letters: false,
design: false,
calibration: false,
};
}
}
config
}
/// Renders the configuration as YAML.
///
/// For `coursebank template config --resolved`, which is the answer to "which
/// layer won?" — a question that is otherwise answered by reading three files
/// and guessing.
///
/// # Returns
///
/// YAML text.
///
/// # Errors
///
/// Returns [`Error::Other`](crate::error::Error::Other) if serialization
/// fails.
pub fn to_yaml(&self) -> Result<String> {
serde_yaml_ng::to_string(self).map_err(Error::other)
}
}
/// The file a course's Typst configuration lives in, under `templates/`.
pub const CONFIG_FILE: &str = "typst.yaml";
/// A course's Typst export configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
/// Schema version this file targets.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_version: Option<String>,
/// Overrides applied to every variant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub defaults: Option<Overrides>,
/// Overrides applied to one variant, on top of `defaults`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub variants: BTreeMap<Variant, Overrides>,
}
impl ConfigFile {
/// Resolves the configuration for one variant.
///
/// # Arguments
///
/// * `variant` - which document.
///
/// # Returns
///
/// The built-in defaults with the file's `defaults` and then its
/// variant-specific block applied.
pub fn resolve(&self, variant: Variant) -> RenderConfig {
let mut config = RenderConfig::for_variant(variant);
if let Some(defaults) = &self.defaults {
defaults.apply(&mut config);
}
if let Some(specific) = self.variants.get(&variant) {
specific.apply(&mut config);
}
config
}
}
/// A partial [`RenderConfig`]: every field optional, so a config file can say one
/// thing without restating the rest.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Overrides {
/// See [`RenderConfig::template`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template: Option<PathBuf>,
/// See [`RenderConfig::question_fn`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub question_fn: Option<String>,
/// See [`RenderConfig::meta_binding`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta_binding: Option<String>,
/// See [`RenderConfig::data_binding`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_binding: Option<String>,
/// See [`RenderConfig::reveal`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reveal: Option<Reveal>,
/// See [`RenderConfig::letters`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub letters: Option<LetterStyle>,
/// See [`RenderConfig::content`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<ContentMode>,
/// See [`RenderConfig::stimulus`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stimulus: Option<StimulusMode>,
/// See [`RenderConfig::fields`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fields: Option<Fields>,
/// See [`RenderConfig::number_from_record`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub number_from_record: Option<bool>,
/// Merged key by key into [`RenderConfig::extra`] rather than replacing it, so
/// a variant can add one flag without repeating the shared block.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, serde_yaml_ng::Value>,
}
impl Overrides {
/// Applies these overrides in place.
///
/// # Arguments
///
/// * `config` - the configuration to modify.
pub fn apply(&self, config: &mut RenderConfig) {
if let Some(v) = &self.template {
config.template = Some(v.clone());
}
if let Some(v) = &self.question_fn {
config.question_fn = v.clone();
}
if let Some(v) = &self.meta_binding {
config.meta_binding = v.clone();
}
if let Some(v) = &self.data_binding {
config.data_binding = v.clone();
}
if let Some(v) = self.reveal {
config.reveal = v;
}
if let Some(v) = self.letters {
config.letters = v;
}
if let Some(v) = self.content {
config.content = v;
}
if let Some(v) = self.stimulus {
config.stimulus = v;
}
if let Some(v) = &self.fields {
config.fields = v.clone();
}
if let Some(v) = self.number_from_record {
config.number_from_record = v;
}
for (key, value) in &self.extra {
config.extra.insert(key.clone(), value.clone());
}
}
}
/// The commented starter config written by `coursebank template config`.
///
/// Written as text rather than serialized from [`ConfigFile`] because the comments
/// are the useful part, and a serializer drops them.
pub const CONFIG_TEMPLATE: &str = r#"# Typst export configuration.
#
# Layers, each overriding the last:
# 1. the built-in defaults for the variant
# 2. `defaults:` below
# 3. `variants:` below
#
# `coursebank template config --resolved` prints what a variant actually ends up
# with, which is the quickest way to check whether a key landed where you meant.
schema_version: "1.0"
defaults:
# The Typst function the `questions` slot calls once per item, with one
# dictionary argument. Your template defines it.
question_fn: render-question
# `content` emits stems as `[...]`, which Typst parses directly.
# `str` emits them as strings for a template that calls `eval`, and is what you
# want if the same payload is also read as JSON.
content: content
# upper | lower | numeric | roman | nothing
letters: upper
# Anything here is passed through untouched and shows up as `extra` in the
# payload. This is where layout decisions belong.
#
# Note the spelling shift: keys above are coursebank's and are snake_case like
# every other coursebank YAML file, but keys under `extra` are yours and reach
# Typst verbatim, so they use Typst's hyphens.
extra:
accent: '#017ab9'
font: 'Libertinus Serif'
variants:
exam:
# Leave this alone unless you are certain. `nothing` is what keeps the answer
# out of the student's copy; a template cannot leak a field it was never given.
reveal: nothing
extra:
show-solutions: false
key:
# nothing | key | everything
reveal: everything
extra:
show-solutions: true
answer-sheet:
reveal: nothing
"#;
/// Serde default: `true`.
fn yes() -> bool {
true
}
/// Serde default: `false`.
fn no() -> bool {
false
}
/// Serde default for [`RenderConfig::question_fn`].
fn default_question_fn() -> String {
"render-question".to_string()
}
/// Serde default for [`RenderConfig::meta_binding`].
fn default_meta_binding() -> String {
"cb-meta".to_string()
}
/// Serde default for [`RenderConfig::data_binding`].
fn default_data_binding() -> String {
"cb-data".to_string()
}
/// Serde default for [`RenderConfig::reveal`].
fn default_reveal() -> Reveal {
Reveal::Nothing
}
/// Serde default for [`RenderConfig::letters`].
fn default_letters() -> LetterStyle {
LetterStyle::Upper
}
/// Serde default for [`RenderConfig::content`].
fn default_content() -> ContentMode {
ContentMode::Content
}
/// Serde default for [`RenderConfig::stimulus`].
fn default_stimulus() -> StimulusMode {
StimulusMode::Inline
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn variant_tokens_round_trip() {
for variant in Variant::ALL {
assert_eq!(Variant::parse(variant.as_str()).unwrap(), variant);
}
// Underscores are tolerated because people type them.
assert_eq!(
Variant::parse("answer_sheet").unwrap(),
Variant::AnswerSheet
);
assert!(Variant::parse("bubbles").is_err());
}
#[test]
fn the_exam_variant_never_reveals_the_key_by_default() {
// This is the one default in this module that is a correctness property
// rather than a preference.
let config = RenderConfig::for_variant(Variant::Exam);
assert_eq!(config.reveal, Reveal::Nothing);
assert!(!config.reveal.shows_key());
}
#[test]
fn letters_are_generated_per_style() {
assert_eq!(LetterStyle::Upper.label(0), "A");
assert_eq!(LetterStyle::Upper.label(3), "D");
assert_eq!(LetterStyle::Lower.label(1), "b");
assert_eq!(LetterStyle::Numeric.label(4), "5");
assert_eq!(LetterStyle::Roman.label(3), "iv");
assert_eq!(LetterStyle::Nothing.label(2), "");
}
#[test]
fn letters_past_z_do_not_run_off_the_alphabet() {
// `b'A' + 26` is `[`. Anything that produced that would be a silent
// corruption of an option label rather than an error.
assert_eq!(LetterStyle::Upper.label(26), "AA");
assert_eq!(LetterStyle::Upper.label(25), "Z");
}
#[test]
fn config_layers_override_in_order() {
let file: ConfigFile = serde_yaml_ng::from_str(
"defaults:\n letters: lower\n extra:\n a: 1\nvariants:\n key:\n letters: \
numeric\n extra:\n b: 2\n",
)
.unwrap();
let exam = file.resolve(Variant::Exam);
assert_eq!(exam.letters, LetterStyle::Lower);
let key = file.resolve(Variant::Key);
assert_eq!(key.letters, LetterStyle::Numeric);
// `extra` merges rather than replacing, so `a` survives.
assert!(key.extra.contains_key("a"));
assert!(key.extra.contains_key("b"));
}
#[test]
fn an_empty_config_file_changes_nothing() {
let file = ConfigFile::default();
for variant in Variant::ALL {
assert_eq!(file.resolve(variant), RenderConfig::for_variant(variant));
}
}
#[test]
fn the_starter_config_parses_and_keeps_the_exam_closed() {
let file: ConfigFile = serde_yaml_ng::from_str(CONFIG_TEMPLATE).unwrap();
assert_eq!(file.resolve(Variant::Exam).reveal, Reveal::Nothing);
assert_eq!(file.resolve(Variant::Key).reveal, Reveal::Everything);
}
}
File diff suppressed because it is too large Load Diff
+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"));
}
}
@@ -0,0 +1,71 @@
// coursebank — answer sheet template
//
// Bubbles are generated from each item's own option count, so an item with three
// options gets three bubbles rather than the five the rest of the form has. That is
// the whole reason this document is generated at all: a hand-drawn sheet drifts out
// of step with the paper, and the drift is invisible until it is scanned.
//
// // coursebank:begin data
// // coursebank:end data
//
// If you scan these, set `bubble-radius` and the column count in
// templates/typst.yaml under `extra` rather than editing the geometry here, and
// check one printed page against your scanner before running a class through it.
// coursebank:begin data
#let cb-data = (
course: (code: "COURSE 101", title: "Sample Course", term: "2026S"),
assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"),
form: (id: "A", count: 1),
totals: (questions: 1, scored: 1, bonus: 0, points: 1.5, bonus-points: 0.0),
extra: (:),
questions: (
(
number: 1,
bonus: false,
stem: [Replaced on the next export.],
options: (
(letter: "A", position: 1, text: [First.]),
(letter: "B", position: 2, text: [Second.]),
),
),
),
)
// coursebank:end data
#let extra = cb-data.at("extra", default: (:))
#let bubble-radius = eval(extra.at("bubble-radius", default: "0.42em"))
#let columns-count = extra.at("columns", default: 2)
#set page(paper: extra.at("paper", default: "us-letter"), margin: 1.5cm)
#set text(size: 10pt)
#let bubble(letter) = circle(radius: bubble-radius, stroke: 0.5pt)[
#align(center + horizon)[#text(size: 0.7em)[#letter]]
]
= #cb-data.assessment.title — answer sheet (form #cb-data.form.id)
#grid(
columns: (auto, 1fr, auto, 1fr),
gutter: 0.6em,
[*Name*], box(width: 100%, repeat[.]),
[*Student ID*], box(width: 100%, repeat[.]),
)
#v(1em)
#columns(columns-count)[
#for q in cb-data.questions {
block(below: 0.45em)[
#box(width: 2em)[#(str(q.number) + ".")]
#for opt in q.at("options", default: ()) {
bubble(opt.letter)
h(0.25em)
}
#if q.at("bonus", default: false) {
text(size: 0.7em, fill: luma(120))[ bonus]
}
]
}
]
+204
View File
@@ -0,0 +1,204 @@
// coursebank — exam paper template
//
// This file is a template, not generated output. `coursebank export typst`
// replaces only the marked regions below and leaves every other line exactly as
// you wrote it, so this is where layout decisions belong.
//
// coursebank template dump write this file into templates/
// typst watch templates/exam.typ restyle it against the sample data
//
// The regions ship with sample values so that last command works before any
// export has happened.
//
// // coursebank:begin meta a dictionary of course and form metadata
// // coursebank:end meta
// // coursebank:begin questions one #render-question(...) call per item
// // coursebank:end questions
//
// An exported document keeps its markers, so exporting into a file you have since
// restyled replaces the questions and leaves the styling alone.
//
// There is no `correct` field on an option, because the render config for the
// paper withholds it. That is deliberate. Do not switch `reveal` to `key`
// here in order to build a solutions copy: export the `key` variant instead,
// or the day you forget an `if` is the day the class gets the answers.
// coursebank:begin meta
#let cb-meta = (
course: (code: "COURSE 101", title: "Sample Course", term: "2026S"),
assessment: (
id: "sample",
title: "Sample assessment",
date: "2026-01-01",
minutes-allowed: 50.0,
instructions: [Choose the single best answer unless a question says otherwise.],
),
form: (id: "A", count: 1),
totals: (questions: 1, scored: 1, bonus: 0, points: 1.5, bonus-points: 0.0),
extra: (:),
)
// coursebank:end meta
// Anything under `extra` in templates/typst.yaml arrives here untouched, which is
// how a course changes the look without editing this file at all.
#let extra = cb-meta.at("extra", default: (:))
#let accent = rgb(extra.at("accent", default: "#1f4e79"))
#let body-font = extra.at("font", default: "Libertinus Serif")
#let body-size = eval(extra.at("font-size", default: "11pt"))
#let paper = extra.at("paper", default: "us-letter")
#let show-name-block = extra.at("name-block", default: true)
#let show-points = extra.at("show-points", default: true)
#let page-per-item = extra.at("page-per-item", default: false)
#let form-note = if cb-meta.form.at("count", default: 1) > 1 {
" · Form " + cb-meta.form.id
} else {
""
}
#set page(
paper: paper,
margin: 2cm,
header: text(size: 0.85em)[
#cb-meta.course.code · #cb-meta.assessment.title#form-note
],
footer: context text(size: 0.85em)[
#counter(page).display("Page 1 of 1", both: true)
],
)
#set text(font: body-font, size: body-size, lang: "en")
#set par(justify: false, leading: 0.65em)
// Markup arrives as content when the render config says `content: content`, and as
// a string when it says `content: str`. Accepting both means switching that
// setting does not require editing the template.
#let markup(v) = if type(v) == str { eval(v, mode: "markup") } else { v }
// 1 point, 2 points, 1.5 points.
#let fmt-points(p) = {
let n = if p == calc.trunc(p) { str(calc.trunc(p)) } else { str(p) }
n + if p == 1 { " point" } else { " points" }
}
#let points-tag(q) = {
let p = q.at("points", default: none)
let bonus = q.at("bonus", default: false)
let label = if bonus and show-points and p != none {
"bonus, " + fmt-points(p)
} else if bonus {
"bonus"
} else if show-points and p != none {
fmt-points(p)
} else {
none
}
if label != none {
text(fill: luma(100), size: 0.9em)[(#label)]
}
}
#let stimulus-block(s) = {
block(stroke: 0.5pt + luma(180), inset: 8pt, radius: 3pt, width: 100%)[
#markup(s.body)
]
let caption = s.at("caption", default: none)
if caption != none {
block(above: 0.3em)[#text(size: 0.85em, style: "italic")[#markup(caption)]]
}
}
// One question, one function. Rename it if you like and set `question-fn` in
// templates/typst.yaml to match. It takes a single dictionary so that turning a
// field on or off in the config never changes this signature.
#let render-question(q) = {
// A stimulus shared by several items is printed with each of them. That repeats
// material, but a student should never have to turn a page to find the passage a
// question refers to.
let s = q.at("stimulus", default: none)
if s != none { stimulus-block(s) }
// The number is the one recorded in the assessment record, not the position on
// the page. Keep it that way: it is the join key to every grading export.
//
// Built in code rather than written as `*#q.number.*` because a field access
// followed by a literal period reads as the start of another field access.
let number-label = str(q.number) + "."
block(above: 1.2em, below: 0.5em)[
*#number-label* #points-tag(q) #markup(q.stem)
]
if q.at("multi-select", default: false) {
block(below: 0.4em)[
#text(size: 0.9em, style: "italic")[Select all that apply.]
]
}
block(inset: (left: 1.2em))[
#for opt in q.at("options", default: ()) {
grid(
columns: (1.4em, 1fr),
gutter: 0.2em,
[#(opt.letter + ".")], [#markup(opt.text)],
)
v(0.15em)
}
]
if page-per-item { pagebreak(weak: true) }
}
#align(center)[
#text(size: 1.4em, weight: "bold", fill: accent)[#cb-meta.assessment.title]\
#text(size: 0.95em)[
#cb-meta.course.code #cb-meta.course.title · #cb-meta.course.term
]\
#text(size: 0.9em)[
#cb-meta.assessment.at("date", default: "")
#{
let m = cb-meta.assessment.at("minutes-allowed", default: none)
if m != none { " · " + str(int(calc.round(m))) + " minutes" }
}
#{
let p = cb-meta.totals.at("points", default: none)
if p != none { " · " + fmt-points(p) }
}
]
]
#if show-name-block {
block(above: 1em, below: 1.5em)[
#grid(
columns: (auto, 1fr, auto, 1fr),
gutter: 0.6em,
[*Name*], box(width: 100%, repeat[.]), [*Student ID*], box(width: 100%, repeat[.]),
)
]
}
#{
let instructions = cb-meta.assessment.at("instructions", default: none)
if instructions != none {
block(fill: luma(245), inset: 8pt, radius: 3pt, width: 100%)[
#markup(instructions)
]
}
}
// coursebank:begin questions
#render-question((
number: 1,
points: 1.5,
bonus: false,
multi-select: false,
stem: [This sample question is replaced on the next export.],
options: (
(letter: "A", position: 1, text: [The first option.]),
(letter: "B", position: 2, text: [The second option.]),
),
))
// coursebank:end questions
+154
View File
@@ -0,0 +1,154 @@
// coursebank — answer key template
//
// This one uses the `data` slot rather than `questions`: it gets the whole payload
// as a single dictionary and loops over it here, which suits a table better than an
// unrolled sequence of calls. Both slots are always available; use whichever fits
// what you are building.
//
// // coursebank:begin data
// // coursebank:end data
//
// The key's render config sets `reveal: everything`, so options here carry
// `correct`, `credit`, and the authored rationale. That is the opposite of the
// paper's config, and it is why these are two templates rather than one with a
// flag.
// coursebank:begin data
#let cb-data = (
course: (code: "COURSE 101", title: "Sample Course", term: "2026S"),
assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"),
form: (id: "A", count: 1),
totals: (questions: 1, scored: 1, bonus: 0, points: 1.5, bonus-points: 0.0),
dropped: (),
extra: (:),
questions: (
(
number: 1,
points: 1.5,
bonus: false,
level: 2,
level-name: "Understand",
stem: [This sample question is replaced on the next export.],
key: ("B",),
options: (
(letter: "A", position: 1, text: [Wrong.], correct: false, credit: 0.0),
(letter: "B", position: 2, text: [Right.], correct: true, credit: 1.0),
),
),
),
)
// coursebank:end data
#let extra = cb-data.at("extra", default: (:))
#let accent = rgb(extra.at("accent", default: "#1f4e79"))
#set page(paper: extra.at("paper", default: "us-letter"), margin: 2cm)
#set text(size: 10pt)
#let markup(v) = if type(v) == str { eval(v, mode: "markup") } else { v }
#let fmt-points(p) = if p == calc.trunc(p) { str(calc.trunc(p)) } else { str(p) }
#let objectives-of(q) = q.at("learning-objectives", default: ()).join(", ")
= #cb-data.assessment.title — answer key (form #cb-data.form.id)
#text(size: 0.9em, style: "italic")[
Letters below are the letters *as printed on this form*. Do not use this key on
another form. Form seed #cb-data.form.at("seed", default: "—").
]
#v(0.5em)
#table(
columns: (auto, auto, auto, auto, 1fr),
align: (right, center, center, right, left),
table.header([*\#*], [*Key*], [*Level*], [*Pts*], [*Objectives*]),
..cb-data.questions
.map(q => (
[#q.number],
[*#q.at("key", default: ()).join("")*],
[#str(q.at("level", default: "—"))],
[#fmt-points(q.at("points", default: 0))],
[#objectives-of(q)],
))
.flatten()
)
// ── Partial credit ──
// Recorded against the letters the students actually saw, so a grader can apply it
// without remapping anything.
#{
let rows = cb-data.questions.filter(q => q.at("credit-overrides", default: (:)).len() > 0)
if rows.len() > 0 {
heading(level: 2)[Partial credit]
list(
..rows.map(q => {
let parts = q
.at("credit-overrides", default: (:))
.pairs()
.map(pair => pair.at(0) + " = " + str(int(calc.round(pair.at(1) * 100))) + "%")
[Question #q.number: #parts.join(", ")]
})
)
}
}
// ── Dropped ──
#{
let dropped = cb-data.at("dropped", default: ())
if dropped.len() > 0 {
heading(level: 2)[Dropped]
[
Question(s) #dropped.map(str).join(", ") were dropped after administration
and are not printed.
]
}
}
// ── Rationale ──
// Everything below exists because a key you can grade from is not the same
// document as a key you can defend a challenge with.
#pagebreak(weak: true)
#heading(level: 2)[Rationale]
#for q in cb-data.questions {
block(breakable: false, above: 1.2em)[
#text(weight: "bold")[Question #q.number]
#{
let uid = q.at("uid", default: none)
if uid != none { text(size: 0.85em, fill: luma(120))[ · #uid] }
}
#markup(q.stem)
#for opt in q.at("options", default: ()) {
let correct = opt.at("correct", default: false)
let credit = opt.at("credit", default: 0.0)
// Drawn rather than typed: a check-mark glyph is not in every font, and a
// missing glyph on an answer key is a box where the answer should be.
let marker = if correct {
box(width: 0.55em, height: 0.55em, radius: 1pt, fill: accent)
} else if credit > 0 {
box(width: 0.55em, height: 0.55em, radius: 1pt, stroke: 0.6pt + accent)
} else {
box(width: 0.55em, height: 0.55em)
}
grid(
columns: (1.2em, 1.4em, 1fr),
gutter: 0.3em,
[#marker], [#(opt.letter + ".")],
[
#markup(opt.text)
#{
let why = opt.at("explanation", default: opt.at("misconception", default: none))
if why != none {
linebreak()
text(size: 0.85em, fill: luma(90), style: "italic")[#markup(why)]
}
}
],
)
}
]
}
+349
View File
@@ -0,0 +1,349 @@
//! A small writer for Typst literals.
//!
//! Everything this crate injects into a template is data, not layout, and data
//! has to arrive as syntax the Typst parser accepts. That is a narrow enough job
//! to do by hand: eight value kinds, one recursive printer, and no dependency on
//! a Typst implementation.
//!
//! Two details in here exist only because Typst's parenthesis syntax is
//! overloaded, and both are the kind of thing that produces a confusing compile
//! error rather than a clear one if you get them wrong:
//!
//! * An empty dictionary is `(:)`, not `()`, because `()` is the empty array.
//! * A one-element array needs a trailing comma — `(1,)` — because `(1)` is just
//! a parenthesized expression. Trailing commas are harmless everywhere else, so
//! this printer always emits them.
//!
//! [`Value::Content`] is the reason this is not simply a JSON writer. Stems and
//! option text are authored in a Typst subset, so they can be emitted as a
//! content block that Typst parses directly. The alternative is a quoted string
//! the template has to `eval`, which is what [`Value::Str`] gives you and what
//! JSON interoperability requires. Both are supported because both are useful;
//! see [`crate::typst::config::ContentMode`].
use std::fmt::Write as _;
/// One value in an emitted Typst literal.
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
/// Typst's `none`.
None,
/// A boolean.
Bool(bool),
/// An integer.
Int(i64),
/// A float. Non-finite values are written as `none`, since Typst has no
/// literal for them and a NaN in a point total is a bug worth seeing.
Float(f64),
/// A quoted string.
Str(String),
/// Markup, emitted as a content block: `[...]`.
Content(String),
/// Verbatim Typst code, emitted with no quoting or escaping at all.
Raw(String),
/// An array.
Array(Vec<Value>),
/// A dictionary. Insertion order is preserved, because the output is meant to
/// be read by a human comparing two exports.
Dict(Vec<(String, Value)>),
}
impl Value {
/// A string value.
pub fn str(s: impl Into<String>) -> Value {
Value::Str(s.into())
}
/// A content-block value.
pub fn content(s: impl Into<String>) -> Value {
Value::Content(s.into())
}
/// An empty dictionary, ready for [`Value::insert`].
pub fn dict() -> Value {
Value::Dict(Vec::new())
}
/// Adds a key to a dictionary, ignoring the call on any other kind.
///
/// # Arguments
///
/// * `key` - the dictionary key.
/// * `value` - the value to store.
pub fn insert(&mut self, key: impl Into<String>, value: Value) {
if let Value::Dict(entries) = self {
entries.push((key.into(), value));
}
}
/// Adds a key only when the value is `Some`, so an absent field is absent
/// from the output rather than present and `none`.
///
/// This distinction carries real weight for the answer key: an exam paper
/// whose payload omits `correct` cannot leak the key through a template that
/// forgot to check a flag, whereas one that emits `correct: none` invites a
/// template to treat the field as present.
///
/// # Arguments
///
/// * `key` - the dictionary key.
/// * `value` - the value, if there is one.
pub fn insert_some(&mut self, key: impl Into<String>, value: Option<Value>) {
if let Some(value) = value {
self.insert(key, value);
}
}
/// Whether this is a dictionary with no entries.
pub fn is_empty_dict(&self) -> bool {
matches!(self, Value::Dict(entries) if entries.is_empty())
}
/// Renders the value as a Typst literal.
///
/// # Arguments
///
/// * `indent` - how many levels of two-space indentation the value starts at.
/// Nested values indent relative to this.
///
/// # Returns
///
/// Typst source. Multi-line for non-empty arrays and dictionaries, single-line
/// for everything else.
pub fn to_typst(&self, indent: usize) -> String {
let mut out = String::new();
write_value(&mut out, self, indent);
out
}
}
/// Writes one value at the given indentation level.
fn write_value(out: &mut String, value: &Value, indent: usize) {
match value {
Value::None => out.push_str("none"),
Value::Bool(true) => out.push_str("true"),
Value::Bool(false) => out.push_str("false"),
Value::Int(n) => {
let _ = write!(out, "{n}");
}
Value::Float(x) => {
if x.is_finite() {
let _ = write!(out, "{x}");
} else {
out.push_str("none");
}
}
Value::Str(s) => write_string(out, s),
Value::Content(s) => {
out.push('[');
out.push_str(s);
out.push(']');
}
Value::Raw(s) => out.push_str(s),
Value::Array(items) => {
if items.is_empty() {
out.push_str("()");
return;
}
out.push_str("(\n");
for item in items {
pad(out, indent + 1);
write_value(out, item, indent + 1);
out.push_str(",\n");
}
pad(out, indent);
out.push(')');
}
Value::Dict(entries) => {
if entries.is_empty() {
// Not `()`, which is the empty array.
out.push_str("(:)");
return;
}
out.push_str("(\n");
for (key, item) in entries {
pad(out, indent + 1);
write_key(out, key);
out.push_str(": ");
write_value(out, item, indent + 1);
out.push_str(",\n");
}
pad(out, indent);
out.push(')');
}
}
}
/// Writes `n` levels of two-space indentation.
fn pad(out: &mut String, n: usize) {
for _ in 0..n {
out.push_str(" ");
}
}
/// Writes a dictionary key, quoting it when it is not a bare identifier.
fn write_key(out: &mut String, key: &str) {
if is_identifier(key) {
out.push_str(key);
} else {
write_string(out, key);
}
}
/// Whether a string can be used as a bare Typst identifier.
///
/// Typst identifiers allow interior hyphens, which is why `render-question` is a
/// legal function name and why this is not simply a Rust identifier check.
fn is_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
}
/// Writes a quoted, escaped Typst string.
fn write_string(out: &mut String, s: &str) {
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(ch),
}
}
out.push('"');
}
/// Converts a parsed YAML value into a Typst value.
///
/// This is how arbitrary user configuration reaches a template: whatever is
/// under `extra` in the render config is carried through unexamined, so a
/// template can be given values this crate has never heard of.
///
/// # Arguments
///
/// * `value` - the YAML value.
/// * `strings_as_content` - when true, strings become content blocks rather than
/// quoted strings.
///
/// # Returns
///
/// The equivalent Typst value. YAML constructs with no Typst equivalent, such as
/// a tagged node, become `none`.
pub fn from_yaml(value: &serde_yaml_ng::Value, strings_as_content: bool) -> Value {
match value {
serde_yaml_ng::Value::Null => Value::None,
serde_yaml_ng::Value::Bool(b) => Value::Bool(*b),
serde_yaml_ng::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::Int(i)
} else if let Some(f) = n.as_f64() {
Value::Float(f)
} else {
Value::None
}
}
serde_yaml_ng::Value::String(s) => {
if strings_as_content {
Value::Content(s.clone())
} else {
Value::Str(s.clone())
}
}
serde_yaml_ng::Value::Sequence(items) => Value::Array(
items
.iter()
.map(|i| from_yaml(i, strings_as_content))
.collect(),
),
serde_yaml_ng::Value::Mapping(map) => {
let mut entries = Vec::new();
for (key, item) in map {
// A non-scalar key has no Typst spelling; skip it rather than
// emit something that will not parse.
let key = match key {
serde_yaml_ng::Value::String(s) => s.clone(),
serde_yaml_ng::Value::Number(n) => n.to_string(),
serde_yaml_ng::Value::Bool(b) => b.to_string(),
_ => continue,
};
entries.push((key, from_yaml(item, strings_as_content)));
}
Value::Dict(entries)
}
// A tagged node, and anything a future YAML version adds.
_ => Value::None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_containers_use_the_right_syntax() {
// `()` is the empty array and `(:)` is the empty dictionary. Swapping
// them produces a type error deep inside the template.
assert_eq!(Value::Array(Vec::new()).to_typst(0), "()");
assert_eq!(Value::dict().to_typst(0), "(:)");
}
#[test]
fn single_element_arrays_keep_the_trailing_comma() {
let v = Value::Array(vec![Value::Int(1)]);
let out = v.to_typst(0);
assert!(out.contains("1,"), "got {out}");
}
#[test]
fn strings_are_escaped() {
assert_eq!(Value::str("a\"b\\c").to_typst(0), "\"a\\\"b\\\\c\"");
assert_eq!(Value::str("two\nlines").to_typst(0), "\"two\\nlines\"");
}
#[test]
fn content_is_not_escaped() {
// Content blocks carry authored markup through verbatim; escaping them
// would turn `*bold*` into literal asterisks.
assert_eq!(Value::content("*bold*").to_typst(0), "[*bold*]");
}
#[test]
fn keys_are_quoted_only_when_they_have_to_be() {
let mut d = Value::dict();
d.insert("error-type", Value::Int(1));
d.insert("2nd", Value::Int(2));
let out = d.to_typst(0);
assert!(out.contains("error-type: 1"), "got {out}");
assert!(out.contains("\"2nd\": 2"), "got {out}");
}
#[test]
fn absent_fields_are_omitted_entirely() {
let mut d = Value::dict();
d.insert_some("correct", None);
d.insert_some("number", Some(Value::Int(3)));
assert!(!d.to_typst(0).contains("correct"));
}
#[test]
fn non_finite_floats_do_not_produce_invalid_syntax() {
assert_eq!(Value::Float(f64::NAN).to_typst(0), "none");
assert_eq!(Value::Float(1.5).to_typst(0), "1.5");
}
#[test]
fn yaml_passes_through() {
let yaml: serde_yaml_ng::Value =
serde_yaml_ng::from_str("a: 1\nb: [x, y]\nc: true\n").unwrap();
let out = from_yaml(&yaml, false).to_typst(0);
assert!(out.contains("a: 1"), "got {out}");
assert!(out.contains("\"x\""), "got {out}");
assert!(out.contains("c: true"), "got {out}");
}
}
+5 -14
View File
@@ -26,7 +26,7 @@
//! ## The loop
//!
//! ```text
//! author items ──▶ validate ──▶ lint ──▶ assemble ──▶ export ──┐
//! author items ──▶ validate ──▶ lint ──▶ assemble ──▶ export ──
//! ▲ │
//! │ administer
//! │ │
@@ -47,29 +47,20 @@
//! already get right and then drifts from it. [`assessment::History`] derives usage
//! by scanning the records.
//!
//! **Fingerprints cover only what a student saw.** Retag an item's metadata and its
//! Fingerprints cover only what a student saw. Retag an item's metadata and its
//! pooled statistics stay valid; reword the stem and they are marked stale. See
//! [`item::Item::fingerprint`].
//!
//! **Validation reports everything at once.** Fixing one typo per run is not a
//! Validation reports everything at once. Fixing one typo per run is not a
//! workflow. [`error::Error::Invalid`] carries a list.
//!
//! **Validation and linting are separate.** [`bank::BankFile::validate`] enforces
//! Validation and linting are separate. [`bank::BankFile::validate`] enforces
//! what must be true; [`lint`] advises on what is usually a mistake, and every rule
//! has a code you can silence.
//!
//! **Small samples are labelled as such.** Every statistic computed from a class of
//! Small samples are labelled as such. Every statistic computed from a class of
//! twenty-five is reported with the caveat it deserves rather than three decimal
//! places of false precision.
//!
//! ## Dependency posture
//!
//! Deliberately small: serde, a YAML parser, clap, thiserror, and csv, plus arrow
//! and parquet behind a default-on feature that can be switched off. Dates, PRNG,
//! hashing, ZIP writing, and the psychometrics are implemented here rather than
//! pulled in — see [`date`], [`rng`], [`hash`], [`zipfile`], [`irt`]. For a tool
//! whose job is to still open a course repository in five years, that tradeoff
//! favours fewer moving parts.
#![warn(missing_docs)]
#![forbid(unsafe_code)]
+318 -15
View File
@@ -84,6 +84,9 @@ enum Command {
/// Produce a Canvas package, a printable exam, or Markdown.
#[command(subcommand)]
Export(ExportCommand),
/// Inspect, dump, and configure the Typst export templates.
#[command(subcommand)]
Template(TemplateCommand),
/// Read a grading export into the response store.
#[command(subcommand)]
Ingest(IngestCommand),
@@ -328,6 +331,10 @@ enum ExportCommand {
no_feedback: bool,
},
/// Render a printable exam, answer key, and answer sheet.
///
/// Each document is produced by injecting data into a Typst template rather
/// than being built from scratch, so the layout is yours to change. Run
/// `coursebank template dump` to get the defaults as editable files.
Typst {
/// Assessment id.
id: String,
@@ -337,6 +344,20 @@ enum ExportCommand {
/// Output directory; defaults to build/.
#[arg(long)]
out: Option<PathBuf>,
/// Which documents to write; defaults to all three.
#[arg(long, value_name = "VARIANT")]
variant: Vec<String>,
/// Use this template file instead of the usual lookup. Only valid with a
/// single --variant, since one file cannot be three documents.
#[arg(long)]
template: Option<PathBuf>,
/// Also write the payload as JSON, for a template that reads it with
/// `json("...")` rather than taking an injected region.
#[arg(long)]
json: bool,
/// Print the payload and the resolved template path without writing.
#[arg(long)]
dry_run: bool,
},
/// Write the items as Markdown, for review.
Md {
@@ -351,6 +372,45 @@ enum ExportCommand {
},
}
#[derive(Debug, Subcommand)]
enum TemplateCommand {
/// Show which template each document would use, and why.
List {
/// Resolve as if exporting this assessment, which brings the
/// per-assessment template override into the lookup.
#[arg(long)]
assessment: Option<String>,
},
/// Write the built-in templates into templates/ so you can edit them.
Dump {
/// Which documents; defaults to all three.
#[arg(long, value_name = "VARIANT")]
variant: Vec<String>,
/// Destination directory; defaults to templates/.
#[arg(long)]
out: Option<PathBuf>,
/// Overwrite files that already exist.
#[arg(long)]
force: bool,
/// Print to stdout instead of writing files.
#[arg(long)]
stdout: bool,
},
/// Write or show the render configuration.
Config {
/// Print the fully resolved configuration for this document, after every
/// layer has been applied, instead of writing a starter file.
#[arg(long, value_name = "VARIANT")]
resolved: Option<String>,
/// Destination path; defaults to templates/typst.yaml.
#[arg(long)]
out: Option<PathBuf>,
/// Overwrite a config file that already exists.
#[arg(long)]
force: bool,
},
}
#[derive(Debug, Args)]
struct IngestCommon {
/// The assessment record these responses belong to.
@@ -547,6 +607,7 @@ fn run(cli: &Cli) -> Result<Outcome> {
Command::Assemble(args) => cmd_assemble(cli, args),
Command::Usage(sub) => cmd_usage(cli, sub),
Command::Export(sub) => cmd_export(cli, sub),
Command::Template(sub) => cmd_template(cli, sub),
Command::Ingest(sub) => cmd_ingest(cli, sub),
Command::Analyze(sub) => cmd_analyze(cli, sub),
Command::Calibrate(args) => cmd_calibrate(cli, args),
@@ -1045,9 +1106,27 @@ fn cmd_export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
println!("Import in Canvas: Settings -> Import Course Content -> QTI .zip file");
Ok(Outcome::Ok)
}
ExportCommand::Typst { id, form, out } => {
ExportCommand::Typst {
id,
form,
out,
variant,
template,
json,
dry_run,
} => {
let record = load_record(&catalog, id)?;
let dir = out.clone().unwrap_or(build);
let variants = pick_variants(variant)?;
if template.is_some() && variants.len() > 1 {
return Err(Error::usage(
"--template applies to one document, but more than one --variant was \
requested; pass --variant exam (or key, or answer-sheet) alongside it"
.to_string(),
));
}
let forms: Vec<Form> = if form == "all" {
if record.forms.is_empty() {
vec![typst::Options::default().form]
@@ -1058,23 +1137,79 @@ fn cmd_export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
vec![pick_form(&record, form)?]
};
for f in forms {
let opts = typst::Options {
form: f.clone(),
..typst::Options::default()
};
let exam = typst::exam(&catalog, &record, &opts)?;
let key = typst::answer_key(&catalog, &record, &opts)?;
let sheet = typst::bubble_sheet(&catalog, &record, &opts)?;
// Read once, outside both loops: the config describes the course, not
// the form, and re-reading it per form would let a mid-run edit make
// form A and form B disagree.
let config_file = typst::load_config(&catalog.layout)?;
let mut warned = false;
let mut used_embedded = false;
for (suffix, contents) in [("", exam), ("-key", key), ("-answer-sheet", sheet)] {
let path = dir.join(format!("{id}-{}{suffix}.typ", f.id));
yaml::write_text(&path, &contents)?;
println!("wrote {}", path.display());
for f in &forms {
for variant in &variants {
let opts = typst::Options {
form: f.clone(),
variant: *variant,
template: template.clone(),
config: config_file.resolve(*variant),
};
let rendered = typst::render(&catalog, &record, &opts)?;
used_embedded |= rendered.origin == typst::Origin::Embedded;
for warning in &rendered.warnings {
eprintln!("warning: {warning}");
warned = true;
}
let stem = format!("{id}-{}{}", f.id, variant.suffix());
if *dry_run {
println!(
"{}: {} question(s) from {} via {}",
stem,
rendered.payload.questions.len(),
rendered.origin,
rendered
.slots
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(" + ")
);
continue;
}
let path = dir.join(format!("{stem}.typ"));
yaml::write_text(&path, &rendered.text)?;
println!("wrote {} (from {})", path.display(), rendered.origin);
if *json {
let json_path = dir.join(format!("{stem}.json"));
yaml::write_text(&json_path, &rendered.payload.to_json()?)?;
println!("wrote {}", json_path.display());
}
}
}
println!("\nCompile with: pixi run -e docs typst compile <file>.typ");
Ok(Outcome::Ok)
if *dry_run {
return Ok(Outcome::Ok);
}
if !cli.quiet {
println!("\nCompile with: pixi run -e docs typst compile <file>.typ");
if used_embedded {
println!(
"Some of these used a built-in template. To take over the layout:\n \
coursebank template dump"
);
}
}
Ok(if warned {
Outcome::Findings
} else {
Outcome::Ok
})
}
ExportCommand::Md { id, with_key, out } => {
let record = load_record(&catalog, id)?;
@@ -1436,6 +1571,174 @@ fn cmd_data(cli: &Cli) -> Result<Outcome> {
Ok(Outcome::Ok)
}
/// Resolves the `--variant` flags, defaulting to every document.
///
/// # Arguments
///
/// * `names` - the raw flag values, possibly empty.
///
/// # Returns
///
/// The variants, deduplicated and in canonical order so that
/// `--variant key --variant exam` still writes the paper first.
///
/// # Errors
///
/// Returns [`Error::Usage`] naming the valid tokens.
fn pick_variants(names: &[String]) -> Result<Vec<typst::Variant>> {
if names.is_empty() {
return Ok(typst::Variant::ALL.to_vec());
}
let mut wanted = Vec::new();
for name in names {
let variant = typst::Variant::parse(name)?;
if !wanted.contains(&variant) {
wanted.push(variant);
}
}
// Canonical order, not the order they were typed.
Ok(typst::Variant::ALL
.into_iter()
.filter(|v| wanted.contains(v))
.collect())
}
fn cmd_template(cli: &Cli, sub: &TemplateCommand) -> Result<Outcome> {
let layout = Layout::new(&cli.course);
match sub {
TemplateCommand::List { assessment } => {
let id = assessment.as_deref();
println!("Templates are looked up in this order, first match wins:\n");
for variant in typst::Variant::ALL {
println!("{}", variant.as_str());
let mut resolved = false;
for path in typst::template::candidates(&layout, variant, id) {
let present = path.is_file();
let mark = if present && !resolved {
resolved = true;
"->"
} else {
" "
};
let state = if present { "" } else { " (absent)" };
println!(" {mark} {}{state}", path.display());
}
let mark = if resolved { " " } else { "->" };
println!(" {mark} built-in");
// Reporting the slots requires parsing, and a template with broken
// markers should be named here rather than at export time.
match typst::template::load(&layout, variant, id, None) {
Ok(template) => {
let slots: Vec<&str> =
template.slots().iter().map(|s| s.as_str()).collect();
if slots.is_empty() {
println!(" slots: none — this template injects nothing");
} else {
println!(" slots: {}", slots.join(", "));
}
}
Err(e) => println!(" unusable: {e}"),
}
println!();
}
let config = typst::config_path(&layout);
if config.is_file() {
println!("Config: {}", config.display());
} else {
println!(
"Config: none ({} is absent, so built-in defaults apply)",
config.display()
);
}
Ok(Outcome::Ok)
}
TemplateCommand::Dump {
variant,
out,
force,
stdout,
} => {
let variants = pick_variants(variant)?;
if *stdout {
for (index, v) in variants.iter().enumerate() {
if index > 0 {
println!();
}
if variants.len() > 1 {
println!("// ── {} ──", v.template_file());
}
print!("{}", typst::template::embedded(*v));
}
return Ok(Outcome::Ok);
}
let dir = out.clone().unwrap_or_else(|| layout.templates());
let (written, skipped) = typst::template::dump(&dir, &variants, *force)?;
for path in &written {
println!("wrote {}", path.display());
}
for path in &skipped {
println!(
"kept {} (already exists; --force to overwrite)",
path.display()
);
}
if !written.is_empty() && !cli.quiet {
println!(
"\nThese are yours to edit. Only the marked regions are replaced on \
export,\nso restyle freely:\n typst watch {}",
dir.join(typst::Variant::Exam.template_file()).display()
);
}
// Skipped files are worth an exit code: a script that expected to
// refresh them did not.
Ok(if skipped.is_empty() {
Outcome::Ok
} else {
Outcome::Findings
})
}
TemplateCommand::Config {
resolved,
out,
force,
} => {
if let Some(name) = resolved {
let variant = typst::Variant::parse(name)?;
let config_file = typst::load_config(&layout)?;
let config = config_file.resolve(variant);
println!(
"# Resolved configuration for `{}`, after every layer.",
variant.as_str()
);
print!("{}", config.to_yaml()?);
return Ok(Outcome::Ok);
}
let path = out.clone().unwrap_or_else(|| typst::config_path(&layout));
if path.exists() && !force {
return Err(Error::usage(format!(
"{} already exists; pass --force to overwrite it, or --resolved <variant> to \
see what it currently produces",
path.display()
)));
}
yaml::write_text(&path, typst::CONFIG_TEMPLATE)?;
println!("wrote {}", path.display());
Ok(Outcome::Ok)
}
}
}
/// Loads the catalog, with a friendlier message when the directory is not a course.
fn load(cli: &Cli) -> Result<Catalog> {
let course_file = Layout::new(&cli.course).course_file();
+3 -3
View File
@@ -5,7 +5,7 @@
//!
//! Grading exports do not know about your item bank. Gradescope gives you
//! `14.csv`; Canvas gives you a column header. Both identify questions by
//! *position on a form*. An item bank identifies questions by stable id. The
//! position on a form. An item bank identifies questions by stable id. The
//! assessment record is the only place those two namespaces meet, and without it
//! there is no way to say that question 14 of Exam 4 was
//! `docking::q-scoring-003` at version 2.
@@ -15,7 +15,7 @@
//! assessment records: an item was used exactly when it appears on a record. The
//! records are the source of truth, and they are small, readable, and diffable.
//!
//! Each placement stores the resolved key and content fingerprint *as used*. A
//! Each placement stores the resolved key and content fingerprint as used. A
//! year later, when the item has been reworded twice, you can still see what the
//! students in front of you were asked.
@@ -66,7 +66,7 @@ pub struct AssessmentFile {
pub struct Assessment {
/// Stable id, e.g. `exam-4-2026s`. Response tables carry this.
pub id: String,
/// Human title as printed, e.g. `Exam 4`.
/// Human title as printed, e.g., `Exam 4`.
pub title: String,
/// The term this administration belongs to. Items outlive terms, so the term
/// lives here rather than on the item.
+12 -12
View File
@@ -3,13 +3,13 @@
//! One bank per topic (or per lecture, if that suits how you teach) is the unit
//! of authoring. Banks are small enough to review in a pull request, they let
//! two people write questions without colliding, and `bank.scope` records what
//! the file is *for* so `coursebank catalog` can tell you that you have eleven
//! the file is for so `coursebank catalog` can tell you that you have eleven
//! items on enzyme kinetics and none on regulation.
//!
//! Validation here is split in two on purpose. [`BankFile::validate`] checks what
//! must be true for the file to be usable at all: ids are unique, a keyed answer
//! exists, an approved item is fully specified, a level and its cognitive process
//! agree. The softer question of whether an item is *well written* lives in
//! agree. The softer question of whether an item is well written lives in
//! [`crate::lint`], because those checks are advisory and you should be able to
//! ship a file that trips a few of them.
@@ -76,7 +76,7 @@ pub struct BankMeta {
/// What a bank is scoped to.
///
/// A bank may be scoped by lecture, by objective, by topic, or by none of them.
/// Declaring the scope is what lets the catalog report *gaps*: it can only tell
/// Declaring the scope is what lets the catalog report gaps: it can only tell
/// you that lecture 12 has no Apply-level items if it knows lecture 12 is
/// supposed to be covered here.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -348,7 +348,7 @@ fn validate_item(
issues.push("version must be at least 1".into());
}
// --- options -----------------------------------------------------------
// --- options ---
if it.options.len() < 2 {
issues.push(format!(
"needs at least 2 options, has {}",
@@ -414,7 +414,7 @@ fn validate_item(
}
}
// --- key ---------------------------------------------------------------
// --- key ---
let keys = it.key_indices();
match it.format {
Format::SingleBestAnswer => {
@@ -446,7 +446,7 @@ fn validate_item(
}
}
// --- level and process must agree -------------------------------------
// --- level and process must agree ---
if let Some(p) = it.cognitive_process {
if !it.level.allows(p) {
issues.push(format!(
@@ -457,7 +457,7 @@ fn validate_item(
}
}
// --- design plausibility ----------------------------------------------
// --- design plausibility ---
if let Some(d) = &it.design {
if let Some(x) = d.expected_difficulty {
if !(0.0..=1.0).contains(&x) {
@@ -475,7 +475,7 @@ fn validate_item(
}
}
// --- calibration plausibility -----------------------------------------
// --- calibration plausibility ---
if let Some(c) = &it.calibration {
if let Some(p) = c.p_value {
if !(0.0..=1.0).contains(&p) {
@@ -510,7 +510,7 @@ fn validate_item(
}
}
// --- history must be coherent -----------------------------------------
// --- history must be coherent ---
let mut last_version = 0u32;
for (i, h) in it.history.iter().enumerate() {
if h.version <= last_version {
@@ -529,7 +529,7 @@ fn validate_item(
));
}
// --- retirement -------------------------------------------------------
// --- retirement ---
if it.retired.is_some() && it.status != Status::Retired {
issues.push(format!(
"has a `retired` block but status is `{}`",
@@ -537,7 +537,7 @@ fn validate_item(
));
}
// --- approval gate ----------------------------------------------------
// --- approval gate ---
// Approval is what permits an item onto a graded assessment, so it is the
// right place to require that the item is fully sourced and designed.
if it.status == Status::Approved {
@@ -555,7 +555,7 @@ fn validate_item(
}
}
// --- cross-file references --------------------------------------------
// --- cross-file references ---
if let Some(c) = course {
for lo in &it.learning_objectives {
match c.learning_objectives.get(lo) {
+11
View File
@@ -609,6 +609,17 @@ impl Layout {
self.root.join("schema")
}
/// Directory holding Typst export templates and their configuration.
///
/// Unlike the other directories, this one is *not* created by
/// [`Layout::create_all`]. Its absence is meaningful: a course with no
/// `templates/` directory uses the templates compiled into the binary, and
/// creating an empty one on `init` would suggest a customization step is
/// required when it is not. `coursebank template dump` creates it on demand.
pub fn templates(&self) -> PathBuf {
self.root.join("templates")
}
/// Creates every directory in the layout.
///
/// # Errors
+1 -1
View File
@@ -8,7 +8,7 @@
//! Keeping them adjacent is what turns a question bank into an instrument you
//! can improve, because every administration produces a checkable prediction.
//!
//! One deliberate departure from a naive design: [`Calibration`] is *cumulative*
//! One deliberate departure from a naive design: [`Calibration`] is cumulative
//! rather than per-administration. Raw per-response data belongs in the Parquet
//! tables under `data/`, which are far better at holding it, and an item's YAML
//! holds the rolled-up estimate plus a list of which administrations went into
-7
View File
@@ -1,12 +1,5 @@
//! The pedagogical vocabulary: levels, cognitive processes, error types, and
//! workflow states.
//!
//! These are enums rather than strings on purpose. A typo in
//! `cognitive_process` should fail to parse, not silently create a new category
//! that then splits your coverage report in two. Just as importantly, the
//! relation between a level and the processes that belong to it is encoded here
//! in one place, so "level 3, cognitive_process: recall" is a validation error
//! rather than a label that quietly contradicts itself.
use std::fmt;