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 CHANGED
@@ -1,4 +1,4 @@
1
- # 🎨 Markdansi: Wraps, colors, links - no baggage.
1
+ # 🎨 Markdansi: Wraps, colors, linksβ€”no baggage.
2
2
  ![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)
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.1` (released)
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
- if (helper.stdout)
8
- return helper.stdout(stream);
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 (const node of children) {
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 lines = wrapWithPrefix(text, ctx.options.width ?? 80, ctx.options.wrap, prefix);
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 innerWidth = Math.max(ctx.options.codeWrap ? wrapTarget : maxLine, minInner);
236
- const topLang = node.lang
237
- ? `${ctx.style(`[${node.lang}]`, { dim: true })} `
238
- : "";
239
- const h = "─".repeat(Math.max(innerWidth, topLang.length));
240
- const top = `β”Œ ${topLang}${h.slice(topLang.length)}┐`;
241
- const bottom = `β””${"─".repeat(h.length + 1)}β”˜`;
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, h.length - visibleWidth(ln));
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 – Design Spec
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.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
  }