markdansi 0.2.0 → 0.3.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Peter Steinberger
3
+ Copyright (c) 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
@@ -11,6 +11,7 @@ Tiny, dependency-light Markdown → ANSI renderer and CLI for modern Node (>=22)
11
11
  Published on npm as `markdansi`.
12
12
 
13
13
  ## Install
14
+
14
15
  Grab it from npm; no native deps, so install is instant on Node 22+.
15
16
 
16
17
  ```bash
@@ -22,85 +23,90 @@ npm install markdansi
22
23
  ```
23
24
 
24
25
  ## CLI
26
+
25
27
  Quick one-shot renderer: pipe Markdown in, ANSI comes out. Flags let you pick width, wrap, colors, links, and table/list styling.
26
28
 
27
29
  ```bash
28
- markdansi [--in FILE] [--out FILE] [--width N] [--no-wrap] [--no-color] [--no-links] [--theme default|dim|bright]
30
+ markdansi [FILE] [--in FILE] [--out FILE] [--width N] [--no-wrap] [--no-color] [--no-links] [--theme default|dim|bright]
29
31
  [--list-indent N] [--quote-prefix STR]
30
32
  ```
31
33
 
32
- - Input: stdin if `--in` not given (use `--in -` for stdin explicitly).
34
+ - Input: positional `FILE`, `--in FILE`, or stdin when neither is given (use `--in -` for stdin explicitly).
33
35
  - Output: stdout unless `--out` provided.
34
36
  - Wrapping: on by default; `--no-wrap` disables hard wrapping.
35
37
  - Links: OSC‑8 when supported; `--no-links` disables.
36
38
  - Lists/quotes: `--list-indent` sets spaces per nesting level (default 2); `--quote-prefix` sets blockquote prefix (default `│ `).
37
39
 
38
40
  ## Library
41
+
39
42
  Use the renderer directly in Node/TS for customizable theming, optional syntax highlighting hooks, and OSC‑8 link control.
40
43
 
41
44
  ### ESM / CommonJS
45
+
42
46
  Markdansi ships ESM (`"type":"module"`). If you’re in CommonJS (or a tool like `tsx` running your script as CJS), prefer dynamic import:
43
47
 
44
48
  ```js
45
- const { render } = await import('markdansi');
46
- console.log(render('# hello'));
49
+ const { render } = await import("markdansi");
50
+ console.log(render("# hello"));
47
51
  ```
48
52
 
49
53
  ### Streaming (recommended: hybrid blocks)
54
+
50
55
  If you’re streaming Markdown (LLM output), keep scrollback safe by emitting **completed fragments only**
51
56
  and writing them once (append-only; no in-place redraw).
52
57
 
53
58
  Hybrid mode streams regular lines as they complete, but buffers multi-line constructs that need context:
59
+
54
60
  - Fenced code blocks (``` / ~~~) — flushed only after the closing fence
55
61
  - Tables — flushed only after the header separator row + until the table ends
56
62
 
57
63
  ```js
58
- import { createMarkdownStreamer, render } from 'markdansi';
64
+ import { createMarkdownStreamer, render } from "markdansi";
59
65
 
60
66
  const streamer = createMarkdownStreamer({
61
67
  render: (md) => render(md, { width: process.stdout.columns ?? 80 }),
62
- spacing: 'single', // collapse consecutive blank lines
68
+ spacing: "single", // collapse consecutive blank lines
63
69
  });
64
70
 
65
- process.stdin.setEncoding('utf8');
66
- process.stdin.on('data', (delta) => {
71
+ process.stdin.setEncoding("utf8");
72
+ process.stdin.on("data", (delta) => {
67
73
  const chunk = streamer.push(delta);
68
74
  if (chunk) process.stdout.write(chunk);
69
75
  });
70
- process.stdin.on('end', () => {
76
+ process.stdin.on("end", () => {
71
77
  const tail = streamer.finish();
72
78
  if (tail) process.stdout.write(tail);
73
79
  });
74
80
  ```
75
81
 
76
- ```js
77
- import { render, createRenderer, strip, themes } from 'markdansi';
82
+ ````js
83
+ import { render, createRenderer, strip, themes } from "markdansi";
78
84
 
79
- const ansi = render('# Hello **world**', { width: 60 });
85
+ const ansi = render("# Hello **world**", { width: 60 });
80
86
 
81
87
  const renderNoWrap = createRenderer({ wrap: false });
82
- const out = renderNoWrap('A very long line...');
88
+ const out = renderNoWrap("A very long line...");
83
89
 
84
90
  // Plain text (no ANSI/OSC)
85
- const plain = strip('link to [x](https://example.com)');
91
+ const plain = strip("link to [x](https://example.com)");
86
92
 
87
93
  // Custom theme and highlighter hook
88
94
  const custom = createRenderer({
89
95
  theme: {
90
96
  ...themes.default,
91
- code: { color: 'cyan', dim: true }, // fallback used for inline/block
92
- inlineCode: { color: 'red' },
93
- blockCode: { color: 'green' },
97
+ code: { color: "cyan", dim: true }, // fallback used for inline/block
98
+ inlineCode: { color: "red" },
99
+ blockCode: { color: "green" },
94
100
  },
95
101
  highlighter: (code, lang) => code.toUpperCase(),
96
102
  });
97
- console.log(custom('`inline`\n\n```\nblock code\n```'));
103
+ console.log(custom("`inline`\n\n```\nblock code\n```"));
98
104
 
99
105
  // Example: real syntax highlighting with Shiki (TS + Swift)
100
- import { bundledLanguages, bundledThemes, createHighlighter } from 'shiki';
106
+ import { bundledLanguages, bundledThemes, createHighlighter } from "shiki";
101
107
 
102
108
  const shiki = await createHighlighter({
103
- themes: [bundledThemes['github-dark']],
109
+ themes: [bundledThemes["github-dark"]],
104
110
  langs: [bundledLanguages.typescript, bundledLanguages.swift],
105
111
  });
106
112
 
@@ -108,27 +114,29 @@ const highlighted = createRenderer({
108
114
  highlighter: (code, lang) => {
109
115
  if (!lang) return code;
110
116
  const normalized = lang.toLowerCase();
111
- if (!['ts', 'typescript', 'swift'].includes(normalized)) return code;
117
+ if (!["ts", "typescript", "swift"].includes(normalized)) return code;
112
118
  const { tokens } = shiki.codeToTokens(code, {
113
- lang: normalized === 'swift' ? 'swift' : 'ts',
114
- theme: 'github-dark',
119
+ lang: normalized === "swift" ? "swift" : "ts",
120
+ theme: "github-dark",
115
121
  });
116
122
  return tokens
117
123
  .map((line) =>
118
124
  line
119
125
  .map((token) =>
120
- token.color ? `\u001b[38;2;${parseInt(token.color.slice(1, 3), 16)};${parseInt(
121
- token.color.slice(3, 5),
122
- 16,
123
- )};${parseInt(token.color.slice(5, 7), 16)}m${token.content}\u001b[39m` : token.content,
126
+ token.color
127
+ ? `\u001b[38;2;${parseInt(token.color.slice(1, 3), 16)};${parseInt(
128
+ token.color.slice(3, 5),
129
+ 16,
130
+ )};${parseInt(token.color.slice(5, 7), 16)}m${token.content}\u001b[39m`
131
+ : token.content,
124
132
  )
125
- .join(''),
133
+ .join(""),
126
134
  )
127
- .join('\n');
135
+ .join("\n");
128
136
  },
129
137
  });
130
- console.log(highlighted('```ts\nconst x: number = 1\n```\n```swift\nlet x = 1\n```'));
131
- ```
138
+ console.log(highlighted("```ts\nconst x: number = 1\n```\n```swift\nlet x = 1\n```"));
139
+ ````
132
140
 
133
141
  ### Options
134
142
 
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.
@@ -104,6 +104,8 @@ export function parseArgs(argv) {
104
104
  }
105
105
  else if (a === "--help" || a === "-h")
106
106
  args.help = true;
107
+ else if (!a.startsWith("-") && !args.in)
108
+ args.in = a;
107
109
  }
108
110
  return args;
109
111
  }
@@ -114,7 +116,13 @@ function main() {
114
116
  handleStdoutEpipe();
115
117
  const args = parseArgs(process.argv);
116
118
  if (args.help) {
117
- process.stdout.write(`markdansi options:
119
+ process.stdout.write(`markdansi [FILE] [options]
120
+
121
+ markdansi file.md Render file
122
+ markdansi --in file.md Same (explicit)
123
+ cat file.md | markdansi Read from stdin
124
+
125
+ Options:
118
126
  --in FILE Input file (default: stdin)
119
127
  --out FILE Output file (default: stdout)
120
128
  --width N Wrap width (default: TTY cols or 80)
@@ -145,9 +153,7 @@ function main() {
145
153
  ...(args.hyperlinks !== undefined ? { hyperlinks: args.hyperlinks } : {}),
146
154
  ...(args.theme !== undefined ? { theme: args.theme } : {}),
147
155
  ...(args.listIndent !== undefined ? { listIndent: args.listIndent } : {}),
148
- ...(args.quotePrefix !== undefined
149
- ? { quotePrefix: args.quotePrefix }
150
- : {}),
156
+ ...(args.quotePrefix !== undefined ? { quotePrefix: args.quotePrefix } : {}),
151
157
  };
152
158
  const output = render(input, renderOptions);
153
159
  if (args.out) {
@@ -157,10 +163,19 @@ function main() {
157
163
  process.stdout.write(output);
158
164
  }
159
165
  }
166
+ export function isDirectCliInvocation(metaUrl, argv1) {
167
+ if (!argv1)
168
+ return false;
169
+ try {
170
+ const entry = fs.realpathSync(argv1);
171
+ const self = fs.realpathSync(fileURLToPath(metaUrl));
172
+ return entry === self;
173
+ }
174
+ catch {
175
+ return false;
176
+ }
177
+ }
160
178
  // 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) {
179
+ if (isDirectCliInvocation(import.meta.url, process.argv[1])) {
165
180
  main();
166
181
  }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import { createRenderer, render as renderMarkdown } from "./render.js";
2
2
  import { createMarkdownStreamer } from "./stream.js";
3
3
  import { themes } from "./theme.js";
4
4
  import type { RenderOptions, Theme, ThemeName } from "./types.js";
5
- export { createMarkdownStreamer, createRenderer, renderMarkdown as render, themes, };
5
+ export { createMarkdownStreamer, createRenderer, renderMarkdown as render, themes };
6
6
  export type { RenderOptions, Theme, ThemeName };
7
7
  /**
8
8
  * Render Markdown to plain text (no ANSI, no hyperlinks) while preserving layout/wrapping.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRenderer, render as renderMarkdown } from "./render.js";
2
2
  import { createMarkdownStreamer } from "./stream.js";
3
3
  import { themes } from "./theme.js";
4
- export { createMarkdownStreamer, 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
@@ -1,3 +1,4 @@
1
+ import sliceAnsi from "slice-ansi";
1
2
  import stringWidth from "string-width";
2
3
  import stripAnsi from "strip-ansi";
3
4
  import { hyperlinkSupported, osc8 } from "./hyperlink.js";
@@ -21,9 +22,7 @@ function resolveOptions(userOptions = {}) {
21
22
  const baseWidth = userOptions.width ?? (wrap ? process.stdout.columns || 80 : undefined);
22
23
  const color = userOptions.color !== undefined ? userOptions.color : process.stdout.isTTY;
23
24
  // OSC hyperlinks require color support; if color is off, force hyperlinks off too
24
- const hyperlinks = userOptions.hyperlinks !== undefined
25
- ? userOptions.hyperlinks
26
- : color && hyperlinkSupported();
25
+ const hyperlinks = userOptions.hyperlinks !== undefined ? userOptions.hyperlinks : color && hyperlinkSupported();
27
26
  const effectiveHyperlinks = color ? hyperlinks : false;
28
27
  const baseTheme = themes.default ?? {};
29
28
  const userTheme = userOptions.theme && typeof userOptions.theme === "object"
@@ -31,17 +30,9 @@ function resolveOptions(userOptions = {}) {
31
30
  : themes[userOptions.theme || "default"] || baseTheme;
32
31
  const mergedTheme = {
33
32
  ...baseTheme,
34
- ...(userTheme || {}),
35
- inlineCode: userTheme?.inlineCode ||
36
- userTheme?.code ||
37
- baseTheme.inlineCode ||
38
- baseTheme.code ||
39
- {},
40
- blockCode: userTheme?.blockCode ||
41
- userTheme?.code ||
42
- baseTheme.blockCode ||
43
- baseTheme.code ||
44
- {},
33
+ ...userTheme,
34
+ inlineCode: userTheme?.inlineCode || userTheme?.code || baseTheme.inlineCode || baseTheme.code || {},
35
+ blockCode: userTheme?.blockCode || userTheme?.code || baseTheme.blockCode || baseTheme.code || {},
45
36
  };
46
37
  const highlighter = userOptions.highlighter;
47
38
  const listIndent = userOptions.listIndent ?? 2;
@@ -163,8 +154,7 @@ function mergeAdjacentCodeBlocks(nodes) {
163
154
  };
164
155
  for (const node of nodes) {
165
156
  if (node?.type === "code") {
166
- if (pending &&
167
- (pending.lang === node.lang || (!pending.lang && !node.lang))) {
157
+ if (pending && (pending.lang === node.lang || (!pending.lang && !node.lang))) {
168
158
  const nextValue = `${pending.value}\n${node.value}`;
169
159
  pending = {
170
160
  type: "code",
@@ -183,9 +173,7 @@ function mergeAdjacentCodeBlocks(nodes) {
183
173
  if (node?.type === "list") {
184
174
  const flattened = flattenCodeList(node);
185
175
  if (flattened) {
186
- if (pending &&
187
- (pending.lang === flattened.lang ||
188
- (!pending.lang && !flattened.lang))) {
176
+ if (pending && (pending.lang === flattened.lang || (!pending.lang && !flattened.lang))) {
189
177
  const nextValue = `${pending.value}\n${flattened.value}`;
190
178
  pending = {
191
179
  type: "code",
@@ -329,9 +317,7 @@ function renderParagraph(node, ctx, indentLevel) {
329
317
  const defPattern = /^\[[^\]]+]:\s+\S/;
330
318
  let inDefinitions = false;
331
319
  for (const line of rawLines) {
332
- if (defPattern.test(line) &&
333
- normalized.length > 0 &&
334
- normalized.at(-1) !== "") {
320
+ if (defPattern.test(line) && normalized.length > 0 && normalized.at(-1) !== "") {
335
321
  normalized.push(""); // insert blank line before footer-style definitions
336
322
  }
337
323
  if (defPattern.test(line)) {
@@ -355,9 +341,7 @@ function renderHeading(node, ctx) {
355
341
  return [`\n${styled}\n`];
356
342
  }
357
343
  function renderHr(ctx) {
358
- const width = ctx.options.wrap
359
- ? Math.min(ctx.options.width ?? HR_WIDTH, HR_WIDTH)
360
- : HR_WIDTH;
344
+ const width = ctx.options.wrap ? Math.min(ctx.options.width ?? HR_WIDTH, HR_WIDTH) : HR_WIDTH;
361
345
  const line = "—".repeat(width);
362
346
  return [`${ctx.style(line, ctx.options.theme.hr)}\n`];
363
347
  }
@@ -389,9 +373,7 @@ function renderListItem(node, ctx, indentLevel, tight, ordered = false, start =
389
373
  const isTask = typeof node.checked === "boolean";
390
374
  const box = isTask && node.checked ? "[x]" : "[ ]";
391
375
  const firstBullet = " ".repeat(ctx.options.listIndent * indentLevel) +
392
- (isTask
393
- ? `${ctx.style(box, ctx.options.theme.listMarker)} `
394
- : `${markerStyled} `);
376
+ (isTask ? `${ctx.style(box, ctx.options.theme.listMarker)} ` : `${markerStyled} `);
395
377
  const lines = [];
396
378
  content.forEach((line, i) => {
397
379
  const clean = line.replace(/^\s+/, "");
@@ -414,9 +396,7 @@ function renderCodeBlock(node, ctx) {
414
396
  const theme = ctx.options.theme.blockCode || ctx.options.theme.inlineCode;
415
397
  const lines = (node.value ?? "").split("\n");
416
398
  const isDiff = node.lang === "diff";
417
- const gutterWidth = ctx.options.codeGutter
418
- ? String(lines.length).length + 2
419
- : 0;
399
+ const gutterWidth = ctx.options.codeGutter ? String(lines.length).length + 2 : 0;
420
400
  const shouldWrap = isDiff ? false : ctx.options.codeWrap;
421
401
  const useBox = ctx.options.codeBox && lines.length > 1;
422
402
  const boxPadding = useBox ? 4 : 0;
@@ -426,13 +406,10 @@ function renderCodeBlock(node, ctx) {
426
406
  const contentLines = lines.flatMap((line, idx) => {
427
407
  const segments = wrapLimit !== undefined ? wrapCodeLine(line, wrapLimit) : [line];
428
408
  return segments.map((segment, segIdx) => {
429
- const highlighted = ctx.options.highlighter?.(segment, node.lang ?? undefined) ??
430
- ctx.style(segment, theme);
409
+ const highlighted = ctx.options.highlighter?.(segment, node.lang ?? undefined) ?? ctx.style(segment, theme);
431
410
  if (!ctx.options.codeGutter)
432
411
  return highlighted;
433
- const num = segIdx === 0
434
- ? String(idx + 1).padStart(gutterWidth - 2, " ")
435
- : " ".repeat(gutterWidth - 1);
412
+ const num = segIdx === 0 ? String(idx + 1).padStart(gutterWidth - 2, " ") : " ".repeat(gutterWidth - 1);
436
413
  return `${ctx.style(num, { dim: true })} ${highlighted}`;
437
414
  });
438
415
  });
@@ -530,10 +507,7 @@ function normalizeParagraphInlineText(text) {
530
507
  }
531
508
  const leftTrim = left.trimStart();
532
509
  const rightTrim = right.trimStart();
533
- const keepNewline = left === "" ||
534
- right === "" ||
535
- defPattern.test(leftTrim) ||
536
- defPattern.test(rightTrim);
510
+ const keepNewline = left === "" || right === "" || defPattern.test(leftTrim) || defPattern.test(rightTrim);
537
511
  out += keepNewline ? "\n" : " ";
538
512
  out += rightTrim;
539
513
  }
@@ -550,8 +524,7 @@ function renderLink(node, ctx) {
550
524
  return osc8(url, label);
551
525
  }
552
526
  if (url && label !== url) {
553
- return (ctx.style(label, ctx.options.theme.link) +
554
- ctx.style(` (${url})`, { dim: true }));
527
+ return ctx.style(label, ctx.options.theme.link) + ctx.style(` (${url})`, { dim: true });
555
528
  }
556
529
  return ctx.style(label, ctx.options.theme.link);
557
530
  }
@@ -562,16 +535,18 @@ function renderTable(node, ctx) {
562
535
  const rows = node.children.slice(1);
563
536
  const cells = [header, ...rows].map((row) => row.children.map((cell) => renderInline(cell.children, ctx)));
564
537
  const colCount = Math.max(...cells.map((r) => r.length));
565
- const widths = new Array(colCount).fill(1);
538
+ const widths = Array.from({ length: colCount }, () => 1);
566
539
  const aligns = node.align || [];
567
540
  const pad = ctx.options.tablePadding;
541
+ const padStr = " ".repeat(Math.max(0, pad));
568
542
  const minContent = Math.max(1, ctx.options.tableEllipsis.length + 1);
569
543
  // ensure we always have room for at least one visible char + ellipsis + padding
570
544
  const minColWidth = Math.max(1, pad * 2 + minContent);
571
545
  cells.forEach((row) => {
572
546
  row.forEach((cell, idx) => {
547
+ const padded = `${padStr}${cell}${padStr}`;
573
548
  // Cap each column to MAX_COL but keep at least 1
574
- widths[idx] = Math.max(widths[idx], Math.min(MAX_COL, visibleWidth(cell)));
549
+ widths[idx] = Math.max(widths[idx] ?? 1, Math.min(MAX_COL, visibleWidth(padded)));
575
550
  });
576
551
  });
577
552
  const totalWidth = widths.reduce((a, b) => a + b, 0) + 3 * colCount + 1;
@@ -580,32 +555,36 @@ function renderTable(node, ctx) {
580
555
  let over = totalWidth - ctx.options.width;
581
556
  while (over > 0) {
582
557
  const i = widths.indexOf(Math.max(...widths));
583
- if (widths[i] <= minColWidth)
558
+ if ((widths[i] ?? minColWidth) <= minColWidth)
584
559
  break;
585
- widths[i] -= 1;
560
+ widths[i] = (widths[i] ?? minColWidth) - 1;
586
561
  over -= 1;
587
562
  }
588
563
  }
589
564
  for (let i = 0; i < widths.length; i += 1) {
590
- if (widths[i] < minColWidth)
565
+ if ((widths[i] ?? minColWidth) < minColWidth)
591
566
  widths[i] = minColWidth;
592
567
  }
593
568
  const renderRow = (row, isHeader = false) => {
594
569
  const linesPerCol = row.map((cell, idx) => {
595
- const padded = ` ${cell} `;
596
- const target = Math.max(minContent, widths[idx] - pad * 2);
597
- const cellText = ctx.options.tableTruncate
570
+ const width = widths[idx] ?? minColWidth;
571
+ const target = Math.max(minContent, width - pad * 2);
572
+ const content = ctx.options.tableTruncate
598
573
  ? 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));
574
+ : cell;
575
+ const wrapped = wrapText(content, ctx.options.wrap ? target : Number.MAX_SAFE_INTEGER, ctx.options.wrap);
576
+ return wrapped.map((l) => {
577
+ const aligned = padCell(l, target, aligns[idx] ?? "left");
578
+ const padded = `${padStr}${aligned}${padStr}`;
579
+ return padCell(padded, width, "left");
580
+ });
602
581
  });
603
582
  // Row height = max wrapped lines in any column; pad shorter ones
604
583
  const height = Math.max(...linesPerCol.map((c) => c.length));
605
584
  const out = [];
606
585
  for (let i = 0; i < height; i += 1) {
607
586
  const parts = linesPerCol.map((col, idx) => {
608
- const content = col[i] ?? padCell("", widths[idx], aligns[idx] ?? "left");
587
+ const content = col[i] ?? padCell("", widths[idx] ?? minColWidth, aligns[idx] ?? "left");
609
588
  return isHeader
610
589
  ? ctx.style(content, ctx.options.theme.tableHeader)
611
590
  : ctx.style(content, ctx.options.theme.tableCell);
@@ -617,38 +596,36 @@ function renderTable(node, ctx) {
617
596
  const headerRows = renderRow(header.children.map((c) => renderInline(c.children, ctx)), true);
618
597
  const bodyRows = rows.flatMap((r) => renderRow(r.children.map((c) => renderInline(c.children, ctx))));
619
598
  if (ctx.options.tableBorder === "none") {
620
- const lines = [...headerRows, ...bodyRows]
621
- .map((row) => row.join(" | "))
622
- .join("\n");
599
+ const lines = [...headerRows, ...bodyRows].map((row) => row.join(" | ")).join("\n");
623
600
  return [`${lines}\n\n`];
624
601
  }
625
602
  const box = TABLE_BOX[ctx.options.tableBorder] || TABLE_BOX.unicode;
626
- const hLine = (sepMid, sepLeft, sepRight) => `${sepLeft}${widths
627
- .map((w) => box.hSep.repeat(w))
628
- .join(sepMid)}${sepRight}\n`;
603
+ const hLine = (sepMid, sepLeft, sepRight) => `${sepLeft}${widths.map((w) => box.hSep.repeat(w)).join(sepMid)}${sepRight}\n`;
629
604
  const top = hLine(box.tSep, box.topLeft, box.topRight);
630
605
  const mid = hLine(box.mSep, box.mLeft, box.mRight);
631
606
  const bottom = hLine(box.bSep, box.bottomLeft, box.bottomRight);
632
- const renderFlat = (rowsArr) => rowsArr
633
- .map((r) => `${box.vSep}${r.map((c) => c).join(box.vSep)}${box.vSep}\n`)
634
- .join("");
607
+ const renderFlat = (rowsArr) => rowsArr.map((r) => `${box.vSep}${r.map((c) => c).join(box.vSep)}${box.vSep}\n`).join("");
635
608
  const dense = ctx.options.tableDense;
636
- const out = [
637
- top,
638
- renderFlat(headerRows),
639
- dense ? "" : mid,
640
- renderFlat(bodyRows),
641
- bottom,
642
- "\n",
643
- ];
609
+ const out = [top, renderFlat(headerRows), dense ? "" : mid, renderFlat(bodyRows), bottom, "\n"];
644
610
  return out;
645
611
  }
646
612
  function truncateCell(text, width, ellipsis) {
647
613
  if (stringWidth(text) <= width)
648
614
  return text;
649
- if (width <= ellipsis.length)
650
- return ellipsis.slice(0, width);
651
- return text.slice(0, width - ellipsis.length) + ellipsis;
615
+ const ellipsisWidth = stringWidth(ellipsis);
616
+ if (width <= ellipsisWidth)
617
+ return sliceCellContent(ellipsis, width);
618
+ return `${sliceCellContent(text, width - ellipsisWidth)}${ellipsis}`;
619
+ }
620
+ function sliceCellContent(text, width) {
621
+ let end = Math.max(0, width);
622
+ let sliced = sliceAnsi(text, 0, end);
623
+ // slice-ansi owns ANSI/OSC/grapheme boundaries; clamp with Markdansi's width metric.
624
+ while (end > 0 && stringWidth(sliced) > width) {
625
+ end -= 1;
626
+ sliced = sliceAnsi(text, 0, end);
627
+ }
628
+ return sliced;
652
629
  }
653
630
  function wrapCodeLine(text, width) {
654
631
  // Hard-wrap code even without spaces while keeping ANSI-safe width accounting.
@@ -656,7 +633,7 @@ function wrapCodeLine(text, width) {
656
633
  return [text];
657
634
  const parts = [];
658
635
  let current = "";
659
- for (const ch of [...text]) {
636
+ for (const ch of text) {
660
637
  const chWidth = stringWidth(ch);
661
638
  if (visibleWidth(current) + chWidth > width) {
662
639
  parts.push(current);
package/dist/stream.js CHANGED
@@ -126,8 +126,7 @@ export function createMarkdownStreamer(options) {
126
126
  fence = fenceStart;
127
127
  fenceBuffer = `${line}\n`;
128
128
  // Some fences are single-line in streams (rare). Handle close immediately.
129
- if (isFenceEnd(line, fenceStart) &&
130
- line.trimStart().match(/^(```+|~~~+)\s*$/)) {
129
+ if (isFenceEnd(line, fenceStart) && line.trimStart().match(/^(```+|~~~+)\s*$/)) {
131
130
  return out + flushFence();
132
131
  }
133
132
  return out;
package/docs/spec.md CHANGED
@@ -3,6 +3,7 @@
3
3
  Goal: Tiny, dependency‑light Markdown → ANSI renderer & CLI for Node ≥22, using pnpm. Output is terminal ANSI only (no HTML). Focus on readable defaults, sensible wrapping, and minimal runtime deps.
4
4
 
5
5
  ## Core Dependencies (runtime)
6
+
6
7
  - `micromark`, `micromark-extension-gfm`, `micromark-util-combine-extensions`: GFM parsing (tables, task lists, strikethrough, autolink literals).
7
8
  - `chalk`: small, ESM‑only color/style helper.
8
9
  - `string-width`: correct visible width (emoji / wide chars).
@@ -12,7 +13,9 @@ Goal: Tiny, dependency‑light Markdown → ANSI renderer & CLI for Node ≥22,
12
13
  Dev: `vitest`, TypeScript (NodeNext).
13
14
 
14
15
  ## Surface Area
16
+
15
17
  ### Library (ESM default, CJS export provided)
18
+
16
19
  `render(markdown: string, options?: RenderOptions): string`
17
20
 
18
21
  `createRenderer(options?: RenderOptions): (md: string) => string`
@@ -42,13 +45,16 @@ Each theme entry holds simple SGR intents (bold/italic/fg color names). `inlineC
42
45
  `strip(markdown: string): string` — convenience: render with `color=false`, `hyperlinks=false`.
43
46
 
44
47
  ### CLI
45
- `markdansi [--in FILE] [--out FILE] [--width N] [--no-wrap] [--no-color] [--no-links] [--theme default|dim|bright]`
46
- - Input: stdin if no `--in`.
48
+
49
+ `markdansi [FILE] [--in FILE] [--out FILE] [--width N] [--no-wrap] [--no-color] [--no-links] [--theme default|dim|bright]`
50
+
51
+ - Input: positional `FILE`, `--in FILE`, or stdin when neither is given.
47
52
  - Output: stdout if no `--out`.
48
53
  - Wrap: on by default; `--no-wrap` disables; width auto from TTY when not provided.
49
54
  - Links: OSC‑8 hyperlinks enabled when terminal supports; `--no-links` disables.
50
55
 
51
56
  ## Feature Scope (v1)
57
+
52
58
  - Blocks: paragraphs, headings (1–6), blockquotes, fenced/indented code blocks, HR, tables, unordered/ordered lists, task lists.
53
59
  - Inline: strong, emphasis, code spans, autolinks/links, strikethrough (GFM `~~`), backslash escapes.
54
60
  - Code blocks: monospace box (unicode or ascii; `codeBox=false` disables). Optional gutter with 1‑based line numbers when `codeGutter=true`. If `lang` present, show faint header label. Highlighter hook may recolor text but must not add/remove newlines. Code blocks wrap to the available width by default (hard-wrap long tokens); set `codeWrap=false` to allow overflow.
@@ -58,15 +64,17 @@ Each theme entry holds simple SGR intents (bold/italic/fg color names). `inlineC
58
64
  - Error handling: never throw on malformed emphasis; leave literals untouched if unmatched.
59
65
 
60
66
  ## Rendering Pipeline
61
- 1) **Parse** via micromark with combined GFM extensions → AST events.
62
- 2) **Build light IR** (nodes: paragraph, heading, list, listItem, taskItem, table, tableRow, tableCell, code, inline text/emph/strong/del/code/link).
63
- 3) **Render** to ANSI:
67
+
68
+ 1. **Parse** via micromark with combined GFM extensions AST events.
69
+ 2. **Build light IR** (nodes: paragraph, heading, list, listItem, taskItem, table, tableRow, tableCell, code, inline text/emph/strong/del/code/link).
70
+ 3. **Render** to ANSI:
64
71
  - Style map from theme to SGR codes.
65
72
  - Wrap paragraphs/table cells using `string-width` + `strip-ansi`; wrap only breaks on spaces.
66
73
  - OSC‑8 links when `hyperlinks` true; otherwise underline + optional URL suffix.
67
74
  - Track active SGR for wrapping splits to re-open styles on new lines.
68
75
 
69
76
  ## Themes (initial)
77
+
70
78
  - `default`: bold headings, blue links, cyan inline code, green block code, yellow table headers, subtle quotes/hr.
71
79
  - `dim`: muted colors for low-contrast terminals.
72
80
  - `bright`: higher contrast variant.
@@ -75,17 +83,21 @@ Each theme entry holds simple SGR intents (bold/italic/fg color names). `inlineC
75
83
  - `contrast`: magenta headings, cyan inline, green block code, yellow headers, bright markers.
76
84
 
77
85
  ## Testing (vitest)
86
+
78
87
  - Unit: inline formatting (emph/strong/code/strike), links/hyperlinks on/off, wrap/no-wrap behavior, table alignment and wrapping, task lists, strikethrough.
79
88
  - Snapshot-ish string comparisons for representative documents (with colors off to avoid brittle codes).
80
89
 
81
90
  ## Non-Goals (v1)
91
+
82
92
  - Images, footnotes, math, HTML passthrough, syntax highlighting bundle.
83
93
 
84
94
  ## Notes
95
+
85
96
  - Highlighting: built-in is “label-only”; extensibility via `highlighter` hook. No extra deps added for highlighting.
86
97
  - ESM-first; provide CJS export entry for compatibility.
87
98
 
88
99
  ## Behaviors & edge-case rules
100
+
89
101
  - Wrap/width precedence: `wrap=false` disables all hard wrapping; `width` is ignored in that mode. When `wrap=true`, width is `options.width ?? ttyColumns ?? 80`.
90
102
  - Color flag: `color=false` removes all ANSI/OSC output (no bold/italic/underline, no hyperlinks); output is plain text.
91
103
  - Hyperlinks fallback: inline links render as `label (url)` when OSC‑8 disabled; autolinks render as the URL only. URLs count toward width.
package/package.json CHANGED
@@ -1,83 +1,88 @@
1
1
  {
2
- "name": "markdansi",
3
- "version": "0.2.0",
4
- "description": "Tiny dependency-light markdown to ANSI converter.",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "exports": {
8
- ".": {
9
- "types": "./dist/index.d.ts",
10
- "import": "./dist/index.js",
11
- "default": "./dist/index.js"
12
- },
13
- "./cli": "./dist/cli.js"
14
- },
15
- "bin": {
16
- "markdansi": "dist/cli.js"
17
- },
18
- "scripts": {
19
- "build": "pnpm lint && pnpm test && pnpm compile",
20
- "clean": "rm -rf dist",
21
- "lint": "rm -rf dist coverage && biome check .",
22
- "test": "vitest run",
23
- "test:coverage": "vitest run --coverage",
24
- "types": "tsc -p tsconfig.json --emitDeclarationOnly",
25
- "compile": "tsc -p tsconfig.json",
26
- "prepare": "pnpm compile",
27
- "markdansi": "tsx src/cli.ts"
28
- },
29
- "keywords": [
30
- "markdown",
31
- "ansi",
32
- "terminal",
33
- "cli"
34
- ],
35
- "engines": {
36
- "node": ">=22"
37
- },
38
- "repository": {
39
- "type": "git",
40
- "url": "git+https://github.com/steipete/Markdansi.git"
41
- },
42
- "bugs": {
43
- "url": "https://github.com/steipete/Markdansi/issues"
44
- },
45
- "homepage": "https://github.com/steipete/Markdansi#readme",
46
- "author": "Peter Steinberger",
47
- "license": "MIT",
48
- "sideEffects": false,
49
- "files": [
50
- "dist",
51
- "README.md",
52
- "docs/spec.md",
53
- "package.json",
54
- "tsconfig.json",
55
- ".biome.json"
56
- ],
57
- "types": "dist/index.d.ts",
58
- "dependencies": {
59
- "chalk": "^5.6.2",
60
- "mdast-util-from-markdown": "^2.0.2",
61
- "mdast-util-gfm": "^3.1.0",
62
- "micromark": "^4.0.2",
63
- "micromark-extension-gfm": "^3.0.0",
64
- "micromark-util-combine-extensions": "^2.0.1",
65
- "string-width": "^8.1.0",
66
- "strip-ansi": "^7.1.2",
67
- "supports-hyperlinks": "^4.4.0"
68
- },
69
- "devDependencies": {
70
- "@biomejs/biome": "^2.3.10",
71
- "@types/mdast": "^4.0.4",
72
- "@types/node": "^25.0.3",
73
- "@vitest/coverage-v8": "^4.0.16",
74
- "tsx": "^4.21.0",
75
- "typescript": "^5.9.3",
76
- "vitest": "^4.0.16"
77
- },
78
- "pnpm": {
79
- "onlyBuiltDependencies": [
80
- "esbuild"
81
- ]
82
- }
2
+ "name": "markdansi",
3
+ "version": "0.3.0",
4
+ "description": "Tiny dependency-light markdown to ANSI converter.",
5
+ "keywords": [
6
+ "ansi",
7
+ "cli",
8
+ "markdown",
9
+ "terminal"
10
+ ],
11
+ "homepage": "https://github.com/steipete/Markdansi#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/steipete/Markdansi/issues"
14
+ },
15
+ "license": "MIT",
16
+ "author": "Peter Steinberger",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/steipete/Markdansi.git"
20
+ },
21
+ "bin": {
22
+ "markdansi": "dist/cli.js"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "README.md",
27
+ "docs/spec.md",
28
+ "package.json",
29
+ "tsconfig.json"
30
+ ],
31
+ "type": "module",
32
+ "sideEffects": false,
33
+ "main": "dist/index.js",
34
+ "types": "dist/index.d.ts",
35
+ "exports": {
36
+ ".": {
37
+ "types": "./dist/index.d.ts",
38
+ "import": "./dist/index.js",
39
+ "default": "./dist/index.js"
40
+ },
41
+ "./cli": "./dist/cli.js"
42
+ },
43
+ "scripts": {
44
+ "build": "pnpm lint && pnpm typecheck && pnpm test && pnpm compile",
45
+ "clean": "rm -rf dist",
46
+ "format": "oxfmt --write .",
47
+ "format:check": "oxfmt --check .",
48
+ "lint": "rm -rf dist coverage && pnpm format:check && oxlint --deny-warnings src test",
49
+ "test": "vitest run",
50
+ "test:coverage": "vitest run --coverage",
51
+ "typecheck": "tsgo -p tsconfig.json --noEmit",
52
+ "types": "tsgo -p tsconfig.json --emitDeclarationOnly",
53
+ "compile": "tsgo -p tsconfig.json",
54
+ "prepare": "pnpm compile",
55
+ "markdansi": "tsx src/cli.ts"
56
+ },
57
+ "dependencies": {
58
+ "chalk": "^5.6.2",
59
+ "mdast-util-from-markdown": "^2.0.3",
60
+ "mdast-util-gfm": "^3.1.0",
61
+ "micromark": "^4.0.2",
62
+ "micromark-extension-gfm": "^3.0.0",
63
+ "micromark-util-combine-extensions": "^2.0.1",
64
+ "slice-ansi": "^9.0.0",
65
+ "string-width": "^8.2.1",
66
+ "strip-ansi": "^7.2.0",
67
+ "supports-hyperlinks": "^4.4.0"
68
+ },
69
+ "devDependencies": {
70
+ "@types/mdast": "^4.0.4",
71
+ "@types/node": "^25.6.0",
72
+ "@typescript/native-preview": "7.0.0-dev.20260503.1",
73
+ "@vitest/coverage-v8": "^4.1.5",
74
+ "oxfmt": "^0.47.0",
75
+ "oxlint": "^1.62.0",
76
+ "tsx": "^4.21.0",
77
+ "typescript": "^6.0.3",
78
+ "vitest": "^4.1.5"
79
+ },
80
+ "engines": {
81
+ "node": ">=22"
82
+ },
83
+ "pnpm": {
84
+ "onlyBuiltDependencies": [
85
+ "esbuild"
86
+ ]
87
+ }
83
88
  }
package/tsconfig.json CHANGED
@@ -1,17 +1,18 @@
1
1
  {
2
- "compilerOptions": {
3
- "strict": true,
4
- "noUncheckedIndexedAccess": true,
5
- "exactOptionalPropertyTypes": true,
6
- "noPropertyAccessFromIndexSignature": true,
7
- "declaration": true,
8
- "outDir": "dist",
9
- "rootDir": "src",
10
- "module": "NodeNext",
11
- "moduleResolution": "NodeNext",
12
- "target": "ES2022",
13
- "resolveJsonModule": true
14
- },
15
- "include": ["src/**/*.ts"],
16
- "exclude": ["node_modules", "dist", "coverage"]
2
+ "compilerOptions": {
3
+ "strict": true,
4
+ "noUncheckedIndexedAccess": true,
5
+ "exactOptionalPropertyTypes": true,
6
+ "noPropertyAccessFromIndexSignature": true,
7
+ "declaration": true,
8
+ "outDir": "dist",
9
+ "rootDir": "src",
10
+ "module": "NodeNext",
11
+ "moduleResolution": "NodeNext",
12
+ "target": "ES2022",
13
+ "types": ["node"],
14
+ "resolveJsonModule": true
15
+ },
16
+ "include": ["src/**/*.ts"],
17
+ "exclude": ["node_modules", "dist", "coverage"]
17
18
  }
package/.biome.json DELETED
@@ -1,24 +0,0 @@
1
- {
2
- "$schema": "https://biomejs.dev/schemas/1.8.0/schema.json",
3
- "extends": ["biome:recommended"],
4
- "formatter": {
5
- "enabled": true
6
- },
7
- "organizeImports": {
8
- "enabled": true
9
- },
10
- "files": {
11
- "ignore": [
12
- "coverage/**",
13
- "**/coverage/**",
14
- "node_modules/**",
15
- "dist/**",
16
- "**/dist/**"
17
- ]
18
- },
19
- "linter": {
20
- "rules": {
21
- "recommended": true
22
- }
23
- }
24
- }