npm — @minjun0219/mdwire

npm versionnpm monthly downloads

The Rust core compiled to WASM, plus hand-written JS for React and an event list. Runs in Node, Bun and bundlers.

npm install @minjun0219/mdwire

The package picks its build by exports condition: Node gets a CommonJS build that loads the WASM from disk, everything else gets the ESM bundler build. Under a bundler the WASM is imported as an ES module — Vite needs vite-plugin-wasm and build.target: "esnext" (for the top-level await). See examples/react-streaming/vite.config.js.

Channel names are the strings listed on Concepts.

render(input, channel, options?)

import { render } from "@minjun0219/mdwire";

const parts: string[] = render(markdown, "telegram-html");

Converts a finished document. Returns the parts to send — one string unless the channel’s limit (or options.limit) splits it.

renderWithReport(input, channel, options?)

import { renderWithReport } from "@minjun0219/mdwire";

const out = renderWithReport(markdown, "slack-markdown");
out.parts;                   // string[]
out.repairs.closedEmphasis;  // see "Repairs" below
out.free();                  // the result lives in WASM memory

render plus the repair report. The result and its repairs are WASM objects: call free() when done, or use using (Symbol.dispose).

limit(channel)

The channel’s part limit as a number. html has none and returns 4294967295.

new Streamer(channel, options?)

import { Streamer } from "@minjun0219/mdwire";

const s = new Streamer("telegram-html");
let acc = "";
for await (const token of tokens) {
  acc += s.push(token);           // final — never rewritten
  await edit(acc + s.preview());  // + what is held, drawn as if the input ended here
}
acc += s.finish();
if (s.revised()) await edit(acc); // skip when the last frame already equals the result
s.free();
Method Returns
push(chunk) string What is safe to send now. Final.
preview() string The tail to append when redrawing the whole message. Does not change state; call it when you draw, not per chunk.
closeOpen() string The tail that only closes block markup already sent. Does not change state.
finish() string The rest, with open markup closed.
revised() boolean After finish: does the result differ from the last preview() frame?
repairs() Repairs What was repaired so far — the whole document after finish.
free() Releases the WASM memory.

For an append-only channel (Slack appendStream), send each push piece and never call preview(). See the streaming contract.

RenderOptions

interface RenderOptions {
  limit?: number;                       // a positive integer; below 256 is raised to 256
  html?: {                              // the "html" channel only
    lineBreaks?: "br" | "space";        // default "br"
    images?: "link" | "load";           // default "link"
    schemes?: string[];                 // default ["http", "https", "mailto"]; replaces, does not add
  };
}

An unknown channel name, an unknown html value, or a limit that is not a positive integer throws an Error.

Repairs

Field
closedEmphasis · closedFence · revertedCodeSpan · droppedMarker · guessedPair next release Repairs
escapedChar · tagEmphasis · strippedHtml · rewrittenBullet · rewrittenTable · convertedMarker Channel rewrites

What each counts: Concepts.

React — @minjun0219/mdwire/react

Builds React elements with createElement — no innerHTML. Escaping, the tag set and link schemes are decided by the core’s html channel; you choose which component draws each tag.

import { Markdown, useMarkdownStream } from "@minjun0219/mdwire/react";

<Markdown text={answer} components={{ a: RouterLink }} />

const { elements, push, finish } = useMarkdownStream();      // push(token) … finish()
const settled = useMarkdownStream({ eager: false });        // only what is final
<Markdown text components? options? /> A finished answer. components maps a tag (a, code, …) to your component.
useMarkdownStream({ components?, options?, eager?, onSettled? }) Streaming. Returns { elements, push, finish }. eager (default true) draws held content early; false shows only what is final. onSettled(html, revised) runs after finish. Options are read once, except onSettled.
toElements(html, components?, schemes?) html channel output (streaming: accumulated output + preview()) to React nodes. Pass the same schemes you gave the core.

Events — @minjun0219/mdwire/events

import { toEvents } from "@minjun0219/mdwire/events";

toEvents(html); // [{ type: "open", tag: "p", attrs: {} }, { type: "text", text: "…" }, { type: "close", tag: "p" }, …]

The same html output as a flat open / text / close / void list, for other frameworks. Attributes carry only what the core emits: href on a, class (language-…) on code, style (text-align:…) on th and td, start on ol, and src · alt on img when images load.

lezer — @minjun0219/mdwire/lezer next release

import { GFM, parser } from "@lezer/markdown";
import { koreanEmphasis } from "@minjun0219/mdwire/lezer";

const p = parser.configure([GFM, koreanEmphasis]); // after GFM: it replaces Emphasis and Strikethrough by name
// CodeMirror: markdown({ base: markdownLanguage, extensions: [koreanEmphasis] })

For screens that draw Markdown with @lezer/markdown or CodeMirror instead of going through mdwire. The extension reads emphasis with the same rules as the core, so **설정(config)**을 becomes bold instead of leaving its asterisks on screen. No WASM; @lezer/markdown (1.5 or later) is an optional peer dependency.

Three things differ from the core, by design: markers that do not pair stay as text (the core drops some of them), emphasis left open at the end of a paragraph stays as text (the core closes it), and a run of four or more * is text. Checked against the core’s output on 6,965 real paragraphs.