-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathcli.ts
More file actions
197 lines (176 loc) · 7.03 KB
/
cli.ts
File metadata and controls
197 lines (176 loc) · 7.03 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
#!/usr/bin/env node
/**
* html-anything CLI.
*
* Pipeline: input file → parser → ParsedFile → htmlize (LLM) → HTML.
*
* html-anything <input> write <input-stem>.html alongside
* html-anything <input> --out X write to X
* html-anything <input> --title T override the document title
* html-anything <input> --model M override the LLM model
*/
import * as fs from "node:fs/promises"
import * as path from "node:path"
import { pickParser } from "./parse/index.js"
import { parser as knowledgeBaseParser } from "./parse/knowledge-base.js"
import { parser as photosTakeoutParser } from "./parse/photos-takeout.js"
import { getStyleReferenceAssets, htmlize, selectStyleForContent } from "./htmlize.js"
import { makeLlm } from "./llm.js"
import type { ConverterOptions, HtmlAnythingStyle, Parser } from "./types.js"
interface ParsedArgs {
input: string
out?: string
options: ConverterOptions
help: boolean
version: boolean
}
const PKG_VERSION = "0.1.0"
function parseArgs(argv: string[]): ParsedArgs {
let input = ""
let out: string | undefined
let title: string | undefined
let style: ConverterOptions["style"] | undefined
let model: string | undefined
let maxTokens: number | undefined
let help = false
let version = false
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === "-h" || a === "--help") help = true
else if (a === "-V" || a === "--version") version = true
else if (a === "--out" || a === "-o") out = argv[++i]
else if (a === "--title") title = argv[++i]
else if (a === "--style") style = parseStyle(argv[++i])
else if (a === "--model") model = argv[++i]
else if (a === "--max-tokens") maxTokens = parseInt(argv[++i] || "", 10)
else if (a.startsWith("-")) throw new Error(`unknown flag: ${a}`)
else if (!input) input = a
else throw new Error(`unexpected positional argument: ${a}`)
}
return { input, out, options: { title, style, model, maxTokens }, help, version }
}
const STYLES = new Set<HtmlAnythingStyle | "auto">([
"auto",
"default",
"teaching",
"love-romance-3d",
"living-essay",
"dashboard",
"soft-saas",
"kinetic-scoreboard",
"timeline-story",
"global-travel",
"map-atlas",
"network-map",
"document",
"kami-reading",
"digital-eguide",
"editorial-carousel",
"architectural-spread",
"terminal-cli",
"developer",
])
function parseStyle(value: string | undefined): ConverterOptions["style"] {
if (!value) throw new Error("--style requires a value")
if (!STYLES.has(value as HtmlAnythingStyle | "auto")) {
throw new Error(`unknown style: ${value} (expected ${[...STYLES].join(", ")})`)
}
return value as ConverterOptions["style"]
}
const HELP = `\
html-anything — turn any file into a beautiful, interactive HTML
Usage:
html-anything <input> write <input-stem>.html alongside
html-anything <input> --out OUT write to OUT
html-anything <input> --title "Title" override the document title
html-anything <input> --style STYLE auto, teaching, love-romance-3d, ...
html-anything <input> --model MODEL LLM model (default: claude-sonnet-4-6)
html-anything <input> --max-tokens N LLM output budget (default: 16384)
Required env: ANTHROPIC_API_KEY (or OPENAI_API_KEY).
The LLM designs the reading experience for *this specific content* — the same
input type renders differently depending on shape (2-person chat → bubble
timeline; 200-person channel → folded by sender). The full data is inlined
into the output; the LLM only ever sees a representative sample.
Default style is auto. Auto injects one of the built-in style prompts
(teaching, love-romance-3d, living-essay, dashboard,
kinetic-scoreboard, timeline-story, global-travel, map-atlas, network-map, document, kami-reading,
editorial-carousel, developer, or default) based on the parsed content type.
Explicit overrides also include digital-eguide, architectural-spread, and
terminal-cli.
`
async function main() {
let args: ParsedArgs
try {
args = parseArgs(process.argv.slice(2))
} catch (err) {
console.error(`html-anything: ${(err as Error).message}\n`)
console.error(HELP)
process.exit(2)
}
if (args.version) { console.log(PKG_VERSION); return }
if (args.help || !args.input) { console.log(HELP); return }
const filepath = path.resolve(args.input)
let stat: Awaited<ReturnType<typeof fs.stat>>
try {
stat = await fs.stat(filepath)
} catch {
console.error(`html-anything: input not found: ${filepath}`)
process.exit(1)
return
}
let parser: Parser | null
if (stat.isDirectory()) {
if (photosTakeoutParser.detect && await photosTakeoutParser.detect(filepath)) parser = photosTakeoutParser
else if (knowledgeBaseParser.detect && await knowledgeBaseParser.detect(filepath)) parser = knowledgeBaseParser
else parser = null
} else {
parser = await pickParser(filepath)
}
if (!parser) {
const reason = stat.isDirectory()
? "(directory: no Google Photos sidecars and no markdown files found)"
: (path.extname(filepath) || "(no extension)")
console.error(`html-anything: no parser for ${reason}`)
process.exit(1)
}
process.stderr.write(`→ parsing as ${parser.name}…\n`)
const parsed = await parser.parse(filepath)
process.stderr.write(` ${parsed.summary}\n`)
const llm = makeLlm()
if (!llm) {
console.error(`html-anything: ANTHROPIC_API_KEY (or OPENAI_API_KEY) required for LLM-driven rendering.`)
console.error(` See https://github.com/clockless-org/html-anything for setup.`)
process.exit(1)
}
process.stderr.write(`→ designing page…\n`)
const selectedStyle = selectStyleForContent(parsed.contentType, args.options)
const html = await htmlize(parsed, llm, args.options)
const outPath = args.out
? path.resolve(args.out)
: stat.isDirectory()
? path.join(path.dirname(filepath), `${path.basename(filepath)}.html`)
: path.join(path.dirname(filepath), `${path.basename(filepath, path.extname(filepath))}.html`)
await fs.writeFile(outPath, html, "utf8")
const copiedAssets = await copyReferencedStyleAssets(selectedStyle, path.dirname(outPath), html)
if (copiedAssets.length > 0) {
process.stderr.write(`→ copied style assets: ${copiedAssets.join(", ")}\n`)
}
console.log(`✓ ${path.basename(outPath)} (${(html.length / 1024).toFixed(1)} KB) — open in your browser`)
}
async function copyReferencedStyleAssets(style: HtmlAnythingStyle, outDir: string, html: string): Promise<string[]> {
const assets = await getStyleReferenceAssets(style)
const copied: string[] = []
for (const asset of assets) {
const htmlPath = asset.outputRelativePath.split(path.sep).join("/")
if (!html.includes(htmlPath)) continue
const target = path.join(outDir, asset.outputRelativePath)
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.cp(asset.sourcePath, target, { recursive: true })
copied.push(asset.outputRelativePath)
}
return copied
}
main().catch(err => {
console.error(`html-anything: ${(err as Error).message}`)
process.exit(1)
})