Files
alexm abc0bdf621
Sync README to GitHub / sync (push) Successful in 12s
CI / check (push) Successful in 8m31s
Deploy docs / deploy (push) Successful in 6m44s
Nightly / nightly (push) Successful in 10m33s
Dev (#1)
Reviewed-on: #1
2026-08-07 15:48:11 -04:00

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.typ works 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

  1. --template <path>, or template: in the render config
  2. templates/<assessment-id>-<variant>.typ, a one-off layout for one exam
  3. templates/<variant>.typ, the course's own default
  4. 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