|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * marknative CLI |
| 4 | + * |
| 5 | + * Render a Markdown file (or stdin) to PNG or SVG pages. |
| 6 | + * |
| 7 | + * Usage: |
| 8 | + * marknative [options] [input.md] |
| 9 | + * cat notes.md | marknative [options] |
| 10 | + */ |
| 11 | + |
| 12 | +import { parseArgs } from 'node:util' |
| 13 | +import { readFile, writeFile, mkdir } from 'node:fs/promises' |
| 14 | +import { resolve, dirname, basename, extname } from 'node:path' |
| 15 | + |
| 16 | +import { renderMarkdown } from './render/render-markdown' |
| 17 | +import type { RenderMarkdownOptions } from './render/render-markdown' |
| 18 | +import { BUILT_IN_THEME_NAMES } from './theme/built-in-themes' |
| 19 | + |
| 20 | +// ─── Argument parsing ───────────────────────────────────────────────────────── |
| 21 | + |
| 22 | +const { values, positionals } = parseArgs({ |
| 23 | + args: process.argv.slice(2), |
| 24 | + options: { |
| 25 | + format: { type: 'string', short: 'f', default: 'png' }, |
| 26 | + output: { type: 'string', short: 'o' }, |
| 27 | + theme: { type: 'string', short: 't' }, |
| 28 | + scale: { type: 'string', short: 's' }, |
| 29 | + 'single-page': { type: 'boolean' }, |
| 30 | + 'code-theme': { type: 'string' }, |
| 31 | + json: { type: 'boolean' }, |
| 32 | + help: { type: 'boolean', short: 'h' }, |
| 33 | + }, |
| 34 | + allowPositionals: true, |
| 35 | +}) |
| 36 | + |
| 37 | +if (values.help) { |
| 38 | + printHelp() |
| 39 | + process.exit(0) |
| 40 | +} |
| 41 | + |
| 42 | +// ─── Read input ─────────────────────────────────────────────────────────────── |
| 43 | + |
| 44 | +const inputFile = positionals[0] |
| 45 | +const markdown = inputFile |
| 46 | + ? await readFile(inputFile, 'utf8') |
| 47 | + : await readStdin() |
| 48 | + |
| 49 | +if (!markdown.trim()) { |
| 50 | + die('No markdown input. Provide a file path or pipe content to stdin.') |
| 51 | +} |
| 52 | + |
| 53 | +// ─── Build render options ───────────────────────────────────────────────────── |
| 54 | + |
| 55 | +const format = (values.format ?? 'png') as 'png' | 'svg' |
| 56 | +if (format !== 'png' && format !== 'svg') die(`Unknown format "${values.format}". Use png or svg.`) |
| 57 | + |
| 58 | +const options: RenderMarkdownOptions = { format, singlePage: values['single-page'] } |
| 59 | + |
| 60 | +if (values.theme) { |
| 61 | + try { |
| 62 | + options.theme = JSON.parse(values.theme) |
| 63 | + } catch { |
| 64 | + options.theme = values.theme as Parameters<typeof renderMarkdown>[1] extends { theme?: infer T } ? T : never |
| 65 | + } |
| 66 | +} |
| 67 | + |
| 68 | +if (values.scale !== undefined) { |
| 69 | + const scale = Number(values.scale) |
| 70 | + if (isNaN(scale) || scale <= 0) die(`Invalid scale "${values.scale}". Must be a positive number.`) |
| 71 | + options.scale = scale |
| 72 | +} |
| 73 | + |
| 74 | +if (values['code-theme']) { |
| 75 | + options.codeHighlighting = { theme: values['code-theme'] } |
| 76 | +} |
| 77 | + |
| 78 | +// ─── Render ─────────────────────────────────────────────────────────────────── |
| 79 | + |
| 80 | +const pages = await renderMarkdown(markdown, options) |
| 81 | +const ext = format === 'svg' ? '.svg' : '.png' |
| 82 | + |
| 83 | +// ─── Resolve output paths ───────────────────────────────────────────────────── |
| 84 | + |
| 85 | +// SVG single-page with no --output → write to stdout (pipe-friendly) |
| 86 | +const svgToStdout = format === 'svg' && pages.length === 1 && !values.output && !values.json |
| 87 | + |
| 88 | +const outputPaths: string[] = [] |
| 89 | + |
| 90 | +if (!svgToStdout) { |
| 91 | + if (values.output) { |
| 92 | + // Treat as directory if: multiple pages, trailing slash, or no file extension |
| 93 | + const asDir = pages.length > 1 || values.output.endsWith('/') || values.output.endsWith('\\') || !extname(values.output) |
| 94 | + if (!asDir) { |
| 95 | + outputPaths.push(resolve(values.output)) |
| 96 | + } else { |
| 97 | + const dir = resolve(values.output) |
| 98 | + await mkdir(dir, { recursive: true }) |
| 99 | + const stem = inputFile ? basename(inputFile, extname(inputFile)) : 'output' |
| 100 | + for (let i = 0; i < pages.length; i++) { |
| 101 | + const suffix = pages.length === 1 ? '' : `-${pad(i + 1)}` |
| 102 | + outputPaths.push(resolve(dir, `${stem}${suffix}${ext}`)) |
| 103 | + } |
| 104 | + } |
| 105 | + } else { |
| 106 | + // Default: next to the input file, or cwd when reading stdin |
| 107 | + const stem = inputFile ? basename(inputFile, extname(inputFile)) : 'output' |
| 108 | + const dir = inputFile ? dirname(resolve(inputFile)) : process.cwd() |
| 109 | + for (let i = 0; i < pages.length; i++) { |
| 110 | + const suffix = pages.length === 1 ? '' : `-${pad(i + 1)}` |
| 111 | + outputPaths.push(resolve(dir, `${stem}${suffix}${ext}`)) |
| 112 | + } |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 116 | +// ─── Write output ───────────────────────────────────────────────────────────── |
| 117 | + |
| 118 | +if (svgToStdout) { |
| 119 | + const page = pages[0]! |
| 120 | + if (page.format === 'svg') process.stdout.write(page.data) |
| 121 | +} else { |
| 122 | + for (let i = 0; i < pages.length; i++) { |
| 123 | + const page = pages[i]! |
| 124 | + const outPath = outputPaths[i]! |
| 125 | + await mkdir(dirname(outPath), { recursive: true }) |
| 126 | + await writeFile(outPath, page.format === 'png' ? page.data : page.data, page.format === 'png' ? undefined : 'utf8') |
| 127 | + } |
| 128 | + |
| 129 | + if (values.json) { |
| 130 | + process.stdout.write( |
| 131 | + JSON.stringify({ |
| 132 | + pages: outputPaths.map((path, i) => ({ index: i + 1, path, format })), |
| 133 | + }, null, 2) + '\n', |
| 134 | + ) |
| 135 | + } else { |
| 136 | + for (const p of outputPaths) process.stdout.write(p + '\n') |
| 137 | + } |
| 138 | +} |
| 139 | + |
| 140 | +// ─── Helpers ────────────────────────────────────────────────────────────────── |
| 141 | + |
| 142 | +async function readStdin(): Promise<string> { |
| 143 | + if (process.stdin.isTTY) return '' |
| 144 | + const chunks: Buffer[] = [] |
| 145 | + for await (const chunk of process.stdin) chunks.push(chunk as Buffer) |
| 146 | + return Buffer.concat(chunks).toString('utf8') |
| 147 | +} |
| 148 | + |
| 149 | +function pad(n: number): string { |
| 150 | + return String(n).padStart(2, '0') |
| 151 | +} |
| 152 | + |
| 153 | +function die(msg: string): never { |
| 154 | + process.stderr.write(`error: ${msg}\n`) |
| 155 | + process.exit(1) |
| 156 | +} |
| 157 | + |
| 158 | +function printHelp(): void { |
| 159 | + const themes = BUILT_IN_THEME_NAMES.join(', ') |
| 160 | + process.stdout.write(` |
| 161 | +marknative — render Markdown to PNG or SVG |
| 162 | +
|
| 163 | +USAGE |
| 164 | + marknative [options] [input.md] |
| 165 | + cat notes.md | marknative [options] |
| 166 | +
|
| 167 | +INPUT |
| 168 | + [input.md] Markdown file to render (reads stdin when omitted) |
| 169 | +
|
| 170 | +OUTPUT |
| 171 | + -o, --output <path> Write to this file (single page) or directory (multi-page) |
| 172 | + SVG single-page is written to stdout when omitted |
| 173 | + --json Print a JSON manifest of written files instead of paths |
| 174 | +
|
| 175 | +RENDER OPTIONS |
| 176 | + -f, --format <fmt> Output format: png · svg (default: png) |
| 177 | + -t, --theme <name|json> Built-in theme name or a JSON ThemeOverrides object |
| 178 | + Names: ${themes} |
| 179 | + -s, --scale <n> PNG pixel density multiplier (default: 2) |
| 180 | + 1 ≈ 29 ms/page 2 ≈ 99 ms 3 ≈ 214 ms |
| 181 | + --single-page Render into one image instead of paginating |
| 182 | + --code-theme <t> Shiki theme for code blocks (default: auto from page bg) |
| 183 | +
|
| 184 | + -h, --help Show this message |
| 185 | +
|
| 186 | +EXAMPLES |
| 187 | + # Render a file → page-01.png, page-02.png … next to the source |
| 188 | + marknative README.md |
| 189 | +
|
| 190 | + # Write pages into a directory |
| 191 | + marknative README.md -o out/ |
| 192 | +
|
| 193 | + # Single SVG file |
| 194 | + marknative diagram.md -f svg -o diagram.svg |
| 195 | +
|
| 196 | + # Pipe markdown in, dark theme, scale 1 (fast preview) |
| 197 | + cat notes.md | marknative -t dark -s 1 -o preview.png |
| 198 | +
|
| 199 | + # Machine-readable output for agents / scripts |
| 200 | + marknative report.md --json |
| 201 | + # → {"pages":[{"index":1,"path":"/abs/report-01.png","format":"png"},…]} |
| 202 | +
|
| 203 | + # SVG to stdout (pipe into another tool) |
| 204 | + marknative slide.md -f svg | rsvg-convert -o slide.pdf |
| 205 | +`.trimStart()) |
| 206 | +} |
0 commit comments