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

@vectojs/markdown

v0.20.3

Published

Canvas-native Markdown (and TeX math) rendering for VectoJS: a Markdown entity plus CodeBlock, built on @vectojs/ui components with marked + @vectojs/tex.

Readme

@vectojs/markdown

Canvas-native Markdown (with TeX math) rendering for VectoJS.

Markdown is a high-level entity that parses Markdown with marked, renders TeX math to SVG with @vectojs/tex, and lays the result out using @vectojs/ui components (RichText, Stack, Table, Text, Image). It also exports CodeBlock.

This package was split out of @vectojs/ui so that the heavy marked + @vectojs/tex dependencies are only pulled in by apps that actually render Markdown. Because it depends on @vectojs/ui components, it sits above ui in the dependency graph — install it alongside @vectojs/ui and @vectojs/core.

Install

bun add @vectojs/markdown @vectojs/ui @vectojs/core

Usage

import { Markdown } from "@vectojs/markdown";

const md = new Markdown("# Hello\n\nInline math $E = mc^2$.");
scene.add(md);

Streaming

createStream() coalesces accepted chunks into at most one Markdown parse/layout commit per animation frame. write() applies backpressure when its bounded buffer is full; await it when consuming an async token source.

const md = new Markdown("");
scene.add(md);

const stream = md.createStream();
for await (const token of tokens) {
  await stream.write(token);
}
// Commits the final text, then waits for the parse to be applied — once this
// resolves, the rendered document reflects everything written.
await stream.close();

Add fixed-rate typewriter pacing without changing producer code:

const stream = md.createStream({
  pacing: { graphemesPerSecond: 48 },
  maxBufferedChars: 64 * 1024,
  signal: abortController.signal,
});

Pacing slices by grapheme cluster, so combining marks, emoji ZWJ sequences, flags, and surrogate pairs stay intact across ordinary chunk/frame boundaries. abort() discards uncommitted text; Markdown.destroy() does the same cleanup automatically. The existing appendMarkdown() API remains synchronous and flushes submitted controller text before a direct append.

Incomplete Markdown while streaming

Mid-stream, the trailing text of a paragraph is often an unclosed inline construct — **bo, `cod, [text](url. marked has no signal that more characters are coming, so it lexes those as plain text. incompleteMode chooses what you show in the meantime:

const stream = md.createStream({ incompleteMode: "optimistic" });

| Mode | Unclosed **bo renders as | | --------------------- | ------------------------------------ | | 'literal' (default) | **bo — the literal characters | | 'optimistic' | bo — guessed bold, syntax hidden |

'optimistic' covers strong, emphasis, and inline code. An unclosed link shows its label as plain text: with no closing ) there is no URL yet, so nothing is made clickable. Only the document's last paragraph is ever guessed at, and only while the stream is open — headings, list items, table cells, and any earlier paragraph are always literal.

The guess is display-only. Markdown.tokens is never affected, and close() unwinds it, so a 'literal' and an 'optimistic' stream of the same source end at an identical document. 'literal' remains the default: it is what every prior release rendered.

Knowing when the document is final

onStable fires once, after close() has committed the last chunk and the parse has been applied. Use it for one-time work you do not want repeated against content still in flight:

const stream = md.createStream({
  onStable: (blocks) => {
    for (const block of blocks) fadeIn(block);
  },
});

It never fires on flush(), abort(), or destroy() — none of those mean the content stopped changing. It receives a snapshot array, not a live reference. Calling appendMarkdown() or setContent() from inside the callback throws; a throw from the callback rejects the close() promise. onStable is independent of incompleteMode and works with the 'literal' default.

Streamed TeX math

A fenced math block is typeset only once its closing fence arrives:

```math
\int_0^1 x\,dx = \frac{1}{2}
```

While the fence is still open the block renders as an ordinary code block showing the TeX source, then becomes the formula on the chunk that closes it. This is deliberate. marked lexes an unterminated fence as a complete token as soon as it reads the info string, so a formula streamed a few characters at a time arrives as a long run of whole tokens, nearly all of them invalid TeX — typesetting each one spends the most expensive call in this package rendering an error glyph that is replaced by the next chunk. Showing the source is both cheaper and more honest: the formula genuinely is not finished.

Converted formulas are cached (bounded, process-wide), so a repeated formula is converted once no matter how many documents or instances render it.

Inline $...$ math is a separate path: it is currently shown as styled source text, not typeset.

The math engine is loaded on demand

TeX math is typeset by @vectojs/tex, which is imported dynamically the first time a document actually has a formula. It is by far the heaviest thing this package can pull in — measured against a browser bundle of a consumer that renders only prose, built with code splitting and minification:

| prose-only consumer | raw | gzip | chunks | | ---------------------- | --------: | ------: | -----: | | mathjax-full | 2,199,869 | 748,713 | 19 | | @vectojs/tex (now) | 758,249 | 273,754 | 3 | | no math at all (floor) | 379,224 | 118,670 | 3 |

Against that floor the math path itself is 630,043 gzip under mathjax-full and 155,033 under @vectojs/tex4.06x smaller. The eagerly-downloaded entry chunk a prose-only consumer actually pays for is 117,889 gzip, within 1 KB of the no-math floor.

Your bundler needs code splitting enabled to see this; without it the bytes are still in the output, just not evaluated until first use.

The tradeoff is that the first formula on a page cannot be typeset synchronously. It renders as a code block of TeX source — the same state an unclosed fence already shows — and is replaced once the module resolves. Every formula after that is synchronous again. (The engine itself is synchronous; the lazy import is what defers it, and it is kept for the bundle size above.)

While streaming this is invisible: the load starts as soon as an opening math fence appears, several chunks before the closing one, so the formula is typeset on the chunk that closes it. await stream.close() and onStable also wait for a pending load, so a "final" document never hands you an untypeset formula.

If you need the very first formula typeset in the same tick — measuring layout immediately after construction, for instance — preload it:

import { Markdown, preloadMathJax } from "@vectojs/markdown";

await preloadMathJax();
const md = new Markdown("```math\n\\int_0^1 x\\,dx\n```"); // typeset synchronously

preloadMathJax() is idempotent and shared across every document, so calling it from several places starts one load. isMathJaxReady() reports whether formulas currently typeset without waiting. If the load fails, formulas keep rendering as TeX source rather than throwing.

Both names are historical: they date from when mathjax-full was the engine and mean "the math engine", whichever one that is. They keep those names because they are public API and a rename would break every consumer for cosmetics.

A formula containing a symbol outside the engine's shipped glyph corpus also renders as TeX source rather than being drawn with that symbol missing.

Images

An image renders in one of two ways, decided by where it is written.

On its own, or in a paragraph, blockquote or list item, the paragraph splits into blocks and the image becomes an Image entity at its natural size, capped to the available width. This is the ordinary ![alt](url) case.

On a line it shares with text — in a heading, or in a table cell — it renders as an inline box in the text run, so the prose flows around it and selection and the accessible name still work. Its height is a multiple of the run's font size (theme.inlineImageScale, default 1.15) and its width follows the image's natural aspect ratio, so a badge stays wide and a square icon stays square:

const md = new Markdown("# Build ![passing](https://img.example/badge.svg)");

This is a deliberate departure from HTML, which would render an inline image at its intrinsic size. A 512px logo written into an h1 would otherwise tower over its own heading, and an inline box has to be sized before the image has decoded. The height is fixed up front for the same reason: the line box never moves, and only the width settles once the aspect ratio is known.

The alt text is the accessible name and the copied text — never painted as visible prose. If the image fails to load, the box is replaced by the alt text rather than left as an invisible gap.

Syntax coverage

Everything in CommonMark plus the GFM extensions this renderer draws: tables, strikethrough, task lists, autolinks, plus $…$ / $$…$$ TeX math and ```math fences.

Two constructs are deliberately not supported, and both are pinned by tests so the behaviour cannot drift silently:

  • Definition lists. Term then : definition renders as the two literal lines the source contains, colon included.
  • Raw HTML blocks. <details>, <div>, <iframe> and HTML comments render nothing at all.

Definition lists are neither CommonMark nor GFM; when they arrive it will be through the same syntax-extension mechanism footnotes need. Raw HTML blocks cannot work in a zero-DOM renderer — there is no DOM to hand markup to. <svg> is the one exception, because a self-contained SVG document can be rasterized.

Footnotes ([^1]) are not yet parsed and currently render as literal source.

Migrating from @vectojs/ui ≤ 1.x? Markdown and CodeBlock used to be exported from @vectojs/ui. As of @vectojs/[email protected] they live here — change import { Markdown } from '@vectojs/ui' to from '@vectojs/markdown'.