npm — @minjun0219/mdwire
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.