Creating Quills¶
A quill is a document format: the fields a document fills in, the layout that prints them, and the assets the layout uses. Quills live in a quiver, a collection of quills kept as an npm project. This tutorial starts a quiver with one quill in it, opens it in studio (a form beside a live page), and then changes the quill one step at a time.
You need Node.js 22 or later. Nothing else is installed by hand.
The quill here uses the typst backend, which draws each page from a Typst file you write. If you already have a PDF form to fill, the acroform backend fills that form instead: start at the PDF Form Backend (acroform).
1. Start a quiver¶
init writes a quiver holding one working quill, a letter. studio serves it and prints a local address. Open that address: the letter's form is on one side and its rendered page on the other. Edit a field and the page repaints. Click text on the page and the form jumps to the field that printed it.
Keep studio running. When you save a file in the quiver, studio repacks it; reload the page to see the change.
quillkit & studio covers the tool itself. This page is about the quill.
2. What init wrote¶
my-quiver/
├── Quiver.yaml
├── package.json
├── .gitignore
├── .prettierignore
└── quills/
└── letter/
└── 0.1.0/
├── Quill.yaml
├── plate.typ
└── example.md
Quiver.yamlnames the collection and describes it in one line.package.jsonpinsquillkit,@quillmark/quiverand@quillmark/wasm.npm testrunsquillkit test, which renders every quill in the quiver and fails on any error or warning..prettierignorekeeps prettier out ofquills/(why). Keep this file if your project runs prettier.quills/letter/0.1.0/is the quill. A quill lives atquills/<name>/<version>/, and both must match what itsQuill.yamldeclares.
The quill itself is three files:
Quill.yaml: the quill's name and version, and the fields a document fills in.plate.typ: the Typst file that lays out the page. Each quill with thetypstbackend has a plate.example.md: a sample document. Studio opens it, andquillkit testrenders it.
The next four steps read these files one idea at a time.
3. Fields¶
Quill.yaml opens with the quill's identity:
quill:
name: letter
version: 0.1.0
backend: typst
description: A one-page letter with a date, a subject line and optional P.S. notes
name, version, backend and description are all required, and name is snake_case. Under main.fields are the fields of the document itself. The first two are plain text:
main:
description: A letter from one person to another.
fields:
sender:
type: text
description: Your name, as it appears in the letterhead and the signature.
recipient:
type: text
description: Who the letter is addressed to.
plate.typ prints them:
#import "@local/quillmark-helper:0.2.0" as qm
#text(size: 16pt, weight: "bold", qm.ink(qm.data, "sender"))
#if qm.data.recipient != "" [Dear #qm.ink(qm.data, "recipient"),]
#qm.data.at("$body", default: "")
The plate reads the document through the helper package, imported as qm:
qm.dataholds the document's values. Use it to compute:qm.data.recipient != ""tests whether a recipient was given.qm.ink(qm.data, "sender")prints a field. Text printed this way stays linked to its field, which is what lets a click on the page in studio find the field.$bodyis the Markdown under the document's fields.
A field nobody filled in arrives as its blank: "" for text. The plate checks for it, so an empty letter prints no stray "Dear ,". Every plate has to render a document with every field blank, because that is the document an author starts from.
To pick between text, richtext and enum, see Picking a text type below.
4. A date¶
date:
type: date
default: today
description: YYYY-MM-DD, or today to date the letter when it is rendered.
type: date holds a calendar date, written 2026-01-15. default is the value a document gets when it leaves the field out, and today is the date of the render. The plate prints it with a pattern:
qm.data.date is a Typst datetime, for comparing or taking apart. See Dates.
5. A choice from a list¶
closing:
type: enum
values: [sincerely, regards, thanks]
default: sincerely
description: The word before your signature.
An enum stores one of its values, which are ids rather than the words printed. The plate maps each id to its words:
#let closing-text = (sincerely: "Sincerely", regards: "Kind regards", thanks: "With thanks")
#if qm.data.closing != "" {
qm.region("closing")[#closing-text.at(qm.data.closing),]
}
An enum can also be blank, "", so the plate checks for it. The words here are drawn by the plate rather than printed with qm.ink, so qm.region("closing") is what links them back to the field. See Tying Composed Content to a Field.
List only real choices in values. Quillmark supplies the blank itself, and declaring "" is a load error. See the blank.
6. A card¶
A card is a repeatable part of a document, such as a P.S. note. Quill.yaml declares each kind of card under card_kinds:
In a document, each card is its own ~~~ block naming its kind, followed by its own Markdown body. From example.md:
The plate loops over the document's cards:
#for card in qm.data.at("$cards", default: ()) {
let body = card.at("$body", default: "")
if card.at("$kind", default: none) == "postscript" and body != "" [P.S. #body]
}
A card kind can declare fields of its own. See Body, arrays, and cards and card_kinds.
7. Add a field¶
Now change the quill. Add a title to print under the signature. In Quill.yaml, under main.fields:
signer_title:
type: text
default: ""
description: Your title, printed under your name. Leave blank for none.
At the end of the signature in plate.typ, after #qm.ink(qm.data, "sender"):
Save both files and reload studio. The form has a new field, and what you type in it prints under the signature. Add a value to example.md, between its ~~~ lines, so the sample shows it too:
Then run the gate:
quillkit test renders the quill's starter document and its example.md, and fails on any error or warning, such as a value example.md gives a field the quill does not declare.
8. The CLI¶
The quillmark CLI renders a quill straight to a file and inspects it, which suits scripts and CI. Install it, then from the quiver's root:
quillmark render quills/letter/0.1.0 quills/letter/0.1.0/example.md -o letter.pdf
quillmark check --strict --regions quills/letter/0.1.0 quills/letter/0.1.0/example.md
check --regions lists each field the example fills that no click on the page can reach: a field the plate never prints, or prints without qm.ink or qm.region. See the CLI Reference.
Picking a text type¶
Three types hold text, and two questions pick one:
- Is the set of allowed values closed? Then it is an
enum. - Otherwise: should
*text*render as emphasis, or stay literal? (richtextovertext.)
text is one line and richtext one paragraph; multiline: true admits more, and a richtext declares multiline: either way. A name, a URL or a reference key is a one-line text, and an abstract or a summary is a richtext. The letter needs no richtext field: its prose is the document body, which is already rich text. A plate that computes with a text value (upper, a comparison, set document(author: ..)) reads it through qm.str.
Pick before documents exist. Changing a declared type reinterprets every value already stored in that field, and text → richtext is lossy: see Choosing among enum, text, and richtext.
Use default for the value most authors will accept as-is. A field with no default is one the author is expected to answer; left unanswered, it renders its blank. See default. For every field type, UI hint and constraint, see the Quill.yaml Reference.
A larger plate¶
status_report puts the same ideas in a longer plate: card kinds with fields of their own, a date on each card, and an enum mapped to a banner. Its Quill.yaml:
quill:
name: status_report
version: 0.2.0
backend: typst
description: A project status report — a date, a state enum, and one card per milestone
typst:
plate_file: plate.typ
main:
description: One period's report on one project.
body:
example: The cutover rehearsal slipped a week while the data owners review the field mapping. Production cutover holds its date if they sign off by the rehearsal.
fields:
project:
type: text
example: Harbor Migration
description: Project this report covers.
issued:
type: date
default: ""
example: 2026-01-15
description: YYYY-MM-DD. Leave blank to omit the issue line.
state:
type: enum
values:
- on_track
- at_risk
- blocked
default: ""
example: at_risk
description: Where the project stands. Leave blank for no banner.
lead:
type: text
default: ""
example: Jane Doe
description: Who to ask about this report.
card_kinds:
milestone:
title: Milestones
description: One milestone and what is left on it.
body:
example: Sign-off pending from the data owners.
fields:
title:
type: text
example: Cutover rehearsal
description: What the milestone delivers.
due:
type: date
default: ""
example: 2026-02-02
description: YYYY-MM-DD. Leave blank while the date is unset.
Its plate.typ:
#import "@local/quillmark-helper:0.2.0" as qm
#set page(paper: "us-letter", margin: 1in)
#set text(size: 11pt)
// A stored enum value is an id. The words it prints are the plate's.
#let state-text = (on_track: "On track", at_risk: "At risk", blocked: "Blocked")
// The banner's glyphs are drawn here rather than by the field, so nothing ties
// a click on them to `state` until `qm.region` claims them.
#let banner(level) = box(fill: luma(230), inset: 6pt, radius: 2pt)[*#upper(state-text.at(level))*]
// An enum's blank is a value no `values:` list holds: here, nobody has said
// where the project stands. Guarding on it is what keeps an `else` over the
// three declared states from rendering one nobody picked.
#if qm.data.state != "" {
qm.region("state")[#banner(qm.data.state)]
}
= #qm.ink(qm.data, "project")
// A blank date is `none`. `qm.ink` places the date's ink, so the printed date
// stays click-to-edit; `qm.data.issued` is the `datetime` itself, for
// comparison, components, or a package that formats its own.
#if qm.data.issued != none [
Issued #qm.ink(qm.data, "issued", "[day padding:none] [month repr:long] [year]")
]
#if qm.data.lead != "" [Lead: #qm.ink(qm.data, "lead")]
#qm.data.at("$body", default: "")
// `$kind` is document-defined: a card may name a kind this quill does not
// declare, so let every other kind fall through. A field declared on the kind
// arrives filled the way a `main` field does, so `card.title` is a plain read
// while `card.at("$body")` — a `$`-sigiled key — still takes its default.
#for card in qm.data.at("$cards", default: ()) {
if card.at("$kind", default: none) == "milestone" {
// Every iteration reads through one loop variable, so a print of
// `card.title` names no card. Its ink is the card's own: compute with
// `card`, and print and format with `qm.ink(card, ..)`.
heading(level: 2, qm.ink(card, "title"))
if card.due != none {
[Due #qm.ink(card, "due", "[year]-[month]-[day]")]
parbreak()
}
card.at("$body", default: "")
}
}
Three things it adds to the letter:
- Printing a card's fields. Every card in the loop arrives through one variable, so
qm.ink(card, "title")prints the field of that card, andqm.ink(card, "due", ..)formats its date. Compute withcard, print withqm.ink(card, ..). See Print withqm.ink. - A guard on a blank enum. An
elseon a branch over the declared values would also catch the blank and print a state nobody picked, so the plate testsqm.data.state != ""first. #setrules. Page and text defaults are ordinary Typst. See Typesetting.
Next steps¶
- quillkit & studio: the authoring loop, the gate, and publishing a quiver as a site
- Quill.yaml Reference: every field type, UI hint,
card_kinds, and thetypstsection - Typst Backend: reading data, card loops, and the helper package
- Quill Versioning: when a change needs a new version directory
- Typst's tutorial: the language a plate is written in
Tip: A quill ships everything in its directory except .git/, target/, node_modules/ and what .gitignore names, so keep scratch files out of it.