markdansi 0.1.7 → 0.2.0

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
@@ -6,7 +6,7 @@
6
6
 
7
7
  ![npm](https://img.shields.io/npm/v/markdansi) ![license MIT](https://img.shields.io/badge/license-MIT-blue.svg) ![node >=22](https://img.shields.io/badge/node-%3E%3D22-brightgreen) ![tests vitest](https://img.shields.io/badge/tests-vitest-blue?logo=vitest)
8
8
 
9
- Tiny, dependency-light Markdown → ANSI renderer and CLI for modern Node (>=22). Focuses on readable terminal output with sensible wrapping, GFM support (tables, task lists, strikethrough), optional OSC‑8 hyperlinks, and zero built‑in syntax highlighting (pluggable hook). Includes live in-place terminal rendering for streaming updates (`createLiveRenderer`). Written in TypeScript, ships ESM.
9
+ Tiny, dependency-light Markdown → ANSI renderer and CLI for modern Node (>=22). Focuses on readable terminal output with sensible wrapping, GFM support (tables, task lists, strikethrough), optional OSC‑8 hyperlinks, and zero built‑in syntax highlighting (pluggable hook). Written in TypeScript, ships ESM.
10
10
 
11
11
  Published on npm as `markdansi`.
12
12
 
@@ -46,23 +46,31 @@ const { render } = await import('markdansi');
46
46
  console.log(render('# hello'));
47
47
  ```
48
48
 
49
- ### Live streaming / in-place rendering
50
- For streaming output (LLM responses, logs, progress), use `createLiveRenderer` to re-render and redraw in-place. Uses terminal “synchronized output” when supported.
49
+ ### Streaming (recommended: hybrid blocks)
50
+ If you’re streaming Markdown (LLM output), keep scrollback safe by emitting **completed fragments only**
51
+ and writing them once (append-only; no in-place redraw).
52
+
53
+ Hybrid mode streams regular lines as they complete, but buffers multi-line constructs that need context:
54
+ - Fenced code blocks (``` / ~~~) — flushed only after the closing fence
55
+ - Tables — flushed only after the header separator row + until the table ends
51
56
 
52
57
  ```js
53
- import { createLiveRenderer, render } from 'markdansi';
58
+ import { createMarkdownStreamer, render } from 'markdansi';
54
59
 
55
- const live = createLiveRenderer({
56
- renderFrame: (markdown) => render(markdown),
57
- write: process.stdout.write.bind(process.stdout),
60
+ const streamer = createMarkdownStreamer({
61
+ render: (md) => render(md, { width: process.stdout.columns ?? 80 }),
62
+ spacing: 'single', // collapse consecutive blank lines
58
63
  });
59
64
 
60
- let buffer = '';
61
- buffer += '# Hello\\n';
62
- live.render(buffer);
63
- buffer += '\\nMore…\\n';
64
- live.render(buffer);
65
- live.finish();
65
+ process.stdin.setEncoding('utf8');
66
+ process.stdin.on('data', (delta) => {
67
+ const chunk = streamer.push(delta);
68
+ if (chunk) process.stdout.write(chunk);
69
+ });
70
+ process.stdin.on('end', () => {
71
+ const tail = streamer.finish();
72
+ if (tail) process.stdout.write(tail);
73
+ });
66
74
  ```
67
75
 
68
76
  ```js
package/dist/cli.js CHANGED
File without changes
package/dist/index.d.ts CHANGED
@@ -1,11 +1,9 @@
1
- import type { LiveRenderer, LiveRendererOptions } from "./live.js";
2
- import { createLiveRenderer } from "./live.js";
3
1
  import { createRenderer, render as renderMarkdown } from "./render.js";
2
+ import { createMarkdownStreamer } from "./stream.js";
4
3
  import { themes } from "./theme.js";
5
4
  import type { RenderOptions, Theme, ThemeName } from "./types.js";
6
- export { createLiveRenderer, createRenderer, renderMarkdown as render, themes };
5
+ export { createMarkdownStreamer, createRenderer, renderMarkdown as render, themes, };
7
6
  export type { RenderOptions, Theme, ThemeName };
8
- export type { LiveRenderer, LiveRendererOptions };
9
7
  /**
10
8
  * Render Markdown to plain text (no ANSI, no hyperlinks) while preserving layout/wrapping.
11
9
  */
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { createLiveRenderer } from "./live.js";
2
1
  import { createRenderer, render as renderMarkdown } from "./render.js";
2
+ import { createMarkdownStreamer } from "./stream.js";
3
3
  import { themes } from "./theme.js";
4
- export { createLiveRenderer, createRenderer, renderMarkdown as render, themes };
4
+ export { createMarkdownStreamer, createRenderer, renderMarkdown as render, themes, };
5
5
  /**
6
6
  * Render Markdown to plain text (no ANSI, no hyperlinks) while preserving layout/wrapping.
7
7
  */
@@ -0,0 +1,39 @@
1
+ export type MarkdownStreamerSpacing = "preserve" | "single" | "tight";
2
+ export type MarkdownStreamer = {
3
+ /**
4
+ * Push an appended Markdown delta (chunk) into the streamer.
5
+ * Returns ANSI text to write to the terminal (append-only).
6
+ */
7
+ push: (delta: string) => string;
8
+ /**
9
+ * Flush remaining buffered content and finish the stream.
10
+ * Optionally accepts one last delta.
11
+ */
12
+ finish: (finalDelta?: string) => string;
13
+ /**
14
+ * Reset internal state (buffer, fence/table detection, spacing).
15
+ */
16
+ reset: () => void;
17
+ };
18
+ export type MarkdownStreamerOptions = {
19
+ /**
20
+ * Function used to render a Markdown fragment (block or line) to ANSI.
21
+ * Must be pure (no cursor control) and must not rely on prior terminal state.
22
+ */
23
+ render: (markdown: string) => string;
24
+ /**
25
+ * Hybrid streaming: emit complete lines immediately, but buffer multi-line
26
+ * constructs (fenced code blocks + tables) until they are complete.
27
+ *
28
+ * This is designed for terminal scrollback safety: no in-place redraw, no cursor moves.
29
+ */
30
+ mode?: "hybrid";
31
+ /**
32
+ * Controls how blank lines are emitted.
33
+ * - preserve: emit blank lines exactly as received
34
+ * - single: collapse consecutive blank lines to a single blank line
35
+ * - tight: drop blank lines entirely (dense output)
36
+ */
37
+ spacing?: MarkdownStreamerSpacing;
38
+ };
39
+ export declare function createMarkdownStreamer(options: MarkdownStreamerOptions): MarkdownStreamer;
package/dist/stream.js ADDED
@@ -0,0 +1,193 @@
1
+ function normalizeNewlines(input) {
2
+ return input.replace(/\r\n?/g, "\n");
3
+ }
4
+ function isFenceStart(line) {
5
+ const trimmed = line.trimStart();
6
+ const match = trimmed.match(/^(```+|~~~+)/);
7
+ if (!match?.[1])
8
+ return null;
9
+ const token = match[1];
10
+ const char = token[0] === "~" ? "~" : "`";
11
+ return { char, len: token.length };
12
+ }
13
+ function isFenceEnd(line, fence) {
14
+ const trimmed = line.trimStart();
15
+ const token = fence.char.repeat(fence.len);
16
+ return trimmed.startsWith(token);
17
+ }
18
+ function looksLikeTableHeader(line) {
19
+ if (!line.includes("|"))
20
+ return false;
21
+ return /[^\s|]/.test(line);
22
+ }
23
+ function isTableSeparator(line) {
24
+ // Examples:
25
+ // | --- | --- |
26
+ // |:--- | ---:|
27
+ // --- | ---
28
+ const trimmed = line.trim();
29
+ if (!trimmed.includes("-"))
30
+ return false;
31
+ return /^\|?(?:\s*:?-+:?\s*\|)+\s*:?-+:?\s*\|?$/.test(trimmed);
32
+ }
33
+ function looksLikeTableRow(line) {
34
+ if (!line.includes("|"))
35
+ return false;
36
+ return /[^\s|]/.test(line);
37
+ }
38
+ function normalizeRenderedFragment(rendered) {
39
+ // Markdansi intentionally prefixes some blocks (e.g. headings) with a newline when rendering
40
+ // whole documents. For streaming fragments, strip leading newlines to avoid double spacing.
41
+ const trimmedStart = rendered.replace(/^\n+/, "");
42
+ // For fragment streaming, normalize to a single trailing newline so spacing is controlled
43
+ // by the streamer (blank-line collapsing) rather than renderer block heuristics.
44
+ const trimmedEnd = trimmedStart.replace(/\n+$/, "");
45
+ return `${trimmedEnd}\n`;
46
+ }
47
+ export function createMarkdownStreamer(options) {
48
+ const render = options.render;
49
+ const spacing = options.spacing ?? "single";
50
+ let buffer = "";
51
+ let blankStreak = 0;
52
+ let started = false;
53
+ let heldTableHeader = null;
54
+ let inTable = false;
55
+ let tableBuffer = "";
56
+ let fence = null;
57
+ let fenceBuffer = "";
58
+ const emitBlankLine = () => {
59
+ if (!started)
60
+ return "";
61
+ if (spacing === "tight")
62
+ return "";
63
+ if (spacing === "single" && blankStreak >= 1)
64
+ return "";
65
+ blankStreak += 1;
66
+ return "\n";
67
+ };
68
+ const emitRendered = (markdown) => {
69
+ if (!markdown)
70
+ return "";
71
+ blankStreak = 0;
72
+ started = true;
73
+ return normalizeRenderedFragment(render(markdown));
74
+ };
75
+ const flushHeldHeader = () => {
76
+ if (!heldTableHeader)
77
+ return "";
78
+ const md = heldTableHeader;
79
+ heldTableHeader = null;
80
+ return emitRendered(md);
81
+ };
82
+ const flushTable = () => {
83
+ if (!inTable)
84
+ return "";
85
+ inTable = false;
86
+ const md = tableBuffer;
87
+ tableBuffer = "";
88
+ return emitRendered(md);
89
+ };
90
+ const flushFence = () => {
91
+ if (!fence)
92
+ return "";
93
+ fence = null;
94
+ const md = fenceBuffer;
95
+ fenceBuffer = "";
96
+ return emitRendered(md);
97
+ };
98
+ const processLine = (line) => {
99
+ // Fence mode: buffer everything until the closing fence.
100
+ if (fence) {
101
+ fenceBuffer += `${line}\n`;
102
+ if (isFenceEnd(line, fence)) {
103
+ return flushFence();
104
+ }
105
+ return "";
106
+ }
107
+ // Table mode: buffer table rows; flush when it ends.
108
+ if (inTable) {
109
+ if (line.trim().length === 0) {
110
+ return flushTable() + emitBlankLine();
111
+ }
112
+ if (!looksLikeTableRow(line)) {
113
+ return flushTable() + processLine(line);
114
+ }
115
+ tableBuffer += `${line}\n`;
116
+ return "";
117
+ }
118
+ // Blank line: flush any held header and emit spacing.
119
+ if (line.trim().length === 0) {
120
+ return flushHeldHeader() + emitBlankLine();
121
+ }
122
+ // Fence start: flush held header and enter fence mode.
123
+ const fenceStart = isFenceStart(line);
124
+ if (fenceStart) {
125
+ const out = flushHeldHeader();
126
+ fence = fenceStart;
127
+ fenceBuffer = `${line}\n`;
128
+ // Some fences are single-line in streams (rare). Handle close immediately.
129
+ if (isFenceEnd(line, fenceStart) &&
130
+ line.trimStart().match(/^(```+|~~~+)\s*$/)) {
131
+ return out + flushFence();
132
+ }
133
+ return out;
134
+ }
135
+ // If we held a possible table header, check if this line starts a table.
136
+ if (heldTableHeader) {
137
+ if (isTableSeparator(line) && looksLikeTableHeader(heldTableHeader)) {
138
+ inTable = true;
139
+ tableBuffer = `${heldTableHeader}\n${line}\n`;
140
+ heldTableHeader = null;
141
+ return "";
142
+ }
143
+ const out = flushHeldHeader();
144
+ return out + processLine(line);
145
+ }
146
+ // Potential table header: delay emission until we see the next line.
147
+ if (looksLikeTableHeader(line)) {
148
+ heldTableHeader = line;
149
+ return "";
150
+ }
151
+ // Normal line: render immediately.
152
+ return emitRendered(line);
153
+ };
154
+ const push = (delta) => {
155
+ if (!delta)
156
+ return "";
157
+ buffer += normalizeNewlines(delta);
158
+ let out = "";
159
+ while (true) {
160
+ const idx = buffer.indexOf("\n");
161
+ if (idx < 0)
162
+ break;
163
+ const line = buffer.slice(0, idx);
164
+ buffer = buffer.slice(idx + 1);
165
+ out += processLine(line);
166
+ }
167
+ return out;
168
+ };
169
+ const finish = (finalDelta) => {
170
+ let out = "";
171
+ if (finalDelta)
172
+ out += push(finalDelta);
173
+ if (buffer.length > 0) {
174
+ out += processLine(buffer);
175
+ buffer = "";
176
+ }
177
+ out += flushHeldHeader();
178
+ out += flushFence();
179
+ out += flushTable();
180
+ return out;
181
+ };
182
+ const reset = () => {
183
+ buffer = "";
184
+ blankStreak = 0;
185
+ started = false;
186
+ heldTableHeader = null;
187
+ inTable = false;
188
+ tableBuffer = "";
189
+ fence = null;
190
+ fenceBuffer = "";
191
+ };
192
+ return { push, finish, reset };
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "markdansi",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "Tiny dependency-light markdown to ANSI converter.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -64,7 +64,7 @@
64
64
  "micromark-util-combine-extensions": "^2.0.1",
65
65
  "string-width": "^8.1.0",
66
66
  "strip-ansi": "^7.1.2",
67
- "supports-hyperlinks": "^4.3.0"
67
+ "supports-hyperlinks": "^4.4.0"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@biomejs/biome": "^2.3.10",
package/dist/live.d.ts DELETED
@@ -1,35 +0,0 @@
1
- export type LiveRenderer = {
2
- render: (input: string) => void;
3
- finish: () => void;
4
- };
5
- export type LiveRendererOptions = {
6
- /**
7
- * Function that converts the current full input into a rendered frame.
8
- * Typically this is Markdansi's `render()` or an app-specific wrapper.
9
- */
10
- renderFrame: (input: string) => string;
11
- /**
12
- * Where to write ANSI output (usually `process.stdout.write.bind(process.stdout)`).
13
- */
14
- write: (chunk: string) => void;
15
- /**
16
- * Enable terminal "synchronized output" framing (DEC private mode 2026).
17
- * Most terminals ignore this sequence if unsupported.
18
- */
19
- synchronizedOutput?: boolean;
20
- /**
21
- * Hide cursor during live updates.
22
- */
23
- hideCursor?: boolean;
24
- /**
25
- * Terminal width in columns used for row accounting.
26
- * If omitted, defaults to 80.
27
- */
28
- width?: number;
29
- };
30
- /**
31
- * Create a live renderer that repeatedly re-renders the entire buffer and redraws in-place.
32
- *
33
- * This is intentionally "terminal plumbing" and renderer-agnostic: you inject `renderFrame()`.
34
- */
35
- export declare function createLiveRenderer(options: LiveRendererOptions): LiveRenderer;
package/dist/live.js DELETED
@@ -1,75 +0,0 @@
1
- import stringWidth from "string-width";
2
- import stripAnsi from "strip-ansi";
3
- const BSU = "\u001b[?2026h";
4
- const ESU = "\u001b[?2026l";
5
- const HIDE_CURSOR = "\u001b[?25l";
6
- const SHOW_CURSOR = "\u001b[?25h";
7
- const CLEAR_TO_END = "\u001b[0J";
8
- function cursorUp(lines) {
9
- if (lines <= 0)
10
- return "";
11
- return `\u001b[${lines}A`;
12
- }
13
- /**
14
- * Create a live renderer that repeatedly re-renders the entire buffer and redraws in-place.
15
- *
16
- * This is intentionally "terminal plumbing" and renderer-agnostic: you inject `renderFrame()`.
17
- */
18
- export function createLiveRenderer(options) {
19
- let previousRows = 0;
20
- let cursorHidden = false;
21
- const synchronizedOutput = options.synchronizedOutput !== false;
22
- const hideCursor = options.hideCursor !== false;
23
- const width = typeof options.width === "number" &&
24
- Number.isFinite(options.width) &&
25
- options.width > 0
26
- ? Math.floor(options.width)
27
- : 80;
28
- const countRows = (text) => {
29
- const lines = text.split("\n");
30
- if (lines.length > 0 && lines.at(-1) === "")
31
- lines.pop();
32
- let rows = 0;
33
- for (const line of lines) {
34
- const visible = stripAnsi(line);
35
- const w = stringWidth(visible);
36
- rows += Math.max(1, Math.ceil(Math.max(0, w) / width));
37
- }
38
- return rows;
39
- };
40
- const render = (input) => {
41
- const renderedRaw = options.renderFrame(input);
42
- const rendered = renderedRaw.endsWith("\n")
43
- ? renderedRaw
44
- : `${renderedRaw}\n`;
45
- const lines = rendered.split("\n");
46
- if (lines.length > 0 && lines.at(-1) === "")
47
- lines.pop();
48
- const newRows = countRows(rendered);
49
- let frame = "";
50
- if (hideCursor && !cursorHidden) {
51
- frame += HIDE_CURSOR;
52
- cursorHidden = true;
53
- }
54
- if (synchronizedOutput)
55
- frame += BSU;
56
- frame += previousRows > 0 ? `${cursorUp(previousRows)}\r` : "\r";
57
- frame += CLEAR_TO_END;
58
- for (const line of lines) {
59
- frame += "\r";
60
- frame += line;
61
- frame += "\r\n";
62
- }
63
- if (synchronizedOutput)
64
- frame += ESU;
65
- options.write(frame);
66
- previousRows = newRows;
67
- };
68
- const finish = () => {
69
- if (hideCursor && cursorHidden) {
70
- options.write(SHOW_CURSOR);
71
- cursorHidden = false;
72
- }
73
- };
74
- return { render, finish };
75
- }