5.9 KiB
Typst export
The tool does not write Typst documents. It loads a Typst file you own, finds the markers in it, and injects data. Layout is yours; the payload is the tool's.
$ coursebank template dump # get the built-in templates as files
$ coursebank template list # see which template each document uses
$ coursebank template config # write templates/typst.yaml
$ coursebank export typst exam-2 # render paper, key, and answer sheet
Markers
Injection points are Typst line comments, so a template is a valid .typ file that compiles on its own:
// coursebank:begin questions
#render-question((number: 1, stem: [Sample.], options: ()))
// coursebank:end questions
Everything between the markers is replaced. The marker lines survive. Two consequences:
- The bundled templates ship with sample data inside their regions, so
typst watch templates/exam.typworks before you have exported anything. Restyle against the sample, then export. - An exported document is itself a valid template. Exporting into a file you have since restyled replaces the questions and leaves your edits alone. This is the difference between a generator you can use twice and one you copy out of once.
A bare // coursebank:questions also works.
It is rewritten into a region on output, so the second export behaves like every one after it.
Malformed markers are reported all at once, with line numbers, and the export stops: an unknown slot name, an unclosed region, a stray end, a nested region, or the same slot claimed twice.
Slots
| Slot | Injected |
|---|---|
meta |
#let cb-meta = (...): course, assessment, form, totals, objectives |
questions |
one #render-question((...)) call per printed item |
data |
#let cb-data = (...): the metadata and the questions together |
questions unrolls the loop using the record's own numbering.
data hands you the array and gets out of the way; the bundled key and answer sheet use it because a table suits a loop better than a sequence of calls.
Use either, both, or neither.
Only slots your template actually contains are built, so nothing costs anything until it is asked for.
Each question arrives as a single positional dictionary, not named arguments, so turning a field on or off in the config never changes your function's signature.
Read optional fields with q.at("level", default: none).
Configuration
templates/typst.yaml, three layers, each overriding the last: built-in defaults for the variant, then defaults:, then variants:.
defaults:
question_fn: render-question # the function the `questions` slot calls
content: content # `content` -> [...] | `str` -> "..." for eval()
letters: upper # upper | lower | numeric | roman | nothing
extra:
accent: "#017ab9"
font: Roboto
variants:
key:
reveal: everything
extra:
show-solutions: true
Keys are snake_case, matching every other coursebank YAML file.
Keys under extra are yours and reach Typst verbatim, so they conventionally use hyphens.
coursebank template config --resolved exam prints what a variant actually ends up with, which is the quickest way to find out which layer won.
extra is the escape hatch
Anything under extra is carried through untouched and arrives as extra in the payload.
Tier colours, a font stack, a show-solutions flag, a watermark, column counts.
Put it there and read it in the template.
Nothing about appearance needs to be added to this crate.
reveal is not cosmetic
reveal controls whether the payload contains the answer at all.
The exam variant uses nothing, and that means an option dictionary on the paper has no correct field, not correct: false.
A template cannot leak a field it was never given, and that stays true after someone edits the template without reading this page.
Do not set reveal: key on the exam variant to build a solutions copy.
Export the key variant.
The failure mode of the other approach is one forgotten if away, and it is discovered by the whole room at once.
Template lookup
--template <path>, ortemplate:in the render configtemplates/<assessment-id>-<variant>.typ, a one-off layout for one examtemplates/<variant>.typ, the course's own default- the template compiled into the binary
coursebank template list prints this chain with the resolved entry marked, plus the slots each template declares.
coursebank template dump writes step 4 into step 3.
JSON
coursebank export typst exam-2 --json also writes the payload as JSON, for a template that reads json("exam-2-A.json") instead of taking an injected region.
Key spellings are identical between the two paths (level-name, not level_name), so a template can move between them without edits.
JSON has no content type, so set content: str and eval(q.stem, mode: "markup") if you use this path.
What is still guaranteed
The key matches its paper. Option order comes from the form's recorded seed via select::option_order, never from anything stored, and the paper, key, and answer sheet are built from one payload.
Every export of form B agrees with every other.
Question numbers are the recorded ones.
Not positions on the page.
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.
number-from-record: false exists but you almost certainly want it left alone.
Advisory warnings
export typst exits 2 and prints warnings, without refusing to write, when:
- a stem or option has unbalanced
[], which would otherwise surface as a Typst parse error somewhere downstream of the item that caused it, with no way for the compiler to name the question - a template declares no markers at all, so nothing was injected