@minjun0219/mdwire 0.1.4
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/LICENSE +21 -0
- package/README.md +188 -0
- package/bundler/mdwire.d.ts +101 -0
- package/bundler/mdwire.js +9 -0
- package/bundler/mdwire_bg.js +497 -0
- package/bundler/mdwire_bg.wasm +0 -0
- package/bundler/mdwire_bg.wasm.d.ts +28 -0
- package/bundler/package.json +1 -0
- package/node/mdwire.d.ts +101 -0
- package/node/mdwire.js +507 -0
- package/node/mdwire_bg.wasm +0 -0
- package/node/mdwire_bg.wasm.d.ts +28 -0
- package/node/package.json +1 -0
- package/package.json +22 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Minjun Kim
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# mdwire
|
|
2
|
+
|
|
3
|
+
English | [한국어](README.ko.md)
|
|
4
|
+
|
|
5
|
+
Send LLM-generated Markdown to chat channels without it breaking.
|
|
6
|
+
|
|
7
|
+
Agents emit Markdown. Chat channels don't accept it — each has its own subset, its own
|
|
8
|
+
escaping rules, and its own length limit. Existing converters assume the input is
|
|
9
|
+
well-formed CommonMark and target one channel at a time. Neither assumption holds for
|
|
10
|
+
agent output.
|
|
11
|
+
|
|
12
|
+
**Status: v0.1.4.** Normalizing, rendering, splitting and streaming work for three
|
|
13
|
+
channels — Telegram HTML, Slack `markdown_text`, and plain text — from a Rust core, a CLI,
|
|
14
|
+
an npm package (WASM), and a Go port. See `SPEC.md` for what is in v0.1 and what was
|
|
15
|
+
deliberately deferred. `SPEC.md` and `DESIGN.md` are written in Korean.
|
|
16
|
+
|
|
17
|
+
## What it does
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
LLM markdown → normalize → render for channel → split safely → send
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
1. **Normalize.** Agent output is not well-formed. Unpaired `**`, emphasis that spans a
|
|
24
|
+
line break in wrapped prose, unclosed code fences. Repair before rendering.
|
|
25
|
+
2. **Render.** Emit the syntax the channel actually accepts. Telegram HTML allows nine
|
|
26
|
+
tags; Slack `markdown_text` takes standard Markdown directly.
|
|
27
|
+
3. **Split.** Respect the channel's limit — and never cut through markup. This also
|
|
28
|
+
covers streaming: a chunk boundary must not land inside `**bold**`.
|
|
29
|
+
|
|
30
|
+
Two options around that pipeline. **Input dialect:** an agent that learned Slack from its
|
|
31
|
+
docs writes legacy `mrkdwn` (`*bold*`, `~strike~`, `<url|text>`); `--from slack-mrkdwn`
|
|
32
|
+
reads it as such instead of as standard Markdown. **Repair report:** how many times the
|
|
33
|
+
normalizer stepped in — unclosed emphasis, unclosed fence, unpaired backticks, dropped
|
|
34
|
+
markers — so you can log how often the model breaks its own formatting.
|
|
35
|
+
|
|
36
|
+
## Use it
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
cat agent-output.md | mdwire --channel telegram-html # parts separated by NUL
|
|
40
|
+
cat agent-output.md | mdwire --channel slack-markdown --stream # emit as it arrives
|
|
41
|
+
|
|
42
|
+
# The agent wrote Slack's legacy mrkdwn (*bold*, ~strike~)? Say so. --report prints what
|
|
43
|
+
# the normalizer fixed (unclosed emphasis, unclosed fence, …) as one JSON line on stderr.
|
|
44
|
+
cat agent-output.md | mdwire --channel slack-markdown --from slack-mrkdwn --report
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```rust
|
|
48
|
+
let parts = mdwire::render(input, Channel::TelegramHtml);
|
|
49
|
+
|
|
50
|
+
let mut s = Streamer::new(Channel::SlackMarkdown);
|
|
51
|
+
s.push_into(chunk, &mut out); // no allocation per chunk
|
|
52
|
+
s.finish_into(&mut out); // flush, closing anything left open
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import { render, renderWithReport, Streamer } from "@minjun0219/mdwire"; // npm — bundlers, Node, Bun
|
|
57
|
+
|
|
58
|
+
const parts = render(markdown, "telegram-html");
|
|
59
|
+
const { repairs } = renderWithReport(markdown, "slack-markdown", { from: "slack-mrkdwn" });
|
|
60
|
+
|
|
61
|
+
// A channel that rewrites the whole message (Telegram edit): send acc plus the tail
|
|
62
|
+
// that closes open blocks. Keep acc itself untouched.
|
|
63
|
+
const s = new Streamer("telegram-html");
|
|
64
|
+
let acc = "";
|
|
65
|
+
for await (const chunk of tokens) {
|
|
66
|
+
acc += s.push(chunk);
|
|
67
|
+
await edit(acc + s.closeOpen());
|
|
68
|
+
}
|
|
69
|
+
acc += s.finish();
|
|
70
|
+
|
|
71
|
+
// An append-only channel (Slack appendStream): send each piece as is — never closeOpen.
|
|
72
|
+
const t = new Streamer("slack-markdown");
|
|
73
|
+
for await (const chunk of tokens) {
|
|
74
|
+
const piece = t.push(chunk);
|
|
75
|
+
if (piece) await append(piece);
|
|
76
|
+
}
|
|
77
|
+
await append(t.finish());
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Append-only contract.** What `push` returns is final — a later chunk never rewrites it —
|
|
81
|
+
and `finish` only appends the tail. So the pieces concatenated equal a one-shot `render`,
|
|
82
|
+
whatever the chunk size (unless the document is long enough to be split into parts).
|
|
83
|
+
This is tested on the corpus, by fuzzing, and by `mdwire-check --scan <dir>`, which streams
|
|
84
|
+
every file one character and 64 characters at a time and reports any divergence. Runs in
|
|
85
|
+
Node, Bun, and bundlers. See `SPEC.md` §8.2.
|
|
86
|
+
|
|
87
|
+
The streamer holds back only what it must: a prefix it cannot classify yet, a marker run
|
|
88
|
+
at the end of a chunk, and the inside of an emphasis that has not closed. Paragraphs are
|
|
89
|
+
never held — a renderer that waits for a newline is not streaming.
|
|
90
|
+
|
|
91
|
+
## Why another one
|
|
92
|
+
|
|
93
|
+
Three gaps in what exists today, each measured rather than assumed:
|
|
94
|
+
|
|
95
|
+
- **Broken input is the normal case.** In a sample of 60 agent-generated documents,
|
|
96
|
+
44 contained emphasis spanning a line break. A regex-based converter mispaired those
|
|
97
|
+
into *inverted* emphasis ranges — and the channel returned HTTP 200, so nothing caught it.
|
|
98
|
+
- **CJK is guessed at.** One converter pads emphasis with U+200B next to Korean text;
|
|
99
|
+
another does not. Neither measured the channel. We did: Slack `markdown_text` follows
|
|
100
|
+
CommonMark, so `_italic_` dies next to a Korean particle and `*italic*` lives. mdwire
|
|
101
|
+
emits `*` and pads nothing.
|
|
102
|
+
- **Streaming has no answer.** Every converter is batch: parse the whole document, build an
|
|
103
|
+
AST, render. When tokens arrive incrementally, markup splits across chunk boundaries.
|
|
104
|
+
|
|
105
|
+
## Design
|
|
106
|
+
|
|
107
|
+
- **No dependencies in the core.** Not a purity stance — batch parsers are structurally
|
|
108
|
+
wrong for streaming. See `DESIGN.md`.
|
|
109
|
+
- **Rust core, many front ends.** WASM for npm, a single static binary for the CLI.
|
|
110
|
+
The CLI matters most: any agent in any language can pipe through it with no bindings.
|
|
111
|
+
A Go port lives in `go/` (stdlib only) and is held to the same corpus — and to the Rust
|
|
112
|
+
core itself: random inputs and real documents must render identically.
|
|
113
|
+
- **The test corpus is a first-class artifact.** `corpus/` holds input → expected output
|
|
114
|
+
per channel. A port in another language is correct when it passes the corpus. This is
|
|
115
|
+
how consistency survives more than one implementation.
|
|
116
|
+
|
|
117
|
+
## Installing
|
|
118
|
+
|
|
119
|
+
Every release carries its own artifacts — no registry needed:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
# npm package (works under a bundler and in plain Node)
|
|
123
|
+
npm install https://github.com/minjun0219/mdwire/releases/download/v0.1.4/mdwire-0.1.4.tgz
|
|
124
|
+
|
|
125
|
+
# CLI binary — macOS (Apple silicon) or Linux (x86_64)
|
|
126
|
+
curl -L https://github.com/minjun0219/mdwire/releases/download/v0.1.4/mdwire-v0.1.4-aarch64-apple-darwin.tar.gz | tar xz
|
|
127
|
+
curl -L https://github.com/minjun0219/mdwire/releases/download/v0.1.4/mdwire-v0.1.4-x86_64-unknown-linux-gnu.tar.gz | tar xz
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The release notes list a SHA-256 for every artifact — pin to it when installing by URL.
|
|
131
|
+
|
|
132
|
+
Or from source: `cargo install --path crates/mdwire-cli`.
|
|
133
|
+
|
|
134
|
+
Go, as a library or a CLI with the same flags:
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
go get github.com/minjun0219/mdwire/go@latest
|
|
138
|
+
go install github.com/minjun0219/mdwire/go/cmd/mdwire@latest
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```go
|
|
142
|
+
parts := mdwire.Render(input, mdwire.TelegramHTML)
|
|
143
|
+
|
|
144
|
+
s := mdwire.NewStreamer(mdwire.SlackMarkdown)
|
|
145
|
+
s.PushTo(chunk, &out) // no allocation per chunk
|
|
146
|
+
s.FinishTo(&out)
|
|
147
|
+
|
|
148
|
+
out := mdwire.RenderWith(input, mdwire.SlackMarkdown, mdwire.Options{From: mdwire.SlackMrkdwn})
|
|
149
|
+
log.Printf("%+v", out.Repairs)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Building
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
./scripts/build-npm.sh # the npm package into pkg/ (needs `cargo install wasm-pack`)
|
|
156
|
+
./scripts/smoke.sh # install it into a scratch project; call it from Node, Bun, and TypeScript
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The wasm binary is 111 KB, release with `wasm-opt`. The package carries two builds and
|
|
160
|
+
picks by `exports` condition: `node` gets a CommonJS build that loads the wasm from disk,
|
|
161
|
+
everything else gets the ESM bundler build. The script writes the root `package.json`
|
|
162
|
+
itself: the crate has to stay `mdwire-wasm` because the core's library is already named
|
|
163
|
+
`mdwire`, and wasm-pack takes the npm name from the crate.
|
|
164
|
+
|
|
165
|
+
```sh
|
|
166
|
+
cargo test --workspace # unit tests, the corpus, and the allocation gate
|
|
167
|
+
cargo clippy --workspace
|
|
168
|
+
cargo run --release -p mdwire-bench # allocation counts and throughput
|
|
169
|
+
cargo run -p mdwire-harness --bin mdwire-check # corpus + invariant scoring
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`mdwire-check` scores any implementation that reads stdin and writes stdout, so a port in
|
|
173
|
+
another language can be measured with the same yardstick:
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
mdwire-check --cmd "node convert.js --to {channel}"
|
|
177
|
+
mdwire-check --scan ./some-directory-of-markdown # invariants only, no expected output
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Releasing
|
|
181
|
+
|
|
182
|
+
Nobody edits the version by hand. After every merge to `main` a bot keeps a
|
|
183
|
+
`release: X.Y.Z` pull request open; merging it tags `vX.Y.Z` and `go/vX.Y.Z` and publishes
|
|
184
|
+
the release with its artifacts. See `AGENTS.md`.
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* tslint:disable */
|
|
2
|
+
/* eslint-disable */
|
|
3
|
+
|
|
4
|
+
/** 변환 옵션. 생략하면 표준 마크다운 입력이다. */
|
|
5
|
+
export interface RenderOptions {
|
|
6
|
+
/** 입력 표기. 슬랙 레거시 mrkdwn(`*굵게*` `~취소~`)으로 쓴 에이전트 출력이면 "slack-mrkdwn". */
|
|
7
|
+
from?: "markdown" | "slack-mrkdwn";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `renderWithReport` 의 결과.
|
|
14
|
+
*/
|
|
15
|
+
export class Rendered {
|
|
16
|
+
private constructor();
|
|
17
|
+
free(): void;
|
|
18
|
+
[Symbol.dispose](): void;
|
|
19
|
+
/**
|
|
20
|
+
* 조각들. 한도를 넘지 않았으면 하나다.
|
|
21
|
+
*/
|
|
22
|
+
readonly parts: string[];
|
|
23
|
+
/**
|
|
24
|
+
* 정규화가 고친 것.
|
|
25
|
+
*/
|
|
26
|
+
readonly repairs: Repairs;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 정규화가 고친 것의 개수.
|
|
31
|
+
*/
|
|
32
|
+
export class Repairs {
|
|
33
|
+
private constructor();
|
|
34
|
+
free(): void;
|
|
35
|
+
[Symbol.dispose](): void;
|
|
36
|
+
/**
|
|
37
|
+
* 블록이 끝나도록 안 닫혀서 닫아 준 강조.
|
|
38
|
+
*/
|
|
39
|
+
closedEmphasis: number;
|
|
40
|
+
/**
|
|
41
|
+
* 문서 끝까지 안 닫혀서 닫아 준 코드펜스.
|
|
42
|
+
*/
|
|
43
|
+
closedFence: number;
|
|
44
|
+
/**
|
|
45
|
+
* 짝 잃은 채 버린 `**`.
|
|
46
|
+
*/
|
|
47
|
+
droppedMarker: number;
|
|
48
|
+
/**
|
|
49
|
+
* 짝이 없어 글자로 되돌린 백틱 런.
|
|
50
|
+
*/
|
|
51
|
+
revertedCodeSpan: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 스트리밍 변환기.
|
|
56
|
+
*
|
|
57
|
+
* 조각을 넣으면 지금 안전하게 내보낼 수 있는 만큼만 돌려준다. 경계에 걸린 마크업은
|
|
58
|
+
* 안에 남는다 — 토큰이 흘러들어오는 대로 화면에 붙이는 쪽이 이것 때문에 쓴다.
|
|
59
|
+
*/
|
|
60
|
+
export class Streamer {
|
|
61
|
+
free(): void;
|
|
62
|
+
[Symbol.dispose](): void;
|
|
63
|
+
/**
|
|
64
|
+
* **지금까지 받은 것을 그대로 보내려면 이걸 뒤에 붙인다.**
|
|
65
|
+
*
|
|
66
|
+
* 상태는 건드리지 않으므로 붙인 뒤에도 스트리밍은 이어진다. 누적본 자체에는
|
|
67
|
+
* 넣지 말고, 보내기 직전에만 붙인다. 토큰이 오는 대로 메시지를 편집하는 쪽이 쓴다.
|
|
68
|
+
*
|
|
69
|
+
* ```js
|
|
70
|
+
* acc += s.push(chunk);
|
|
71
|
+
* await edit(acc + s.closeOpen()); // 누적본은 그대로 둔다
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
closeOpen(): string;
|
|
75
|
+
/**
|
|
76
|
+
* 입력이 끝났다. 남은 것을 내보내고 열린 마크업을 닫는다.
|
|
77
|
+
*/
|
|
78
|
+
finish(): string;
|
|
79
|
+
constructor(channel: string, options?: RenderOptions | null);
|
|
80
|
+
push(chunk: string): string;
|
|
81
|
+
/**
|
|
82
|
+
* 지금까지 정규화가 고친 것. `finish` 뒤에 보면 문서 전체의 값이다.
|
|
83
|
+
*/
|
|
84
|
+
repairs(): Repairs;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 채널의 길이 한도(문자 수). 조각을 직접 다루려는 호출자를 위해 열어 둔다.
|
|
89
|
+
*/
|
|
90
|
+
export function limit(channel: string): number;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 완성된 문서를 변환한다. 한도를 넘으면 조각 배열로 돌아온다.
|
|
94
|
+
*/
|
|
95
|
+
export function render(input: string, channel: string, options?: RenderOptions | null): string[];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* [`render`] 에 **정규화가 고친 것**을 같이 돌려준다. 모델이 얼마나 자주 서식을 깨는지
|
|
99
|
+
* 로그로 남기려는 쪽이 쓴다.
|
|
100
|
+
*/
|
|
101
|
+
export function renderWithReport(input: string, channel: string, options?: RenderOptions | null): Rendered;
|