@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 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 accept it — each has its own subset, its own
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.9.** Normalizing, rendering, splitting and streaming work for six
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 output is not well-formed. Unpaired `**`, emphasis that spans a
26
- line break in wrapped prose, unclosed code fences. Repair before rendering.
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 — `**(a)**`
31
- followed directly by a Korean particle — goes out as `<strong>`. Notion draws that bold as is
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` draws
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
- Two options around that pipeline. **Input dialect:** an agent that learned Slack from its
42
- docs writes legacy `mrkdwn` (`*bold*`, `~strike~`, `<url|text>`); `--from slack-mrkdwn`
43
- reads it as such instead of as standard Markdown. **Change report:** how many times the
44
- normalizer stepped in — unclosed emphasis, unclosed fence, unpaired backticks, dropped
45
- markers — and what it rewrote for the channel — escaped characters, tags for emphasis,
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
- # The agent wrote Slack's legacy mrkdwn (*bold*, ~strike~)? Say so. --report prints what
57
- # the normalizer fixed and rewrote (unclosed emphasis, escaped `~`, stripped tags, …) as one
58
- # JSON line on stderr.
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", { from: "slack-mrkdwn" });
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. Paragraphs are
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
- Three gaps in what exists today, each measured rather than assumed:
127
+ We found three gaps in existing tools:
130
128
 
131
- - **Broken input is the normal case.** In a sample of 60 agent-generated documents,
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
- - **CJK is guessed at.** One converter pads emphasis with U+200B next to Korean text;
135
- another does not. Neither measured the channel. We did: Slack `markdown_text` follows
136
- CommonMark, so `_italic_` dies next to a Korean particle and `*italic*` lives. mdwire
137
- emits `*` and pads nothing.
138
- - **Streaming has no answer.** Every converter is batch: parse the whole document, build an
139
- AST, render. When tokens arrive incrementally, markup splits across chunk boundaries.
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.9/mdwire-0.1.9.tgz
174
+ npm install https://github.com/minjun0219/mdwire/releases/download/v0.1.11/mdwire-0.1.11.tgz
168
175
 
169
- # CLI binary — macOS (Apple silicon) or Linux (x86_64)
170
- curl -L https://github.com/minjun0219/mdwire/releases/download/v0.1.9/mdwire-v0.1.9-aarch64-apple-darwin.tar.gz | tar xz
171
- curl -L https://github.com/minjun0219/mdwire/releases/download/v0.1.9/mdwire-v0.1.9-x86_64-unknown-linux-gnu.tar.gz | tar xz
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 — pin to it when installing by URL.
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{From: mdwire.SlackMrkdwn})
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, release with `wasm-opt`. The package carries two builds and
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
@@ -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
- /** 입력 표기. 슬랙 레거시 mrkdwn(`*굵게*` `~취소~`)으로 쓴 에이전트 출력이면 "slack-mrkdwn". */
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
- /** 브라우저 채널("html")의 정책. 기본값이 가장 보수적이다. */
8
+ /** Policy for the browser channel ("html"). The defaults are the most conservative. */
11
9
  html?: {
12
- /** 블록 안 줄바꿈. "br"(기본) 은 `<br>`, "space" 는 브라우저가 공백으로 접게 둔다. */
10
+ /** Line breaks inside a block. "br" (default) emits `<br>`; "space" lets the browser collapse them into spaces. */
13
11
  lineBreaks?: "br" | "space";
14
- /** 이미지. "link"(기본) 는 누르기 전에 아무것도 안 불러온다, "load" 는 `<img>`. */
12
+ /** Images. "link" (default) loads nothing until clicked; "load" emits `<img>`. */
15
13
  images?: "link" | "load";
16
- /** 링크·이미지 주소로 받는 스킴(콜론 없이). 주면 그것만 받는다. 기본 ["http","https","mailto"]. */
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
- * 다른 표기로 바꿔 쓴 강조 마커와 `<url|텍스트>` 링크.
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
- * 채널이 구문으로 읽을 글자를 탈출한 수(GitHub 의 `\~`·`\<`·`\*`).
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
- * 벗긴 원문 HTML(태그·주석·줄바꿈으로 바꾼 `<br>`).
78
+ * Raw HTML stripped from the source (tags, comments, and `<br>` turned into line breaks).
81
79
  */
82
80
  strippedHtml: number;
83
81
  /**
84
- * 마커 대신 태그로 낸 강조(GitHub 의 `<strong>`).
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
- * **지금 입력이 끝났다면 확정분 뒤에 붙을 꼬리.** `closeOpen` 과 같은 자리에 들어가지만
117
- * 붙들고 있던 것(열린 강조, 표 행, 코드 스팬)까지 그린다. 누적본을 통째로 다시 그리는 쪽의
118
- * 기본값이다. 추측이라 뒤 조각이 모양을 바꿀 수 있다 — 끝난 뒤 `revised()` 로 본다.
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
- * 지금까지 정규화가 고친 것. `finish` 뒤에 보면 문서 전체의 값이다.
131
+ * What normalization has repaired so far. After `finish`, this covers the whole document.
131
132
  */
132
133
  repairs(): Repairs;
133
134
  /**
134
- * 완성본이 마지막 `preview()` 와 다른가 — `finish` 뒤에 본다.
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;