Initial commit: add terminal markdown renderer
This commit is contained in:
commit
1c6c9b0f5f
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules/
|
||||||
|
.sandlot/
|
||||||
21
bun.lock
Normal file
21
bun.lock
Normal file
|
|
@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
28
package.json
Normal file
28
package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/cli.ts
Normal file
20
src/cli.ts
Normal file
|
|
@ -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))
|
||||||
132
src/index.test.ts
Normal file
132
src/index.test.ts
Normal file
|
|
@ -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("")
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
152
src/index.ts
Normal file
152
src/index.ts
Normal file
|
|
@ -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(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, (_, text, href) => {
|
||||||
|
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
|
||||||
|
}
|
||||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"types": ["bun-types"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user