npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@quillmark/wasm

v0.124.0

Published

WebAssembly bindings for Quillmark, a schema-driven document engine

Readme

Quillmark WASM

WebAssembly bindings for Quillmark.

Maintained by TTQ.

Overview

Quillmark in browsers and Node, over explicit in-memory trees (Map<string, Uint8Array> / Record<string, Uint8Array>).

The package has one import surface: @quillmark/wasm, whose init resolves to Quill and Document, plus an Engine that renders them.

Quill and Document are the internal Typst-less core build's own classes, handed out verbatim by init, so 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: the render build, carrying both backends (typst, acroform), is a private WASM binary with its own linear memory, lazily loaded on the first render. The Engine clones a Quill / Document into that memory as data and frees the clones: you never hold a backend object or cross a memory boundary yourself.

Build

bash scripts/build-wasm.sh

The script builds two variants, the core (no backend) and the render build (default features, both backends), each with --target web and --weak-refs enabled (see Initialization and Lifecycle). It then asserts none of them carries a .wasm ESM import or a top-level await.

Test

bash scripts/build-wasm.sh
cd crates/bindings/wasm
npm install
npm test

Usage

import { init, Engine } from "@quillmark/wasm";

const { Quill, Document } = await init(); // see Initialization

const quill = Quill.fromTree(tree);   // no engine needed: 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" });

Initialization

init resolves to the core surface: Quill, Document, and the free functions. Everything after the await is the synchronous surface the rest of this README describes.

import { init, Engine } from "@quillmark/wasm";
const { Quill, Document } = await init();

The same line works everywhere: the binary streams from a URL in a browser and is read off disk under Node, chosen by the package's #quillmark-env subpath import rather than a runtime environment check. No bundler plugin is required: the builds are --target web, so nothing in the package graph imports a .wasm module or carries a top-level await, and a static import of this package is safe anywhere, SSR included.

init is idempotent and concurrency-safe: every non-conflicting call returns the same promise, so several entry points may each await init() for one instantiation. Destructure at every entry point (route loader, hydration path, worker) rather than threading one result around. A failed init clears the memo, so a retry works. Each realm initializes its own copy, a Worker included.

Backends need nothing. Engine instantiates a backend inside its lazy load, on the first render against it.

Overriding the source. init(source) accepts bytes, a Response, a WebAssembly.Module, or a URL, for hosts that route assets themselves or embed the binary. Pass it on the first call; a later call passing a different source rejects with runtime::init_conflict rather than silently ignoring it. Passing the same value again is fine, so several entry points may each await init(BYTES) against one constant.

Both failures reject. runtime::init_conflict and runtime::init_failed alike ride the returned promise, so one catch around await init(...) covers the gate. The core surface has no static export, so a call site that skips the await has no name to call.

Vite's dev server pre-bundles dependencies, which moves the package away from its binary. Exclude it:

// vite.config.js
export default { optimizeDeps: { exclude: ["@quillmark/wasm"] } };

A load failure surfaces as runtime::init_failed, whose hint names that line.

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, load, and supportedFormats 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 } }) where load is the lazy thunk returning the backend module and formats is the required static capability manifest. A malformed descriptor throws at new Engine(...), naming the backend id, and so does any options key other than backends.

The format probe is always free. supportedFormats depends only on quill.backendId, and answers from the descriptor's required formats manifest: never loading the multi-MB backend binary and never cloning the quill. Use it as a non-failing pre-render probe.

An editor does not wait on the render build. open reads the document before it awaits the backend load, so a session opened while the build loads compiles the document as it stood at the call. Mount the editor on core, start the load, and open once it lands:

void engine.load(quill);          // start the fetch as soon as the quill resolves
mountEditor(quill, doc);          // core alone
await engine.load(quill);         // memoized: the same load
const session = await engine.open(quill, doc); // one compile, current document

load reads only quill.backendId and clones nothing. A failed load rejects with runtime::backend_load_failed, and the next call retries.

The two doors: Document.fromMarkdown vs quill.parse / quill.conform

Document.fromMarkdown is the quill-free transport door (migrations, $ext stamping, a quill that will not load, opening a document to fix its $quill). It needs a root ~~~ block carrying a $quill line, and a content field rests as authored.

quill.parse is the bound door, and the primary ingestion path. It is Document.fromMarkdown followed by conform: the returned document's declared content fields rest at one form per codec (a richtext field as the canonical content object, a plaintext field as its literal string), so getStored answers "content object or string?" by the field's declared codec rather than by how the document was built. Parse warnings and the conform::* warnings both ride doc.warnings.

quill.conform(doc) is the same walk in place on a document that arrived any other way (fromStored, a stored row), returning the conform::* Diagnostic[] ([] when everything rested). It is idempotent and a byte no-op on an already-canonical document, YAML comments included, so calling it on every load is safe. A value the strict write refuses stays as authored under a warning. Both throw when the document declares a $quill this quill does not answer to, before any mutation.

const doc = quill.parse(markdown);          // rests canonical
const stale = Document.fromStored(row);
const diags = quill.conform(stale);         // converges in place

Storage compatibility across versions

Persist doc.toStored(), not doc.toMarkdown(): the DTO wire format is frozen per schema version, whereas Markdown syntax evolves, and toMarkdown output is normalised rather than byte-equal to the source. Document.storageVersionOf discriminates the two formats without exceptions as control flow: it answers undefined for anything that is not a storage DTO.

const doc = Document.storageVersionOf(content)
  ? Document.fromStored(content)
  : Document.fromMarkdown(content);

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.124.x patch release reads and writes that same value.

  • Upgrading is safe. A newer build reads documents an older build's writer produced. 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 fromStored. 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. The exception is a row a host authored a content construct of its own into — a line kind, container, mark type or island type outside the vocabulary. Those are refused from 0.113 on; see that release's migration guide. An island loss is not one of them: 0.115 drops the key, so a row spelling one opens whatever the class and comes back without it.
  • Downgrading is not. fromStored rejects an unknown (i.e. newer) schema version 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.storageVersionOf(blob); // undefined | string
if (v && v !== Document.currentStorageVersion()) {
  // payload is from a build with a different model version
}

storageVersionOf 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 fromStored throws.

In short: persist the toStored string, upgrade freely, never downgrade. The full design (including how migrations are added) is in prose/canon/DOCUMENT_STORAGE.md.

Cards, seeds, and addresses

To render a form editor, read field definitions from quill.schema (walk fields in key order: declaration order is display order) and the authored values from the Document payload: there is no separate form-view projection. quill.validate(doc) scores it without invoking the backend.

quill.exampleDocument() returns the quill's example, a filled-in page whose values are made up, pinned to the quill's name@version; undefined when the quill has no example.md at its root.

quill.seedDocument() returns a starter document: one card per kind, each carrying the kind's seed from Quill.yaml and no other field; quill.seedMain() and quill.seedCard(kind) seed one card. All return the read Card shape of doc.main / doc.cards, which doc.insertCard accepts directly:

doc.insertCard(quill.seedCard("note"));                 // seed → append
doc.insertCard({ kind: "note", body: "Plain **markdown**." });  // bare inline
doc.insertCard({ kind: "note" }, 0);                    // insert at index 0
doc.insertCard({                                        // fields → payload items
  kind: "note",
  payloadItems: [{ type: "field", key: "x", value: 1 }],
});

Reads and writes are two aligned shapes. A read Card always has body: Content (canonical content, never a raw string): no narrowing, no guessing whether the body was normalized. The write shape CardInput widens body to Content | string (a markdown string imports to the content) and makes every field but kind optional. Every Card is a valid CardInput, so insertCard still takes exactly what cards / removeCard / seedCard return. A fresh card is an object literal: one { type: "field", key, value } per field in payloadItems, in the order they should appear.

One address for the whole surface. Reads and writes navigate by an Addr: { card?, field? }, absent card = main, absent field = body, and a bare string is shorthand for { field }. So doc.storeField("qty", 3) targets the main card's qty, doc.storeField({ card: 2, field: "qty" }, 3) a composable card's. Reads are total over the field axis (getStored → undefined for an absent field; only an out-of-range card throws); field writes throw on a body address. getStored is the verbatim transport read, distinct from the interpreted quill.reader(doc).get; bodyMarkdown is the body markdown read (a CardAddr; a field's markdown is read through quill.reader(doc).get(field)). A content field's stored form rests per its declared codec through the bound door (a richtext field as the content object, a plaintext field as its literal string), and as authored through the transport door. For the Content either way read quill.reader(doc).getContent(addr), which decodes through the codec the field's declared type names. Card-scoped verbs take a CardAddr ({ card? }) first: doc.getExt({ card: 2 }), and the batch below.

Batch mutation: doc.storeFields({}, {...}) / doc.storeFields({ card: index }, {...}) apply a whole object atomically: on any invalid field nothing is applied and the thrown error carries one diagnostic per offending field (path = field name). The address is first (never shape-overloaded, since card is a legal field name), and parses strictly: a stray key throws rather than silently reading as {}. The main card is {}, or MAIN_CARD_ADDR (from @quillmark/wasm), a frozen alias that spells the intent: doc.storeFields(MAIN_CARD_ADDR, {...}).

Typed writes: commit* is the default, store* is the quill-free primitive

A Document holds only a $quill reference, not the resolved schema, so typed writes go through the schema-bound writer while the quill-free opaque store sits on Document itself (store = verbatim, set = typed):

  • quill.writer(doc): the typed door whenever a quill is in hand. Bind the schema once and issue bare set / setAll / reviseBody / reviseField / addCard / card(i). Each resolves the field's schema type, coerces the value to its canonical form ("3" → 3, a markdown string → a richtext content), and fails now on a mismatch instead of at render. A name the schema does not declare throws UnknownField rather than falling to the opaque store: on the typed path an undeclared name is a typo, not a fallback. The batch form (setAll) is all-or-nothing: an undeclared name aborts the whole write and its per-field diagnostics name every offending field, so a whole-form submit surfaces every typo storeFields would silently absorb. (The raw wasm class carries the quill-taking _commitField / _commitFields / _addCard / _reviseField ABI the writer delegates to, hidden from the .d.ts.)

  • store*: the deliberate quill-free primitive. doc.storeField(addr, value) / doc.storeFields(cardAddr, {...}) validate only the field name/depth/kind and store the value verbatim, no quill required. Reach for it on purpose when you want the opaque store: quill-agnostic storage/migration infra that has no bundle and must write regardless of a drifted schema; store-now-validate-later editors holding in-progress input that commit would reject; or verbatim passthrough of fields the schema doesn't own. It is the lower layer, not a lighter commit: a typo'd field name stores silently and only surfaces at quill.validate / render.

Per-keystroke cost is the same either way (both mutate the in-memory Document in place; no seam is crossed), so steering to the writer buys the type check for free.

DocumentWriter / CardWriter: bind the quill once

quill.writer(doc) binds the quill's schema to the document once, so a form editor or MCP writer that holds both issues bare verbs (the writer forwards to the per-call _commit* ABI):

const ed = quill.writer(doc);                       // Rust `quill.writer(doc)` twin; new DocumentWriter(quill, doc) also works
ed.set("subject", "Q3 results");                    // strict-committed to the schema type
ed.setAll({ qty: "3", subject: "Q3" });             // all-or-nothing batch
ed.reviseField("subject", "Q3 **results**");        // typed AND anchor-preserving; returns { delta, warnings }
ed.set("titel", "x");                               // throws UnknownField: a typo, not a fallback
ed.card(2).set("body", "**note**");                 // composable card, resolved by its $kind

DocumentWriter / CardWriter are pure JS holding references to your existing quill and doc: no WASM handle of their own, nothing to free(). card(i) is lazy: it never throws; an out-of-range index throws IndexOutOfRange at the write.

DocumentReader / CardReader: the read twin

quill.reader(doc) carries the writer's ephemerality and schema authority:

const v = quill.reader(doc);
v.get("subject");                                   // the values form: every content leaf as its codec's text, else as stored
v.getContent("subject");                            // the same read as a `Content`, whichever lane stored it
v.bodyMarkdown();                                   // the main body markdown (quill-free)
v.card(0).get("body");                              // a card field, resolved by its $kind
v.resolve();                                        // the render view: blank-filled, coerced, each field tagged with its rung

get projects and getContent returns the Content; both decode through the codec the field's declared type names, which is why they bind the quill and the verbatim doc.getStored does not. An undeclared name throws UnknownField, a type that is not a content leaf throws FieldNotContent, and an undecodable value throws FieldDecode; an absent field reads back undefined and a present-null null. A read never coerces a scalar (qty: "3" reads "3"); resolve() is the coerced view.

engine.render(quill, parsed, opts?, today?) vs. engine.open(quill, parsed, today?)

Use engine.render for one-shot exports (PDF/SVG/PNG): compiles, emits artifacts, done. Use LiveSession (returned by engine.open) for reactive previews: the session is a persistent compiler. paint / render / regions / fieldAt read its current compile without recompiling, and update(doc) recompiles in place on each edit, returning a ChangeSet whose dirtyPages tells you which pages to repaint (dirty ∩ visible). update is transactional: on throw, every read keeps serving the last-good compile. Don't open a session per export, and don't re-open per edit: update instead.

Both take the render date as an optional last argument (YYYY-MM-DD, default the local date): what a today date field and a plate's datetime.today() render as. A session keeps the date it opened with, so a preview left open past midnight renders yesterday's until it is reopened.

opts takes format, ppi, pages and regions, on engine.render and session.render alike. Any other key throws rather than reading as absent, today included, so engine.render(quill, doc, { today }) rejects instead of rendering the local date. The date goes last:

await engine.render(quill, doc, { format: "pdf" }, "2026-03-14");

A document that compiles to zero pages still produces a valid session (pageCount === 0); paint(ctx, 0, scale) and pageSize(0) then throw. Branch on pageCount === 0 to render a "no pages to preview" UI rather than relying on the throw.

Their warnings differ in reach. engine.render returns every quill.validate(doc) warning but validation::declined_construct, which the compile raises as backend::declined_construct, then the compile's own; session.render and session.warnings carry the compile's alone, so read quill.validate(doc) beside them. Neither carries the load's: those stay on doc.warnings, and a revise's on its receipt.

Canvas Preview

session.paint(ctx, page, scale) rasterizes a page directly into a CanvasRenderingContext2D (main thread) or OffscreenCanvasRenderingContext2D (Worker), skipping PNG/SVG byte round-trips.

scale is backing-store pixels per point. The painter owns canvas.width / canvas.height, reducing scale where it must so neither exceeds 16384 px; consumers own canvas.style.*. A canvas styled to fill its page box needs nothing back from the paint:

canvas.style.width = "100%";                 // the box sets the display size
const cssPxPerPt = canvas.clientWidth / session.pageSize(0).widthPt;
if (cssPxPerPt > 0) {                        // 0 while the canvas has no layout box
  session.paint(canvas.getContext("2d")!, 0, cssPxPerPt * window.devicePixelRatio);
}
  • Fold devicePixelRatio, in-app zoom, and visualViewport.scale into scale.
  • A canvas with no layout box (display: none on it or an ancestor, detached, in a zero-width container) has clientWidth 0, and a 0 scale throws backend::invalid_raster_scale: skip the paint, and paint when a ResizeObserver reports a width.
  • paint writes the whole backing store with putImageData, which ignores the 2D context transform, globalAlpha, and clip. Give each visible page its own <canvas>: no compositing, sub-rect, or transform reaches through paint.
  • paint is always a full repaint, and there is no per-page raster cache. Keep a page's canvas alive while it stays near the viewport: an idle canvas retains its pixels for free, whereas pooling one canvas across pages re-renders on every scroll.
  • pageCount and pageSize(page) read the current compile, not the session: cache them between committed updates only. After one, the count is ChangeSet.pageCount, and every page in ChangeSet.dirtyPages needs its pageSize re-read.
  • In a Worker, pass an OffscreenCanvasRenderingContext2D. An OffscreenCanvas has no layout box to measure, so the main thread posts the scale. Loading the WASM module inside the Worker is the host's responsibility.
  • paint / pageSize throw on a page the compile does not have, a zero-page compile included, naming the index and the pageCount that excludes it. That throw is the whole contract: open the session and handle it.

Schema model

A field declares no required key: nothing is required.

default: — what an unanswered field renders. With one, quill.blueprint renders that value under a type-only # <type> annotation and the render path uses it when the document omits the field. Without one, the blueprint leaves the cell empty and an absent field blank-fills.

An unanswered field draws no diagnostic. Partial documents are accepted, and engine.render(quill, doc) throws only for malformed input.

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
  }
}

Delivery follows the function, not the failure. A synchronous method throws; a promise-returning one rejects. The promise-returning surface is init and the four Engine verbs (render, open, load, supportedFormats), so a programming error reached through one of them (a foreign handle, an unregistered backend) rejects like any other failure. Nothing here both returns a promise and throws, so a .catch on a promise-returning call is a whole guard.

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, and message is derived from it. diagnostics[0] is an error. A failed Typst engine.render or engine.open also carries the quill's load warnings after its errors, so read each entry's severity. The same shape applies to every throw site:

  • Document.fromMarkdown: parse errors (missing root $quill metadata, YAML errors, parse::input_too_large for inputs > 10 MiB).
  • Document mutators (storeField, insertCard, the writer's set, etc.): mutator failures carry a namespaced edit::* code on diagnostics[0] (edit::invalid_field_name, edit::unknown_field, edit::index_out_of_range, edit::field_coercion_failed, …). Route on diagnostics[0].code, never on message text.
  • engine.render / session.render: backend compilation failures and validation errors.
  • An object argument carrying a key its verb does not read: the render options, new Engine options, an Addr, a CardInput. Every own string key counts, a non-enumerable one and one holding undefined included. The argument must be a plain object, its prototype null, Object.prototype of any realm, or a null-prototype object whose keys count too, so a Map, a class instance or Object.create({ … }) throws. A payload item, the fields object storeFields takes, and a $ext or $seed value read their own enumerable keys alone: a non-enumerable or inherited key there is neither read nor refused. The diagnostic names the key or what was passed, and carries no code: it is a call site to fix, not a condition to route on.
  • engine.render(quill, parsed) against a quill whose name differs (quill::name_mismatch) or whose version falls outside the document's selector (quill::version_mismatch): a throw, never a warning.
  • The four Engine verbs against a quill whose declared backend: is not in the registry: engine::backend_not_found, the code core raises for the same condition, hinting the registered ids.
  • The Engine verbs, session.update, and the writer/reader binds against a value that is not one of this copy's handles — the wrong type, or the right class from a second copy of @quillmark/wasm: runtime::not_a_quill / runtime::not_a_document, hinting npm ls @quillmark/wasm for the second case. Two copies are two WASM memories and two Quill/Document classes; dedupe to one. Elsewhere a foreign handle meets wasm-bindgen's own expected instance of …, which is not a QuillmarkError.

Lifecycle

Handles begin at init, which instantiates the core build; Engine instantiates a backend on the first render against it.

The wasm bindings are built with --weak-refs, so dropped Document, Quill, and LiveSession handles are reclaimed by FinalizationRegistry without manual .free() discipline. .free() is still emitted as an eager teardown hook for callers that want deterministic release.

engine.render and engine.open read the quill and doc handles synchronously, before their first await, so freeing a handle as soon as the call returns: try { return engine.render(quill, doc); } finally { doc.free(); }: is safe even on the first render, while the backend binary is still loading.

The package floor is Node 24+ (engines: { node: ">=24" }) and current evergreen browsers; --weak-refs itself only needs Node 14.6+. The using sugar (explicit resource management) is on that floor and optional; an explicit try / finally is the equivalent, and the form that also runs in a browser that hasn't shipped it:

const session = await engine.open(quill, doc);
try {
  for (let p = 0; p < session.pageCount; p++) {
    session.paint(canvases[p].getContext("2d")!, p, scale);
  }
} finally {
  session.free();
}

Changelog

See the changelog and the GitHub Releases page for release notes and version history.

License

Apache-2.0