Quickstart¶
From nothing to a PDF. Every path below renders the same two-file quill: a
document format that declares one field, title, and prints it above the
document body. To try Quillmark without installing anything, open the
playground.
1. Write a quill¶
A quill is a document format: a schema for a document's fields, a layout for
its pages, and the assets the layout uses. On disk it is a directory. Create
my_quill/Quill.yaml:
quill:
name: my_quill
backend: typst
version: "1.0.0"
description: A title above the body
main:
fields:
title:
type: text
description: The heading at the top of the page
and my_quill/plate.typ, the Typst layout that prints it, which the backend
finds by its name:
#import "@local/quillmark-helper:0.2.0" as qm
#set page(width: 6in, height: 4in, margin: 0.75in)
= #qm.ink(qm.data, "title")
#qm.data.at("$body", default: "")
2. Write a document¶
Next to my_quill/, create document.md. The ~~~ block on top holds the
fields, and $quill names the quill that renders them:
~~~
$quill: my_quill
title: Hello, Quillmark
~~~
This paragraph is the document body. *Markdown* works here.
3. Render it¶
cargo install builds from source, so it needs a
Rust toolchain. Prebuilt binaries need none:
cargo binstall quillmark-cli installs one, and each
GitHub Release carries the
archives (Installation).
render writes document.pdf. quillmark check my_quill document.md
reports what the document gets wrong without rendering, and
quillmark schema my_quill prints the fields the quill expects.
The package runs in Node and the browser, so this builds the quill in memory rather than reading the directory:
import { init, Engine } from "@quillmark/wasm";
const { Quill } = await init();
const enc = new TextEncoder();
const quill = Quill.fromTree(new Map([
["Quill.yaml", enc.encode(`quill:
name: my_quill
backend: typst
version: "1.0.0"
description: A title above the body
main:
fields:
title:
type: text
description: The heading at the top of the page
`)],
["plate.typ", enc.encode(`#import "@local/quillmark-helper:0.2.0" as qm
#set page(width: 6in, height: 4in, margin: 0.75in)
= #qm.ink(qm.data, "title")
#qm.data.at("$body", default: "")
`)],
]));
const doc = quill.parse(`~~~
$quill: my_quill
title: Hello, Quillmark
~~~
This paragraph is the document body. *Markdown* works here.
`);
const engine = new Engine();
const result = await engine.render(quill, doc, { format: "pdf" });
const pdfBytes = result.artifacts[0].bytes;
Top-level await runs in Node and in a module script. A bundler targeting
older browsers (Vite's default does) refuses it: run the code inside an
async function, or set the build target to es2022.
The first render loads the Typst backend, a separate multi-MB binary;
init alone loads only the small core build, enough to parse and validate.
init is idempotent and concurrency-safe, so await it at each entry point
(route loader, hydration path, worker) rather than threading one result
around. A type annotation needs no await: Quill and Document are also
exported as types (import type { Quill } from "@quillmark/wasm").
use quillmark::{quill_from_path, Engine, OutputFormat, RenderOptions};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let quill = quill_from_path("my_quill")?;
let doc = quill.parse(&std::fs::read_to_string("document.md")?)?.document;
// A render reads no clock: the caller passes the render date.
let today = "2026-03-14".parse()?;
let options = RenderOptions::default().with_output_format(OutputFormat::Pdf);
let result = Engine::new().render(&quill, &doc, today, &options)?;
std::fs::write("document.pdf", &result.artifacts[0].bytes)?;
Ok(())
}
Next¶
- Get something wrong on purpose: misspell
title, or drop the$from$quill. The diagnostic names the mistake and the fix. - Creating Quills: more fields, dates, and repeating cards.
- Markdown Syntax and card-yaml Blocks: what a document can say.
- Integrating Quillmark: documents built in code, error handling, storage, and live preview in a canvas.