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 +36 -2
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +15 -0
- package/dist/hyperlink.js +12 -2
- package/dist/render.js +234 -14
- 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.
|
|
@@ -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.
|
|
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
|
-
|
|
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,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 (
|
|
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
|
|
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
|
|
210
|
-
const
|
|
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 (!
|
|
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
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
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,
|
|
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
|
+
# 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.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
|
}
|