my pkgs monorepo
at main 168 lines 6.8 kB view raw
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`