markdansi 0.1.0

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/.biome.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/1.8.0/schema.json",
3
+ "extends": ["biome:recommended"],
4
+ "formatter": {
5
+ "enabled": true
6
+ },
7
+ "organizeImports": {
8
+ "enabled": true
9
+ },
10
+ "files": {
11
+ "ignore": [
12
+ "coverage/**",
13
+ "**/coverage/**",
14
+ "node_modules/**",
15
+ "dist/**",
16
+ "**/dist/**"
17
+ ]
18
+ },
19
+ "linter": {
20
+ "rules": {
21
+ "recommended": true
22
+ }
23
+ }
24
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Peter Steinberger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # Markdansi
2
+
3
+ 🎨 Markdansi: Wraps, colors, links—no baggage.
4
+ ![npm unpublished](https://img.shields.io/badge/npm-unpublished-lightgrey) ![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)
5
+
6
+ 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).
7
+
8
+ ## Install
9
+
10
+ > Not yet published to npm (name available as of November 16, 2025). Install from git or local path until released.
11
+
12
+ ```bash
13
+ pnpm add markdansi
14
+ # or
15
+ npm install markdansi
16
+ ```
17
+
18
+ ## CLI
19
+
20
+ ```bash
21
+ markdansi [--in FILE] [--out FILE] [--width N] [--no-wrap] [--no-color] [--no-links] [--theme default|dim|bright]
22
+ [--list-indent N] [--quote-prefix STR]
23
+ ```
24
+
25
+ - Input: stdin if `--in` not given (use `--in -` for stdin explicitly).
26
+ - Output: stdout unless `--out` provided.
27
+ - Wrapping: on by default; `--no-wrap` disables hard wrapping.
28
+ - Links: OSC‑8 when supported; `--no-links` disables.
29
+ - Lists/quotes: `--list-indent` sets spaces per nesting level (default 2); `--quote-prefix` sets blockquote prefix (default `│ `).
30
+
31
+ ## Library
32
+
33
+ ```js
34
+ import { render, createRenderer, strip, themes } from 'markdansi';
35
+
36
+ const ansi = render('# Hello **world**', { width: 60 });
37
+
38
+ const renderNoWrap = createRenderer({ wrap: false });
39
+ const out = renderNoWrap('A very long line...');
40
+
41
+ // Plain text (no ANSI/OSC)
42
+ const plain = strip('link to [x](https://example.com)');
43
+
44
+ // Custom theme and highlighter hook
45
+ const custom = createRenderer({
46
+ theme: {
47
+ ...themes.default,
48
+ code: { color: 'cyan', dim: true }, // fallback used for inline/block
49
+ inlineCode: { color: 'red' },
50
+ blockCode: { color: 'green' },
51
+ },
52
+ highlighter: (code, lang) => code.toUpperCase(),
53
+ });
54
+ console.log(custom('`inline`\n\n```\nblock code\n```'));
55
+ ```
56
+
57
+ ### Options
58
+
59
+ - `wrap` (default `true`): if `false`, no hard wrapping anywhere.
60
+ - `width`: used only when `wrap===true`; default TTY columns or 80.
61
+ - `color` (default TTY): `false` removes all ANSI/OSC.
62
+ - `hyperlinks` (default auto): enable/disable OSC‑8 links.
63
+ - `theme`: `default | dim | bright` or custom theme object.
64
+ - `listIndent`: spaces per nesting level (default 2).
65
+ - `quotePrefix`: blockquote line prefix (default `│ `).
66
+ - `highlighter(code, lang)`: optional hook to recolor code blocks; must not add/remove newlines.
67
+
68
+ ## Status
69
+
70
+ Version: `0.1.0`
71
+ Tests: `pnpm test`
72
+ License: MIT
73
+
74
+ ## Notes
75
+
76
+ - Code blocks never hard‑wrap; long lines may overflow. If `lang` is present, a faint `[lang]` label is shown.
77
+ - Tables are ASCII boxed, align using GFM alignment, and wrap cell text on spaces; very long words may overflow.
78
+ - Tight vs loose lists follow GFM; task items render `[ ]` / `[x]`.
79
+
80
+ See `docs/spec.md` for full behavior details.*** End Patch
@@ -0,0 +1,46 @@
1
+ export type ColorName = "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white" | `#${string}` | `${number}`;
2
+ export type StyleIntent = {
3
+ color?: ColorName;
4
+ bgColor?: ColorName;
5
+ bold?: boolean;
6
+ italic?: boolean;
7
+ underline?: boolean;
8
+ dim?: boolean;
9
+ strike?: boolean;
10
+ };
11
+ export type Theme = {
12
+ heading?: StyleIntent;
13
+ strong?: StyleIntent;
14
+ emph?: StyleIntent;
15
+ inlineCode?: StyleIntent;
16
+ blockCode?: StyleIntent;
17
+ code?: StyleIntent;
18
+ link?: StyleIntent;
19
+ quote?: StyleIntent;
20
+ hr?: StyleIntent;
21
+ listMarker?: StyleIntent;
22
+ tableHeader?: StyleIntent;
23
+ tableCell?: StyleIntent;
24
+ };
25
+ export type ThemeName = "default" | "dim" | "bright";
26
+ export type Highlighter = (code: string, lang?: string) => string;
27
+ export interface RenderOptions {
28
+ wrap?: boolean;
29
+ width?: number;
30
+ hyperlinks?: boolean;
31
+ color?: boolean;
32
+ theme?: ThemeName | Theme;
33
+ /**
34
+ * Spaces per nesting level for lists (default 2).
35
+ */
36
+ listIndent?: number;
37
+ /**
38
+ * Prefix used for blockquotes (default "│ ").
39
+ */
40
+ quotePrefix?: string;
41
+ highlighter?: Highlighter;
42
+ }
43
+ export declare function render(markdown: string, options?: RenderOptions): string;
44
+ export declare function createRenderer(options?: RenderOptions): (markdown: string) => string;
45
+ export declare function strip(markdown: string, options?: RenderOptions): string;
46
+ export declare const themes: Record<ThemeName | string, Theme>;
package/docs/spec.md ADDED
@@ -0,0 +1,83 @@
1
+ # Markdansi v1 – Design Spec
2
+
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
+
5
+ ## Core Dependencies (runtime)
6
+ - `micromark`, `micromark-extension-gfm`, `micromark-util-combine-extensions`: GFM parsing (tables, task lists, strikethrough, autolink literals).
7
+ - `chalk`: small, ESM‑only color/style helper.
8
+ - `string-width`: correct visible width (emoji / wide chars).
9
+ - `strip-ansi`: strip codes for width/wrapping.
10
+ - `supports-hyperlinks`: detect OSC‑8 hyperlink support.
11
+
12
+ Dev: `vitest`.
13
+
14
+ ## Surface Area
15
+ ### Library (ESM default, CJS export provided)
16
+ `render(markdown: string, options?: RenderOptions): string`
17
+
18
+ `createRenderer(options?: RenderOptions): (md: string) => string`
19
+
20
+ `type RenderOptions = {`
21
+ ` wrap?: boolean; // default: true; if false => no hard wraps anywhere`
22
+ ` width?: number; // used only when wrap===true; default: TTY cols or 80`
23
+ ` hyperlinks?: boolean; // default: auto via supports-hyperlinks`
24
+ ` color?: boolean; // default: true if TTY; if false => no ANSI/OSC at all`
25
+ ` theme?: ThemeName | Theme; // built-ins: default, dim, bright`
26
+ ` highlighter?: (code: string, lang?: string) => string; // hook, must not add newlines`
27
+ `}``
28
+
29
+ `type Theme = { heading, strong, emph, inlineCode, blockCode, code?, link, quote, hr, listMarker, tableHeader, tableCell }`
30
+ Each theme entry holds simple SGR intents (bold/italic/fg color names). `inlineCode` / `blockCode` are used if present; otherwise `code` acts as a fallback for both.
31
+
32
+ `strip(markdown: string): string` — convenience: render with `color=false`, `hyperlinks=false`.
33
+
34
+ ### CLI
35
+ `markdansi [--in FILE] [--out FILE] [--width N] [--no-wrap] [--no-color] [--no-links] [--theme default|dim|bright]`
36
+ - Input: stdin if no `--in`.
37
+ - Output: stdout if no `--out`.
38
+ - Wrap: on by default; `--no-wrap` disables; width auto from TTY when not provided.
39
+ - Links: OSC‑8 hyperlinks enabled when terminal supports; `--no-links` disables.
40
+
41
+ ## Feature Scope (v1)
42
+ - Blocks: paragraphs, headings (1–6), blockquotes, fenced/indented code blocks, HR, tables, unordered/ordered lists, task lists.
43
+ - Inline: strong, emphasis, code spans, autolinks/links, strikethrough (GFM `~~`), backslash escapes.
44
+ - Code blocks: monospace dim box; if `lang` present, show a faint `[lang]` tag. Highlighter hook may recolor text but must not add/remove newlines. Code blocks never hard-wrap; long lines may overflow.
45
+ - Tables: render with simple ASCII borders; align based on GFM alignment; wrap cell text respecting width.
46
+ - Wrapping: word-wrap on spaces; uses `string-width` on stripped text. Preserve hard breaks; words longer than width are allowed to overflow. Code blocks ignore wrap.
47
+ - Hyperlinks: OSC‑8 when supported and allowed; fallback to underlined text plus URL in parentheses.
48
+ - Error handling: never throw on malformed emphasis; leave literals untouched if unmatched.
49
+
50
+ ## Rendering Pipeline
51
+ 1) **Parse** via micromark with combined GFM extensions → AST events.
52
+ 2) **Build light IR** (nodes: paragraph, heading, list, listItem, taskItem, table, tableRow, tableCell, code, inline text/emph/strong/del/code/link).
53
+ 3) **Render** to ANSI:
54
+ - Style map from theme to SGR codes.
55
+ - Wrap paragraphs/table cells using `string-width` + `strip-ansi`; wrap only breaks on spaces.
56
+ - OSC‑8 links when `hyperlinks` true; otherwise underline + optional URL suffix.
57
+ - Track active SGR for wrapping splits to re-open styles on new lines.
58
+
59
+ ## Themes (initial)
60
+ - `default`: bold headings, blue links, cyan code, subtle quotes/hr.
61
+ - `dim`: muted colors for low-contrast terminals.
62
+ - `bright`: higher contrast variant.
63
+
64
+ ## Testing (vitest)
65
+ - Unit: inline formatting (emph/strong/code/strike), links/hyperlinks on/off, wrap/no-wrap behavior, table alignment and wrapping, task lists, strikethrough.
66
+ - Snapshot-ish string comparisons for representative documents (with colors off to avoid brittle codes).
67
+
68
+ ## Non-Goals (v1)
69
+ - Images, footnotes, math, HTML passthrough, syntax highlighting bundle.
70
+
71
+ ## Notes
72
+ - Highlighting: built-in is “label-only”; extensibility via `highlighter` hook. No extra deps added for highlighting.
73
+ - ESM-first; provide CJS export entry for compatibility.
74
+
75
+ ## Behaviors & edge-case rules
76
+ - Wrap/width precedence: `wrap=false` disables all hard wrapping; `width` is ignored in that mode. When `wrap=true`, width is `options.width ?? ttyColumns ?? 80`.
77
+ - Color flag: `color=false` removes all ANSI/OSC output (no bold/italic/underline, no hyperlinks); output is plain text.
78
+ - Hyperlinks fallback: inline links render as `label (url)` when OSC‑8 disabled; autolinks render as the URL only. URLs count toward width.
79
+ - Highlighter hook: receives raw code and optional lang; may return ANSI-colored text but must not add or remove newlines. Markdansi owns indentation/padding; code blocks never hard-wrap.
80
+ - Tables width algorithm: compute desired column widths from content (cap at e.g. 40). While total exceeds width, decrement widest columns until it fits; if even minimums won’t fit, allow overflow. Respect GFM alignment per column. Cells with newlines keep those breaks.
81
+ - 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.
82
+ - Blockquotes: prefix each wrapped line with `│ ` (configurable via `quotePrefix`); quote content wraps accounting for the prefix width.
83
+ - List indent is configurable via `listIndent` (default 2 spaces per level).
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "markdansi",
3
+ "version": "0.1.0",
4
+ "description": "Tiny dependency-light markdown to ANSI converter.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./src/index.js"
10
+ },
11
+ "./cli": "./src/cli.js"
12
+ },
13
+ "bin": {
14
+ "markdansi": "./src/cli.js"
15
+ },
16
+ "keywords": [
17
+ "markdown",
18
+ "ansi",
19
+ "terminal",
20
+ "cli"
21
+ ],
22
+ "engines": {
23
+ "node": ">=22"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/steipete/Markdansi.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/steipete/Markdansi/issues"
31
+ },
32
+ "homepage": "https://github.com/steipete/Markdansi#readme",
33
+ "author": "Peter Steinberger",
34
+ "license": "MIT",
35
+ "sideEffects": false,
36
+ "files": [
37
+ "src",
38
+ "dist",
39
+ "README.md",
40
+ "docs/spec.md",
41
+ "package.json",
42
+ "tsconfig.json",
43
+ ".biome.json",
44
+ "types"
45
+ ],
46
+ "types": "dist/index.d.ts",
47
+ "dependencies": {
48
+ "chalk": "^5.6.2",
49
+ "mdast-util-from-markdown": "^2.0.2",
50
+ "mdast-util-gfm": "^3.1.0",
51
+ "micromark": "^4.0.2",
52
+ "micromark-extension-gfm": "^3.0.0",
53
+ "micromark-util-combine-extensions": "^2.0.1",
54
+ "string-width": "^8.1.0",
55
+ "strip-ansi": "^7.1.2",
56
+ "supports-hyperlinks": "^4.3.0"
57
+ },
58
+ "devDependencies": {
59
+ "@biomejs/biome": "^2.3.5",
60
+ "@types/node": "^24.10.1",
61
+ "@vitest/coverage-v8": "^4.0.9",
62
+ "typescript": "^5.9.3",
63
+ "vitest": "^4.0.9"
64
+ },
65
+ "scripts": {
66
+ "build": "pnpm lint && pnpm test",
67
+ "clean": "rm -rf dist",
68
+ "lint": "rm -rf dist coverage && biome check .",
69
+ "test": "vitest run",
70
+ "test:coverage": "vitest run --coverage",
71
+ "types": "tsc -p tsconfig.json"
72
+ }
73
+ }
package/src/cli.js ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { render } from "./index.js";
5
+
6
+ function parseArgs(argv) {
7
+ const args = {};
8
+ for (let i = 2; i < argv.length; i += 1) {
9
+ const a = argv[i];
10
+ if (a === "--no-wrap") args.wrap = false;
11
+ else if (a === "--no-color") args.color = false;
12
+ else if (a === "--no-links") args.hyperlinks = false;
13
+ else if (a === "--in") args.in = argv[++i];
14
+ else if (a === "--out") args.out = argv[++i];
15
+ else if (a === "--width") args.width = Number(argv[++i]);
16
+ else if (a.startsWith("--theme=")) args.theme = a.split("=")[1];
17
+ else if (a === "--list-indent") args.listIndent = Number(argv[++i]);
18
+ else if (a === "--quote-prefix") args.quotePrefix = argv[++i];
19
+ else if (a === "--help" || a === "-h") args.help = true;
20
+ }
21
+ return args;
22
+ }
23
+
24
+ function main() {
25
+ const args = parseArgs(process.argv);
26
+ if (args.help) {
27
+ process.stdout.write(`markdansi options:
28
+ --in FILE Input file (default: stdin)
29
+ --out FILE Output file (default: stdout)
30
+ --width N Wrap width (default: TTY cols or 80)
31
+ --no-wrap Disable hard wrapping
32
+ --no-color Disable ANSI/OSC output
33
+ --no-links Disable OSC-8 hyperlinks
34
+ --theme NAME Theme (default|dim|bright)
35
+ --list-indent N Spaces per list nesting level (default: 2)
36
+ --quote-prefix STR Prefix for blockquotes (default: "│ ")
37
+ `);
38
+ process.exit(0);
39
+ }
40
+ const input =
41
+ args.in && args.in !== "-"
42
+ ? fs.readFileSync(path.resolve(args.in), "utf8")
43
+ : fs.readFileSync(0, "utf8");
44
+
45
+ const output = render(input, {
46
+ wrap: args.wrap,
47
+ width: args.width,
48
+ color: args.color,
49
+ hyperlinks: args.hyperlinks,
50
+ theme: args.theme,
51
+ listIndent: args.listIndent,
52
+ quotePrefix: args.quotePrefix,
53
+ });
54
+
55
+ if (args.out) {
56
+ fs.writeFileSync(path.resolve(args.out), output, "utf8");
57
+ } else {
58
+ process.stdout.write(output);
59
+ }
60
+ }
61
+
62
+ main();
@@ -0,0 +1,15 @@
1
+ import supportsHyperlinks from "supports-hyperlinks";
2
+
3
+ /**
4
+ * Detect OSC-8 hyperlink support for a given stream (defaults to stdout).
5
+ */
6
+ export function hyperlinkSupported(stream = process.stdout) {
7
+ if (supportsHyperlinks && typeof supportsHyperlinks.stdout === "function") {
8
+ return supportsHyperlinks.stdout(stream);
9
+ }
10
+ return false;
11
+ }
12
+
13
+ export function osc8(url, text) {
14
+ return `\u001B]8;;${url}\u0007${text}\u001B]8;;\u0007`;
15
+ }
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ import { createRenderer, render as renderMarkdown } from "./render.js";
2
+ import { themes } from "./theme.js";
3
+
4
+ export { renderMarkdown as render, createRenderer, themes };
5
+
6
+ export function strip(markdown, options = {}) {
7
+ return renderMarkdown(markdown, {
8
+ ...options,
9
+ color: false,
10
+ hyperlinks: false,
11
+ });
12
+ }
package/src/parser.js ADDED
@@ -0,0 +1,10 @@
1
+ import { fromMarkdown } from "mdast-util-from-markdown";
2
+ import { gfmFromMarkdown } from "mdast-util-gfm";
3
+ import { gfm as gfmSyntax } from "micromark-extension-gfm";
4
+
5
+ export function parse(markdown) {
6
+ return fromMarkdown(markdown, {
7
+ extensions: [gfmSyntax()],
8
+ mdastExtensions: [gfmFromMarkdown()],
9
+ });
10
+ }
package/src/render.js ADDED
@@ -0,0 +1,342 @@
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
+
8
+ function resolveOptions(userOptions = {}) {
9
+ const wrap = userOptions.wrap !== undefined ? userOptions.wrap : true;
10
+ const baseWidth =
11
+ userOptions.width ?? (wrap ? process.stdout.columns || 80 : undefined);
12
+ const color =
13
+ userOptions.color !== undefined ? userOptions.color : process.stdout.isTTY;
14
+ // OSC hyperlinks require color support; if color is off, force hyperlinks off too
15
+ const hyperlinks =
16
+ userOptions.hyperlinks !== undefined
17
+ ? userOptions.hyperlinks
18
+ : color && hyperlinkSupported();
19
+ const effectiveHyperlinks = color ? hyperlinks : false;
20
+ const theme =
21
+ userOptions.theme && typeof userOptions.theme === "object"
22
+ ? userOptions.theme
23
+ : themes[userOptions.theme || "default"] || themes.default;
24
+ const mergedTheme = {
25
+ ...themes.default,
26
+ ...(theme || {}),
27
+ // optional fallback: if only `code` provided, reuse for inline/block
28
+ inlineCode: theme?.inlineCode || theme?.code || themes.default.inlineCode,
29
+ blockCode: theme?.blockCode || theme?.code || themes.default.blockCode,
30
+ };
31
+ const highlighter = userOptions.highlighter;
32
+ const listIndent = userOptions.listIndent ?? 2;
33
+ const quotePrefix = userOptions.quotePrefix ?? "│ ";
34
+ return {
35
+ wrap,
36
+ width: baseWidth,
37
+ color,
38
+ hyperlinks: effectiveHyperlinks,
39
+ theme: mergedTheme,
40
+ highlighter,
41
+ listIndent,
42
+ quotePrefix,
43
+ };
44
+ }
45
+
46
+ const HR_WIDTH = 40;
47
+ const MAX_COL = 40;
48
+
49
+ export function render(markdown, userOptions = {}) {
50
+ const options = resolveOptions(userOptions);
51
+ const style = createStyler({ color: options.color });
52
+ const tree = parse(markdown);
53
+ const ctx = { options, style };
54
+ const body = renderChildren(tree.children, ctx, 0, true).join("");
55
+ return options.color ? body : stripAnsi(body);
56
+ }
57
+
58
+ export function createRenderer(options) {
59
+ return (md) => render(md, options);
60
+ }
61
+
62
+ function renderChildren(children, ctx, indentLevel = 0, isTightList = false) {
63
+ const out = [];
64
+ for (const node of children) {
65
+ out.push(renderNode(node, ctx, indentLevel, isTightList));
66
+ }
67
+ return out.flat();
68
+ }
69
+
70
+ function renderNode(node, ctx, indentLevel, isTightList) {
71
+ switch (node.type) {
72
+ case "paragraph":
73
+ return renderParagraph(node, ctx, indentLevel);
74
+ case "heading":
75
+ return renderHeading(node, ctx);
76
+ case "thematicBreak":
77
+ return renderHr(ctx);
78
+ case "blockquote":
79
+ return renderBlockquote(node, ctx, indentLevel);
80
+ case "list":
81
+ return renderList(node, ctx, indentLevel);
82
+ case "listItem":
83
+ return renderListItem(node, ctx, indentLevel, isTightList);
84
+ case "code":
85
+ return renderCodeBlock(node, ctx);
86
+ case "table":
87
+ return renderTable(node, ctx);
88
+ default:
89
+ return []; // inline handled elsewhere or intentionally skipped
90
+ }
91
+ }
92
+
93
+ function renderParagraph(node, ctx, indentLevel) {
94
+ const text = renderInline(node.children, ctx);
95
+ const prefix = " ".repeat(ctx.options.listIndent * indentLevel);
96
+ const lines = wrapWithPrefix(
97
+ text,
98
+ ctx.options.width ?? 80,
99
+ ctx.options.wrap,
100
+ prefix,
101
+ );
102
+ return lines.map((l) => `${l}\n`);
103
+ }
104
+
105
+ function renderHeading(node, ctx) {
106
+ const text = renderInline(node.children, ctx);
107
+ const styled = ctx.style(text, ctx.options.theme.heading);
108
+ return [`\n${styled}\n`];
109
+ }
110
+
111
+ function renderHr(ctx) {
112
+ const width = ctx.options.wrap
113
+ ? Math.min(ctx.options.width ?? HR_WIDTH, HR_WIDTH)
114
+ : HR_WIDTH;
115
+ const line = "—".repeat(width);
116
+ return [`${ctx.style(line, ctx.options.theme.hr)}\n`];
117
+ }
118
+
119
+ function renderBlockquote(node, ctx, indentLevel) {
120
+ // Render blockquote children as text, then wrap with the quote prefix so
121
+ // wrapping accounts for prefix width.
122
+ const inner = renderChildren(node.children, ctx, indentLevel);
123
+ const prefix = ctx.style(ctx.options.quotePrefix, ctx.options.theme.quote);
124
+ const text = inner.join("").trimEnd();
125
+ const wrapped = wrapWithPrefix(
126
+ text,
127
+ ctx.options.width ?? 80,
128
+ ctx.options.wrap,
129
+ prefix,
130
+ );
131
+ return wrapped.map((l) => `${l}\n`);
132
+ }
133
+
134
+ function renderList(node, ctx, indentLevel) {
135
+ const tight = node.spread === false;
136
+ const items = node.children.flatMap((item, idx) =>
137
+ renderListItem(
138
+ item,
139
+ ctx,
140
+ indentLevel,
141
+ tight,
142
+ node.ordered,
143
+ node.start ?? 1,
144
+ idx,
145
+ ),
146
+ );
147
+ return items;
148
+ }
149
+
150
+ function renderListItem(
151
+ node,
152
+ ctx,
153
+ indentLevel,
154
+ tight,
155
+ ordered = false,
156
+ start = 1,
157
+ idx = 0,
158
+ ) {
159
+ const marker = ordered ? `${start + idx}.` : "-";
160
+ const markerStyled = ctx.style(marker, ctx.options.theme.listMarker);
161
+ const content = renderChildren(node.children, ctx, indentLevel + 1, tight)
162
+ .join("")
163
+ .trimEnd()
164
+ .split("\n");
165
+
166
+ // Drop leading blank lines so bullets prefix real content (e.g., headings in lists)
167
+ while (content.length && content[0].trim() === "") {
168
+ content.shift();
169
+ }
170
+
171
+ const isTask = typeof node.checked === "boolean";
172
+ const box = isTask ? (node.checked ? "[x]" : "[ ]") : null;
173
+ const firstBullet =
174
+ " ".repeat(ctx.options.listIndent * indentLevel) +
175
+ (isTask
176
+ ? `${ctx.style(box, ctx.options.theme.listMarker)} `
177
+ : `${markerStyled} `);
178
+
179
+ const lines = [];
180
+ content.forEach((line, i) => {
181
+ const clean = line.replace(/^\s+/, "");
182
+ const prefix =
183
+ i === 0
184
+ ? firstBullet
185
+ : `${" ".repeat(ctx.options.listIndent * indentLevel)}${" ".repeat(
186
+ ctx.options.listIndent,
187
+ )}`;
188
+ lines.push(prefix + clean);
189
+ });
190
+ if (!tight) lines.push("");
191
+ return lines.map((l) => `${l}\n`);
192
+ }
193
+
194
+ function renderCodeBlock(node, ctx) {
195
+ const theme = ctx.options.theme.blockCode || ctx.options.theme.inlineCode;
196
+ const label = node.lang ? ctx.style(`[${node.lang}] `, { dim: true }) : "";
197
+ let body = node.value ?? "";
198
+ if (ctx.options.highlighter) {
199
+ const res = ctx.options.highlighter(body, node.lang);
200
+ if (typeof res === "string") body = res;
201
+ } else {
202
+ body = body
203
+ .split("\n")
204
+ .map((l) => ctx.style(l, theme))
205
+ .join("\n");
206
+ }
207
+ return [`${label}${body}\n\n`];
208
+ }
209
+
210
+ function renderInline(children, ctx) {
211
+ let out = "";
212
+ for (const node of children) {
213
+ switch (node.type) {
214
+ case "text":
215
+ out += node.value;
216
+ break;
217
+ case "emphasis":
218
+ out += ctx.style(
219
+ renderInline(node.children, ctx),
220
+ ctx.options.theme.emph,
221
+ );
222
+ break;
223
+ case "strong":
224
+ out += ctx.style(
225
+ renderInline(node.children, ctx),
226
+ ctx.options.theme.strong,
227
+ );
228
+ break;
229
+ case "delete":
230
+ out += ctx.style(renderInline(node.children, ctx), { strike: true });
231
+ break;
232
+ case "inlineCode": {
233
+ const codeTheme =
234
+ ctx.options.theme.inlineCode || ctx.options.theme.blockCode;
235
+ const content = ctx.style(node.value, codeTheme);
236
+ out += content;
237
+ break;
238
+ }
239
+ case "link":
240
+ out += renderLink(node, ctx);
241
+ break;
242
+ case "break":
243
+ out += "\n";
244
+ break;
245
+ default:
246
+ if (node.value) out += node.value;
247
+ }
248
+ }
249
+ return out;
250
+ }
251
+
252
+ function renderLink(node, ctx) {
253
+ const label = renderInline(node.children, ctx) || node.url;
254
+ const url = node.url || "";
255
+ if (ctx.options.hyperlinks && url) {
256
+ return osc8(url, label);
257
+ }
258
+ if (url && label !== url) {
259
+ return (
260
+ ctx.style(label, ctx.options.theme.link) +
261
+ ctx.style(` (${url})`, { dim: true })
262
+ );
263
+ }
264
+ return ctx.style(label, ctx.options.theme.link);
265
+ }
266
+
267
+ function renderTable(node, ctx) {
268
+ const header = node.children[0];
269
+ const rows = node.children.slice(1);
270
+ const cells = [header, ...rows].map((row) =>
271
+ row.children.map((cell) => renderInline(cell.children, ctx)),
272
+ );
273
+ const colCount = Math.max(...cells.map((r) => r.length));
274
+ const widths = new Array(colCount).fill(1);
275
+ const aligns = node.align || [];
276
+
277
+ cells.forEach((row) => {
278
+ row.forEach((cell, idx) => {
279
+ // Cap each column to MAX_COL but keep at least 1
280
+ widths[idx] = Math.max(
281
+ widths[idx],
282
+ Math.min(MAX_COL, visibleWidth(cell)),
283
+ );
284
+ });
285
+ });
286
+
287
+ const totalWidth = widths.reduce((a, b) => a + b, 0) + 3 * colCount + 1;
288
+ if (ctx.options.wrap && ctx.options.width && totalWidth > ctx.options.width) {
289
+ // Shrink widest columns until the table fits; allow overflow if already at minima
290
+ let over = totalWidth - ctx.options.width;
291
+ while (over > 0) {
292
+ const i = widths.indexOf(Math.max(...widths));
293
+ if (widths[i] <= 1) break;
294
+ widths[i] -= 1;
295
+ over -= 1;
296
+ }
297
+ }
298
+
299
+ const renderRow = (row, isHeader = false) => {
300
+ const linesPerCol = row.map((cell, idx) =>
301
+ wrapText(cell, widths[idx], ctx.options.wrap).map((l) =>
302
+ padCell(l, widths[idx], aligns[idx]),
303
+ ),
304
+ );
305
+ // Row height = max wrapped lines in any column; pad shorter ones
306
+ const height = Math.max(...linesPerCol.map((c) => c.length));
307
+ const out = [];
308
+ for (let i = 0; i < height; i += 1) {
309
+ const parts = linesPerCol.map((col, idx) => {
310
+ const content = col[i] ?? padCell("", widths[idx], aligns[idx]);
311
+ return isHeader
312
+ ? ctx.style(content, ctx.options.theme.tableHeader)
313
+ : ctx.style(content, ctx.options.theme.tableCell);
314
+ });
315
+ out.push(`| ${parts.join(" | ")} |\n`);
316
+ }
317
+ return out;
318
+ };
319
+
320
+ const headerRows = renderRow(
321
+ header.children.map((c) => renderInline(c.children, ctx)),
322
+ true,
323
+ );
324
+ const divider = `| ${widths.map((w) => "—".repeat(w)).join(" | ")} |\n`;
325
+ const bodyRows = rows.flatMap((r) =>
326
+ renderRow(r.children.map((c) => renderInline(c.children, ctx))),
327
+ );
328
+
329
+ return [...headerRows, divider, ...bodyRows, "\n"];
330
+ }
331
+
332
+ function padCell(text, width, align = "left") {
333
+ const pad = width - stringWidth(stripAnsi(text));
334
+ if (pad <= 0) return text;
335
+ if (align === "right") return `${" ".repeat(pad)}${text}`;
336
+ if (align === "center") {
337
+ const left = Math.floor(pad / 2);
338
+ const right = pad - left;
339
+ return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
340
+ }
341
+ return `${text}${" ".repeat(pad)}`;
342
+ }
package/src/theme.js ADDED
@@ -0,0 +1,53 @@
1
+ import { Chalk } from "chalk";
2
+
3
+ const base = {
4
+ heading: { color: "yellow", bold: true },
5
+ strong: { bold: true },
6
+ emph: { italic: true },
7
+ inlineCode: { color: "cyan", dim: true },
8
+ blockCode: { color: "cyan", dim: true },
9
+ link: { color: "blue", underline: true },
10
+ quote: { dim: true },
11
+ hr: { dim: true },
12
+ listMarker: { color: "cyan" },
13
+ tableHeader: { bold: true },
14
+ tableCell: {},
15
+ };
16
+
17
+ const dim = {
18
+ ...base,
19
+ heading: { color: "white", bold: true, dim: true },
20
+ link: { color: "blue", underline: true, dim: true },
21
+ };
22
+
23
+ const bright = {
24
+ ...base,
25
+ heading: { color: "magenta", bold: true },
26
+ link: { color: "cyan", underline: true },
27
+ inlineCode: { color: "green" },
28
+ blockCode: { color: "green" },
29
+ };
30
+
31
+ export const themes = {
32
+ default: Object.freeze(base),
33
+ dim: Object.freeze(dim),
34
+ bright: Object.freeze(bright),
35
+ };
36
+
37
+ export function createStyler({ color }) {
38
+ const level = color ? 3 : 0;
39
+ const chalk = new Chalk({ level });
40
+ const apply = (text, style = {}) => {
41
+ if (!color) return text;
42
+ let fn = chalk;
43
+ if (style.color && fn[style.color]) fn = fn[style.color];
44
+ if (style.bgColor && fn[style.bgColor]) fn = fn[style.bgColor];
45
+ if (style.bold) fn = fn.bold;
46
+ if (style.italic) fn = fn.italic;
47
+ if (style.underline) fn = fn.underline;
48
+ if (style.dim) fn = fn.dim;
49
+ if (style.strike) fn = fn.strikethrough;
50
+ return fn(text);
51
+ };
52
+ return apply;
53
+ }
package/src/wrap.js ADDED
@@ -0,0 +1,45 @@
1
+ import stringWidth from "string-width";
2
+ import stripAnsi from "strip-ansi";
3
+
4
+ export function visibleWidth(text) {
5
+ return stringWidth(stripAnsi(text));
6
+ }
7
+
8
+ /**
9
+ * Wrap a single paragraph string into lines respecting visible width.
10
+ * Breaks only on spaces. Words longer than width overflow.
11
+ */
12
+ export function wrapText(text, width, wrap) {
13
+ if (!wrap || width <= 0) return [text];
14
+ const words = text.split(/(\s+)/).filter((w) => w.length > 0);
15
+ const lines = [];
16
+ let current = "";
17
+ let currentWidth = 0;
18
+
19
+ for (const word of words) {
20
+ const w = visibleWidth(word);
21
+ if (current !== "" && currentWidth + w > width && !/^\s+$/.test(word)) {
22
+ lines.push(current);
23
+ current = word.replace(/^\s+/, "");
24
+ currentWidth = visibleWidth(current);
25
+ continue;
26
+ }
27
+ current += word;
28
+ currentWidth = visibleWidth(current);
29
+ }
30
+
31
+ if (current !== "") lines.push(current);
32
+ if (lines.length === 0) lines.push("");
33
+ return lines;
34
+ }
35
+
36
+ export function wrapWithPrefix(text, width, wrap, prefix = "") {
37
+ if (!wrap) return text.split("\n").map((line) => prefix + line);
38
+ const out = [];
39
+ const w = Math.max(1, width - visibleWidth(prefix));
40
+ for (const line of text.split("\n")) {
41
+ const parts = wrapText(line, w, wrap);
42
+ for (const p of parts) out.push(prefix + p);
43
+ }
44
+ return out;
45
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "strict": true,
4
+ "noUncheckedIndexedAccess": true,
5
+ "exactOptionalPropertyTypes": true,
6
+ "noPropertyAccessFromIndexSignature": true,
7
+ "allowJs": true,
8
+ "checkJs": true,
9
+ "declaration": true,
10
+ "emitDeclarationOnly": true,
11
+ "outDir": "dist",
12
+ "module": "NodeNext",
13
+ "moduleResolution": "NodeNext",
14
+ "target": "ES2022",
15
+ "resolveJsonModule": true
16
+ },
17
+ "include": ["types/**/*.ts"],
18
+ "exclude": ["node_modules", "dist", "coverage"]
19
+ }
package/types/index.ts ADDED
@@ -0,0 +1,74 @@
1
+ // Public API typings for Markdansi.
2
+ // Hand-authored source for `pnpm types` (tsc --emitDeclarationOnly)
3
+ // to produce dist/index.d.ts. Keep in sync with src/ changes.
4
+
5
+ export type ColorName =
6
+ | "black"
7
+ | "red"
8
+ | "green"
9
+ | "yellow"
10
+ | "blue"
11
+ | "magenta"
12
+ | "cyan"
13
+ | "white"
14
+ | `#${string}`
15
+ | `${number}`;
16
+
17
+ export type StyleIntent = {
18
+ color?: ColorName;
19
+ bgColor?: ColorName;
20
+ bold?: boolean;
21
+ italic?: boolean;
22
+ underline?: boolean;
23
+ dim?: boolean;
24
+ strike?: boolean;
25
+ };
26
+
27
+ export type Theme = {
28
+ heading?: StyleIntent;
29
+ strong?: StyleIntent;
30
+ emph?: StyleIntent;
31
+ inlineCode?: StyleIntent;
32
+ blockCode?: StyleIntent;
33
+ code?: StyleIntent;
34
+ link?: StyleIntent;
35
+ quote?: StyleIntent;
36
+ hr?: StyleIntent;
37
+ listMarker?: StyleIntent;
38
+ tableHeader?: StyleIntent;
39
+ tableCell?: StyleIntent;
40
+ };
41
+
42
+ export type ThemeName = "default" | "dim" | "bright";
43
+
44
+ export type Highlighter = (code: string, lang?: string) => string;
45
+
46
+ export interface RenderOptions {
47
+ wrap?: boolean;
48
+ width?: number;
49
+ hyperlinks?: boolean;
50
+ color?: boolean;
51
+ theme?: ThemeName | Theme;
52
+ /**
53
+ * Spaces per nesting level for lists (default 2).
54
+ */
55
+ listIndent?: number;
56
+ /**
57
+ * Prefix used for blockquotes (default "│ ").
58
+ */
59
+ quotePrefix?: string;
60
+ highlighter?: Highlighter;
61
+ }
62
+
63
+ export declare function render(
64
+ markdown: string,
65
+ options?: RenderOptions,
66
+ ): string;
67
+ export declare function createRenderer(
68
+ options?: RenderOptions,
69
+ ): (markdown: string) => string;
70
+ export declare function strip(
71
+ markdown: string,
72
+ options?: RenderOptions,
73
+ ): string;
74
+ export declare const themes: Record<ThemeName | string, Theme>;