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.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 an Engine that 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.sh

The 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 test

Usage

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);               // true

Throws 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. fromJson 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.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 map

There 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 the supportsCanvas probe — ships ahead of its first production consumer and may change shape in any 0.x release. engine.render is 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. Fold window.devicePixelRatio, in-app zoom, and visualViewport.scale (pinch-zoom) into a single value here. Pass devicePixelRatio for crisp output on high-DPI displays.
  • The effective rasterization scale is layoutScale * densityScale. If that would exceed the safe maximum (16384 px per side), densityScale is clamped proportionally; compare result.pixelWidth against Math.round(result.layoutWidth * densityScale) to detect.
  • paint is always a full repaint — setting the backing-store width / height clears it. No clearRect required.
  • pageCount and pageSize(page) are stable for the session's lifetime (immutable snapshot) — cache them.
  • Worker support: pass an OffscreenCanvasRenderingContext2D and the same call signature works. layoutWidth / layoutHeight are informational in that mode (no CSS layout box); fold everything into densityScale. Loading the WASM module inside a Worker is the host's responsibility.
  • Backend support: gated by supportsCanvas. Probe upfront with engine.supportsCanvas(quill) (or session.supportsCanvas) before mounting a canvas-based UI; the throw on paint / pageSize remains the enforcement contract and includes the resolved backendId for debugging.

Schema model

A field's cell is inferred from whether its schema declares a default::

  • Unendorsed (no default:) — quill.blueprint renders <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.blueprint renders the default value followed by a ; delete-ok annotation, 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 $quill metadata, YAML errors, parse::input_too_large for inputs > 10 MB).
  • Document mutators (setField, updateCardField, etc.) — EditError variants (InvalidFieldName, InvalidKindName, ReservedKind, IndexOutOfRange) appear in diagnostics[0].message with 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-yaml opener is still accepted but non-canonical) with a $quill system-metadata line. Empty input surfaces a dedicated "Empty markdown input cannot be parsed" message.
  • A $quill mismatch during engine.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