markdansi 0.1.1 β 0.1.2
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 +3 -2
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +15 -0
- package/dist/hyperlink.js +12 -2
- package/dist/render.js +135 -11
- package/docs/spec.md +2 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# π¨ Markdansi: Wraps, colors, links
|
|
1
|
+
# π¨ Markdansi: Wraps, colors, linksβno baggage.
|
|
2
2
|
   
|
|
3
3
|
|
|
4
4
|
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.
|
|
@@ -69,13 +69,14 @@ console.log(custom('`inline`\n\n```\nblock code\n```'));
|
|
|
69
69
|
|
|
70
70
|
## Status
|
|
71
71
|
|
|
72
|
-
Version: `0.1.
|
|
72
|
+
Version: `0.1.2` (released)
|
|
73
73
|
Tests: `pnpm test`
|
|
74
74
|
License: MIT
|
|
75
75
|
|
|
76
76
|
## Notes
|
|
77
77
|
|
|
78
78
|
- Code blocks wrap to the render width by default; disable with `codeWrap=false`. If `lang` is present, a faint `[lang]` label is shown and boxes use unicode borders.
|
|
79
|
+
- Link/reference definitions that spill their titles onto indented lines are merged back into one line so copied notes donβt turn into boxed code.
|
|
79
80
|
- Tables use unicode borders by default, include padding, respect GFM alignment, and truncate long cells with `β¦` so layouts stay tidy. Turn off truncation with `tableTruncate=false`.
|
|
80
81
|
- Tight vs loose lists follow GFM; task items render `[ ]` / `[x]`.
|
|
81
82
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -5,6 +5,10 @@ type CliArgs = Partial<RenderOptions> & {
|
|
|
5
5
|
out?: string;
|
|
6
6
|
help?: boolean;
|
|
7
7
|
};
|
|
8
|
+
/**
|
|
9
|
+
* Ignore EPIPE when downstream (e.g., `head`) closes early.
|
|
10
|
+
*/
|
|
11
|
+
export declare function handleStdoutEpipe(): void;
|
|
8
12
|
/**
|
|
9
13
|
* Parse CLI arguments into RenderOptions-ish object (plus in/out paths).
|
|
10
14
|
*/
|
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,20 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { render } from "./index.js";
|
|
6
|
+
/**
|
|
7
|
+
* Ignore EPIPE when downstream (e.g., `head`) closes early.
|
|
8
|
+
*/
|
|
9
|
+
export function handleStdoutEpipe() {
|
|
10
|
+
process.stdout.on("error", (err) => {
|
|
11
|
+
if (err && err.code === "EPIPE") {
|
|
12
|
+
process.exit(0);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
// For other stdout errors, fail fast but don't throw unhandled.
|
|
16
|
+
console.error(err instanceof Error ? err.message : err);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
6
20
|
/**
|
|
7
21
|
* Parse CLI arguments into RenderOptions-ish object (plus in/out paths).
|
|
8
22
|
*/
|
|
@@ -97,6 +111,7 @@ export function parseArgs(argv) {
|
|
|
97
111
|
* CLI entrypoint.
|
|
98
112
|
*/
|
|
99
113
|
function main() {
|
|
114
|
+
handleStdoutEpipe();
|
|
100
115
|
const args = parseArgs(process.argv);
|
|
101
116
|
if (args.help) {
|
|
102
117
|
process.stdout.write(`markdansi options:
|
package/dist/hyperlink.js
CHANGED
|
@@ -4,8 +4,18 @@ import supportsHyperlinks from "supports-hyperlinks";
|
|
|
4
4
|
*/
|
|
5
5
|
export function hyperlinkSupported(stream = process.stdout) {
|
|
6
6
|
const helper = supportsHyperlinks;
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
try {
|
|
8
|
+
if (typeof supportsHyperlinks === "function") {
|
|
9
|
+
return Boolean(supportsHyperlinks(stream));
|
|
10
|
+
}
|
|
11
|
+
if (helper.stdout)
|
|
12
|
+
return Boolean(helper.stdout(stream));
|
|
13
|
+
if (helper.default && typeof helper.default === "function")
|
|
14
|
+
return Boolean(helper.default(stream));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
9
19
|
return false;
|
|
10
20
|
}
|
|
11
21
|
/**
|
package/dist/render.js
CHANGED
|
@@ -4,6 +4,18 @@ import { hyperlinkSupported, osc8 } from "./hyperlink.js";
|
|
|
4
4
|
import { parse } from "./parser.js";
|
|
5
5
|
import { createStyler, themes } from "./theme.js";
|
|
6
6
|
import { visibleWidth, wrapText, wrapWithPrefix } from "./wrap.js";
|
|
7
|
+
function dedent(markdown) {
|
|
8
|
+
const lines = markdown.split("\n");
|
|
9
|
+
const indents = lines
|
|
10
|
+
.filter((l) => l.trim() !== "")
|
|
11
|
+
.map((l) => l.match(/^[ \t]*/)?.[0].length ?? 0);
|
|
12
|
+
if (indents.length === 0)
|
|
13
|
+
return markdown;
|
|
14
|
+
const minIndent = Math.min(...indents);
|
|
15
|
+
if (minIndent === 0)
|
|
16
|
+
return markdown;
|
|
17
|
+
return lines.map((l) => l.slice(Math.min(minIndent, l.length))).join("\n");
|
|
18
|
+
}
|
|
7
19
|
function resolveOptions(userOptions = {}) {
|
|
8
20
|
const wrap = userOptions.wrap !== undefined ? userOptions.wrap : true;
|
|
9
21
|
const baseWidth = userOptions.width ?? (wrap ? process.stdout.columns || 80 : undefined);
|
|
@@ -63,6 +75,65 @@ function resolveOptions(userOptions = {}) {
|
|
|
63
75
|
resolved.width = baseWidth;
|
|
64
76
|
return resolved;
|
|
65
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Normalize parsed nodes to avoid misclassifying reference-style metadata as code.
|
|
80
|
+
* Some sources emit footnote-like link definitions where the title is placed
|
|
81
|
+
* on the following indented lines (often copied with tabs). When GFM parsing
|
|
82
|
+
* misses the closing quote, those continuations become indented code blocks.
|
|
83
|
+
* To keep output readable, merge such continuations back into the definition
|
|
84
|
+
* paragraph and render as regular text.
|
|
85
|
+
*/
|
|
86
|
+
function extractText(node) {
|
|
87
|
+
if (typeof node.value === "string")
|
|
88
|
+
return node.value;
|
|
89
|
+
if (Array.isArray(node.children)) {
|
|
90
|
+
return node.children.map((child) => extractText(child)).join("");
|
|
91
|
+
}
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
function getParagraphText(node) {
|
|
95
|
+
return extractText(node);
|
|
96
|
+
}
|
|
97
|
+
function normalizeNodes(tree) {
|
|
98
|
+
const normalized = [];
|
|
99
|
+
for (let i = 0; i < tree.children.length; i += 1) {
|
|
100
|
+
const node = tree.children[i];
|
|
101
|
+
if (node?.type === "paragraph" && node.children.length >= 1) {
|
|
102
|
+
const text = getParagraphText(node);
|
|
103
|
+
const defMatch = text.match(/^\[(\d+|\w+)]:\s+\S.*"\s*$/);
|
|
104
|
+
const next = tree.children[i + 1];
|
|
105
|
+
if (defMatch && next?.type === "code" && !next.lang) {
|
|
106
|
+
const continuation = next.value
|
|
107
|
+
.replace(/^[ \t>]+/gm, " ")
|
|
108
|
+
.replace(/\s+/g, " ")
|
|
109
|
+
.trim();
|
|
110
|
+
const merged = `${text.replace(/\s+"$/, '"')} ${continuation ? continuation.replace(/^"+|"+$/g, "").trim() : ""}`.trim();
|
|
111
|
+
normalized.push({
|
|
112
|
+
type: "paragraph",
|
|
113
|
+
children: [{ type: "text", value: merged }],
|
|
114
|
+
position: node.position,
|
|
115
|
+
});
|
|
116
|
+
i += 1; // skip the consumed code block
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// As a fallback, rewrite indented code blocks that look like lone definitions.
|
|
121
|
+
if (node?.type === "code" && !node.lang) {
|
|
122
|
+
const stripped = node.value.replace(/^[ \t>]+/gm, "").trim();
|
|
123
|
+
if (/^\[(\d+|\w+)]:\s+\S+/.test(stripped)) {
|
|
124
|
+
normalized.push({
|
|
125
|
+
type: "paragraph",
|
|
126
|
+
children: [{ type: "text", value: stripped }],
|
|
127
|
+
position: node.position,
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (node)
|
|
133
|
+
normalized.push(node);
|
|
134
|
+
}
|
|
135
|
+
return { ...tree, children: normalized };
|
|
136
|
+
}
|
|
66
137
|
const HR_WIDTH = 40;
|
|
67
138
|
const MAX_COL = 40;
|
|
68
139
|
const TABLE_BOX = {
|
|
@@ -99,7 +170,7 @@ const TABLE_BOX = {
|
|
|
99
170
|
export function render(markdown, userOptions = {}) {
|
|
100
171
|
const options = resolveOptions(userOptions);
|
|
101
172
|
const style = createStyler({ color: options.color });
|
|
102
|
-
const tree = parse(markdown);
|
|
173
|
+
const tree = normalizeNodes(parse(dedent(markdown)));
|
|
103
174
|
const ctx = { options, style };
|
|
104
175
|
const body = renderChildren(tree.children, ctx, 0, true).join("");
|
|
105
176
|
return options.color ? body : stripAnsi(body);
|
|
@@ -112,7 +183,23 @@ export function createRenderer(options) {
|
|
|
112
183
|
}
|
|
113
184
|
function renderChildren(children, ctx, indentLevel = 0, isTightList = false) {
|
|
114
185
|
const out = [];
|
|
115
|
-
for (
|
|
186
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
187
|
+
const node = children[i];
|
|
188
|
+
if (!node)
|
|
189
|
+
continue;
|
|
190
|
+
// Heuristic: some sources emit a standalone "[lang]" line before a fenced block.
|
|
191
|
+
if (node.type === "paragraph" &&
|
|
192
|
+
node.children.length === 1 &&
|
|
193
|
+
node.children[0]?.type === "text") {
|
|
194
|
+
const langMatch = node.children[0]?.value.trim().match(/^\[([^\]]+)]$/);
|
|
195
|
+
const next = children[i + 1];
|
|
196
|
+
if (langMatch && next && next.type === "code" && !next.lang) {
|
|
197
|
+
next.lang = langMatch[1];
|
|
198
|
+
i += 1; // skip label paragraph, render the code next
|
|
199
|
+
out.push(renderNode(next, ctx, indentLevel, isTightList));
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
116
203
|
out.push(renderNode(node, ctx, indentLevel, isTightList));
|
|
117
204
|
}
|
|
118
205
|
return out.flat();
|
|
@@ -135,6 +222,8 @@ function renderNode(node, ctx, indentLevel, isTightList) {
|
|
|
135
222
|
return renderCodeBlock(node, ctx);
|
|
136
223
|
case "table":
|
|
137
224
|
return renderTable(node, ctx);
|
|
225
|
+
case "definition":
|
|
226
|
+
return renderDefinition(node, ctx);
|
|
138
227
|
default:
|
|
139
228
|
return []; // inline handled elsewhere or intentionally skipped
|
|
140
229
|
}
|
|
@@ -142,7 +231,29 @@ function renderNode(node, ctx, indentLevel, isTightList) {
|
|
|
142
231
|
function renderParagraph(node, ctx, indentLevel) {
|
|
143
232
|
const text = renderInline(node.children, ctx);
|
|
144
233
|
const prefix = " ".repeat(ctx.options.listIndent * indentLevel);
|
|
145
|
-
const
|
|
234
|
+
const rawLines = text.split("\n");
|
|
235
|
+
const normalized = [];
|
|
236
|
+
const defPattern = /^\[[^\]]+]:\s+\S/;
|
|
237
|
+
let inDefinitions = false;
|
|
238
|
+
for (const line of rawLines) {
|
|
239
|
+
if (defPattern.test(line) &&
|
|
240
|
+
normalized.length > 0 &&
|
|
241
|
+
normalized.at(-1) !== "") {
|
|
242
|
+
normalized.push(""); // insert blank line before footer-style definitions
|
|
243
|
+
}
|
|
244
|
+
if (defPattern.test(line)) {
|
|
245
|
+
inDefinitions = true;
|
|
246
|
+
normalized.push(line);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (inDefinitions && line.trim() === "") {
|
|
250
|
+
// skip extra blank lines inside the definitions block
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
inDefinitions = false;
|
|
254
|
+
normalized.push(line);
|
|
255
|
+
}
|
|
256
|
+
const lines = normalized.flatMap((l) => wrapWithPrefix(l, ctx.options.width ?? 80, ctx.options.wrap, prefix));
|
|
146
257
|
return lines.map((l) => `${l}\n`);
|
|
147
258
|
}
|
|
148
259
|
function renderHeading(node, ctx) {
|
|
@@ -200,6 +311,12 @@ function renderListItem(node, ctx, indentLevel, tight, ordered = false, start =
|
|
|
200
311
|
lines.push("");
|
|
201
312
|
return lines.map((l) => `${l}\n`);
|
|
202
313
|
}
|
|
314
|
+
function renderDefinition(node, _ctx) {
|
|
315
|
+
const title = node.title ? ` "${node.title}"` : "";
|
|
316
|
+
const line = `[${node.identifier}]: ${node.url ?? ""}${title}`;
|
|
317
|
+
// Insert a leading blank line to visually separate footers from body.
|
|
318
|
+
return [`\n${line}\n`];
|
|
319
|
+
}
|
|
203
320
|
function renderCodeBlock(node, ctx) {
|
|
204
321
|
const theme = ctx.options.theme.blockCode || ctx.options.theme.inlineCode;
|
|
205
322
|
const lines = (node.value ?? "").split("\n");
|
|
@@ -232,15 +349,18 @@ function renderCodeBlock(node, ctx) {
|
|
|
232
349
|
const wrapTarget = ctx.options.codeWrap && ctx.options.width
|
|
233
350
|
? Math.min(maxLine, Math.max(1, ctx.options.width - 4))
|
|
234
351
|
: maxLine;
|
|
235
|
-
const
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
352
|
+
const labelRaw = node.lang ? `[${node.lang}]` : "";
|
|
353
|
+
const labelStyled = labelRaw ? ctx.style(labelRaw, { dim: true }) : "";
|
|
354
|
+
const innerWidth = Math.max(ctx.options.codeWrap ? wrapTarget : maxLine, minInner, labelRaw.length);
|
|
355
|
+
const topPadding = Math.max(0, innerWidth - labelRaw.length + 1);
|
|
356
|
+
const topRaw = labelRaw.length > 0
|
|
357
|
+
? `β ${labelStyled}${"β".repeat(topPadding)}β`
|
|
358
|
+
: `β ${"β".repeat(innerWidth)} β`;
|
|
359
|
+
const bottomRaw = `β${"β".repeat(innerWidth + 2)}β`;
|
|
360
|
+
const top = ctx.style(topRaw, { dim: true });
|
|
361
|
+
const bottom = ctx.style(bottomRaw, { dim: true });
|
|
242
362
|
const boxLines = contentLines.map((ln) => {
|
|
243
|
-
const pad = Math.max(0,
|
|
363
|
+
const pad = Math.max(0, innerWidth - visibleWidth(ln));
|
|
244
364
|
const left = ctx.style("β ", { dim: true });
|
|
245
365
|
const right = ctx.style(" β", { dim: true });
|
|
246
366
|
return `${left}${ln}${" ".repeat(pad)}${right}`;
|
|
@@ -285,6 +405,10 @@ function renderInline(children, ctx) {
|
|
|
285
405
|
function renderLink(node, ctx) {
|
|
286
406
|
const label = renderInline(node.children, ctx) || node.url;
|
|
287
407
|
const url = node.url || "";
|
|
408
|
+
if (url.startsWith("mailto:")) {
|
|
409
|
+
// Treat mailto autolinks as plain text to avoid unwanted styling in tables.
|
|
410
|
+
return label;
|
|
411
|
+
}
|
|
288
412
|
if (ctx.options.hyperlinks && url) {
|
|
289
413
|
return osc8(url, label);
|
|
290
414
|
}
|
package/docs/spec.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Markdansi v0.1.
|
|
1
|
+
# Markdansi v0.1.2 β Design Spec
|
|
2
2
|
|
|
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
|
|
|
@@ -94,3 +94,4 @@ Each theme entry holds simple SGR intents (bold/italic/fg color names). `inlineC
|
|
|
94
94
|
- Lists: honor GFM tight vs loose lists (tight => no blank line between items; loose => blank line). Nesting indent = 2 spaces per level; bullets use `-`; ordered lists use input numbering.
|
|
95
95
|
- Blockquotes: prefix each wrapped line with `β ` (configurable via `quotePrefix`); quote content wraps accounting for the prefix width.
|
|
96
96
|
- List indent is configurable via `listIndent` (default 2 spaces per level).
|
|
97
|
+
- Reference-style definitions with indented title continuations are merged into a single paragraph (instead of becoming indented code blocks), preventing stray boxed output in copied logs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "markdansi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Tiny dependency-light markdown to ANSI converter.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"@types/mdast": "^4.0.4",
|
|
59
59
|
"@types/node": "^24.10.1",
|
|
60
60
|
"@vitest/coverage-v8": "^4.0.9",
|
|
61
|
+
"tsx": "^4.20.6",
|
|
61
62
|
"typescript": "^5.9.3",
|
|
62
63
|
"vitest": "^4.0.9"
|
|
63
64
|
},
|
|
@@ -68,6 +69,7 @@
|
|
|
68
69
|
"test": "vitest run",
|
|
69
70
|
"test:coverage": "vitest run --coverage",
|
|
70
71
|
"types": "tsc -p tsconfig.json --emitDeclarationOnly",
|
|
71
|
-
"compile": "tsc -p tsconfig.json"
|
|
72
|
+
"compile": "tsc -p tsconfig.json",
|
|
73
|
+
"markdansi": "tsx src/cli.ts"
|
|
72
74
|
}
|
|
73
75
|
}
|