markdansi 0.2.1 → 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 +1 -1
- package/README.md +39 -31
- package/dist/cli.js +10 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/render.js +43 -71
- package/dist/stream.js +1 -2
- package/docs/spec.md +17 -5
- package/package.json +86 -81
- package/tsconfig.json +16 -15
- package/.biome.json +0 -24
package/LICENSE
CHANGED
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:
|
|
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(
|
|
46
|
-
console.log(render(
|
|
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
|
|
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:
|
|
68
|
+
spacing: "single", // collapse consecutive blank lines
|
|
63
69
|
});
|
|
64
70
|
|
|
65
|
-
process.stdin.setEncoding(
|
|
66
|
-
process.stdin.on(
|
|
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(
|
|
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
|
-
|
|
77
|
-
import { render, createRenderer, strip, themes } from
|
|
82
|
+
````js
|
|
83
|
+
import { render, createRenderer, strip, themes } from "markdansi";
|
|
78
84
|
|
|
79
|
-
const ansi = render(
|
|
85
|
+
const ansi = render("# Hello **world**", { width: 60 });
|
|
80
86
|
|
|
81
87
|
const renderNoWrap = createRenderer({ wrap: false });
|
|
82
|
-
const out = renderNoWrap(
|
|
88
|
+
const out = renderNoWrap("A very long line...");
|
|
83
89
|
|
|
84
90
|
// Plain text (no ANSI/OSC)
|
|
85
|
-
const plain = strip(
|
|
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:
|
|
92
|
-
inlineCode: { color:
|
|
93
|
-
blockCode: { color:
|
|
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(
|
|
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
|
|
106
|
+
import { bundledLanguages, bundledThemes, createHighlighter } from "shiki";
|
|
101
107
|
|
|
102
108
|
const shiki = await createHighlighter({
|
|
103
|
-
themes: [bundledThemes[
|
|
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 (![
|
|
117
|
+
if (!["ts", "typescript", "swift"].includes(normalized)) return code;
|
|
112
118
|
const { tokens } = shiki.codeToTokens(code, {
|
|
113
|
-
lang: normalized ===
|
|
114
|
-
theme:
|
|
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
|
|
121
|
-
token.color.slice(3,
|
|
122
|
-
|
|
123
|
-
|
|
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(
|
|
135
|
+
.join("\n");
|
|
128
136
|
},
|
|
129
137
|
});
|
|
130
|
-
console.log(highlighted(
|
|
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.js
CHANGED
|
@@ -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) {
|
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
|
-
...
|
|
35
|
-
inlineCode: userTheme?.inlineCode ||
|
|
36
|
-
|
|
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
|
|
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,7 +535,7 @@ 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 =
|
|
538
|
+
const widths = Array.from({ length: colCount }, () => 1);
|
|
566
539
|
const aligns = node.align || [];
|
|
567
540
|
const pad = ctx.options.tablePadding;
|
|
568
541
|
const padStr = " ".repeat(Math.max(0, pad));
|
|
@@ -573,7 +546,7 @@ function renderTable(node, ctx) {
|
|
|
573
546
|
row.forEach((cell, idx) => {
|
|
574
547
|
const padded = `${padStr}${cell}${padStr}`;
|
|
575
548
|
// Cap each column to MAX_COL but keep at least 1
|
|
576
|
-
widths[idx] = Math.max(widths[idx], Math.min(MAX_COL, visibleWidth(padded)));
|
|
549
|
+
widths[idx] = Math.max(widths[idx] ?? 1, Math.min(MAX_COL, visibleWidth(padded)));
|
|
577
550
|
});
|
|
578
551
|
});
|
|
579
552
|
const totalWidth = widths.reduce((a, b) => a + b, 0) + 3 * colCount + 1;
|
|
@@ -582,19 +555,20 @@ function renderTable(node, ctx) {
|
|
|
582
555
|
let over = totalWidth - ctx.options.width;
|
|
583
556
|
while (over > 0) {
|
|
584
557
|
const i = widths.indexOf(Math.max(...widths));
|
|
585
|
-
if (widths[i] <= minColWidth)
|
|
558
|
+
if ((widths[i] ?? minColWidth) <= minColWidth)
|
|
586
559
|
break;
|
|
587
|
-
widths[i]
|
|
560
|
+
widths[i] = (widths[i] ?? minColWidth) - 1;
|
|
588
561
|
over -= 1;
|
|
589
562
|
}
|
|
590
563
|
}
|
|
591
564
|
for (let i = 0; i < widths.length; i += 1) {
|
|
592
|
-
if (widths[i] < minColWidth)
|
|
565
|
+
if ((widths[i] ?? minColWidth) < minColWidth)
|
|
593
566
|
widths[i] = minColWidth;
|
|
594
567
|
}
|
|
595
568
|
const renderRow = (row, isHeader = false) => {
|
|
596
569
|
const linesPerCol = row.map((cell, idx) => {
|
|
597
|
-
const
|
|
570
|
+
const width = widths[idx] ?? minColWidth;
|
|
571
|
+
const target = Math.max(minContent, width - pad * 2);
|
|
598
572
|
const content = ctx.options.tableTruncate
|
|
599
573
|
? truncateCell(cell, target, ctx.options.tableEllipsis)
|
|
600
574
|
: cell;
|
|
@@ -602,7 +576,7 @@ function renderTable(node, ctx) {
|
|
|
602
576
|
return wrapped.map((l) => {
|
|
603
577
|
const aligned = padCell(l, target, aligns[idx] ?? "left");
|
|
604
578
|
const padded = `${padStr}${aligned}${padStr}`;
|
|
605
|
-
return padCell(padded,
|
|
579
|
+
return padCell(padded, width, "left");
|
|
606
580
|
});
|
|
607
581
|
});
|
|
608
582
|
// Row height = max wrapped lines in any column; pad shorter ones
|
|
@@ -610,7 +584,7 @@ function renderTable(node, ctx) {
|
|
|
610
584
|
const out = [];
|
|
611
585
|
for (let i = 0; i < height; i += 1) {
|
|
612
586
|
const parts = linesPerCol.map((col, idx) => {
|
|
613
|
-
const content = col[i] ?? padCell("", widths[idx], aligns[idx] ?? "left");
|
|
587
|
+
const content = col[i] ?? padCell("", widths[idx] ?? minColWidth, aligns[idx] ?? "left");
|
|
614
588
|
return isHeader
|
|
615
589
|
? ctx.style(content, ctx.options.theme.tableHeader)
|
|
616
590
|
: ctx.style(content, ctx.options.theme.tableCell);
|
|
@@ -622,38 +596,36 @@ function renderTable(node, ctx) {
|
|
|
622
596
|
const headerRows = renderRow(header.children.map((c) => renderInline(c.children, ctx)), true);
|
|
623
597
|
const bodyRows = rows.flatMap((r) => renderRow(r.children.map((c) => renderInline(c.children, ctx))));
|
|
624
598
|
if (ctx.options.tableBorder === "none") {
|
|
625
|
-
const lines = [...headerRows, ...bodyRows]
|
|
626
|
-
.map((row) => row.join(" | "))
|
|
627
|
-
.join("\n");
|
|
599
|
+
const lines = [...headerRows, ...bodyRows].map((row) => row.join(" | ")).join("\n");
|
|
628
600
|
return [`${lines}\n\n`];
|
|
629
601
|
}
|
|
630
602
|
const box = TABLE_BOX[ctx.options.tableBorder] || TABLE_BOX.unicode;
|
|
631
|
-
const hLine = (sepMid, sepLeft, sepRight) => `${sepLeft}${widths
|
|
632
|
-
.map((w) => box.hSep.repeat(w))
|
|
633
|
-
.join(sepMid)}${sepRight}\n`;
|
|
603
|
+
const hLine = (sepMid, sepLeft, sepRight) => `${sepLeft}${widths.map((w) => box.hSep.repeat(w)).join(sepMid)}${sepRight}\n`;
|
|
634
604
|
const top = hLine(box.tSep, box.topLeft, box.topRight);
|
|
635
605
|
const mid = hLine(box.mSep, box.mLeft, box.mRight);
|
|
636
606
|
const bottom = hLine(box.bSep, box.bottomLeft, box.bottomRight);
|
|
637
|
-
const renderFlat = (rowsArr) => rowsArr
|
|
638
|
-
.map((r) => `${box.vSep}${r.map((c) => c).join(box.vSep)}${box.vSep}\n`)
|
|
639
|
-
.join("");
|
|
607
|
+
const renderFlat = (rowsArr) => rowsArr.map((r) => `${box.vSep}${r.map((c) => c).join(box.vSep)}${box.vSep}\n`).join("");
|
|
640
608
|
const dense = ctx.options.tableDense;
|
|
641
|
-
const out = [
|
|
642
|
-
top,
|
|
643
|
-
renderFlat(headerRows),
|
|
644
|
-
dense ? "" : mid,
|
|
645
|
-
renderFlat(bodyRows),
|
|
646
|
-
bottom,
|
|
647
|
-
"\n",
|
|
648
|
-
];
|
|
609
|
+
const out = [top, renderFlat(headerRows), dense ? "" : mid, renderFlat(bodyRows), bottom, "\n"];
|
|
649
610
|
return out;
|
|
650
611
|
}
|
|
651
612
|
function truncateCell(text, width, ellipsis) {
|
|
652
613
|
if (stringWidth(text) <= width)
|
|
653
614
|
return text;
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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;
|
|
657
629
|
}
|
|
658
630
|
function wrapCodeLine(text, width) {
|
|
659
631
|
// Hard-wrap code even without spaces while keeping ANSI-safe width accounting.
|
|
@@ -661,7 +633,7 @@ function wrapCodeLine(text, width) {
|
|
|
661
633
|
return [text];
|
|
662
634
|
const parts = [];
|
|
663
635
|
let current = "";
|
|
664
|
-
for (const ch of
|
|
636
|
+
for (const ch of text) {
|
|
665
637
|
const chWidth = stringWidth(ch);
|
|
666
638
|
if (visibleWidth(current) + chWidth > width) {
|
|
667
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
}
|