markdansi 0.1.4 → 0.1.6

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). 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). Includes live in-place terminal rendering for streaming updates (`createLiveRenderer`). Written in TypeScript, ships ESM.
10
10
 
11
11
  Published on npm as `markdansi`.
12
12
 
@@ -46,6 +46,25 @@ 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.
51
+
52
+ ```js
53
+ import { createLiveRenderer, render } from 'markdansi';
54
+
55
+ const live = createLiveRenderer({
56
+ renderFrame: (markdown) => render(markdown),
57
+ write: process.stdout.write.bind(process.stdout),
58
+ });
59
+
60
+ let buffer = '';
61
+ buffer += '# Hello\\n';
62
+ live.render(buffer);
63
+ buffer += '\\nMore…\\n';
64
+ live.render(buffer);
65
+ live.finish();
66
+ ```
67
+
49
68
  ```js
50
69
  import { render, createRenderer, strip, themes } from 'markdansi';
51
70
 
package/dist/index.d.ts CHANGED
@@ -1,8 +1,11 @@
1
+ import type { LiveRenderer, LiveRendererOptions } from "./live.js";
2
+ import { createLiveRenderer } from "./live.js";
1
3
  import { createRenderer, render as renderMarkdown } from "./render.js";
2
4
  import { themes } from "./theme.js";
3
5
  import type { RenderOptions, Theme, ThemeName } from "./types.js";
4
- export { renderMarkdown as render, createRenderer, themes };
6
+ export { createLiveRenderer, createRenderer, renderMarkdown as render, themes };
5
7
  export type { RenderOptions, Theme, ThemeName };
8
+ export type { LiveRenderer, LiveRendererOptions };
6
9
  /**
7
10
  * Render Markdown to plain text (no ANSI, no hyperlinks) while preserving layout/wrapping.
8
11
  */
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { createLiveRenderer } from "./live.js";
1
2
  import { createRenderer, render as renderMarkdown } from "./render.js";
2
3
  import { themes } from "./theme.js";
3
- export { renderMarkdown as render, createRenderer, themes };
4
+ export { createLiveRenderer, createRenderer, renderMarkdown as render, themes };
4
5
  /**
5
6
  * Render Markdown to plain text (no ANSI, no hyperlinks) while preserving layout/wrapping.
6
7
  */
package/dist/live.d.ts ADDED
@@ -0,0 +1,35 @@
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 ADDED
@@ -0,0 +1,75 @@
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
+ }
package/dist/render.js CHANGED
@@ -322,7 +322,7 @@ function renderNode(node, ctx, indentLevel, isTightList) {
322
322
  }
323
323
  }
324
324
  function renderParagraph(node, ctx, indentLevel) {
325
- const text = renderInline(node.children, ctx);
325
+ const text = normalizeParagraphInlineText(renderInline(node.children, ctx));
326
326
  const prefix = " ".repeat(ctx.options.listIndent * indentLevel);
327
327
  const rawLines = text.split("\n");
328
328
  const normalized = [];
@@ -489,7 +489,7 @@ function renderInline(children, ctx) {
489
489
  out += renderLink(node, ctx);
490
490
  break;
491
491
  case "break":
492
- out += "\n";
492
+ out += HARD_BREAK;
493
493
  break;
494
494
  default:
495
495
  if ("value" in node && typeof node.value === "string")
@@ -498,6 +498,47 @@ function renderInline(children, ctx) {
498
498
  }
499
499
  return out;
500
500
  }
501
+ const HARD_BREAK = "\u000B";
502
+ function normalizeParagraphInlineText(text) {
503
+ if (!text.includes("\n") && !text.includes(HARD_BREAK))
504
+ return text;
505
+ const segments = [];
506
+ let current = "";
507
+ for (let i = 0; i < text.length; i += 1) {
508
+ const ch = text[i];
509
+ if (ch === "\n" || ch === HARD_BREAK) {
510
+ segments.push({
511
+ text: current,
512
+ breakAfter: ch === HARD_BREAK ? "hard" : "soft",
513
+ });
514
+ current = "";
515
+ continue;
516
+ }
517
+ current += ch;
518
+ }
519
+ segments.push({ text: current });
520
+ const defPattern = /^\[[^\]]+]:\s+\S/;
521
+ let out = segments[0]?.text ?? "";
522
+ for (let i = 0; i < segments.length - 1; i += 1) {
523
+ const kind = segments[i]?.breakAfter ?? "soft";
524
+ const left = segments[i]?.text ?? "";
525
+ const right = segments[i + 1]?.text ?? "";
526
+ if (kind === "hard") {
527
+ out += "\n";
528
+ out += right;
529
+ continue;
530
+ }
531
+ const leftTrim = left.trimStart();
532
+ const rightTrim = right.trimStart();
533
+ const keepNewline = left === "" ||
534
+ right === "" ||
535
+ defPattern.test(leftTrim) ||
536
+ defPattern.test(rightTrim);
537
+ out += keepNewline ? "\n" : " ";
538
+ out += rightTrim;
539
+ }
540
+ return out;
541
+ }
501
542
  function renderLink(node, ctx) {
502
543
  const label = renderInline(node.children, ctx) || node.url;
503
544
  const url = node.url || "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "markdansi",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Tiny dependency-light markdown to ANSI converter.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -67,12 +67,17 @@
67
67
  "supports-hyperlinks": "^4.3.0"
68
68
  },
69
69
  "devDependencies": {
70
- "@biomejs/biome": "^2.3.5",
70
+ "@biomejs/biome": "^2.3.10",
71
71
  "@types/mdast": "^4.0.4",
72
- "@types/node": "^24.10.1",
73
- "@vitest/coverage-v8": "^4.0.9",
74
- "tsx": "^4.20.6",
72
+ "@types/node": "^25.0.3",
73
+ "@vitest/coverage-v8": "^4.0.16",
74
+ "tsx": "^4.21.0",
75
75
  "typescript": "^5.9.3",
76
- "vitest": "^4.0.9"
76
+ "vitest": "^4.0.16"
77
+ },
78
+ "pnpm": {
79
+ "onlyBuiltDependencies": [
80
+ "esbuild"
81
+ ]
77
82
  }
78
83
  }