markdansi 0.1.0 → 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 +13 -10
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +166 -0
- package/dist/hyperlink.d.ts +9 -0
- package/dist/hyperlink.js +26 -0
- package/dist/index.d.ts +8 -45
- package/dist/index.js +15 -0
- package/dist/parser.d.ts +2 -0
- package/{src → dist}/parser.js +4 -5
- package/dist/render.d.ts +9 -0
- package/dist/render.js +548 -0
- package/dist/theme.d.ts +18 -0
- package/dist/theme.js +105 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +1 -0
- package/dist/wrap.d.ts +10 -0
- package/dist/wrap.js +48 -0
- package/docs/spec.md +24 -10
- package/package.json +12 -10
- package/tsconfig.json +2 -4
- package/src/cli.js +0 -62
- package/src/hyperlink.js +0 -15
- package/src/index.js +0 -12
- package/src/render.js +0 -342
- package/src/theme.js +0 -53
- package/src/wrap.js +0 -45
- package/types/index.ts +0 -74
package/dist/render.js
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
import stripAnsi from "strip-ansi";
|
|
3
|
+
import { hyperlinkSupported, osc8 } from "./hyperlink.js";
|
|
4
|
+
import { parse } from "./parser.js";
|
|
5
|
+
import { createStyler, themes } from "./theme.js";
|
|
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
|
+
}
|
|
19
|
+
function resolveOptions(userOptions = {}) {
|
|
20
|
+
const wrap = userOptions.wrap !== undefined ? userOptions.wrap : true;
|
|
21
|
+
const baseWidth = userOptions.width ?? (wrap ? process.stdout.columns || 80 : undefined);
|
|
22
|
+
const color = userOptions.color !== undefined ? userOptions.color : process.stdout.isTTY;
|
|
23
|
+
// 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();
|
|
27
|
+
const effectiveHyperlinks = color ? hyperlinks : false;
|
|
28
|
+
const baseTheme = themes.default ?? {};
|
|
29
|
+
const userTheme = userOptions.theme && typeof userOptions.theme === "object"
|
|
30
|
+
? userOptions.theme
|
|
31
|
+
: themes[userOptions.theme || "default"] || baseTheme;
|
|
32
|
+
const mergedTheme = {
|
|
33
|
+
...baseTheme,
|
|
34
|
+
...(userTheme || {}),
|
|
35
|
+
inlineCode: userTheme?.inlineCode ||
|
|
36
|
+
userTheme?.code ||
|
|
37
|
+
baseTheme.inlineCode ||
|
|
38
|
+
baseTheme.code ||
|
|
39
|
+
{},
|
|
40
|
+
blockCode: userTheme?.blockCode ||
|
|
41
|
+
userTheme?.code ||
|
|
42
|
+
baseTheme.blockCode ||
|
|
43
|
+
baseTheme.code ||
|
|
44
|
+
{},
|
|
45
|
+
};
|
|
46
|
+
const highlighter = userOptions.highlighter;
|
|
47
|
+
const listIndent = userOptions.listIndent ?? 2;
|
|
48
|
+
const quotePrefix = userOptions.quotePrefix ?? "│ ";
|
|
49
|
+
const tableBorder = userOptions.tableBorder || "unicode";
|
|
50
|
+
const tablePadding = userOptions.tablePadding ?? 1;
|
|
51
|
+
const tableDense = userOptions.tableDense ?? false;
|
|
52
|
+
const tableTruncate = userOptions.tableTruncate ?? true;
|
|
53
|
+
const tableEllipsis = userOptions.tableEllipsis ?? "…";
|
|
54
|
+
const codeBox = userOptions.codeBox ?? true;
|
|
55
|
+
const codeGutter = userOptions.codeGutter ?? false;
|
|
56
|
+
const codeWrap = userOptions.codeWrap ?? true;
|
|
57
|
+
const resolved = {
|
|
58
|
+
wrap,
|
|
59
|
+
color,
|
|
60
|
+
hyperlinks: effectiveHyperlinks,
|
|
61
|
+
theme: mergedTheme,
|
|
62
|
+
highlighter,
|
|
63
|
+
listIndent,
|
|
64
|
+
quotePrefix,
|
|
65
|
+
tableBorder,
|
|
66
|
+
tablePadding,
|
|
67
|
+
tableDense,
|
|
68
|
+
tableTruncate,
|
|
69
|
+
tableEllipsis,
|
|
70
|
+
codeBox,
|
|
71
|
+
codeGutter,
|
|
72
|
+
codeWrap,
|
|
73
|
+
};
|
|
74
|
+
if (baseWidth !== undefined)
|
|
75
|
+
resolved.width = baseWidth;
|
|
76
|
+
return resolved;
|
|
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
|
+
}
|
|
137
|
+
const HR_WIDTH = 40;
|
|
138
|
+
const MAX_COL = 40;
|
|
139
|
+
const TABLE_BOX = {
|
|
140
|
+
unicode: {
|
|
141
|
+
topLeft: "┌",
|
|
142
|
+
topRight: "┐",
|
|
143
|
+
bottomLeft: "└",
|
|
144
|
+
bottomRight: "┘",
|
|
145
|
+
hSep: "─",
|
|
146
|
+
vSep: "│",
|
|
147
|
+
tSep: "┬",
|
|
148
|
+
mSep: "┼",
|
|
149
|
+
bSep: "┴",
|
|
150
|
+
mLeft: "├",
|
|
151
|
+
mRight: "┤",
|
|
152
|
+
},
|
|
153
|
+
ascii: {
|
|
154
|
+
topLeft: "+",
|
|
155
|
+
topRight: "+",
|
|
156
|
+
bottomLeft: "+",
|
|
157
|
+
bottomRight: "+",
|
|
158
|
+
hSep: "-",
|
|
159
|
+
vSep: "|",
|
|
160
|
+
tSep: "+",
|
|
161
|
+
mSep: "+",
|
|
162
|
+
bSep: "+",
|
|
163
|
+
mLeft: "+",
|
|
164
|
+
mRight: "+",
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* Render Markdown input to an ANSI string.
|
|
169
|
+
*/
|
|
170
|
+
export function render(markdown, userOptions = {}) {
|
|
171
|
+
const options = resolveOptions(userOptions);
|
|
172
|
+
const style = createStyler({ color: options.color });
|
|
173
|
+
const tree = normalizeNodes(parse(dedent(markdown)));
|
|
174
|
+
const ctx = { options, style };
|
|
175
|
+
const body = renderChildren(tree.children, ctx, 0, true).join("");
|
|
176
|
+
return options.color ? body : stripAnsi(body);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Create a reusable renderer with fixed options.
|
|
180
|
+
*/
|
|
181
|
+
export function createRenderer(options) {
|
|
182
|
+
return (md) => render(md, options);
|
|
183
|
+
}
|
|
184
|
+
function renderChildren(children, ctx, indentLevel = 0, isTightList = false) {
|
|
185
|
+
const out = [];
|
|
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
|
+
}
|
|
203
|
+
out.push(renderNode(node, ctx, indentLevel, isTightList));
|
|
204
|
+
}
|
|
205
|
+
return out.flat();
|
|
206
|
+
}
|
|
207
|
+
function renderNode(node, ctx, indentLevel, isTightList) {
|
|
208
|
+
switch (node.type) {
|
|
209
|
+
case "paragraph":
|
|
210
|
+
return renderParagraph(node, ctx, indentLevel);
|
|
211
|
+
case "heading":
|
|
212
|
+
return renderHeading(node, ctx);
|
|
213
|
+
case "thematicBreak":
|
|
214
|
+
return renderHr(ctx);
|
|
215
|
+
case "blockquote":
|
|
216
|
+
return renderBlockquote(node, ctx, indentLevel);
|
|
217
|
+
case "list":
|
|
218
|
+
return renderList(node, ctx, indentLevel);
|
|
219
|
+
case "listItem":
|
|
220
|
+
return renderListItem(node, ctx, indentLevel, isTightList);
|
|
221
|
+
case "code":
|
|
222
|
+
return renderCodeBlock(node, ctx);
|
|
223
|
+
case "table":
|
|
224
|
+
return renderTable(node, ctx);
|
|
225
|
+
case "definition":
|
|
226
|
+
return renderDefinition(node, ctx);
|
|
227
|
+
default:
|
|
228
|
+
return []; // inline handled elsewhere or intentionally skipped
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function renderParagraph(node, ctx, indentLevel) {
|
|
232
|
+
const text = renderInline(node.children, ctx);
|
|
233
|
+
const prefix = " ".repeat(ctx.options.listIndent * indentLevel);
|
|
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));
|
|
257
|
+
return lines.map((l) => `${l}\n`);
|
|
258
|
+
}
|
|
259
|
+
function renderHeading(node, ctx) {
|
|
260
|
+
const text = renderInline(node.children, ctx);
|
|
261
|
+
const styled = ctx.style(text, ctx.options.theme.heading);
|
|
262
|
+
return [`\n${styled}\n`];
|
|
263
|
+
}
|
|
264
|
+
function renderHr(ctx) {
|
|
265
|
+
const width = ctx.options.wrap
|
|
266
|
+
? Math.min(ctx.options.width ?? HR_WIDTH, HR_WIDTH)
|
|
267
|
+
: HR_WIDTH;
|
|
268
|
+
const line = "—".repeat(width);
|
|
269
|
+
return [`${ctx.style(line, ctx.options.theme.hr)}\n`];
|
|
270
|
+
}
|
|
271
|
+
function renderBlockquote(node, ctx, indentLevel) {
|
|
272
|
+
// Render blockquote children as text, then wrap with the quote prefix so
|
|
273
|
+
// wrapping accounts for prefix width.
|
|
274
|
+
const inner = renderChildren(node.children, ctx, indentLevel);
|
|
275
|
+
const prefix = ctx.style(ctx.options.quotePrefix, ctx.options.theme.quote);
|
|
276
|
+
const text = inner.join("").trimEnd();
|
|
277
|
+
const wrapped = wrapWithPrefix(text, ctx.options.width ?? 80, ctx.options.wrap, prefix);
|
|
278
|
+
return wrapped.map((l) => `${l}\n`);
|
|
279
|
+
}
|
|
280
|
+
function renderList(node, ctx, indentLevel) {
|
|
281
|
+
const tight = node.spread === false;
|
|
282
|
+
const items = node.children.flatMap((item, idx) => renderListItem(item, ctx, indentLevel, tight, Boolean(node.ordered), node.start ?? 1, idx));
|
|
283
|
+
return items;
|
|
284
|
+
}
|
|
285
|
+
function renderListItem(node, ctx, indentLevel, tight, ordered = false, start = 1, idx = 0) {
|
|
286
|
+
const marker = ordered ? `${start + idx}.` : "-";
|
|
287
|
+
const markerStyled = ctx.style(marker, ctx.options.theme.listMarker);
|
|
288
|
+
const content = renderChildren(node.children, ctx, indentLevel + 1, tight)
|
|
289
|
+
.join("")
|
|
290
|
+
.trimEnd()
|
|
291
|
+
.split("\n");
|
|
292
|
+
// Drop leading blank lines so bullets prefix real content (e.g., headings in lists)
|
|
293
|
+
while (content.length && (content[0]?.trim() ?? "") === "") {
|
|
294
|
+
content.shift();
|
|
295
|
+
}
|
|
296
|
+
const isTask = typeof node.checked === "boolean";
|
|
297
|
+
const box = isTask && node.checked ? "[x]" : "[ ]";
|
|
298
|
+
const firstBullet = " ".repeat(ctx.options.listIndent * indentLevel) +
|
|
299
|
+
(isTask
|
|
300
|
+
? `${ctx.style(box, ctx.options.theme.listMarker)} `
|
|
301
|
+
: `${markerStyled} `);
|
|
302
|
+
const lines = [];
|
|
303
|
+
content.forEach((line, i) => {
|
|
304
|
+
const clean = line.replace(/^\s+/, "");
|
|
305
|
+
const prefix = i === 0
|
|
306
|
+
? firstBullet
|
|
307
|
+
: `${" ".repeat(ctx.options.listIndent * indentLevel)}${" ".repeat(ctx.options.listIndent)}`;
|
|
308
|
+
lines.push(prefix + clean);
|
|
309
|
+
});
|
|
310
|
+
if (!tight)
|
|
311
|
+
lines.push("");
|
|
312
|
+
return lines.map((l) => `${l}\n`);
|
|
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
|
+
}
|
|
320
|
+
function renderCodeBlock(node, ctx) {
|
|
321
|
+
const theme = ctx.options.theme.blockCode || ctx.options.theme.inlineCode;
|
|
322
|
+
const lines = (node.value ?? "").split("\n");
|
|
323
|
+
const gutterWidth = ctx.options.codeGutter
|
|
324
|
+
? String(lines.length).length + 2
|
|
325
|
+
: 0;
|
|
326
|
+
const boxPadding = ctx.options.codeBox ? 4 : 0;
|
|
327
|
+
const wrapLimit = ctx.options.codeWrap && ctx.options.wrap && ctx.options.width
|
|
328
|
+
? Math.max(1, ctx.options.width - boxPadding - gutterWidth)
|
|
329
|
+
: undefined; // undefined => no hard wrap limit
|
|
330
|
+
const contentLines = lines.flatMap((line, idx) => {
|
|
331
|
+
const segments = wrapLimit !== undefined ? wrapCodeLine(line, wrapLimit) : [line];
|
|
332
|
+
return segments.map((segment, segIdx) => {
|
|
333
|
+
const highlighted = ctx.options.highlighter?.(segment, node.lang ?? undefined) ??
|
|
334
|
+
ctx.style(segment, theme);
|
|
335
|
+
if (!ctx.options.codeGutter)
|
|
336
|
+
return highlighted;
|
|
337
|
+
const num = segIdx === 0
|
|
338
|
+
? String(idx + 1).padStart(gutterWidth - 2, " ")
|
|
339
|
+
: " ".repeat(gutterWidth - 1);
|
|
340
|
+
return `${ctx.style(num, { dim: true })} ${highlighted}`;
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
if (!ctx.options.codeBox) {
|
|
344
|
+
return [`${contentLines.join("\n")}\n\n`];
|
|
345
|
+
}
|
|
346
|
+
// Boxed block
|
|
347
|
+
const maxLine = Math.max(...contentLines.map((l) => visibleWidth(l)), 0);
|
|
348
|
+
const minInner = node.lang ? node.lang.length + 2 : 0;
|
|
349
|
+
const wrapTarget = ctx.options.codeWrap && ctx.options.width
|
|
350
|
+
? Math.min(maxLine, Math.max(1, ctx.options.width - 4))
|
|
351
|
+
: maxLine;
|
|
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 });
|
|
362
|
+
const boxLines = contentLines.map((ln) => {
|
|
363
|
+
const pad = Math.max(0, innerWidth - visibleWidth(ln));
|
|
364
|
+
const left = ctx.style("│ ", { dim: true });
|
|
365
|
+
const right = ctx.style(" │", { dim: true });
|
|
366
|
+
return `${left}${ln}${" ".repeat(pad)}${right}`;
|
|
367
|
+
});
|
|
368
|
+
return [`${top}\n${boxLines.join("\n")}\n${bottom}\n\n`];
|
|
369
|
+
}
|
|
370
|
+
function renderInline(children, ctx) {
|
|
371
|
+
let out = "";
|
|
372
|
+
for (const node of children) {
|
|
373
|
+
switch (node.type) {
|
|
374
|
+
case "text":
|
|
375
|
+
out += node.value;
|
|
376
|
+
break;
|
|
377
|
+
case "emphasis":
|
|
378
|
+
out += ctx.style(renderInline(node.children, ctx), ctx.options.theme.emph);
|
|
379
|
+
break;
|
|
380
|
+
case "strong":
|
|
381
|
+
out += ctx.style(renderInline(node.children, ctx), ctx.options.theme.strong);
|
|
382
|
+
break;
|
|
383
|
+
case "delete":
|
|
384
|
+
out += ctx.style(renderInline(node.children, ctx), { strike: true });
|
|
385
|
+
break;
|
|
386
|
+
case "inlineCode": {
|
|
387
|
+
const codeTheme = ctx.options.theme.inlineCode || ctx.options.theme.blockCode;
|
|
388
|
+
const content = ctx.style(node.value, codeTheme);
|
|
389
|
+
out += content;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
case "link":
|
|
393
|
+
out += renderLink(node, ctx);
|
|
394
|
+
break;
|
|
395
|
+
case "break":
|
|
396
|
+
out += "\n";
|
|
397
|
+
break;
|
|
398
|
+
default:
|
|
399
|
+
if ("value" in node && typeof node.value === "string")
|
|
400
|
+
out += node.value;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return out;
|
|
404
|
+
}
|
|
405
|
+
function renderLink(node, ctx) {
|
|
406
|
+
const label = renderInline(node.children, ctx) || node.url;
|
|
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
|
+
}
|
|
412
|
+
if (ctx.options.hyperlinks && url) {
|
|
413
|
+
return osc8(url, label);
|
|
414
|
+
}
|
|
415
|
+
if (url && label !== url) {
|
|
416
|
+
return (ctx.style(label, ctx.options.theme.link) +
|
|
417
|
+
ctx.style(` (${url})`, { dim: true }));
|
|
418
|
+
}
|
|
419
|
+
return ctx.style(label, ctx.options.theme.link);
|
|
420
|
+
}
|
|
421
|
+
function renderTable(node, ctx) {
|
|
422
|
+
const header = node.children[0];
|
|
423
|
+
if (!header)
|
|
424
|
+
return [];
|
|
425
|
+
const rows = node.children.slice(1);
|
|
426
|
+
const cells = [header, ...rows].map((row) => row.children.map((cell) => renderInline(cell.children, ctx)));
|
|
427
|
+
const colCount = Math.max(...cells.map((r) => r.length));
|
|
428
|
+
const widths = new Array(colCount).fill(1);
|
|
429
|
+
const aligns = node.align || [];
|
|
430
|
+
const pad = ctx.options.tablePadding;
|
|
431
|
+
const minContent = Math.max(1, ctx.options.tableEllipsis.length + 1);
|
|
432
|
+
// ensure we always have room for at least one visible char + ellipsis + padding
|
|
433
|
+
const minColWidth = Math.max(1, pad * 2 + minContent);
|
|
434
|
+
cells.forEach((row) => {
|
|
435
|
+
row.forEach((cell, idx) => {
|
|
436
|
+
// Cap each column to MAX_COL but keep at least 1
|
|
437
|
+
widths[idx] = Math.max(widths[idx], Math.min(MAX_COL, visibleWidth(cell)));
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
const totalWidth = widths.reduce((a, b) => a + b, 0) + 3 * colCount + 1;
|
|
441
|
+
if (ctx.options.wrap && ctx.options.width && totalWidth > ctx.options.width) {
|
|
442
|
+
// Shrink widest columns until the table fits; allow overflow if already at minima
|
|
443
|
+
let over = totalWidth - ctx.options.width;
|
|
444
|
+
while (over > 0) {
|
|
445
|
+
const i = widths.indexOf(Math.max(...widths));
|
|
446
|
+
if (widths[i] <= minColWidth)
|
|
447
|
+
break;
|
|
448
|
+
widths[i] -= 1;
|
|
449
|
+
over -= 1;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
for (let i = 0; i < widths.length; i += 1) {
|
|
453
|
+
if (widths[i] < minColWidth)
|
|
454
|
+
widths[i] = minColWidth;
|
|
455
|
+
}
|
|
456
|
+
const renderRow = (row, isHeader = false) => {
|
|
457
|
+
const linesPerCol = row.map((cell, idx) => {
|
|
458
|
+
const padded = ` ${cell} `;
|
|
459
|
+
const target = Math.max(minContent, widths[idx] - pad * 2);
|
|
460
|
+
const cellText = ctx.options.tableTruncate
|
|
461
|
+
? truncateCell(cell, target, ctx.options.tableEllipsis)
|
|
462
|
+
: padded;
|
|
463
|
+
const wrapped = wrapText(cellText, ctx.options.wrap ? target : Number.MAX_SAFE_INTEGER, ctx.options.wrap);
|
|
464
|
+
return wrapped.map((l) => padCell(` ${l} `, widths[idx], aligns[idx] ?? "left", ctx.options.tablePadding));
|
|
465
|
+
});
|
|
466
|
+
// Row height = max wrapped lines in any column; pad shorter ones
|
|
467
|
+
const height = Math.max(...linesPerCol.map((c) => c.length));
|
|
468
|
+
const out = [];
|
|
469
|
+
for (let i = 0; i < height; i += 1) {
|
|
470
|
+
const parts = linesPerCol.map((col, idx) => {
|
|
471
|
+
const content = col[i] ?? padCell("", widths[idx], aligns[idx] ?? "left");
|
|
472
|
+
return isHeader
|
|
473
|
+
? ctx.style(content, ctx.options.theme.tableHeader)
|
|
474
|
+
: ctx.style(content, ctx.options.theme.tableCell);
|
|
475
|
+
});
|
|
476
|
+
out.push(parts);
|
|
477
|
+
}
|
|
478
|
+
return out;
|
|
479
|
+
};
|
|
480
|
+
const headerRows = renderRow(header.children.map((c) => renderInline(c.children, ctx)), true);
|
|
481
|
+
const bodyRows = rows.flatMap((r) => renderRow(r.children.map((c) => renderInline(c.children, ctx))));
|
|
482
|
+
if (ctx.options.tableBorder === "none") {
|
|
483
|
+
const lines = [...headerRows, ...bodyRows]
|
|
484
|
+
.map((row) => row.join(" | "))
|
|
485
|
+
.join("\n");
|
|
486
|
+
return [`${lines}\n\n`];
|
|
487
|
+
}
|
|
488
|
+
const box = TABLE_BOX[ctx.options.tableBorder] || TABLE_BOX.unicode;
|
|
489
|
+
const hLine = (sepMid, sepLeft, sepRight) => `${sepLeft}${widths
|
|
490
|
+
.map((w) => box.hSep.repeat(w))
|
|
491
|
+
.join(sepMid)}${sepRight}\n`;
|
|
492
|
+
const top = hLine(box.tSep, box.topLeft, box.topRight);
|
|
493
|
+
const mid = hLine(box.mSep, box.mLeft, box.mRight);
|
|
494
|
+
const bottom = hLine(box.bSep, box.bottomLeft, box.bottomRight);
|
|
495
|
+
const renderFlat = (rowsArr) => rowsArr
|
|
496
|
+
.map((r) => `${box.vSep}${r.map((c) => c).join(box.vSep)}${box.vSep}\n`)
|
|
497
|
+
.join("");
|
|
498
|
+
const dense = ctx.options.tableDense;
|
|
499
|
+
const out = [
|
|
500
|
+
top,
|
|
501
|
+
renderFlat(headerRows),
|
|
502
|
+
dense ? "" : mid,
|
|
503
|
+
renderFlat(bodyRows),
|
|
504
|
+
bottom,
|
|
505
|
+
"\n",
|
|
506
|
+
];
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
function truncateCell(text, width, ellipsis) {
|
|
510
|
+
if (stringWidth(text) <= width)
|
|
511
|
+
return text;
|
|
512
|
+
if (width <= ellipsis.length)
|
|
513
|
+
return ellipsis.slice(0, width);
|
|
514
|
+
return text.slice(0, width - ellipsis.length) + ellipsis;
|
|
515
|
+
}
|
|
516
|
+
function wrapCodeLine(text, width) {
|
|
517
|
+
// Hard-wrap code even without spaces while keeping ANSI-safe width accounting.
|
|
518
|
+
if (width <= 0)
|
|
519
|
+
return [text];
|
|
520
|
+
const parts = [];
|
|
521
|
+
let current = "";
|
|
522
|
+
for (const ch of [...text]) {
|
|
523
|
+
const chWidth = stringWidth(ch);
|
|
524
|
+
if (visibleWidth(current) + chWidth > width) {
|
|
525
|
+
parts.push(current);
|
|
526
|
+
current = ch;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
current += ch;
|
|
530
|
+
}
|
|
531
|
+
if (current !== "")
|
|
532
|
+
parts.push(current);
|
|
533
|
+
return parts.length ? parts : [""];
|
|
534
|
+
}
|
|
535
|
+
function padCell(text, width, align = "left", _padSpaces = 0) {
|
|
536
|
+
const core = text;
|
|
537
|
+
const pad = width - stringWidth(stripAnsi(core));
|
|
538
|
+
if (pad <= 0)
|
|
539
|
+
return core;
|
|
540
|
+
if (align === "right")
|
|
541
|
+
return `${" ".repeat(pad)}${core}`;
|
|
542
|
+
if (align === "center") {
|
|
543
|
+
const left = Math.floor(pad / 2);
|
|
544
|
+
const right = pad - left;
|
|
545
|
+
return `${" ".repeat(left)}${core}${" ".repeat(right)}`;
|
|
546
|
+
}
|
|
547
|
+
return `${core}${" ".repeat(pad)}`;
|
|
548
|
+
}
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { StyleIntent, Theme } from "./types.js";
|
|
2
|
+
export interface Themes {
|
|
3
|
+
default: Theme;
|
|
4
|
+
dim: Theme;
|
|
5
|
+
bright: Theme;
|
|
6
|
+
solarized: Theme;
|
|
7
|
+
monochrome: Theme;
|
|
8
|
+
contrast: Theme;
|
|
9
|
+
[key: string]: Theme;
|
|
10
|
+
}
|
|
11
|
+
export declare const themes: Themes;
|
|
12
|
+
export type Styler = (text: string, style?: StyleIntent) => string;
|
|
13
|
+
/**
|
|
14
|
+
* Create a Chalk-based styling helper that applies StyleIntent safely.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createStyler({ color }: {
|
|
17
|
+
color: boolean;
|
|
18
|
+
}): Styler;
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Chalk } from "chalk";
|
|
2
|
+
const base = {
|
|
3
|
+
heading: { color: "yellow", bold: true },
|
|
4
|
+
strong: { bold: true },
|
|
5
|
+
emph: { italic: true },
|
|
6
|
+
inlineCode: { color: "cyan" },
|
|
7
|
+
blockCode: { color: "green" },
|
|
8
|
+
link: { color: "blue", underline: true },
|
|
9
|
+
quote: { dim: true },
|
|
10
|
+
hr: { dim: true },
|
|
11
|
+
listMarker: { color: "cyan" },
|
|
12
|
+
tableHeader: { bold: true, color: "yellow" },
|
|
13
|
+
tableCell: {},
|
|
14
|
+
};
|
|
15
|
+
const dim = {
|
|
16
|
+
...base,
|
|
17
|
+
heading: { color: "white", bold: true, dim: true },
|
|
18
|
+
link: { color: "blue", underline: true, dim: true },
|
|
19
|
+
};
|
|
20
|
+
const bright = {
|
|
21
|
+
...base,
|
|
22
|
+
heading: { color: "magenta", bold: true },
|
|
23
|
+
link: { color: "cyan", underline: true },
|
|
24
|
+
inlineCode: { color: "green" },
|
|
25
|
+
blockCode: { color: "green" },
|
|
26
|
+
};
|
|
27
|
+
const solarized = {
|
|
28
|
+
heading: { color: "yellow", bold: true },
|
|
29
|
+
strong: { bold: true },
|
|
30
|
+
emph: { italic: true },
|
|
31
|
+
inlineCode: { color: "cyan" },
|
|
32
|
+
blockCode: { color: "#2aa198" },
|
|
33
|
+
link: { color: "blue", underline: true },
|
|
34
|
+
quote: { color: "white", dim: true },
|
|
35
|
+
hr: { color: "white", dim: true },
|
|
36
|
+
listMarker: { color: "cyan" },
|
|
37
|
+
tableHeader: { color: "yellow", bold: true },
|
|
38
|
+
};
|
|
39
|
+
const monochrome = {
|
|
40
|
+
heading: { bold: true },
|
|
41
|
+
strong: { bold: true },
|
|
42
|
+
emph: { italic: true },
|
|
43
|
+
inlineCode: { dim: true },
|
|
44
|
+
blockCode: { dim: true },
|
|
45
|
+
link: { underline: true },
|
|
46
|
+
quote: { dim: true },
|
|
47
|
+
hr: { dim: true },
|
|
48
|
+
listMarker: { dim: true },
|
|
49
|
+
tableHeader: { bold: true },
|
|
50
|
+
};
|
|
51
|
+
const contrast = {
|
|
52
|
+
heading: { color: "magenta", bold: true },
|
|
53
|
+
strong: { color: "white", bold: true },
|
|
54
|
+
emph: { color: "white", italic: true },
|
|
55
|
+
inlineCode: { color: "cyan", bold: true },
|
|
56
|
+
blockCode: { color: "green", bold: true },
|
|
57
|
+
link: { color: "blue", underline: true },
|
|
58
|
+
quote: { color: "white", dim: true },
|
|
59
|
+
hr: { color: "white", dim: true },
|
|
60
|
+
listMarker: { color: "yellow", bold: true },
|
|
61
|
+
tableHeader: { color: "yellow", bold: true },
|
|
62
|
+
tableCell: { color: "white" },
|
|
63
|
+
};
|
|
64
|
+
export const themes = {
|
|
65
|
+
default: Object.freeze(base),
|
|
66
|
+
dim: Object.freeze(dim),
|
|
67
|
+
bright: Object.freeze(bright),
|
|
68
|
+
solarized: Object.freeze(solarized),
|
|
69
|
+
monochrome: Object.freeze(monochrome),
|
|
70
|
+
contrast: Object.freeze(contrast),
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Create a Chalk-based styling helper that applies StyleIntent safely.
|
|
74
|
+
*/
|
|
75
|
+
export function createStyler({ color }) {
|
|
76
|
+
const level = color ? 3 : 0;
|
|
77
|
+
const chalk = new Chalk({ level });
|
|
78
|
+
const apply = (text, style = {}) => {
|
|
79
|
+
if (!color)
|
|
80
|
+
return text;
|
|
81
|
+
let fn = chalk;
|
|
82
|
+
if (style.color) {
|
|
83
|
+
const indexed = fn;
|
|
84
|
+
if (indexed[style.color])
|
|
85
|
+
fn = indexed[style.color];
|
|
86
|
+
}
|
|
87
|
+
if (style.bgColor) {
|
|
88
|
+
const indexed = fn;
|
|
89
|
+
if (indexed[style.bgColor])
|
|
90
|
+
fn = indexed[style.bgColor];
|
|
91
|
+
}
|
|
92
|
+
if (style.bold)
|
|
93
|
+
fn = fn.bold;
|
|
94
|
+
if (style.italic)
|
|
95
|
+
fn = fn.italic;
|
|
96
|
+
if (style.underline)
|
|
97
|
+
fn = fn.underline;
|
|
98
|
+
if (style.dim)
|
|
99
|
+
fn = fn.dim;
|
|
100
|
+
if (style.strike)
|
|
101
|
+
fn = fn.strikethrough;
|
|
102
|
+
return fn(text);
|
|
103
|
+
};
|
|
104
|
+
return apply;
|
|
105
|
+
}
|