Skip to content

Programmatic Construction

Build a Document in memory (no Markdown text) through validated, schema-bound mutators. Every write enforces the same field-name, depth, and kind rules the Markdown parser does, so a constructed document cannot be invalid. This is the surface for programs: a database row becomes a rendered PDF without assembling YAML or Markdown by hand.

Markdown authoring, the authoring document (for LLMs), and these mutators all produce the same Document; render, validation, and storage do not distinguish how it was built, and to_markdown() round-trips any of them.

Blank canvas vs seeded starter

  • Document(quill_ref) is the blank canvas: a main card carrying only $quill, an empty body, no cards. Absent fields resolve at render time (schema default:, else the field's blank), so nothing you did not set reaches the output. Start here when the data is authoritative.
  • quill.seed_document() is the starter: one card per declared kind, each carrying its kind's seed and every other field absent. Hand it to a human or an editor to fill in; see Seeding & the Empty Document.
  • quill.example_document() is the quill filled in with made-up values, for showing what the quill looks like: the seed with each example its schema declares committed; None when it declares none. Its values are not the user's, so it is no starting point.

The typed writer

quill.writer(doc) is the schema-bound input path. It resolves each field's declared type, coerces the value ("3" → 3, a Markdown string → richtext content), and fails at the write on a mismatch. A name the schema does not declare is a typo (edit::unknown_field), not a silent fallback. set_all and add_card are atomic: on any bad field nothing is applied, and the raised error carries one diagnostic per offending field, so a whole-form batch surfaces every problem at once.

from quillmark import Document, Engine, Quill, OutputFormat

engine = Engine()
quill = Quill.from_path("invoice-quill")

doc = Document("invoice")                       # blank canvas
w = quill.writer(doc)
w.set_all({"customer": row.name, "total": row.total})
for item in row.items:
    w.add_card("line_item", {"desc": item.desc, "qty": item.qty})

result = engine.render(quill, doc, OutputFormat.PDF)
import { init } from "@quillmark/wasm";

const { Document } = await init();

const doc = new Document("invoice");            // blank canvas
const w = quill.writer(doc);
w.setAll({ customer: row.name, total: row.total });
for (const item of row.items) {
  w.addCard("line_item", { desc: item.desc, qty: item.qty });
}
const result = await engine.render(quill, doc, { format: "pdf" });

add_card fuses build + typed-commit + insert in one atomic call: at inserts at an index, and absent it appends. Values convert in place at each boundary (Python objects, JS values); no surface asks you to serialize YAML.

Reading fields back

quill.reader(doc) is the read twin: reader.get(name) returns each field in the values form (every content leaf as its codec's text, a richtext leaf as Markdown, everything else as stored) with schema authority, so an undeclared name raises edit::unknown_field rather than reading back nothing. A read never coerces: qty: "3" reads "3" until a write canonicalizes it; reader.resolve() (JavaScript) is the coerced, blank-filled render view.

reader.get_content(name) (getContent in JavaScript) is the same read at the other end of the codec, returning the field's content corpus rather than its projection. Reach for it when you hold a content editor: a content field is stored as a corpus when the typed writer committed it and as the authored string when a Markdown parse produced it, and this read decodes both through the codec the declared type names. A type that is not a content leaf raises edit::field_not_content: an integer has no corpus, and an array<richtext> carries content without having one.

Addressing cards for re-render

Card mutators address by index, and Quillmark offers no durable card handle: a remove_card / add_card moves every index after it. For patch-and-re-render automation (a source row changed, re-render the document), carry your own key in the card's $ext under a namespace you own, and resolve the index when patching:

ext = doc.cards[index]["ext"] or {}                                # at build time
doc.store_ext({**ext, "myapp": {"row_id": row_id}}, card=index)
idx = next(i for i, c in enumerate(doc.cards)                      # at patch time
           if (c["ext"] or {}).get("myapp", {}).get("row_id") == row_id)
quill.writer(doc).set_all({"qty": new_qty}, card=idx)

$ext round-trips through Markdown and the storage DTO and never reaches a backend. Quillmark guarantees nothing about its contents: no uniqueness, no collision check, no repair. A key duplicated across two cards resolves to whichever the scan hits first.

Patching one card at a time is for a document that is no longer a pure projection of its source data: where data → document is a pure function, rebuild instead.

Scope note

The typed writer is the recommended path in both bindings. Both also expose the content write on Document: read a body or richtext field, then write it with apply (a change of steps), revise (markdown) or replace (a whole value), passing the hash you read as base so a write over a newer value refuses with edit::stale_base rather than reverting it. Both also expose a quill-free opaque store under doc.unchecked (storeField / store_field and insertCard / insert_card, schema coercion deferred to render, each content value stored in its canonical form), for storage and migration tooling holding no quill.

const base = doc.hash({ field: 'summary' })
const receipt = doc.revise({ field: 'summary' }, 'A **shorter** summary.', { base })
// receipt.droppedAnchors names each comment anchor whose text the rewrite removed

Full model: PROGRAMMATIC.md.