@quillmark/wasm
v0.90.0
Published
WebAssembly bindings for quillmark
Readme
Quillmark WASM
WebAssembly bindings for Quillmark.
Maintained by TTQ.
Overview
Use Quillmark in browsers/Node.js with explicit in-memory trees (Map<string, Uint8Array> / Record<string, Uint8Array>).
The package exposes one import surface:
@quillmark/wasm(the root) — the canonical API:Quill,Document, and anEnginethat renders them.
Quill and Document are re-exported verbatim from the internal Typst-less
core build, so engine-free editor/validation code (Quill.fromTree,
Document.fromMarkdown) loads only that small core binary — no backend is
loaded until you render. The Engine hides everything else: each backend (Typst
today) is a separate, private WASM binary with its own linear memory, lazily
loaded on the first render. The Engine clones a Quill / Document into the
backend's memory as data and frees the clones — you never hold a backend object
or cross a memory boundary yourself.
Build
bash scripts/build-wasm.shThe script builds for bundler and experimental-nodejs-module targets with
--weak-refs enabled (see Lifecycle).
Test
bash scripts/build-wasm.sh
cd crates/bindings/wasm
npm install
npm testUsage
import { Document, Quill, Engine } from "@quillmark/wasm";
const quill = Quill.fromTree(tree); // engine-free: build + validate
const engine = new Engine(); // loads a backend lazily on first render
const markdown = `~~~
$quill: my_quill
$kind: main
title: My Document
~~~
# Hello`;
const parsed = Document.fromMarkdown(markdown);
const result = await engine.render(quill, parsed, { format: "pdf" });API
new Engine(options?)
Create the render dispatcher. Routes each quill to its backend by
quill.backendId, lazily loads that backend binary, and renders — cloning the
quill/document into the backend's memory and freeing the clones internally.
render, open, supportedFormats, and supportsCanvas are async (the
first call may load a backend). Pass { backends } to register or override
backend descriptors. Each entry is a descriptor
({ [backendId]: { load, formats, canvas } }) where load is the lazy thunk
returning the backend module and formats/canvas are the required static
capability manifest. A malformed descriptor throws at new Engine(...), naming
the backend id.
Capability probes are always free. supportedFormats and supportsCanvas
depend only on quill.backendId, and answer from the descriptor's required
formats/canvas manifest — never loading the multi-MB backend binary and
never cloning the quill. Use them as non-failing pre-render probes.
Quill.fromTree(tree)
Build + validate a Quill from an in-memory tree. Pure — the declared backend
is resolved at render time, not here. Loads no backend binary.
Document.fromMarkdown(markdown)
Parse markdown to a parsed document. Throws a JS Error (with .diagnostics
attached, see Errors) on any parse failure, including a missing
root $quill metadata line, malformed YAML, and inputs over the 10 MB
parse::input_too_large limit.
doc.toMarkdown()
Emit canonical Quillmark Markdown. Type-fidelity round-trip safe:
Document.fromMarkdown(doc.toMarkdown()) returns a document equal to doc
under doc.equals. The output is not guaranteed
byte-equal to the original source — YAML quoting, key ordering, and
whitespace are normalised. Use equals (not string comparison) to test
semantic equality.
doc.toJson()
Serialize the document to a versioned storage DTO — a JSON string
carrying a schema version. Use this (not toMarkdown) to persist a
document across a process restart or crate upgrade: the wire format is
frozen per schema version, whereas Markdown syntax evolves. Parse-time
warnings are not part of the DTO.
The string is produced inside the module by serde_json; the JS JSON
global is not involved. It is standard JSON text, so callers may
JSON.parse it to inspect it — but it is intended as an opaque blob you
persist and hand back.
toJson() is deterministic: a Document that is equals to another
serializes to a byte-identical string — across repeated calls, and across
any crate upgrade that keeps the same schema version (every release does until
the Document model changes; see Storage compatibility).
Field order is fixed and object key order is preserved, so content hashes
and string-equality dirty-checks over the output are stable.
Document.fromJson(json)
Reconstruct a Document from a storage DTO string produced by toJson.
Round-trips losslessly:
const stored = doc.toJson(); // persist this string
const restored = Document.fromJson(stored);
restored.equals(doc); // trueThrows a JS Error on malformed JSON, an unknown schema version, or a
malformed payload. The restored document has no parse-time warnings.
Document.tryFromJson(json)
Like fromJson, but returns undefined instead of throwing when json is
not a valid storage DTO. Use it to branch on format without a heuristic or
try/catch as control flow:
// "JSON canonical, Markdown fallback" — no exceptions, no string sniffing
const doc = Document.tryFromJson(content) ?? Document.fromMarkdown(content);undefined means only "not a storage DTO"; fromMarkdown still throws on
genuinely malformed Markdown.
Storage compatibility across versions
The schema value (quillmark/[email protected]) is the model version,
not the running crate version. It is a hand-set constant, bumped only when
the Document model itself changes — so every 0.81.x patch release reads
and writes that same value.
- Upgrading is safe. A newer build always reads documents written by an
older one. Each schema version's wire format is frozen and never changes;
when the model does change, the new build ships a migration that converts
old payloads on
fromJson. A document you commit as your canonical on-disk format keeps loading across crate upgrades — there is no need to pin old wasm to read old data. - Downgrading is not.
fromJsonrejects an unknown (i.e. newer)schemaversion rather than guessing at a format it predates. Don't feed documents written by a newer build back into an older one.
To detect a version mismatch before parsing, use the static accessors:
const v = Document.schemaVersionOf(blob); // undefined | string
if (v && v !== Document.currentSchemaVersion()) {
// payload is from a build with a different model version
}schemaVersionOf does not validate the payload — it only reads the
schema field, returning undefined for non-JSON, non-objects, or
payloads that don't carry one. Use it to distinguish "wrong version" from
"corrupt" when fromJson throws.
In short: persist the toJson string, upgrade freely, never downgrade. The
full design — including how migrations are added — is in
prose/canon/DOCUMENT_STORAGE.md.
doc.equals(other)
Structural equality between two Document handles. Compares main and
cards by value; parse-time warnings are intentionally excluded.
Use this to debounce upstream prop updates: keep the last parsed Document
and compare instead of re-parsing on every keystroke.
doc.cardCount
O(1) getter for the number of composable cards (excluding the main card).
Use this to validate indices before calling card mutators (removeCard,
updateCardField, etc.) without allocating the full cards array.
quill.validate(doc)
Returns Diagnostic[] — the document validated against the quill schema,
without invoking the backend. An empty array means the document is valid.
Each diagnostic carries the canonical validation::* code, path, and
hint. Includes the non-fatal validation::field_absent completeness
signal that render demotes (an absent Unendorsed field zero-fills rather
than failing), so filter by severity/code for blockers vs. hints:
const diagnostics = quill.validate(Document.fromMarkdown(markdown));
const errors = diagnostics.filter(d => d.severity === "error");To render a form editor, read field definitions from quill.schema (sort
fields by each field's ui.order) and the authored values from the
Document payload — there is no separate form-view projection.
quill.seedDocument()
Returns a starter Document seeded from the schema: each field's example:
is committed and every other field is left absent (the render layer fills
default: → type-empty zero). Illustration-first — a field with both an
example and a default renders its example. Use as the initial state for a
"new document" editor.
const doc = quill.seedDocument();
const markdown = doc.toMarkdown();For per-card seeding, quill.seedMain() returns just the $kind: main card
and quill.seedCard(kind) returns a starter composable card (or undefined
if the kind is not declared). Both return the same Card shape as
doc.main / doc.cards, which doc.pushCard / doc.insertCard accept
directly:
doc.pushCard(quill.seedCard("note")); // seed → push
doc.pushCard(Document.makeCard("note", { x: 1 })); // build from a flat mapThere is one Card shape in both directions — pushCard / insertCard take
exactly what cards / removeCard / seedCard return. Build a fresh card
from a flat field map with Document.makeCard(kind, fields?, body?).
engine.render(quill, parsed, opts?) vs. engine.open(quill, parsed)
Experimental: the entire session surface —
engine.open,RenderSession,paint,PaintOptions,PaintResult,PageSize, and thesupportsCanvasprobe — ships ahead of its first production consumer and may change shape in any 0.x release.engine.renderis the stable path.
Use engine.render for one-shot exports (PDF/SVG/PNG) — compiles, emits
artifacts, done. Use RenderSession (returned by engine.open) for
reactive previews where
you'll paint or re-emit pages multiple times: the session retains the compiled
snapshot so subsequent paint / render calls skip recompilation. Don't open
a session per export.
engine.render(quill, parsed, opts?)
Render a pre-parsed Document against quill. Throws UnsupportedBackend if
no registered backend matches the quill's declared backend.
engine.open(quill, parsed) + session.render(opts?)
Open once, render all or selected pages (opts.pages).
The session also exposes pageCount, backendId, supportsCanvas,
warnings (snapshot of session-level diagnostics attached at open time),
pageSize(page), and paint(ctx, page, opts?) for canvas previews. See
below.
A document that compiles to zero pages still produces a valid session
(pageCount === 0); paint(ctx, 0) and pageSize(0) then throw
page index 0 out of range (pageCount=0). Branch on pageCount === 0 to
render a "no pages to preview" UI without relying on the throw.
Canvas Preview (Typst only)
session.paint(ctx, page, opts?) rasterizes a page directly into a
CanvasRenderingContext2D (main thread) or
OffscreenCanvasRenderingContext2D (Worker), skipping PNG/SVG byte
round-trips.
The painter owns canvas.width / canvas.height — it sizes the backing
store itself. Consumers own canvas.style.* (or the layout system that
sets them) and read layoutWidth / layoutHeight from the returned
PaintResult.
const result = session.paint(canvas.getContext("2d"), 0, {
layoutScale: 1, // layout px per Typst pt
densityScale: window.devicePixelRatio, // backing-store density
});
canvas.style.width = `${result.layoutWidth}px`;
canvas.style.height = `${result.layoutHeight}px`;layoutScale(default 1) sets the canvas's display-box size:layoutWidth = widthPt * layoutScale. For on-screen canvases this is CSS pixels per Typst point. Defaults to 1 (one CSS pixel per pt).densityScale(default 1) is the backing-store density multiplier. Foldwindow.devicePixelRatio, in-app zoom, andvisualViewport.scale(pinch-zoom) into a single value here. PassdevicePixelRatiofor crisp output on high-DPI displays.- The effective rasterization scale is
layoutScale * densityScale. If that would exceed the safe maximum (16384 px per side),densityScaleis clamped proportionally; compareresult.pixelWidthagainstMath.round(result.layoutWidth * densityScale)to detect. paintis always a full repaint — setting the backing-store width / height clears it. NoclearRectrequired.pageCountandpageSize(page)are stable for the session's lifetime (immutable snapshot) — cache them.- Worker support: pass an
OffscreenCanvasRenderingContext2Dand the same call signature works.layoutWidth/layoutHeightare informational in that mode (no CSS layout box); fold everything intodensityScale. Loading the WASM module inside a Worker is the host's responsibility. - Backend support: gated by
supportsCanvas. Probe upfront withengine.supportsCanvas(quill)(orsession.supportsCanvas) before mounting a canvas-based UI; the throw onpaint/pageSizeremains the enforcement contract and includes the resolvedbackendIdfor debugging.
Schema model
A field's cell is inferred from whether its schema declares a default::
- Unendorsed (no
default:) —quill.blueprintrenders<must-fill>in the value cell. An absent Unendorsed field is a non-fatal signal (validation::field_absent) — the render path zero-fills it silently. A surviving<must-fill>sentinel is fatal (validation::must_fill_sentinel). Partial documents are first-class;engine.render(quill, doc)only throws for malformed input. - Endorsed (with
default:) —quill.blueprintrenders the default value followed by a; delete-okannotation, and the default is used when the document omits the field.
QuillFieldSchema no longer carries a required axis. The legacy
validation::missing_required code has been replaced by
validation::field_absent; the validation::must_fill_sentinel
code covers unreplaced sentinels.
Errors
Every method that can fail throws a QuillmarkError — a JS Error with
.diagnostics attached. The type and a guard are exported from the root:
import { isQuillmarkError, type QuillmarkError } from "@quillmark/wasm";
try {
const result = await engine.render(quill, doc);
} catch (e) {
if (isQuillmarkError(e)) {
for (const d of e.diagnostics) console.error(d.severity, d.message);
} else {
throw e; // not a quillmark failure — programming error, re-throw
}
}QuillmarkError is a structural interface, not a class — the WASM layer
throws a real Error and attaches the property, so there is no constructor to
instanceof against; narrow with isQuillmarkError (which also works on
errors from any build or WASM instance in the page).
diagnostics is always non-empty — length 1 for most failures, length N for
backend compilation errors. message is derived from diagnostics
(diagnostics[0].message for single-diagnostic errors; an aggregate
"<N> error(s): <first.message>" summary for compilation failures).
Read err.diagnostics[0] for the primary diagnostic; iterate the array for
compilation failures. The same shape applies to every throw site:
Document.fromMarkdown— parse errors (missing root$quillmetadata, YAML errors,parse::input_too_largefor inputs > 10 MB).Documentmutators (setField,updateCardField, etc.) —EditErrorvariants (InvalidFieldName,InvalidKindName,ReservedKind,IndexOutOfRange) appear indiagnostics[0].messagewith the[EditError::<Variant>]prefix.engine.render/session.render— backend compilation failures and validation errors.
Lifecycle
The wasm bindings are built with --weak-refs, so dropped Document,
Quill, and RenderSession handles are reclaimed by FinalizationRegistry
without manual .free() discipline. .free() is still emitted as an eager
teardown hook for callers that want deterministic release.
The package floor is Node 22+ (engines: { node: ">=22" }) and current
evergreen browsers; --weak-refs itself only needs Node 14.6+. The using
sugar shown below (explicit resource management) needs Node 24, but is
optional — the try / finally fallback runs on the Node 22 floor.
For environments where using (the explicit resource management
proposal) hasn't landed, use an explicit try / finally:
const session = engine.open(quill, doc);
try {
for (let p = 0; p < session.pageCount; p++) {
session.paint(ctx, p);
}
} finally {
session.free();
}Notes
- Parsed markdown requires a root
~~~block (a bare three-tilde fence; the legacy~~~card-yamlopener is still accepted but non-canonical) with a$quillsystem-metadata line. Empty input surfaces a dedicated "Empty markdown input cannot be parsed" message. - A
$quillmismatch duringengine.render(quill, parsed)is a thrown error, not a warning: rendering with a quill whose name differs (quill::name_mismatch) or whose version falls outside the selector (quill::version_mismatch) is rejected. - Output schema APIs live on
Quill, not the engine.
Changelog
See the changelog and the GitHub Releases page for release notes and version history.
License
Apache-2.0
