Skip to content

Commit 867d342

Browse files
author
15367279252qq.com
committed
feat: add CLI (marknative command)
src/cli.ts: full CLI wrapping renderMarkdown - Input: file argument or stdin - Output: file path (-o result.png), directory (-o out/), or stdout (SVG) - Flags: -f/--format, -t/--theme (name or JSON), -s/--scale, --single-page, --code-theme, --json, -h/--help - --json prints machine-readable manifest for agent/script consumption - SVG single-page defaults to stdout for piping package.json: add "bin": {"marknative": "./dist/cli.js"} scripts/postbuild.ts: prepend shebang + chmod +x on dist/cli.js after build build script: compile src/cli.ts alongside src/index.ts
1 parent 3435bca commit 867d342

3 files changed

Lines changed: 223 additions & 1 deletion

File tree

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
"main": "./dist/index.js",
88
"module": "./dist/index.js",
99
"types": "./dist/index.d.ts",
10+
"bin": {
11+
"marknative": "./dist/cli.js"
12+
},
1013
"exports": {
1114
".": {
1215
"types": "./dist/index.d.ts",
@@ -38,7 +41,7 @@
3841
"url": "https://github.com/liyown/marknative/issues"
3942
},
4043
"scripts": {
41-
"build": "bun build src/index.ts --outdir dist --target node --format esm --external '@chenglou/pretext' --external 'mdast-util-from-markdown' --external 'mdast-util-gfm' --external 'micromark' --external 'micromark-extension-gfm' --external 'skia-canvas' --external 'shiki' && bunx tsc -p tsconfig.build.json",
44+
"build": "bun build src/index.ts src/cli.ts --outdir dist --target node --format esm --external '@chenglou/pretext' --external 'mdast-util-from-markdown' --external 'mdast-util-gfm' --external 'micromark' --external 'micromark-extension-gfm' --external 'skia-canvas' --external 'shiki' && bunx tsc -p tsconfig.build.json && bun scripts/postbuild.ts",
4245
"typecheck": "bunx tsc --noEmit",
4346
"test": "bun test",
4447
"prepublishOnly": "bun run typecheck && bun run test && bun run build",

scripts/postbuild.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Post-build: prepend shebang and set executable bit on dist/cli.js
3+
*/
4+
import { readFile, writeFile, chmod } from 'node:fs/promises'
5+
6+
const cliPath = new URL('../dist/cli.js', import.meta.url).pathname
7+
8+
const content = await readFile(cliPath, 'utf8')
9+
if (!content.startsWith('#!')) {
10+
await writeFile(cliPath, '#!/usr/bin/env node\n' + content)
11+
}
12+
await chmod(cliPath, 0o755)
13+
console.log('✓ dist/cli.js shebang + executable bit set')

src/cli.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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

Comments
 (0)