my pkgs monorepo
1/**
2 * bismuth CLI
3 *
4 * Converts a pub.leaflet RTF-block document (embedded in a site.standard.document
5 * record) to Markdown.
6 *
7 * Usage:
8 * bismuth [options] [file]
9 *
10 * file Path to a JSON file containing the document. Reads stdin if omitted.
11 *
12 * Options:
13 * -f, --frontmatter Emit YAML front matter from document metadata.
14 * -p, --page-break String used to separate pages (default: "\\n\\n---\\n\\n").
15 * -o, --output Write output to a file instead of stdout.
16 * -h, --help Show this help text and exit.
17 * --version Print version and exit.
18 */
19
20import { readFile, writeFile } from 'node:fs/promises'
21import { createReadStream } from 'node:fs'
22import { parseArgs } from 'node:util'
23import { documentToMarkdown, contentToMarkdown } from './convert.js'
24import type { StandardDocument, LeafletContent } from './types.js'
25
26// ─── Version (injected by tsup at build time) ─────────────────────────────────
27const PKG_VERSION = '__BISMUTH_VERSION__'
28
29// ─── Entry point ─────────────────────────────────────────────────────────────
30
31export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
32 const { values, positionals } = parseArgs({
33 args: argv,
34 options: {
35 frontmatter: { type: 'boolean', short: 'f', default: false },
36 'page-break': { type: 'string', short: 'p' },
37 output: { type: 'string', short: 'o' },
38 help: { type: 'boolean', short: 'h', default: false },
39 version: { type: 'boolean', default: false },
40 },
41 allowPositionals: true,
42 strict: true,
43 })
44
45 if (values.version) {
46 console.log(PKG_VERSION)
47 return
48 }
49
50 if (values.help) {
51 console.log(HELP)
52 return
53 }
54
55 // ── Read input ─────────────────────────────────────────────────────────────
56 let raw: string
57
58 if (positionals.length > 0) {
59 const filePath = positionals[0]!
60 raw = await readFile(filePath, 'utf-8').catch((err: unknown) => {
61 die(`Cannot read file "${filePath}": ${String(err)}`)
62 }) as string
63 } else {
64 raw = await readStdin()
65 }
66
67 // ── Parse ──────────────────────────────────────────────────────────────────
68 let parsed: unknown
69 try {
70 parsed = JSON.parse(raw)
71 } catch (err) {
72 die(`Invalid JSON: ${String(err)}`)
73 }
74
75 const opts = {
76 frontmatter: values.frontmatter,
77 pageBreak: values['page-break'],
78 }
79
80 // ── Dispatch ───────────────────────────────────────────────────────────────
81 let markdown: string
82
83 if (isStandardDocument(parsed)) {
84 markdown = documentToMarkdown(parsed, opts)
85 } else if (isLeafletContent(parsed)) {
86 // Accept a raw pub.leaflet.content object directly.
87 markdown = contentToMarkdown(parsed, opts)
88 } else {
89 die(
90 'Input JSON must be a site.standard.document or pub.leaflet.content object.\n' +
91 'Expected a "$type" field of "site.standard.document" or "pub.leaflet.content".',
92 )
93 }
94
95 // ── Output ─────────────────────────────────────────────────────────────────
96 if (values.output) {
97 await writeFile(values.output, markdown, 'utf-8').catch((err: unknown) => {
98 die(`Cannot write to "${values.output}": ${String(err)}`)
99 })
100 } else {
101 process.stdout.write(markdown)
102 // Ensure a trailing newline when writing to stdout.
103 if (!markdown.endsWith('\n')) process.stdout.write('\n')
104 }
105}
106
107// ─── Type guards ──────────────────────────────────────────────────────────────
108
109function isStandardDocument(v: unknown): v is StandardDocument {
110 if (!v || typeof v !== 'object') return false
111 const r = v as Record<string, unknown>
112 // $type is optional on the type but we want to distinguish the two shapes.
113 if (r['$type'] && r['$type'] !== 'site.standard.document') return false
114 return typeof r['title'] === 'string' && typeof r['site'] === 'string'
115}
116
117function isLeafletContent(v: unknown): v is LeafletContent {
118 if (!v || typeof v !== 'object') return false
119 const r = v as Record<string, unknown>
120 return r['$type'] === 'pub.leaflet.content' && Array.isArray(r['pages'])
121}
122
123// ─── Stdin helper ─────────────────────────────────────────────────────────────
124
125function readStdin(): Promise<string> {
126 return new Promise((resolve, reject) => {
127 const chunks: Buffer[] = []
128 const stream = createReadStream('/dev/stdin')
129 stream.on('data', (chunk) => chunks.push(Buffer.from(chunk as Buffer)))
130 stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
131 stream.on('error', reject)
132 })
133}
134
135// ─── Error helper ─────────────────────────────────────────────────────────────
136
137function die(msg: string): never {
138 console.error(`bismuth: ${msg}`)
139 process.exit(1)
140}
141
142// ─── Help text ────────────────────────────────────────────────────────────────
143
144const HELP = `\
145Usage: bismuth [options] [file]
146
147Convert a pub.leaflet RTF-block document (site.standard.document) to Markdown.
148
149Arguments:
150 file JSON file to read. Reads stdin if omitted.
151
152Options:
153 -f, --frontmatter Emit YAML front matter from document metadata.
154 -p, --page-break STR Separator between pages (default: "\\n\\n---\\n\\n").
155 -o, --output FILE Write output to FILE instead of stdout.
156 -h, --help Show this help text and exit.
157 --version Print version and exit.
158
159Examples:
160 # Convert a document file, with front matter
161 bismuth --frontmatter doc.json
162
163 # Pipe from another command
164 cat doc.json | bismuth --frontmatter > post.md
165
166 # Multi-page document — custom page separator
167 bismuth --page-break $'\\n\\n<!-- page -->\\n\\n' doc.json
168`