@minjun0219/mdwire 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -35
- package/bundler/mdwire.d.ts +42 -41
- package/bundler/mdwire_bg.js +51 -55
- package/bundler/mdwire_bg.wasm +0 -0
- package/js/events.d.ts +5 -5
- package/js/events.js +3 -3
- package/js/react.d.ts +10 -13
- package/js/react.js +26 -21
- package/node/mdwire.d.ts +42 -41
- package/node/mdwire.js +51 -55
- package/node/mdwire_bg.wasm +0 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -5,12 +5,12 @@ English | [한국어](README.ko.md)
|
|
|
5
5
|
Send LLM-generated Markdown to chat channels without it breaking.
|
|
6
6
|
Docs, API reference and a live demo: [mdwire.minjun.dev](https://mdwire.minjun.dev).
|
|
7
7
|
|
|
8
|
-
Agents emit Markdown. Chat channels don't
|
|
8
|
+
Agents emit Markdown. Chat channels don't take it as is — each has its own subset, its own
|
|
9
9
|
escaping rules, and its own length limit. Existing converters assume the input is
|
|
10
10
|
well-formed CommonMark and target one channel at a time. Neither assumption holds for
|
|
11
11
|
agent output.
|
|
12
12
|
|
|
13
|
-
**Status: v0.1.
|
|
13
|
+
**Status: v0.1.11.** Normalizing, rendering, splitting and streaming work for six
|
|
14
14
|
targets — Telegram HTML, Slack `markdown_text`, GitHub comments (GFM), Notion pages, plain text,
|
|
15
15
|
and HTML for the browser — from a Rust core, a CLI,
|
|
16
16
|
an npm package (WASM), and a Go port. See `SPEC.md` for what is in v0.1 and what was
|
|
@@ -22,15 +22,16 @@ deliberately deferred. `SPEC.md` and `DESIGN.md` are written in Korean.
|
|
|
22
22
|
LLM markdown → normalize → render for channel → split safely → send
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
1. **Normalize.** Agent
|
|
26
|
-
|
|
25
|
+
1. **Normalize.** Agent-written Markdown may not render correctly through a standard
|
|
26
|
+
Markdown converter. Typical cases: unpaired `**`, emphasis that spans a line break in wrapped
|
|
27
|
+
prose, unclosed code fences. Repair before rendering.
|
|
27
28
|
2. **Render.** Emit the syntax the channel actually accepts. Telegram HTML allows nine
|
|
28
29
|
tags; Slack `markdown_text` takes standard Markdown directly. GitHub takes it too, but
|
|
29
30
|
reads a lone `~` as strikethrough and `<T>` as an HTML tag — so a `~` or `<` meant as a
|
|
30
|
-
character goes out escaped (`\~`, `\<`), and emphasis GFM would not close
|
|
31
|
-
followed directly by a Korean particle
|
|
31
|
+
character goes out escaped (`\~`, `\<`), and emphasis that GFM would not close (`**(a)**`
|
|
32
|
+
followed directly by a Korean particle) goes out as `<strong>`. Notion renders that bold as is
|
|
32
33
|
but shows inline HTML as text, so `notion-markdown` strips the tags and escapes a literal `*`
|
|
33
|
-
or `\` instead. For the browser, `html`
|
|
34
|
+
or `\` instead. For the browser, `html` renders
|
|
34
35
|
blocks as tags too and is safe to set as `innerHTML`: text is escaped, inline tags from the
|
|
35
36
|
source keep no attributes, and only `http(s)`/`mailto` links become `<a>` — line breaks,
|
|
36
37
|
images and allowed schemes are options. While streaming,
|
|
@@ -38,13 +39,11 @@ LLM markdown → normalize → render for channel → split safely → s
|
|
|
38
39
|
3. **Split.** Respect the channel's limit — and never cut through markup. This also
|
|
39
40
|
covers streaming: a chunk boundary must not land inside `**bold**`.
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
stripped HTML, bullets, tables, converted markers — so you can log how often the model
|
|
47
|
-
breaks its own formatting and see what a channel changes before you adopt it.
|
|
42
|
+
The pipeline has one option. **Repair report:** how many times the normalizer stepped
|
|
43
|
+
in — unclosed emphasis, unclosed fence, unpaired backticks, dropped markers — and what it
|
|
44
|
+
rewrote for the channel — escaped characters, tags for emphasis, stripped HTML, bullets,
|
|
45
|
+
tables, converted markers — so you can log how often the model breaks its own formatting
|
|
46
|
+
and see what a channel changes before you adopt it.
|
|
48
47
|
|
|
49
48
|
## Use it
|
|
50
49
|
|
|
@@ -53,10 +52,9 @@ cat agent-output.md | mdwire --channel telegram-html # parts separated
|
|
|
53
52
|
cat agent-output.md | mdwire --channel slack-markdown --stream # emit as it arrives
|
|
54
53
|
cat agent-output.md | mdwire --channel plain --limit 4096 # plain fallback into Telegram
|
|
55
54
|
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
cat agent-output.md | mdwire --channel slack-markdown --from slack-mrkdwn --report
|
|
55
|
+
# --report prints what the normalizer fixed and rewrote (unclosed emphasis, escaped `~`,
|
|
56
|
+
# stripped tags, …) to stderr as one JSON line.
|
|
57
|
+
cat agent-output.md | mdwire --channel slack-markdown --report
|
|
60
58
|
```
|
|
61
59
|
|
|
62
60
|
```rust
|
|
@@ -71,7 +69,7 @@ s.finish_into(&mut out); // flush, closing anything left open
|
|
|
71
69
|
import { render, renderWithReport, Streamer } from "@minjun0219/mdwire"; // npm — bundlers, Node, Bun
|
|
72
70
|
|
|
73
71
|
const parts = render(markdown, "telegram-html");
|
|
74
|
-
const { repairs } = renderWithReport(markdown, "slack-markdown"
|
|
72
|
+
const { repairs } = renderWithReport(markdown, "slack-markdown");
|
|
75
73
|
|
|
76
74
|
// A channel that rewrites the whole message (Telegram edit): send acc plus the preview —
|
|
77
75
|
// what is still held (open bold, table rows, a code span) drawn as if the input ended here.
|
|
@@ -121,22 +119,31 @@ every file one character and 64 characters at a time and reports any divergence.
|
|
|
121
119
|
`SPEC.md` §8.2.
|
|
122
120
|
|
|
123
121
|
The streamer holds back only what it must: a prefix it cannot classify yet, a marker run
|
|
124
|
-
at the end of a chunk, and the inside of an emphasis that has not closed.
|
|
122
|
+
at the end of a chunk, and the inside of an emphasis that has not closed. A whole paragraph is
|
|
125
123
|
never held — a renderer that waits for a newline is not streaming.
|
|
126
124
|
|
|
127
125
|
## Why another one
|
|
128
126
|
|
|
129
|
-
|
|
127
|
+
We found three gaps in existing tools:
|
|
130
128
|
|
|
131
|
-
- **
|
|
129
|
+
- **Emphasis spanning lines is common.** In a sample of 60 agent-generated documents,
|
|
132
130
|
44 contained emphasis spanning a line break. A regex-based converter mispaired those
|
|
133
131
|
into *inverted* emphasis ranges — and the channel returned HTTP 200, so nothing caught it.
|
|
134
|
-
- **
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
-
|
|
139
|
-
|
|
132
|
+
- **Common Korean notation collides with Markdown.**
|
|
133
|
+
- In `**설정(config)**을` or `**52%**다`, the bold ends in a symbol and a particle follows right
|
|
134
|
+
after. Under CommonMark's rules the bold does not close and the `**` shows as text. GitHub and
|
|
135
|
+
browser renderers follow those rules.
|
|
136
|
+
- In `약 ~40km, 5~6월`, tildes mark an approximation and a range. With two of them in one
|
|
137
|
+
paragraph, GitHub (GFM), which reads a single `~` as strikethrough, pairs them and strikes
|
|
138
|
+
everything in between (`40km, 5`).
|
|
139
|
+
|
|
140
|
+
The two converters we compared disagree on Korean-adjacent emphasis (one pads it with U+200B, the
|
|
141
|
+
other leaves it alone), and neither checked the channel. mdwire measured each channel: on GitHub it writes just that
|
|
142
|
+
bold as `<strong>` and escapes a literal `~` as `\~`.
|
|
143
|
+
- **Chat-channel converters ignore streaming.** Some browser renderers, like Streamdown, patch
|
|
144
|
+
unclosed syntax while tokens stream in. The Telegram and Slack converters we looked at all
|
|
145
|
+
take the whole document and convert it in one pass. When tokens arrive incrementally,
|
|
146
|
+
markup splits across chunk boundaries.
|
|
140
147
|
|
|
141
148
|
## Design
|
|
142
149
|
|
|
@@ -164,14 +171,16 @@ Every release also carries its own artifacts, if you would rather not go through
|
|
|
164
171
|
|
|
165
172
|
```sh
|
|
166
173
|
# npm package (works under a bundler and in plain Node)
|
|
167
|
-
npm install https://github.com/minjun0219/mdwire/releases/download/v0.1.
|
|
174
|
+
npm install https://github.com/minjun0219/mdwire/releases/download/v0.1.11/mdwire-0.1.11.tgz
|
|
168
175
|
|
|
169
|
-
# CLI binary —
|
|
170
|
-
curl -L https://github.com/minjun0219/mdwire/releases/download/v0.1.
|
|
171
|
-
curl -L https://github.com/minjun0219/mdwire/releases/download/v0.1.
|
|
176
|
+
# CLI binary — pick your platform
|
|
177
|
+
curl -L https://github.com/minjun0219/mdwire/releases/download/v0.1.11/mdwire-v0.1.11-aarch64-apple-darwin.tar.gz | tar xz # macOS, Apple silicon
|
|
178
|
+
curl -L https://github.com/minjun0219/mdwire/releases/download/v0.1.11/mdwire-v0.1.11-x86_64-unknown-linux-gnu.tar.gz | tar xz # Linux x86_64 (glibc)
|
|
179
|
+
curl -L https://github.com/minjun0219/mdwire/releases/download/v0.1.11/mdwire-v0.1.11-aarch64-unknown-linux-gnu.tar.gz | tar xz # Linux arm64 (glibc)
|
|
180
|
+
curl -LO https://github.com/minjun0219/mdwire/releases/download/v0.1.11/mdwire-v0.1.11-x86_64-pc-windows-msvc.zip # Windows x86_64
|
|
172
181
|
```
|
|
173
182
|
|
|
174
|
-
The release notes list a SHA-256 for every artifact —
|
|
183
|
+
The release notes list a SHA-256 for every artifact — verify the download against it when installing by URL.
|
|
175
184
|
|
|
176
185
|
Or from source: `cargo install --path crates/mdwire-cli`.
|
|
177
186
|
|
|
@@ -189,10 +198,24 @@ s := mdwire.NewStreamer(mdwire.SlackMarkdown)
|
|
|
189
198
|
s.PushTo(chunk, &out) // no allocation per chunk
|
|
190
199
|
s.FinishTo(&out)
|
|
191
200
|
|
|
192
|
-
out := mdwire.RenderWith(input, mdwire.SlackMarkdown, mdwire.Options{
|
|
201
|
+
out := mdwire.RenderWith(input, mdwire.SlackMarkdown, mdwire.Options{})
|
|
193
202
|
log.Printf("%+v", out.Repairs)
|
|
194
203
|
```
|
|
195
204
|
|
|
205
|
+
## For coding agents
|
|
206
|
+
|
|
207
|
+
mdwire ships an [agent skill](skills/mdwire/SKILL.md) that tells a coding agent when to reach for it,
|
|
208
|
+
which channel and entry point to pick, and how to stream. It follows the Agent Skills format, so agents
|
|
209
|
+
that read `SKILL.md` can use it directly. In Claude Code it installs as a plugin:
|
|
210
|
+
|
|
211
|
+
```sh
|
|
212
|
+
/plugin marketplace add minjun0219/mdwire
|
|
213
|
+
/plugin install mdwire@mdwire
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The site also serves [`llms.txt`](https://mdwire.minjun.dev/llms.txt), and its pages register a WebMCP
|
|
217
|
+
tool, `mdwire_render`, so a browser agent on the site can run mdwire in the page.
|
|
218
|
+
|
|
196
219
|
## Building
|
|
197
220
|
|
|
198
221
|
```sh
|
|
@@ -200,7 +223,7 @@ log.Printf("%+v", out.Repairs)
|
|
|
200
223
|
./scripts/smoke.sh # install it into a scratch project; call it from Node, Bun, and TypeScript
|
|
201
224
|
```
|
|
202
225
|
|
|
203
|
-
The wasm binary is 111 KB
|
|
226
|
+
The wasm binary is 111 KB (release build, after `wasm-opt`). The package carries two builds and
|
|
204
227
|
picks by `exports` condition: `node` gets a CommonJS build that loads the wasm from disk,
|
|
205
228
|
everything else gets the ESM bundler build. The script writes the root `package.json`
|
|
206
229
|
itself: the crate has to stay `mdwire-wasm` because the core's library is already named
|
package/bundler/mdwire.d.ts
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
/* tslint:disable */
|
|
2
2
|
/* eslint-disable */
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/** Conversion options. Anything omitted uses the channel's default. */
|
|
5
5
|
export interface RenderOptions {
|
|
6
|
-
/**
|
|
7
|
-
from?: "markdown" | "slack-mrkdwn";
|
|
8
|
-
/** 조각 한도(글자 수). 생략하면 채널의 한도다 — plain 을 텔레그램에 보내면 4096. 스트리밍은 나누지 않는다. */
|
|
6
|
+
/** Part size limit (in characters). Defaults to the channel's limit; for example, 4096 when sending plain to Telegram. Streaming does not split. */
|
|
9
7
|
limit?: number;
|
|
10
|
-
/**
|
|
8
|
+
/** Policy for the browser channel ("html"). The defaults are the most conservative. */
|
|
11
9
|
html?: {
|
|
12
|
-
/**
|
|
10
|
+
/** Line breaks inside a block. "br" (default) emits `<br>`; "space" lets the browser collapse them into spaces. */
|
|
13
11
|
lineBreaks?: "br" | "space";
|
|
14
|
-
/**
|
|
12
|
+
/** Images. "link" (default) loads nothing until clicked; "load" emits `<img>`. */
|
|
15
13
|
images?: "link" | "load";
|
|
16
|
-
/**
|
|
14
|
+
/** Schemes allowed in link and image URLs (without the colon). If given, only these are allowed. Default ["http","https","mailto"]. */
|
|
17
15
|
schemes?: string[];
|
|
18
16
|
};
|
|
19
17
|
}
|
|
@@ -21,133 +19,136 @@ export interface RenderOptions {
|
|
|
21
19
|
|
|
22
20
|
|
|
23
21
|
/**
|
|
24
|
-
* `renderWithReport
|
|
22
|
+
* The result of `renderWithReport`.
|
|
25
23
|
*/
|
|
26
24
|
export class Rendered {
|
|
27
25
|
private constructor();
|
|
28
26
|
free(): void;
|
|
29
27
|
[Symbol.dispose](): void;
|
|
30
28
|
/**
|
|
31
|
-
*
|
|
29
|
+
* The parts. Just one if the text fit within the limit.
|
|
32
30
|
*/
|
|
33
31
|
readonly parts: string[];
|
|
34
32
|
/**
|
|
35
|
-
*
|
|
33
|
+
* What normalization repaired.
|
|
36
34
|
*/
|
|
37
35
|
readonly repairs: Repairs;
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
/**
|
|
41
|
-
*
|
|
39
|
+
* Counts of what normalization repaired and what was changed to fit the channel.
|
|
42
40
|
*/
|
|
43
41
|
export class Repairs {
|
|
44
42
|
private constructor();
|
|
45
43
|
free(): void;
|
|
46
44
|
[Symbol.dispose](): void;
|
|
47
45
|
/**
|
|
48
|
-
*
|
|
46
|
+
* Emphasis left open at the end of a block, closed for you.
|
|
49
47
|
*/
|
|
50
48
|
closedEmphasis: number;
|
|
51
49
|
/**
|
|
52
|
-
*
|
|
50
|
+
* Code fences left open at the end of the document, closed for you.
|
|
53
51
|
*/
|
|
54
52
|
closedFence: number;
|
|
55
53
|
/**
|
|
56
|
-
*
|
|
54
|
+
* Emphasis markers and `<url|text>` links rewritten in a different notation.
|
|
57
55
|
*/
|
|
58
56
|
convertedMarker: number;
|
|
59
57
|
/**
|
|
60
|
-
*
|
|
58
|
+
* Unmatched `**` markers that were dropped.
|
|
61
59
|
*/
|
|
62
60
|
droppedMarker: number;
|
|
63
61
|
/**
|
|
64
|
-
*
|
|
62
|
+
* Characters escaped because the channel would read them as syntax (GitHub's `\~`, `\<`, `\*`).
|
|
65
63
|
*/
|
|
66
64
|
escapedChar: number;
|
|
67
65
|
/**
|
|
68
|
-
*
|
|
66
|
+
* Unmatched backtick runs, turned back into literal text.
|
|
69
67
|
*/
|
|
70
68
|
revertedCodeSpan: number;
|
|
71
69
|
/**
|
|
72
|
-
*
|
|
70
|
+
* List bullets rewritten with a different symbol.
|
|
73
71
|
*/
|
|
74
72
|
rewrittenBullet: number;
|
|
75
73
|
/**
|
|
76
|
-
*
|
|
74
|
+
* Tables rewritten in a different shape from the source.
|
|
77
75
|
*/
|
|
78
76
|
rewrittenTable: number;
|
|
79
77
|
/**
|
|
80
|
-
*
|
|
78
|
+
* Raw HTML stripped from the source (tags, comments, and `<br>` turned into line breaks).
|
|
81
79
|
*/
|
|
82
80
|
strippedHtml: number;
|
|
83
81
|
/**
|
|
84
|
-
*
|
|
82
|
+
* Emphasis emitted differently because the channel would not read the marker in that position (GitHub's `<strong>`, Slack's U+2060).
|
|
85
83
|
*/
|
|
86
84
|
tagEmphasis: number;
|
|
87
85
|
}
|
|
88
86
|
|
|
89
87
|
/**
|
|
90
|
-
*
|
|
88
|
+
* Streaming converter.
|
|
91
89
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
90
|
+
* Push a chunk and it returns only what is safe to emit now. Markup cut off at the
|
|
91
|
+
* chunk boundary stays inside. This is what you want when appending tokens to the
|
|
92
|
+
* screen as they arrive.
|
|
94
93
|
*/
|
|
95
94
|
export class Streamer {
|
|
96
95
|
free(): void;
|
|
97
96
|
[Symbol.dispose](): void;
|
|
98
97
|
/**
|
|
99
|
-
*
|
|
98
|
+
* **To send what you have received so far as is, append this after it.**
|
|
100
99
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
100
|
+
* It does not touch the state, so streaming continues afterwards. Do not add it
|
|
101
|
+
* to the accumulated output itself; append it only right before sending. Use it
|
|
102
|
+
* when editing a message as tokens arrive.
|
|
103
103
|
*
|
|
104
104
|
* ```js
|
|
105
105
|
* acc += s.push(chunk);
|
|
106
|
-
* await edit(acc + s.closeOpen()); //
|
|
106
|
+
* await edit(acc + s.closeOpen()); // leave acc unchanged
|
|
107
107
|
* ```
|
|
108
108
|
*/
|
|
109
109
|
closeOpen(): string;
|
|
110
110
|
/**
|
|
111
|
-
*
|
|
111
|
+
* Input has ended. Emits what is left and closes open markup.
|
|
112
112
|
*/
|
|
113
113
|
finish(): string;
|
|
114
114
|
constructor(channel: string, options?: RenderOptions | null);
|
|
115
115
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
116
|
+
* **The tail that would follow the final output if input ended now.** It goes in the
|
|
117
|
+
* same place as `closeOpen`, but also draws what is being held back (open emphasis,
|
|
118
|
+
* table rows, code spans). This is the default when you redraw the whole message.
|
|
119
|
+
* It is a guess, so later chunks may change the shape. Check `revised()` after the end.
|
|
119
120
|
*
|
|
120
121
|
* ```js
|
|
121
122
|
* acc += s.push(chunk);
|
|
122
|
-
* await edit(acc + s.preview()); //
|
|
123
|
+
* await edit(acc + s.preview()); // call only when drawing (costs as much as the open block)
|
|
123
124
|
* acc += s.finish();
|
|
124
|
-
* if (s.revised()) await edit(acc); //
|
|
125
|
+
* if (s.revised()) await edit(acc); // skip if the last screen already is the final output
|
|
125
126
|
* ```
|
|
126
127
|
*/
|
|
127
128
|
preview(): string;
|
|
128
129
|
push(chunk: string): string;
|
|
129
130
|
/**
|
|
130
|
-
*
|
|
131
|
+
* What normalization has repaired so far. After `finish`, this covers the whole document.
|
|
131
132
|
*/
|
|
132
133
|
repairs(): Repairs;
|
|
133
134
|
/**
|
|
134
|
-
*
|
|
135
|
+
* Whether the final output differs from the last `preview()`. Check it after `finish`.
|
|
135
136
|
*/
|
|
136
137
|
revised(): boolean;
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
/**
|
|
140
|
-
*
|
|
141
|
+
* The channel's length limit (in characters). Exposed for callers that handle parts themselves.
|
|
141
142
|
*/
|
|
142
143
|
export function limit(channel: string): number;
|
|
143
144
|
|
|
144
145
|
/**
|
|
145
|
-
*
|
|
146
|
+
* Converts a complete document. Returns an array of parts; it has more than one when the text exceeds the limit.
|
|
146
147
|
*/
|
|
147
148
|
export function render(input: string, channel: string, options?: RenderOptions | null): string[];
|
|
148
149
|
|
|
149
150
|
/**
|
|
150
|
-
* [`render`]
|
|
151
|
-
*
|
|
151
|
+
* Like [`render`], but also returns **what normalization repaired**. Use it to log
|
|
152
|
+
* how often the model breaks formatting.
|
|
152
153
|
*/
|
|
153
154
|
export function renderWithReport(input: string, channel: string, options?: RenderOptions | null): Rendered;
|