@dunkinfrunkin/mdcat 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/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # mdcat
2
+
3
+ **View markdown files beautifully in your terminal.**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/mdcat.svg)](https://www.npmjs.com/package/mdcat)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ `mdcat` is a terminal pager for Markdown. It renders GitHub-Flavoured Markdown with full colour, syntax-highlighted code blocks, clickable hyperlinks (OSC 8), and a keyboard-driven TUI — all with zero configuration. Pipe a file into it or open one directly; it just works.
9
+
10
+ ---
11
+
12
+ ## What it looks like
13
+
14
+ - **H1** headings render in a purple Unicode box
15
+ - **H2** headings render in bold blue with an underline bar
16
+ - **H3–H6** headings use green → yellow → cyan → dim
17
+ - **Code blocks** get a bordered box with a language tag and syntax highlighting
18
+ - **Blockquotes** get an amber `▌` bar with dim italic text
19
+ - **Lists** use `●` / `○` / `‣` bullets; task lists use `☑` / `☐`
20
+ - **Tables** render with full Unicode box-drawing borders
21
+ - **Links** are blue and underlined, clickable in iTerm2 / Kitty / WezTerm
22
+
23
+ The top chrome bar shows the filename and file type. The bottom bar shows key hints and scroll percentage. Press `/` to search — matching lines get a gold `▶` gutter marker.
24
+
25
+ ---
26
+
27
+ ## Install
28
+
29
+ **Zero-install (recommended for one-off use):**
30
+
31
+ ```sh
32
+ npx mdcat README.md
33
+ ```
34
+
35
+ **Global install via npm:**
36
+
37
+ ```sh
38
+ npm install -g mdcat
39
+ ```
40
+
41
+ **macOS via Homebrew:**
42
+
43
+ ```sh
44
+ brew install frankchan/tap/mdcat
45
+ ```
46
+
47
+ ---
48
+
49
+ ## Usage
50
+
51
+ ```sh
52
+ # Open a file
53
+ mdcat README.md
54
+
55
+ # Pipe from stdin
56
+ cat CHANGELOG.md | mdcat
57
+
58
+ # Read from curl
59
+ curl -s https://raw.githubusercontent.com/user/repo/main/README.md | mdcat
60
+
61
+ # Check version
62
+ mdcat --version
63
+
64
+ # Help
65
+ mdcat --help
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Keyboard shortcuts
71
+
72
+ | Key | Action |
73
+ |----------------|---------------------------------|
74
+ | `q` | Quit |
75
+ | `j` / `k` | Scroll down / up one line |
76
+ | `↑` / `↓` | Scroll up / down one line |
77
+ | `Space` / `b` | Page down / page up |
78
+ | `d` / `u` | Half-page down / up |
79
+ | `g` / `G` | Jump to top / bottom |
80
+ | `/` | Enter search mode |
81
+ | `n` / `N` | Next / previous search match |
82
+ | `Esc` | Cancel search or clear matches |
83
+ | Mouse wheel | Scroll up / down three lines |
84
+
85
+ ---
86
+
87
+ ## Features
88
+
89
+ - Renders all GitHub-Flavoured Markdown (GFM) elements
90
+ - Syntax-highlighted code blocks via `cli-highlight` / highlight.js
91
+ - One Dark colour palette — easy on the eyes in dark terminals
92
+ - OSC 8 clickable hyperlinks (iTerm2, Kitty, WezTerm, foot, …)
93
+ - Full mouse wheel support
94
+ - Incremental search with `/` — highlights all matches, jumps to each
95
+ - Respects terminal width — long lines and tables are truncated cleanly
96
+ - Works with pipes: `curl … | mdcat`
97
+ - No configuration files, no dependencies to configure
98
+
99
+ ---
100
+
101
+ ## Supported Markdown
102
+
103
+ | Element | Rendering |
104
+ |------------------|------------------------------------------------|
105
+ | H1–H6 headings | Coloured by level; H1 gets a Unicode box |
106
+ | Paragraphs | Word-wrapped to terminal width |
107
+ | **Bold** | Bold white |
108
+ | _Italic_ | Italic |
109
+ | ~~Strikethrough~~| Strikethrough + dim |
110
+ | `inline code` | Amber text on dark background |
111
+ | Fenced code | Bordered box + syntax highlighting |
112
+ | Blockquotes | Amber `▌` bar with dim italic text |
113
+ | Unordered lists | `●` / `○` / `‣` bullets at increasing depth |
114
+ | Ordered lists | Cyan numbers |
115
+ | Task lists | `☑` / `☐` with green / dim colouring |
116
+ | Tables | Box-drawing borders; respects column alignment |
117
+ | Links | Blue underline + OSC 8 clickable |
118
+ | Images | `🖼 alt-text` in dim |
119
+ | Horizontal rules | Full-width dim `─` line |
120
+ | HTML entities | Decoded (`&`, `<`, `>`, …) |
121
+
122
+ ---
123
+
124
+ ## Contributing
125
+
126
+ Contributions are welcome! Please open an issue to discuss a feature or bug before sending a PR. The codebase is small and deliberately dependency-light — keep it that way.
127
+
128
+ ```sh
129
+ git clone https://github.com/frankchan/mdcat
130
+ cd mdcat
131
+ npm install
132
+ npm test
133
+ ```
134
+
135
+ Please make sure `npm test` passes before submitting.
136
+
137
+ ---
138
+
139
+ ## License
140
+
141
+ MIT — see [LICENSE](LICENSE) for details.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@dunkinfrunkin/mdcat",
3
+ "version": "0.1.0",
4
+ "description": "View markdown files beautifully in your terminal",
5
+ "type": "module",
6
+ "bin": {
7
+ "mdcat": "src/cli.js"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "scripts": {
13
+ "start": "node src/cli.js",
14
+ "test": "node --test test/index.js"
15
+ },
16
+ "dependencies": {
17
+ "chalk": "^5.6.2",
18
+ "cli-highlight": "^2.1.11",
19
+ "marked": "^14.0.0",
20
+ "wrap-ansi": "^10.0.0"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "keywords": [
26
+ "markdown",
27
+ "cli",
28
+ "terminal",
29
+ "viewer",
30
+ "cat"
31
+ ],
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/dunkinfrunkin/mdcat.git"
36
+ }
37
+ }
package/src/cli.js ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "fs";
3
+ import { resolve, basename } from "path";
4
+ import { marked } from "marked";
5
+ import { renderTokens } from "./render.js";
6
+ import { launch } from "./tui.js";
7
+
8
+ marked.use({ gfm: true });
9
+
10
+ const args = process.argv.slice(2);
11
+
12
+ if (args[0] === "--help" || args[0] === "-h") {
13
+ console.log("Usage: mdcat <file.md>");
14
+ console.log(" cat file.md | mdcat");
15
+ console.log("\nKeys: q quit j/k or ↑↓ scroll space/b page d/u half-page g/G top/bottom");
16
+ process.exit(0);
17
+ }
18
+
19
+ if (args[0] === "--version" || args[0] === "-v") {
20
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
21
+ console.log(pkg.version);
22
+ process.exit(0);
23
+ }
24
+
25
+ const MAX_COLS = 100; // browser-like reading width cap
26
+
27
+ function run(title, content) {
28
+ const cols = Math.min(process.stdout.columns || 80, MAX_COLS);
29
+ const tokens = marked.lexer(content);
30
+ const lines = renderTokens(tokens, cols);
31
+ launch(title, lines);
32
+ }
33
+
34
+ // Piped input: cat file.md | mdcat
35
+ if (!process.stdin.isTTY && args.length === 0) {
36
+ let input = "";
37
+ process.stdin.setEncoding("utf8");
38
+ process.stdin.on("data", (chunk) => (input += chunk));
39
+ process.stdin.on("end", () => run("stdin", input));
40
+ } else if (args.length === 0) {
41
+ console.error("Usage: mdcat <file.md>");
42
+ process.exit(1);
43
+ } else {
44
+ const filePath = resolve(args[0]);
45
+ let content;
46
+ try {
47
+ content = readFileSync(filePath, "utf8");
48
+ } catch (err) {
49
+ console.error(`mdcat: ${args[0]}: ${err.code === "ENOENT" ? "No such file" : err.message}`);
50
+ process.exit(1);
51
+ }
52
+ run(basename(filePath), content);
53
+ }
package/src/render.js ADDED
@@ -0,0 +1,507 @@
1
+ import { Chalk } from "chalk";
2
+ import wrapAnsi from "wrap-ansi";
3
+ import { highlight as cliHighlight } from "cli-highlight";
4
+
5
+ const chalk = new Chalk({ level: 3 });
6
+
7
+ // ─── One Dark palette ──────────────────────────────────────────────────────────
8
+ const c = {
9
+ // Headings
10
+ h1: chalk.hex("#c678dd").bold, // purple
11
+ h2: chalk.hex("#61afef").bold, // blue
12
+ h3: chalk.hex("#98c379").bold, // green
13
+ h4: chalk.hex("#e5c07b").bold, // yellow
14
+ h5: chalk.hex("#56b6c2"), // cyan
15
+ h6: chalk.dim,
16
+ // Inline
17
+ strong: chalk.bold.white,
18
+ em: chalk.italic,
19
+ del: chalk.strikethrough.dim,
20
+ code: chalk.hex("#e5c07b").bgHex("#2a2a2a"), // amber on dark bg
21
+ link: chalk.hex("#61afef").underline,
22
+ image: chalk.dim,
23
+ // Code block
24
+ border: chalk.dim,
25
+ codeLang: chalk.hex("#e5c07b").dim,
26
+ // Blockquote
27
+ bqBar: chalk.hex("#e5c07b"),
28
+ bqText: chalk.dim.italic,
29
+ // Lists
30
+ bullet0: chalk.hex("#61afef"), // blue ● depth 0
31
+ bullet1: chalk.dim, // dim ○ depth 1
32
+ bullet2: chalk.dim, // very dim ‣ depth 2+
33
+ ordered: chalk.hex("#56b6c2"), // cyan number
34
+ taskDone: chalk.hex("#98c379"), // green ☑
35
+ taskTodo: chalk.dim, // dim ☐
36
+ // Table
37
+ tableBorder: chalk.dim,
38
+ tableHead: chalk.hex("#61afef").bold,
39
+ tableCell: chalk.reset,
40
+ // HR
41
+ hr: chalk.dim,
42
+ // Paragraph text
43
+ fg: chalk.reset,
44
+ };
45
+
46
+ const MARGIN = " "; // 2-space left margin
47
+
48
+ // ─── Visual width helpers ───────────────────────────────────────────────────────
49
+
50
+ /** Strip all ANSI SGR sequences and OSC 8 hyperlinks to measure visual width. */
51
+ function vlen(s) {
52
+ return s
53
+ .replace(/\x1B\]8;;.*?\x1B\\/gs, "")
54
+ .replace(/\x1B\[[0-9;]*m/g, "")
55
+ .length;
56
+ }
57
+
58
+ /**
59
+ * ANSI-safe truncation. Walks the string character by character, skipping
60
+ * escape sequences when counting visual width. Appends reset + ellipsis when
61
+ * the string is truncated.
62
+ */
63
+ function vtrunc(s, maxW) {
64
+ if (vlen(s) <= maxW) return s;
65
+ let vis = 0;
66
+ let i = 0;
67
+ while (i < s.length && vis < maxW) {
68
+ // OSC 8 hyperlink: ESC ] 8 ; ... ST (ST = ESC \)
69
+ if (s[i] === "\x1B" && s[i + 1] === "]") {
70
+ const end = s.indexOf("\x1B\\", i + 2);
71
+ if (end !== -1) { i = end + 2; continue; }
72
+ }
73
+ // CSI SGR sequence: ESC [ ... m
74
+ if (s[i] === "\x1B" && s[i + 1] === "[") {
75
+ const end = s.indexOf("m", i + 2);
76
+ if (end !== -1) { i = end + 1; continue; }
77
+ }
78
+ vis++;
79
+ i++;
80
+ }
81
+ return s.slice(0, i) + "\x1B[0m…";
82
+ }
83
+
84
+ /** Pad string to visual width n. */
85
+ function vpad(s, n) {
86
+ return s + " ".repeat(Math.max(0, n - vlen(s)));
87
+ }
88
+
89
+ // ─── HTML entity decoder ────────────────────────────────────────────────────────
90
+
91
+ function htmlDecode(s) {
92
+ return s
93
+ .replace(/&amp;/g, "&")
94
+ .replace(/&lt;/g, "<")
95
+ .replace(/&gt;/g, ">")
96
+ .replace(/&quot;/g, '"')
97
+ .replace(/&#39;/g, "'")
98
+ .replace(/&nbsp;/g, " ");
99
+ }
100
+
101
+ // ─── Inline renderer ───────────────────────────────────────────────────────────
102
+
103
+ function inline(tokens) {
104
+ if (!tokens?.length) return "";
105
+ return tokens
106
+ .map((tok) => {
107
+ switch (tok.type) {
108
+ case "text":
109
+ return tok.tokens ? inline(tok.tokens) : htmlDecode(tok.text ?? "");
110
+ case "strong":
111
+ return c.strong(inline(tok.tokens));
112
+ case "em":
113
+ return c.em(inline(tok.tokens));
114
+ case "del":
115
+ return c.del(inline(tok.tokens));
116
+ case "codespan":
117
+ return c.code(" " + (tok.text ?? "") + " ");
118
+ case "link": {
119
+ const href = tok.href ?? "";
120
+ // Badge pattern: link whose only child is a single image (e.g. shields.io)
121
+ // Render as a compact [alt] text tag instead of a broken image icon.
122
+ if (tok.tokens?.length === 1 && tok.tokens[0].type === "image") {
123
+ const alt = tok.tokens[0].text || tok.tokens[0].alt || "";
124
+ if (alt) {
125
+ const badge = chalk.dim("[") + chalk.hex("#abb2bf")(alt) + chalk.dim("]");
126
+ return `\x1B]8;;${href}\x1B\\${badge}\x1B]8;;\x1B\\`;
127
+ }
128
+ }
129
+ const label = tok.tokens?.length ? inline(tok.tokens) : href;
130
+ // OSC 8 clickable hyperlink (iTerm2, Kitty, WezTerm, foot, …)
131
+ return `\x1B]8;;${href}\x1B\\${c.link(label)}\x1B]8;;\x1B\\`;
132
+ }
133
+ case "image":
134
+ return c.image(`🖼 ${tok.text || tok.alt || "image"}`);
135
+ case "br":
136
+ return "\n";
137
+ case "escape":
138
+ return tok.text ?? "";
139
+ case "html":
140
+ return "";
141
+ default:
142
+ return tok.raw ?? "";
143
+ }
144
+ })
145
+ .join("");
146
+ }
147
+
148
+ // ─── Block renderers ───────────────────────────────────────────────────────────
149
+
150
+ function heading(tok, w) {
151
+ const text = inline(tok.tokens);
152
+ const lines = [""];
153
+
154
+ switch (tok.depth) {
155
+ case 1: {
156
+ // Purple box with Unicode border
157
+ const label = text.toUpperCase();
158
+ const safeLabel = vlen(label) > w - 4 ? vtrunc(label, w - 4) : label;
159
+ const tlen = vlen(safeLabel);
160
+ const innerW = tlen + 2; // 1 space padding each side
161
+ const topBot = "═".repeat(innerW);
162
+ lines.push(MARGIN + c.h1("╔" + topBot + "╗"));
163
+ lines.push(MARGIN + c.h1("║") + " " + c.h1(safeLabel) + " " + c.h1("║"));
164
+ lines.push(MARGIN + c.h1("╚" + topBot + "╝"));
165
+ break;
166
+ }
167
+ case 2: {
168
+ lines.push(MARGIN + c.h2(text));
169
+ const barLen = Math.min(vlen(text) + 1, w);
170
+ lines.push(MARGIN + c.h2("─".repeat(barLen)));
171
+ break;
172
+ }
173
+ case 3:
174
+ lines.push(MARGIN + c.h3(text));
175
+ break;
176
+ case 4:
177
+ lines.push(MARGIN + c.h4(text));
178
+ break;
179
+ case 5:
180
+ lines.push(MARGIN + c.h5(text));
181
+ break;
182
+ case 6:
183
+ lines.push(MARGIN + c.h6(text));
184
+ break;
185
+ default:
186
+ lines.push(MARGIN + text);
187
+ }
188
+
189
+ lines.push("");
190
+ return lines;
191
+ }
192
+
193
+ function paragraph(tok, w) {
194
+ const text = inline(tok.tokens);
195
+ const wrapped = wrapAnsi(text, w, { hard: true, trim: false });
196
+ return [...wrapped.split("\n").map((l) => MARGIN + c.fg(l)), ""];
197
+ }
198
+
199
+ function code(tok, w) {
200
+ // First word of lang only, e.g. "js {1}" → "js"
201
+ const lang = (tok.lang ?? "").split(/\s/)[0].trim();
202
+ // Expand tabs to 2 spaces
203
+ const rawText = (tok.text ?? "").replace(/\t/g, " ");
204
+
205
+ // innerW = content width between borders (1 space padding each side)
206
+ const innerW = w - 2;
207
+ // borderW = total inner span for the box (includes the 1px padding each side)
208
+ const borderW = innerW + 2;
209
+
210
+ let rawLines;
211
+ if (rawText === "") {
212
+ rawLines = [""];
213
+ } else {
214
+ rawLines = rawText.split("\n");
215
+ }
216
+
217
+ let highlighted;
218
+ try {
219
+ if (lang) {
220
+ const h = cliHighlight(rawText || " ", {
221
+ language: lang,
222
+ ignoreIllegals: true,
223
+ });
224
+ highlighted = h.split("\n").map((l) => l + "\x1B[0m");
225
+ // Trim to match source line count (cli-highlight may add a trailing empty)
226
+ if (
227
+ highlighted.length > rawLines.length &&
228
+ vlen(highlighted[highlighted.length - 1]) === 0
229
+ ) {
230
+ highlighted.pop();
231
+ }
232
+ } else {
233
+ highlighted = rawLines;
234
+ }
235
+ } catch {
236
+ highlighted = rawLines;
237
+ }
238
+
239
+ // Pad to same length just in case highlight produced different line count
240
+ while (highlighted.length < rawLines.length) highlighted.push("");
241
+
242
+ const lines = [""];
243
+
244
+ // Top border: ┌─ lang ─────┐ or ┌──────────────┐
245
+ let top;
246
+ if (lang) {
247
+ const langTag = " " + c.codeLang(lang) + " ";
248
+ const langTagLen = 1 + lang.length + 1; // visual chars
249
+ const fill = Math.max(0, borderW - langTagLen - 1); // -1 for leading "─"
250
+ top =
251
+ c.border("┌─") +
252
+ langTag +
253
+ c.border("─".repeat(fill)) +
254
+ c.border("┐");
255
+ } else {
256
+ top = c.border("┌" + "─".repeat(borderW) + "┐");
257
+ }
258
+ lines.push(MARGIN + top);
259
+
260
+ // Content lines
261
+ for (const hline of highlighted) {
262
+ const truncated =
263
+ vlen(hline) > innerW ? vtrunc(hline, innerW) : hline;
264
+ const pad = Math.max(0, innerW - vlen(truncated));
265
+ lines.push(
266
+ MARGIN +
267
+ c.border("│") +
268
+ " " +
269
+ truncated +
270
+ " ".repeat(pad) +
271
+ " " +
272
+ c.border("│")
273
+ );
274
+ }
275
+
276
+ // Bottom border
277
+ lines.push(MARGIN + c.border("└" + "─".repeat(borderW) + "┘"));
278
+ lines.push("");
279
+ return lines;
280
+ }
281
+
282
+ function blockquote(tok, w) {
283
+ // Render inner tokens at reduced width
284
+ const innerLines = tok.tokens.flatMap((t) => {
285
+ try {
286
+ return token(t, w - 4);
287
+ } catch {
288
+ return [""];
289
+ }
290
+ });
291
+
292
+ const prefix = MARGIN + c.bqBar("▌") + " ";
293
+ const out = [""];
294
+ for (const l of innerLines) {
295
+ const stripped = l.replace(/^[ \t]*/, ""); // strip leading indent from inner render
296
+ out.push(prefix + c.bqText(stripped));
297
+ }
298
+ out.push("");
299
+ return out;
300
+ }
301
+
302
+ function list(tok, w, depth) {
303
+ const pad = " ".repeat(depth);
304
+ const lines = [];
305
+
306
+ tok.items.forEach((item, i) => {
307
+ // Bullet / number
308
+ let bullet;
309
+ if (item.task) {
310
+ // Task list: handled below after building firstLine
311
+ bullet = " "; // placeholder width
312
+ } else if (tok.ordered) {
313
+ const num = String((tok.start ?? 1) + i);
314
+ bullet = c.ordered(num + ".");
315
+ } else {
316
+ if (depth === 0) bullet = c.bullet0("●");
317
+ else if (depth === 1) bullet = c.bullet1("○");
318
+ else bullet = c.bullet2("‣");
319
+ }
320
+
321
+ let firstLine = "";
322
+ const extraLines = [];
323
+
324
+ for (const t of item.tokens) {
325
+ if (t.type === "text") {
326
+ firstLine += inline(t.tokens ?? [{ type: "text", text: t.text ?? "" }]);
327
+ } else if (t.type === "paragraph") {
328
+ firstLine += inline(t.tokens);
329
+ } else if (t.type === "list") {
330
+ extraLines.push(...list(t, w - 2, depth + 1));
331
+ } else {
332
+ try {
333
+ const inner = token(t, w - 2);
334
+ // Skip blank separator lines within loose lists
335
+ inner.forEach((l) => {
336
+ if (l !== "") extraLines.push(l);
337
+ });
338
+ } catch {
339
+ // skip
340
+ }
341
+ }
342
+ }
343
+
344
+ // Task list overlay
345
+ if (item.task) {
346
+ const box = item.checked
347
+ ? c.taskDone("☑")
348
+ : c.taskTodo("☐");
349
+ bullet = box;
350
+ }
351
+
352
+ const prefix = MARGIN + pad + bullet + " ";
353
+ const textIndent = MARGIN + pad + " ".repeat(vlen(bullet) + 1);
354
+ const wrapW = Math.max(20, w - vlen(MARGIN + pad + " ") - vlen(bullet));
355
+ const wrapped = wrapAnsi(firstLine, wrapW, { hard: true });
356
+ const wrappedLines = wrapped.split("\n");
357
+ lines.push(prefix + wrappedLines[0]);
358
+ wrappedLines.slice(1).forEach((l) => lines.push(textIndent + l));
359
+ lines.push(...extraLines);
360
+ });
361
+
362
+ return lines;
363
+ }
364
+
365
+ function table(tok, w) {
366
+ const headers = tok.header.map((h) => inline(h.tokens ?? []));
367
+ const rows = tok.rows.map((row) =>
368
+ row.map((cell) => inline(cell.tokens ?? []))
369
+ );
370
+ const aligns = tok.align ?? [];
371
+
372
+ // Natural column widths
373
+ const colWidths = headers.map((h, i) => {
374
+ const maxCell = rows.reduce(
375
+ (m, r) => Math.max(m, vlen(r[i] ?? "")),
376
+ 0
377
+ );
378
+ return Math.max(vlen(h), maxCell, 1);
379
+ });
380
+
381
+ // Total table width: borders (cols+1) + 2 padding per col + content
382
+ const totalW =
383
+ colWidths.length + 1 + colWidths.reduce((s, cw) => s + cw + 2, 0);
384
+ const marginLen = vlen(MARGIN);
385
+
386
+ // If table overflows, shrink columns proportionally
387
+ if (totalW + marginLen > w) {
388
+ const available = w - marginLen - (colWidths.length + 1) - colWidths.length * 2;
389
+ const naturalTotal = colWidths.reduce((s, cw) => s + cw, 0);
390
+ if (naturalTotal > 0 && available > 0) {
391
+ let allocated = 0;
392
+ const minW = 3; // minimum column width to show "…"
393
+ for (let i = 0; i < colWidths.length; i++) {
394
+ const share = Math.max(
395
+ minW,
396
+ Math.floor((colWidths[i] / naturalTotal) * available)
397
+ );
398
+ colWidths[i] = share;
399
+ allocated += share;
400
+ }
401
+ // Distribute rounding remainder to last column
402
+ const remainder = available - allocated;
403
+ if (remainder > 0) colWidths[colWidths.length - 1] += remainder;
404
+ }
405
+ }
406
+
407
+ const D = c.tableBorder;
408
+ const top = D("┌" + colWidths.map((cw) => "─".repeat(cw + 2)).join("┬") + "┐");
409
+ const mid = D("├" + colWidths.map((cw) => "─".repeat(cw + 2)).join("┼") + "┤");
410
+ const bot = D("└" + colWidths.map((cw) => "─".repeat(cw + 2)).join("┴") + "┘");
411
+ const sep = D("│");
412
+
413
+ function alignCell(cell, cw, align) {
414
+ const cellLen = vlen(cell);
415
+ const truncated = cellLen > cw ? vtrunc(cell, cw) : cell;
416
+ const tlen = vlen(truncated);
417
+ if (align === "right") {
418
+ return " ".repeat(Math.max(0, cw - tlen)) + truncated;
419
+ }
420
+ if (align === "center") {
421
+ const total = Math.max(0, cw - tlen);
422
+ const left = Math.floor(total / 2);
423
+ const right = total - left;
424
+ return " ".repeat(left) + truncated + " ".repeat(right);
425
+ }
426
+ // left / default
427
+ return vpad(truncated, cw);
428
+ }
429
+
430
+ const headerRow =
431
+ sep +
432
+ headers
433
+ .map(
434
+ (h, i) =>
435
+ " " + c.tableHead(alignCell(h, colWidths[i], aligns[i])) + " " + sep
436
+ )
437
+ .join("");
438
+
439
+ const dataRows = rows.map(
440
+ (row) =>
441
+ sep +
442
+ row
443
+ .map(
444
+ (cell, i) =>
445
+ " " +
446
+ c.tableCell(alignCell(cell ?? "", colWidths[i], aligns[i])) +
447
+ " " +
448
+ sep
449
+ )
450
+ .join("")
451
+ );
452
+
453
+ return [
454
+ "",
455
+ ...[top, headerRow, mid, ...dataRows, bot].map((l) => MARGIN + l),
456
+ "",
457
+ ];
458
+ }
459
+
460
+ function hr(w) {
461
+ return [MARGIN + c.hr("─".repeat(Math.max(1, w))), ""];
462
+ }
463
+
464
+ // ─── Token dispatcher ──────────────────────────────────────────────────────────
465
+
466
+ function token(tok, w) {
467
+ try {
468
+ switch (tok.type) {
469
+ case "heading":
470
+ return heading(tok, w);
471
+ case "paragraph":
472
+ return paragraph(tok, w);
473
+ case "code":
474
+ return code(tok, w);
475
+ case "blockquote":
476
+ return blockquote(tok, w);
477
+ case "list":
478
+ return ["", ...list(tok, w, 0), ""];
479
+ case "table":
480
+ return table(tok, w);
481
+ case "hr":
482
+ return hr(w);
483
+ case "space":
484
+ case "html":
485
+ case "def":
486
+ return [];
487
+ default:
488
+ return [];
489
+ }
490
+ } catch {
491
+ return [""];
492
+ }
493
+ }
494
+
495
+ // ─── Public API ────────────────────────────────────────────────────────────────
496
+
497
+ /**
498
+ * Render an array of marked tokens to an array of ANSI-coloured terminal lines.
499
+ *
500
+ * @param {import("marked").Token[]} tokens - Output of `marked.lexer()`
501
+ * @param {number} cols - Terminal width (default 80)
502
+ * @returns {string[]}
503
+ */
504
+ export function renderTokens(tokens, cols = 80) {
505
+ const w = Math.max(20, cols - MARGIN.length * 2);
506
+ return tokens.flatMap((tok) => token(tok, w));
507
+ }
package/src/tui.js ADDED
@@ -0,0 +1,346 @@
1
+ const ESC = "\x1B";
2
+ const ALT_ON = `${ESC}[?1049h`;
3
+ const ALT_OFF = `${ESC}[?1049l`;
4
+ const HIDE_CUR = `${ESC}[?25l`;
5
+ const SHOW_CUR = `${ESC}[?25h`;
6
+ const MOUSE_ON = `${ESC}[?1000h${ESC}[?1006h`;
7
+ const MOUSE_OFF = `${ESC}[?1000l${ESC}[?1006l`;
8
+ const HOME = `${ESC}[H`;
9
+ const ERASE_L = `${ESC}[2K`;
10
+ const RESET = `${ESC}[0m`;
11
+ const move = (r, c) => `${ESC}[${r};${c}H`;
12
+
13
+ // One Dark-aligned color palette (24-bit ANSI)
14
+ const C = {
15
+ chromeBg: `${ESC}[48;2;33;37;43m`, // #21252b — top/bottom chrome bg
16
+ badge: `${ESC}[38;2;198;120;221m`, // #c678dd — purple badge
17
+ titleFg: `${ESC}[97m`, // bright white — filename
18
+ dimFg: `${ESC}[38;2;92;99;112m`, // #5c6370 — dim hints / app name
19
+ accentFg: `${ESC}[38;2;97;175;239m`, // #61afef — blue search prompt
20
+ matchFg: `${ESC}[38;2;229;192;123m`, // #e5c07b — gold current match
21
+ greenFg: `${ESC}[38;2;152;195;121m`, // #98c379 — match count
22
+ redFg: `${ESC}[38;2;224;108;117m`, // #e06c75 — no matches
23
+ otherMatchFg:`${ESC}[38;2;92;99;112m`, // #5c6370 — other match gutter
24
+ bold: `${ESC}[1m`,
25
+ dim: `${ESC}[2m`,
26
+ italic: `${ESC}[3m`,
27
+ rev: `${ESC}[7m`,
28
+ revOff: `${ESC}[27m`,
29
+ };
30
+
31
+ // Strip ANSI from a line to get searchable plain text
32
+ const STRIP_SGR = /\x1B\[[0-9;]*m/g;
33
+ const STRIP_OSC = /\x1B\]8;;.*?\x1B\\/gs;
34
+ function plain(line) {
35
+ return line.replace(STRIP_OSC, "").replace(STRIP_SGR, "");
36
+ }
37
+
38
+ // Visual width of a plain string (already stripped)
39
+ function plen(s) { return s.replace(/\x1B\[[0-9;]*m/g, "").length; }
40
+
41
+ // Highlight all occurrences of `query` within an ANSI-coloured line.
42
+ // Walks the string char-by-char so ANSI escape sequences don't shift offsets.
43
+ const HL_MATCH = `${ESC}[48;2;62;68;82m${ESC}[38;2;229;192;123m`; // other matches
44
+ const HL_CURRENT = `${ESC}[48;2;229;192;123m${ESC}[38;2;0;0;0m`; // current match
45
+ const HL_OFF = `${ESC}[49m${ESC}[39m`;
46
+
47
+ function highlightInLine(line, query, isCurrent) {
48
+ if (!query) return line;
49
+ const p = plain(line).toLowerCase();
50
+ const q = query.toLowerCase();
51
+
52
+ // Collect all [start, end) ranges in plain-text coordinates
53
+ const ranges = [];
54
+ let pos = 0;
55
+ while ((pos = p.indexOf(q, pos)) !== -1) {
56
+ ranges.push([pos, pos + q.length]);
57
+ pos++;
58
+ }
59
+ if (!ranges.length) return line;
60
+
61
+ const ON = isCurrent ? HL_CURRENT : HL_MATCH;
62
+
63
+ let out = "";
64
+ let vis = 0; // visual position in plain text
65
+ let ri = 0; // current range index
66
+ let hl = false;
67
+ let i = 0; // byte index into line
68
+
69
+ while (i < line.length) {
70
+ // Close highlight when we exit the current range
71
+ if (hl && ri < ranges.length && vis >= ranges[ri][1]) {
72
+ out += HL_OFF;
73
+ hl = false;
74
+ ri++;
75
+ }
76
+ // Open highlight when we enter the next range
77
+ if (!hl && ri < ranges.length && vis >= ranges[ri][0]) {
78
+ out += ON;
79
+ hl = true;
80
+ }
81
+
82
+ // OSC 8 hyperlink: ESC ] 8 ; ; url ESC \ — skip, no visual width
83
+ if (line[i] === "\x1B" && line[i + 1] === "]") {
84
+ const end = line.indexOf("\x1B\\", i + 2);
85
+ if (end !== -1) { out += line.slice(i, end + 2); i = end + 2; continue; }
86
+ }
87
+ // CSI SGR: ESC [ … m — skip, no visual width
88
+ if (line[i] === "\x1B" && line[i + 1] === "[") {
89
+ const end = line.indexOf("m", i + 2);
90
+ if (end !== -1) { out += line.slice(i, end + 1); i = end + 1; continue; }
91
+ }
92
+
93
+ out += line[i];
94
+ vis++;
95
+ i++;
96
+ }
97
+
98
+ if (hl) out += HL_OFF;
99
+ return out;
100
+ }
101
+
102
+ export function launch(title, lines) {
103
+ // Viewport state
104
+ let offset = 0;
105
+
106
+ // Search state
107
+ let mode = "normal"; // "normal" | "search" | "matches"
108
+ let searchQuery = "";
109
+ let matchLines = []; // sorted array of line indices with matches
110
+ let matchSet = new Set(); // for O(1) gutter lookup
111
+ let matchIdx = 0;
112
+
113
+ const rows = () => process.stdout.rows || 24;
114
+ const cols = () => process.stdout.columns || 80;
115
+ const visible = () => Math.max(1, rows() - 2); // top bar + bottom bar
116
+ const maxOff = () => Math.max(0, lines.length - visible());
117
+
118
+ // ─── Search helpers ────────────────────────────────────────────────────────
119
+
120
+ function computeMatches() {
121
+ if (!searchQuery) {
122
+ matchLines = []; matchSet = new Set(); matchIdx = 0;
123
+ return;
124
+ }
125
+ const q = searchQuery.toLowerCase();
126
+ matchLines = lines.reduce((acc, line, i) => {
127
+ if (plain(line).toLowerCase().includes(q)) acc.push(i);
128
+ return acc;
129
+ }, []);
130
+ matchSet = new Set(matchLines);
131
+ matchIdx = 0;
132
+ if (matchLines.length > 0) scrollToMatch(0);
133
+ }
134
+
135
+ function scrollToMatch(idx) {
136
+ matchIdx = ((idx % matchLines.length) + matchLines.length) % matchLines.length;
137
+ const target = matchLines[matchIdx];
138
+ if (target !== undefined) {
139
+ offset = Math.max(0, Math.min(target - Math.floor(visible() / 2), maxOff()));
140
+ }
141
+ }
142
+
143
+ // ─── Draw ──────────────────────────────────────────────────────────────────
144
+
145
+ function draw() {
146
+ const h = rows();
147
+ const w = cols();
148
+ const out = [HOME];
149
+
150
+ // Row 1: top chrome bar
151
+ out.push(move(1, 1) + ERASE_L + topBar(title, w));
152
+
153
+ // Rows 2..h-1: content with optional gutter + search highlighting
154
+ const slice = lines.slice(offset, offset + visible());
155
+ for (let i = 0; i < visible(); i++) {
156
+ const absLine = offset + i;
157
+ const gutter = gutterFor(absLine);
158
+ let content = slice[i] ?? "";
159
+ if (searchQuery && matchSet.has(absLine)) {
160
+ content = highlightInLine(content, searchQuery, matchLines[matchIdx] === absLine);
161
+ }
162
+ out.push(move(i + 2, 1) + ERASE_L + gutter + content);
163
+ }
164
+
165
+ // Row h: status / search bar
166
+ out.push(move(h, 1) + ERASE_L + statusBar(w));
167
+
168
+ // Show cursor only in search mode (for the text input)
169
+ out.push(mode === "search" ? SHOW_CUR : HIDE_CUR);
170
+
171
+ process.stdout.write(out.join(""));
172
+ }
173
+
174
+ // ─── Chrome helpers ────────────────────────────────────────────────────────
175
+
176
+ function topBar(title, w) {
177
+ const ext = /\.(md|markdown|mdx)$/i.test(title) ? "md" : "~";
178
+ const badge = `${C.badge}[${ext}]${RESET}${C.chromeBg}`;
179
+ const left = ` ${badge} ${C.bold}${C.titleFg}${title}${RESET}${C.chromeBg}`;
180
+ const right = `${C.dimFg}mdcat ${RESET}`;
181
+ const leftW = 1 + ext.length + 2 + 1 + title.length + 1;
182
+ const rightW = "mdcat ".length;
183
+ const gap = Math.max(0, w - leftW - rightW);
184
+ return `${C.chromeBg}${left}${C.dimFg}${" ".repeat(gap)}${right}${RESET}`;
185
+ }
186
+
187
+ function gutterFor(absLine) {
188
+ if (mode === "normal" || !searchQuery) return " ";
189
+ if (matchLines[matchIdx] === absLine) return `${C.matchFg}▶${RESET} `;
190
+ if (matchSet.has(absLine)) return `${C.otherMatchFg}›${RESET} `;
191
+ return " ";
192
+ }
193
+
194
+ function statusBar(w) {
195
+ if (mode === "search") {
196
+ const prompt = `${C.accentFg}/${RESET}`;
197
+ const cursor = `${C.rev} ${C.revOff}`;
198
+ const qText = searchQuery + cursor;
199
+ const count = matchLines.length > 0
200
+ ? ` ${C.greenFg}${matchLines.length} match${matchLines.length !== 1 ? "es" : ""}${RESET}`
201
+ : searchQuery
202
+ ? ` ${C.redFg}no matches${RESET}`
203
+ : "";
204
+ const hints = `${C.dim} Esc cancel Enter jump${RESET}`;
205
+ const left = ` ${prompt} ${qText}${count}`;
206
+ const leftW = 3 + searchQuery.length + 1 + plain(count).length;
207
+ const hintsW = " Esc cancel Enter jump".length;
208
+ const gap = Math.max(1, w - leftW - hintsW - 1);
209
+ return `${C.chromeBg}${left}${" ".repeat(gap)}${hints} ${RESET}`;
210
+ }
211
+
212
+ if (mode === "matches") {
213
+ const q = `${C.matchFg}"${searchQuery}"${RESET}`;
214
+ const pos = `${C.dimFg}match ${matchIdx + 1} / ${matchLines.length}${RESET}`;
215
+ const hints = `${C.dim} n next N prev Esc clear${RESET}`;
216
+ const left = ` ${q} ${pos}`;
217
+ const leftW = 2 + searchQuery.length + 2 + 2 + `match ${matchIdx + 1} / ${matchLines.length}`.length;
218
+ const hintsW = " n next N prev Esc clear".length;
219
+ const gap = Math.max(1, w - leftW - hintsW - 1);
220
+ return `${C.chromeBg}${left}${" ".repeat(gap)}${hints} ${RESET}`;
221
+ }
222
+
223
+ // Normal mode
224
+ const end = Math.min(offset + visible(), lines.length);
225
+ const pct = lines.length === 0 ? "100%" : Math.round((end / lines.length) * 100) + "%";
226
+ const hints = `${C.dim} q / j k ↑↓ space g G${RESET}`;
227
+ const right = `${C.dimFg} ${pct} ${RESET}`;
228
+ const hintsW = " q / j k ↑↓ space g G".length;
229
+ const rightW = ` ${pct} `.length;
230
+ const gap = Math.max(0, w - hintsW - rightW);
231
+ return `${C.chromeBg}${hints}${" ".repeat(gap)}${right}${RESET}`;
232
+ }
233
+
234
+ // ─── Cleanup ───────────────────────────────────────────────────────────────
235
+
236
+ function cleanup() {
237
+ process.stdout.write(MOUSE_OFF + ALT_OFF + SHOW_CUR + RESET);
238
+ process.stdin.setRawMode?.(false);
239
+ process.exit(0);
240
+ }
241
+
242
+ // ─── Input ─────────────────────────────────────────────────────────────────
243
+
244
+ process.stdin.on("data", (raw) => {
245
+ const s = raw.toString();
246
+
247
+ // Mouse SGR events: \x1B[<btn;col;rowm
248
+ // SGR mouse: ESC[<btn;col;rowM (press) or ESC[<btn;col;rowm (release)
249
+ // Scroll wheel sends press events (M), so match both m and M
250
+ const mouse = s.match(/\x1B\[<(\d+);\d+;\d+[mM]/);
251
+ if (mouse) {
252
+ const btn = parseInt(mouse[1]);
253
+ if (btn === 64) { offset = Math.max(offset - 3, 0); draw(); }
254
+ if (btn === 65) { offset = Math.min(offset + 3, maxOff()); draw(); }
255
+ return;
256
+ }
257
+
258
+ // Search mode — intercept all keystrokes
259
+ if (mode === "search") {
260
+ if (s === "\x1B" || s === "\x03") {
261
+ mode = "normal"; searchQuery = ""; matchLines = []; matchSet = new Set();
262
+ draw(); return;
263
+ }
264
+ if (s === "\r" || s === "\n") {
265
+ mode = matchLines.length > 0 ? "matches" : "normal";
266
+ if (mode === "normal") searchQuery = "";
267
+ draw(); return;
268
+ }
269
+ if (s === "\x7f" || s === "\x08") {
270
+ searchQuery = searchQuery.slice(0, -1);
271
+ computeMatches(); draw(); return;
272
+ }
273
+ if (s.length === 1 && s >= " ") {
274
+ searchQuery += s;
275
+ computeMatches(); draw(); return;
276
+ }
277
+ return;
278
+ }
279
+
280
+ // Normal / matches mode
281
+ let changed = true;
282
+ switch (s) {
283
+ case "q":
284
+ case "\x03":
285
+ return cleanup();
286
+
287
+ case "j": case "\x1B[B":
288
+ offset = Math.min(offset + 1, maxOff()); break;
289
+ case "k": case "\x1B[A":
290
+ offset = Math.max(offset - 1, 0); break;
291
+
292
+ case " ": case "f": case "\x1B[6~":
293
+ offset = Math.min(offset + visible(), maxOff()); break;
294
+ case "b": case "\x1B[5~":
295
+ offset = Math.max(offset - visible(), 0); break;
296
+
297
+ case "d":
298
+ offset = Math.min(offset + Math.floor(visible() / 2), maxOff()); break;
299
+ case "u":
300
+ offset = Math.max(offset - Math.floor(visible() / 2), 0); break;
301
+
302
+ case "g": offset = 0; break;
303
+ case "G": offset = maxOff(); break;
304
+
305
+ case "/": case "\x06": // Ctrl+F
306
+ mode = "search"; searchQuery = ""; matchLines = []; matchSet = new Set();
307
+ draw(); return;
308
+
309
+ case "n":
310
+ if (matchLines.length > 0) { scrollToMatch(matchIdx + 1); } else changed = false;
311
+ break;
312
+ case "N":
313
+ if (matchLines.length > 0) { scrollToMatch(matchIdx - 1); } else changed = false;
314
+ break;
315
+
316
+ case "\x1B": // Esc — clear search from matches mode
317
+ if (mode === "matches") {
318
+ mode = "normal"; searchQuery = ""; matchLines = []; matchSet = new Set();
319
+ } else { changed = false; }
320
+ break;
321
+
322
+ default:
323
+ changed = false;
324
+ }
325
+
326
+ if (changed) draw();
327
+ });
328
+
329
+ // ─── Boot ──────────────────────────────────────────────────────────────────
330
+
331
+ process.stdout.write(ALT_ON + HIDE_CUR + MOUSE_ON);
332
+ if (process.stdin.setRawMode) {
333
+ process.stdin.setRawMode(true);
334
+ process.stdin.resume();
335
+ process.stdin.setEncoding("utf8");
336
+ }
337
+
338
+ draw();
339
+
340
+ process.stdout.on("resize", () => {
341
+ offset = Math.min(offset, maxOff());
342
+ draw();
343
+ });
344
+ process.on("SIGINT", cleanup);
345
+ process.on("SIGTERM", cleanup);
346
+ }