markdansi 0.1.1 β†’ 0.1.3

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.
@@ -50,6 +50,39 @@ const custom = createRenderer({
50
50
  highlighter: (code, lang) => code.toUpperCase(),
51
51
  });
52
52
  console.log(custom('`inline`\n\n```\nblock code\n```'));
53
+
54
+ // Example: real syntax highlighting with Shiki (TS + Swift)
55
+ import { bundledLanguages, bundledThemes, createHighlighter } from 'shiki';
56
+
57
+ const shiki = await createHighlighter({
58
+ themes: [bundledThemes['github-dark']],
59
+ langs: [bundledLanguages.typescript, bundledLanguages.swift],
60
+ });
61
+
62
+ const highlighted = createRenderer({
63
+ highlighter: (code, lang) => {
64
+ if (!lang) return code;
65
+ const normalized = lang.toLowerCase();
66
+ if (!['ts', 'typescript', 'swift'].includes(normalized)) return code;
67
+ const { tokens } = shiki.codeToTokens(code, {
68
+ lang: normalized === 'swift' ? 'swift' : 'ts',
69
+ theme: 'github-dark',
70
+ });
71
+ return tokens
72
+ .map((line) =>
73
+ line
74
+ .map((token) =>
75
+ token.color ? `\u001b[38;2;${parseInt(token.color.slice(1, 3), 16)};${parseInt(
76
+ token.color.slice(3, 5),
77
+ 16,
78
+ )};${parseInt(token.color.slice(5, 7), 16)}m${token.content}\u001b[39m` : token.content,
79
+ )
80
+ .join(''),
81
+ )
82
+ .join('\n');
83
+ },
84
+ });
85
+ console.log(highlighted('```ts\nconst x: number = 1\n```\n```swift\nlet x = 1\n```'));
53
86
  ```
54
87
 
55
88
  ### Options
@@ -69,13 +102,14 @@ console.log(custom('`inline`\n\n```\nblock code\n```'));
69
102
 
70
103
  ## Status
71
104
 
72
- Version: `0.1.1` (released)
105
+ Version: `0.1.2` (released)
73
106
  Tests: `pnpm test`
74
107
  License: MIT
75
108
 
76
109
  ## Notes
77
110
 
78
111
  - 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.
112
+ - 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
113
  - 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
114
  - Tight vs loose lists follow GFM; task items render `[ ]` / `[x]`.
81
115
 
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,158 @@ 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
+ const mergedCodes = mergeAdjacentCodeBlocks(normalized);
136
+ const taggedDiffs = mergedCodes.map((child) => tagDiffBlock(child));
137
+ return { ...tree, children: taggedDiffs };
138
+ }
139
+ function flattenCodeList(list) {
140
+ if (!list.children.length ||
141
+ !list.children.every((item) => item.children.length === 1 &&
142
+ item.children[0]?.type === "code" &&
143
+ item.children[0].value !== undefined))
144
+ return null;
145
+ const codes = list.children.map((item) => item.children[0]);
146
+ const sameLang = codes.every((c) => c.lang === codes[0]?.lang);
147
+ const lang = sameLang ? (codes[0]?.lang ?? undefined) : undefined;
148
+ return {
149
+ type: "code",
150
+ lang: lang ?? undefined,
151
+ value: codes.map((c) => c.value).join("\n"),
152
+ position: list.position,
153
+ };
154
+ }
155
+ function mergeAdjacentCodeBlocks(nodes) {
156
+ const out = [];
157
+ let pending = null;
158
+ const flush = () => {
159
+ if (pending) {
160
+ out.push(pending);
161
+ pending = null;
162
+ }
163
+ };
164
+ for (const node of nodes) {
165
+ if (node?.type === "code") {
166
+ if (pending &&
167
+ (pending.lang === node.lang || (!pending.lang && !node.lang))) {
168
+ const nextValue = `${pending.value}\n${node.value}`;
169
+ pending = {
170
+ type: "code",
171
+ lang: pending.lang,
172
+ meta: pending.meta,
173
+ value: nextValue,
174
+ position: pending.position,
175
+ };
176
+ }
177
+ else {
178
+ flush();
179
+ pending = node;
180
+ }
181
+ continue;
182
+ }
183
+ if (node?.type === "list") {
184
+ const flattened = flattenCodeList(node);
185
+ if (flattened) {
186
+ if (pending &&
187
+ (pending.lang === flattened.lang ||
188
+ (!pending.lang && !flattened.lang))) {
189
+ const nextValue = `${pending.value}\n${flattened.value}`;
190
+ pending = {
191
+ type: "code",
192
+ lang: pending.lang,
193
+ meta: pending.meta,
194
+ value: nextValue,
195
+ position: pending.position,
196
+ };
197
+ }
198
+ else {
199
+ flush();
200
+ pending = flattened;
201
+ }
202
+ continue;
203
+ }
204
+ }
205
+ flush();
206
+ out.push(node);
207
+ }
208
+ flush();
209
+ return out;
210
+ }
211
+ function looksLikeDiff(text) {
212
+ const lines = text.split("\n").map((l) => l.trim());
213
+ if (lines.some((l) => l.startsWith("diff --git") ||
214
+ l.startsWith("--- a/") ||
215
+ l.startsWith("+++ b/") ||
216
+ l.startsWith("@@ ")))
217
+ return true;
218
+ const nonEmpty = lines.filter((l) => l !== "");
219
+ if (nonEmpty.length < 3)
220
+ return false;
221
+ const markers = nonEmpty.filter((l) => /^[+\-@]/.test(l)).length;
222
+ return markers >= Math.max(3, Math.ceil(nonEmpty.length * 0.6));
223
+ }
224
+ function tagDiffBlock(node) {
225
+ if (node?.type === "code" && !node.lang && looksLikeDiff(node.value)) {
226
+ return { ...node, lang: "diff" };
227
+ }
228
+ return node;
229
+ }
66
230
  const HR_WIDTH = 40;
67
231
  const MAX_COL = 40;
68
232
  const TABLE_BOX = {
@@ -99,7 +263,7 @@ const TABLE_BOX = {
99
263
  export function render(markdown, userOptions = {}) {
100
264
  const options = resolveOptions(userOptions);
101
265
  const style = createStyler({ color: options.color });
102
- const tree = parse(markdown);
266
+ const tree = normalizeNodes(parse(dedent(markdown)));
103
267
  const ctx = { options, style };
104
268
  const body = renderChildren(tree.children, ctx, 0, true).join("");
105
269
  return options.color ? body : stripAnsi(body);
@@ -112,7 +276,23 @@ export function createRenderer(options) {
112
276
  }
113
277
  function renderChildren(children, ctx, indentLevel = 0, isTightList = false) {
114
278
  const out = [];
115
- for (const node of children) {
279
+ for (let i = 0; i < children.length; i += 1) {
280
+ const node = children[i];
281
+ if (!node)
282
+ continue;
283
+ // Heuristic: some sources emit a standalone "[lang]" line before a fenced block.
284
+ if (node.type === "paragraph" &&
285
+ node.children.length === 1 &&
286
+ node.children[0]?.type === "text") {
287
+ const langMatch = node.children[0]?.value.trim().match(/^\[([^\]]+)]$/);
288
+ const next = children[i + 1];
289
+ if (langMatch && next && next.type === "code" && !next.lang) {
290
+ next.lang = langMatch[1];
291
+ i += 1; // skip label paragraph, render the code next
292
+ out.push(renderNode(next, ctx, indentLevel, isTightList));
293
+ continue;
294
+ }
295
+ }
116
296
  out.push(renderNode(node, ctx, indentLevel, isTightList));
117
297
  }
118
298
  return out.flat();
@@ -135,6 +315,8 @@ function renderNode(node, ctx, indentLevel, isTightList) {
135
315
  return renderCodeBlock(node, ctx);
136
316
  case "table":
137
317
  return renderTable(node, ctx);
318
+ case "definition":
319
+ return renderDefinition(node, ctx);
138
320
  default:
139
321
  return []; // inline handled elsewhere or intentionally skipped
140
322
  }
@@ -142,7 +324,29 @@ function renderNode(node, ctx, indentLevel, isTightList) {
142
324
  function renderParagraph(node, ctx, indentLevel) {
143
325
  const text = renderInline(node.children, ctx);
144
326
  const prefix = " ".repeat(ctx.options.listIndent * indentLevel);
145
- const lines = wrapWithPrefix(text, ctx.options.width ?? 80, ctx.options.wrap, prefix);
327
+ const rawLines = text.split("\n");
328
+ const normalized = [];
329
+ const defPattern = /^\[[^\]]+]:\s+\S/;
330
+ let inDefinitions = false;
331
+ for (const line of rawLines) {
332
+ if (defPattern.test(line) &&
333
+ normalized.length > 0 &&
334
+ normalized.at(-1) !== "") {
335
+ normalized.push(""); // insert blank line before footer-style definitions
336
+ }
337
+ if (defPattern.test(line)) {
338
+ inDefinitions = true;
339
+ normalized.push(line);
340
+ continue;
341
+ }
342
+ if (inDefinitions && line.trim() === "") {
343
+ // skip extra blank lines inside the definitions block
344
+ continue;
345
+ }
346
+ inDefinitions = false;
347
+ normalized.push(line);
348
+ }
349
+ const lines = normalized.flatMap((l) => wrapWithPrefix(l, ctx.options.width ?? 80, ctx.options.wrap, prefix));
146
350
  return lines.map((l) => `${l}\n`);
147
351
  }
148
352
  function renderHeading(node, ctx) {
@@ -200,14 +404,23 @@ function renderListItem(node, ctx, indentLevel, tight, ordered = false, start =
200
404
  lines.push("");
201
405
  return lines.map((l) => `${l}\n`);
202
406
  }
407
+ function renderDefinition(node, _ctx) {
408
+ const title = node.title ? ` "${node.title}"` : "";
409
+ const line = `[${node.identifier}]: ${node.url ?? ""}${title}`;
410
+ // Insert a leading blank line to visually separate footers from body.
411
+ return [`\n${line}\n`];
412
+ }
203
413
  function renderCodeBlock(node, ctx) {
204
414
  const theme = ctx.options.theme.blockCode || ctx.options.theme.inlineCode;
205
415
  const lines = (node.value ?? "").split("\n");
416
+ const isDiff = node.lang === "diff";
206
417
  const gutterWidth = ctx.options.codeGutter
207
418
  ? String(lines.length).length + 2
208
419
  : 0;
209
- const boxPadding = ctx.options.codeBox ? 4 : 0;
210
- const wrapLimit = ctx.options.codeWrap && ctx.options.wrap && ctx.options.width
420
+ const shouldWrap = isDiff ? false : ctx.options.codeWrap;
421
+ const useBox = ctx.options.codeBox && lines.length > 1;
422
+ const boxPadding = useBox ? 4 : 0;
423
+ const wrapLimit = shouldWrap && ctx.options.wrap && ctx.options.width
211
424
  ? Math.max(1, ctx.options.width - boxPadding - gutterWidth)
212
425
  : undefined; // undefined => no hard wrap limit
213
426
  const contentLines = lines.flatMap((line, idx) => {
@@ -223,7 +436,7 @@ function renderCodeBlock(node, ctx) {
223
436
  return `${ctx.style(num, { dim: true })} ${highlighted}`;
224
437
  });
225
438
  });
226
- if (!ctx.options.codeBox) {
439
+ if (!useBox) {
227
440
  return [`${contentLines.join("\n")}\n\n`];
228
441
  }
229
442
  // Boxed block
@@ -232,15 +445,18 @@ function renderCodeBlock(node, ctx) {
232
445
  const wrapTarget = ctx.options.codeWrap && ctx.options.width
233
446
  ? Math.min(maxLine, Math.max(1, ctx.options.width - 4))
234
447
  : 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)}β”˜`;
448
+ const labelRaw = node.lang ? `[${node.lang}]` : "";
449
+ const labelStyled = labelRaw ? ctx.style(labelRaw, { dim: true }) : "";
450
+ const innerWidth = Math.max(ctx.options.codeWrap ? wrapTarget : maxLine, minInner, labelRaw.length);
451
+ const topPadding = Math.max(0, innerWidth - labelRaw.length + 1);
452
+ const topRaw = labelRaw.length > 0
453
+ ? `β”Œ ${labelStyled}${"─".repeat(topPadding)}┐`
454
+ : `β”Œ ${"─".repeat(innerWidth)} ┐`;
455
+ const bottomRaw = `β””${"─".repeat(innerWidth + 2)}β”˜`;
456
+ const top = ctx.style(topRaw, { dim: true });
457
+ const bottom = ctx.style(bottomRaw, { dim: true });
242
458
  const boxLines = contentLines.map((ln) => {
243
- const pad = Math.max(0, h.length - visibleWidth(ln));
459
+ const pad = Math.max(0, innerWidth - visibleWidth(ln));
244
460
  const left = ctx.style("β”‚ ", { dim: true });
245
461
  const right = ctx.style(" β”‚", { dim: true });
246
462
  return `${left}${ln}${" ".repeat(pad)}${right}`;
@@ -285,6 +501,10 @@ function renderInline(children, ctx) {
285
501
  function renderLink(node, ctx) {
286
502
  const label = renderInline(node.children, ctx) || node.url;
287
503
  const url = node.url || "";
504
+ if (url.startsWith("mailto:")) {
505
+ // Treat mailto autolinks as plain text to avoid unwanted styling in tables.
506
+ return label;
507
+ }
288
508
  if (ctx.options.hyperlinks && url) {
289
509
  return osc8(url, label);
290
510
  }
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.3",
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
  }