markdansi 0.1.7 → 0.2.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Peter Steinberger
3
+ \g<1>2026 Peter Steinberger
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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.d.ts CHANGED
@@ -13,4 +13,5 @@ export declare function handleStdoutEpipe(): void;
13
13
  * Parse CLI arguments into RenderOptions-ish object (plus in/out paths).
14
14
  */
15
15
  export declare function parseArgs(argv: string[]): CliArgs;
16
+ export declare function isDirectCliInvocation(metaUrl: string, argv1?: string): boolean;
16
17
  export {};
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { pathToFileURL } from "node:url";
4
+ import { fileURLToPath } from "node:url";
5
5
  import { render } from "./index.js";
6
6
  /**
7
7
  * Ignore EPIPE when downstream (e.g., `head`) closes early.
@@ -157,10 +157,19 @@ function main() {
157
157
  process.stdout.write(output);
158
158
  }
159
159
  }
160
+ export function isDirectCliInvocation(metaUrl, argv1) {
161
+ if (!argv1)
162
+ return false;
163
+ try {
164
+ const entry = fs.realpathSync(argv1);
165
+ const self = fs.realpathSync(fileURLToPath(metaUrl));
166
+ return entry === self;
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ }
160
172
  // Only run the CLI when executed directly, not when imported for tests.
161
- const entryHref = process.argv[1]
162
- ? pathToFileURL(process.argv[1]).href
163
- : undefined;
164
- if (import.meta.url === entryHref) {
173
+ if (isDirectCliInvocation(import.meta.url, process.argv[1])) {
165
174
  main();
166
175
  }
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
  */
package/dist/render.js CHANGED
@@ -565,13 +565,15 @@ function renderTable(node, ctx) {
565
565
  const widths = new Array(colCount).fill(1);
566
566
  const aligns = node.align || [];
567
567
  const pad = ctx.options.tablePadding;
568
+ const padStr = " ".repeat(Math.max(0, pad));
568
569
  const minContent = Math.max(1, ctx.options.tableEllipsis.length + 1);
569
570
  // ensure we always have room for at least one visible char + ellipsis + padding
570
571
  const minColWidth = Math.max(1, pad * 2 + minContent);
571
572
  cells.forEach((row) => {
572
573
  row.forEach((cell, idx) => {
574
+ const padded = `${padStr}${cell}${padStr}`;
573
575
  // Cap each column to MAX_COL but keep at least 1
574
- widths[idx] = Math.max(widths[idx], Math.min(MAX_COL, visibleWidth(cell)));
576
+ widths[idx] = Math.max(widths[idx], Math.min(MAX_COL, visibleWidth(padded)));
575
577
  });
576
578
  });
577
579
  const totalWidth = widths.reduce((a, b) => a + b, 0) + 3 * colCount + 1;
@@ -592,13 +594,16 @@ function renderTable(node, ctx) {
592
594
  }
593
595
  const renderRow = (row, isHeader = false) => {
594
596
  const linesPerCol = row.map((cell, idx) => {
595
- const padded = ` ${cell} `;
596
597
  const target = Math.max(minContent, widths[idx] - pad * 2);
597
- const cellText = ctx.options.tableTruncate
598
+ const content = ctx.options.tableTruncate
598
599
  ? truncateCell(cell, target, ctx.options.tableEllipsis)
599
- : padded;
600
- const wrapped = wrapText(cellText, ctx.options.wrap ? target : Number.MAX_SAFE_INTEGER, ctx.options.wrap);
601
- return wrapped.map((l) => padCell(` ${l} `, widths[idx], aligns[idx] ?? "left", ctx.options.tablePadding));
600
+ : cell;
601
+ const wrapped = wrapText(content, ctx.options.wrap ? target : Number.MAX_SAFE_INTEGER, ctx.options.wrap);
602
+ return wrapped.map((l) => {
603
+ const aligned = padCell(l, target, aligns[idx] ?? "left");
604
+ const padded = `${padStr}${aligned}${padStr}`;
605
+ return padCell(padded, widths[idx], "left");
606
+ });
602
607
  });
603
608
  // Row height = max wrapped lines in any column; pad shorter ones
604
609
  const height = Math.max(...linesPerCol.map((c) => c.length));
@@ -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.1",
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
- }