From 1c6c9b0f5f278127a2d4dfaf7b93fdd4b1f31250 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Mon, 30 Mar 2026 08:00:37 -0700 Subject: [PATCH] Initial commit: add terminal markdown renderer --- .gitignore | 2 + .npmrc | 1 + bun.lock | 21 +++++++ package.json | 28 +++++++++ src/cli.ts | 20 ++++++ src/index.test.ts | 132 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 152 ++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 13 ++++ 8 files changed, 369 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/index.test.ts create mode 100644 src/index.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0b94c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.sandlot/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..6c57d5c --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +registry=https://npm.nose.space diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..a6a697a --- /dev/null +++ b/bun.lock @@ -0,0 +1,21 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@because/tm", + "devDependencies": { + "@types/bun": "^1.3.9", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f821eb9 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "@because/terminal-markdown", + "version": "0.0.1", + "description": "Convert Markdown to Claude's ANSI-styled output.", + "type": "module", + "bin": { + "tm": "src/cli.ts" + }, + "main": "src/index.ts", + "files": [ + "src" + ], + "keywords": [ + "markdown", + "terminal", + "ansi", + "cli", + "renderer" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://git.nose.space/defunkt/terminal-markdown" + }, + "devDependencies": { + "@types/bun": "^1.3.9" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..04538b1 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env bun + +import { renderMarkdown } from "./index" + +const args = process.argv.slice(2) + +let input: string + +if (args.length > 0 && args[0] !== "-") { + const file = Bun.file(args[0]) + if (!(await file.exists())) { + process.stderr.write(`tm: ${args[0]}: No such file\n`) + process.exit(1) + } + input = await file.text() +} else { + input = await Bun.stdin.text() +} + +process.stdout.write(renderMarkdown(input)) diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..c43ec16 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "bun:test" +import { renderMarkdown } from "./index" + +function strip(s: string): string { + return s.replace(/\x1b\]8;;[^\x07]*\x07/g, "").replace(/\x1b\[[0-9;]*m/g, "") +} + +describe("headings", () => { + test("h1 gets bold+italic+underline", () => { + const out = renderMarkdown("# Hello") + expect(out).toContain("\x1b[1;3;4m") + expect(strip(out)).toBe("Hello") + }) + + test("h2 gets bold", () => { + const out = renderMarkdown("## Section") + expect(out).toContain("\x1b[1m") + expect(strip(out)).toBe("Section") + }) + + test("h3-h6 get bold", () => { + for (const h of ["###", "####", "#####", "######"]) { + const out = renderMarkdown(`${h} Title`) + expect(strip(out)).toBe("Title") + } + }) +}) + +describe("inline formatting", () => { + test("bold", () => { + const out = renderMarkdown("some **bold** text") + expect(out).toContain("\x1b[1m") + expect(strip(out)).toBe("some bold text") + }) + + test("italic", () => { + const out = renderMarkdown("some *italic* text") + expect(out).toContain("\x1b[3m") + expect(strip(out)).toBe("some italic text") + }) + + test("code span", () => { + const out = renderMarkdown("use `foo()` here") + expect(out).toContain("\x1b[38;5;147m") + expect(strip(out)).toBe("use foo() here") + }) + + test("code span contents not processed as bold", () => { + const out = renderMarkdown("`**not bold**`") + expect(strip(out)).toBe("**not bold**") + }) +}) + +describe("links", () => { + test("renders OSC 8 hyperlink", () => { + const out = renderMarkdown("[click](https://example.com)") + expect(out).toContain("\x1b]8;;https://example.com\x07") + expect(strip(out)).toBe("click") + }) + + test("image syntax not turned into link", () => { + const out = renderMarkdown("![alt](https://img.png)") + expect(out).not.toContain("\x1b]8;;") + }) +}) + +describe("blockquotes", () => { + test("renders dim+italic", () => { + const out = renderMarkdown("> quoted text") + expect(out).toContain("\x1b[2;3m") + expect(strip(out)).toBe("quoted text") + }) + + test("bare blockquote line becomes empty", () => { + const out = renderMarkdown(">") + expect(out.trim()).toBe("") + }) +}) + +describe("task lists", () => { + test("checked item gets green checkmark", () => { + const out = renderMarkdown("- [x] Done") + expect(out).toContain("✓") + expect(strip(out)).toContain("Done") + }) + + test("unchecked item gets dim checkbox", () => { + const out = renderMarkdown("- [ ] Todo") + expect(out).toContain("☐") + expect(strip(out)).toContain("Todo") + }) +}) + +describe("code blocks", () => { + test("contents not processed as markdown", () => { + const input = "```\n# not a heading\n**not bold**\n```" + const out = renderMarkdown(input) + expect(strip(out)).toContain("# not a heading") + expect(strip(out)).toContain("**not bold**") + }) +}) + +describe("backslash escapes", () => { + test("escaped chars rendered literally", () => { + const out = renderMarkdown("\\*not italic\\*") + expect(strip(out)).toBe("*not italic*") + expect(out).not.toContain("\x1b[3m") + }) +}) + +describe("tables", () => { + test("renders box-drawing table", () => { + const input = "| A | B |\n| --- | --- |\n| 1 | 2 |\n" + const out = renderMarkdown(input) + expect(out).toContain("┌") + expect(out).toContain("┘") + expect(strip(out)).toContain("A") + expect(strip(out)).toContain("1") + }) +}) + +describe("breathing room", () => { + test("blank line inserted before list after text", () => { + const out = renderMarkdown("text\n- item") + expect(out).toBe("text\n\n- item") + }) + + test("no extra blank line between list items", () => { + const out = renderMarkdown("- a\n- b") + expect(out).toBe("- a\n- b") + }) +}) diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4916d36 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,152 @@ +function stripAnsi(s: string): string { + return s.replace(/\x1b\]8;;[^\x07]*\x07/g, "").replace(/\x1b\[[0-9;]*m/g, "") +} + +function renderTable(block: string): string { + const lines = block.trim().split("\n") + if (lines.length < 2) return block + + const parseRow = (line: string): string[] => + line.replace(/^\|/, "").replace(/\|$/, "").split("|").map(c => c.trim()) + + const header = parseRow(lines[0]) + const sepCells = parseRow(lines[1]) + + // Validate separator row + if (!sepCells.every(s => /^:?-+:?$/.test(s.trim()))) return block + + const cols = header.length + const align: ("left" | "center" | "right")[] = sepCells.map(s => { + const t = s.trim() + if (t.startsWith(":") && t.endsWith(":")) return "center" + if (t.endsWith(":")) return "right" + return "left" + }) + + const rows = lines.slice(2).map(parseRow) + + // Column widths based on visible text (no ANSI codes) + const widths = new Array(cols).fill(0) + for (let c = 0; c < cols; c++) { + widths[c] = Math.max(widths[c], stripAnsi(header[c] ?? "").length) + for (const row of rows) { + widths[c] = Math.max(widths[c], stripAnsi(row[c] ?? "").length) + } + } + + const pad = (text: string, width: number, a: "left" | "center" | "right"): string => { + const needed = width - stripAnsi(text).length + if (needed <= 0) return text + if (a === "right") return " ".repeat(needed) + text + if (a === "center") { + const l = Math.floor(needed / 2) + return " ".repeat(l) + text + " ".repeat(needed - l) + } + return text + " ".repeat(needed) + } + + const D = "\x1b[2m" // dim + const R = "\x1b[22m" // reset intensity + + const renderRow = (cells: string[], bold: boolean): string => { + const parts = cells.map((c, i) => pad(c ?? "", widths[i] ?? 0, align[i] ?? "left")) + if (bold) { + return `${D}│${R} ${parts.map(p => `\x1b[1m${p}\x1b[22m`).join(` ${D}│${R} `)} ${D}│${R}` + } + return `${D}│${R} ${parts.join(` ${D}│${R} `)} ${D}│${R}` + } + + const hline = (l: string, m: string, r: string): string => { + return `${D}${l}${widths.map(w => "─".repeat(w + 2)).join(m)}${r}${R}` + } + + const out: string[] = [] + out.push(hline("┌", "┬", "┐")) + out.push(renderRow(header, true)) + out.push(hline("├", "┼", "┤")) + for (const row of rows) out.push(renderRow(row, false)) + out.push(hline("└", "┴", "┘")) + return out.join("\n") +} + +export function renderMarkdown(text: string): string { + // Extract fenced code blocks before anything else + const codeBlocks: string[] = [] + let result = text.replace(/^```\w*\n([\s\S]*?)^```\s*$/gm, (_, content) => { + codeBlocks.push(content) + return `\x00CODEBLOCK${codeBlocks.length - 1}\x00` + }) + + // Extract backslash escapes so they aren't processed as markdown + const escapes: string[] = [] + result = result.replace(/\\([\\`*_~\[\]()#>!-])/g, (_, ch) => { + escapes.push(ch) + return `\x00ESC${escapes.length - 1}\x00` + }) + + // Extract code spans so their contents aren't processed as bold/italic + const codeSpans: string[] = [] + result = result.replace(/`([^`]+)`/g, (_, code) => { + codeSpans.push(code) + return `\x00CODE${codeSpans.length - 1}\x00` + }) + + // Links: [text](url) → OSC 8 terminal hyperlink, underlined blue + // Must run before H1/bold/italic so ANSI `[` chars don't confuse the link regex + result = result.replace(/(? { + const url = href.replace(/\s+"[^"]*"$/, "") // strip optional title + return `\x1b]8;;${url}\x07\x1b[4;38;5;75m${text}\x1b[24;39m\x1b]8;;\x07` + }) + + // H1: # Header → bold+italic+underline + result = result.replace(/^# (.+)$/gm, "\x1b[1;3;4m$1\x1b[22;23;24m") + + // H2/H3: ## Header / ### Header → bold + result = result.replace(/^#{2,6} (.+)$/gm, "\x1b[1m$1\x1b[22m") + + // Blockquotes: > text → dim+italic + result = result.replace(/^> (.+)$/gm, "\x1b[2;3m$1\x1b[22;23m") + + // Bare blockquote lines: > with no text + result = result.replace(/^>\s*$/gm, "") + + // Task lists: - [x] → ✓ green, - [ ] → ○ dim + result = result.replace(/^(\s*)[-*] \[x\] (.+)$/gm, "$1\x1b[32m✓\x1b[39m $2") + result = result.replace(/^(\s*)[-*] \[ \] (.+)$/gm, "$1\x1b[2m☐\x1b[22m $2") + + // Bold: **text** + result = result.replace(/\*\*(.+?)\*\*/g, "\x1b[1m$1\x1b[22m") + + // Italic: *text* + result = result.replace(/\*(.+?)\*/g, "\x1b[3m$1\x1b[23m") + + // Restore code spans as light blue + result = result.replace(/\x00CODE(\d+)\x00/g, (_, i) => { + return `\x1b[38;5;147m${codeSpans[parseInt(i)]}\x1b[39m` + }) + + // Restore backslash escapes as literal characters + result = result.replace(/\x00ESC(\d+)\x00/g, (_, i) => escapes[parseInt(i)]) + + // Tables: render pipe tables with box-drawing characters + // Processed after inline formatting so cell contents are styled, + // but before code block restoration so tables inside code blocks are ignored. + result = result.replace( + /^(\|[^\n]+\|\n)(\|[\s:|-]+\|\n)((?:\|[^\n]+\|\n?)*)/gm, + (match) => renderTable(match), + ) + + // Restore fenced code blocks as plain text + result = result.replace(/\x00CODEBLOCK(\d+)\x00/g, (_, i) => codeBlocks[parseInt(i)]) + + // Breathe: add blank line before list starts when preceded by non-empty text + const lines = result.split("\n") + for (let i = lines.length - 1; i > 0; i--) { + if (/^[\s]*[-*] /.test(lines[i]) && lines[i - 1].trim() !== "" && !/^[\s]*[-*] /.test(lines[i - 1])) { + lines.splice(i, 0, "") + } + } + result = lines.join("\n") + + return result +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5f06b7b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "types": ["bun-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +}