Files
coursebank/docs/TYPST.md
T
2026-08-06 17:48:53 -04:00

128 lines
5.9 KiB
Markdown

# 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.
```console
$ 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:
```typst
// 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:`.
```yaml
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