@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 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;
@@ -0,0 +1,9 @@
1
+ /* @ts-self-types="./mdwire.d.ts" */
2
+ import * as wasm from "./mdwire_bg.wasm";
3
+ import { __wbg_set_wasm } from "./mdwire_bg.js";
4
+
5
+ __wbg_set_wasm(wasm);
6
+
7
+ export {
8
+ Rendered, Repairs, Streamer, limit, render, renderWithReport
9
+ } from "./mdwire_bg.js";