* feat(draw-image): add SVG template render engine * feat(draw-image): add opencode plugin and tool tests * test(draw-image): add vitest unit integration e2e suite * chore(draw-image): wire CI dependabot docs and ADR * docs(handoff): set PR number * docs(handoff): fix PR number placeholder in frontmatter * fix(ci): use tempfile and trailing newline in draw-image tests --------- Co-authored-by: opencode-agent <agent@opencode.local>
84 lines
No EOL
2.6 KiB
TypeScript
84 lines
No EOL
2.6 KiB
TypeScript
import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs"
|
|
import path from "node:path"
|
|
import { fileURLToPath } from "node:url"
|
|
import { spawnSync } from "node:child_process"
|
|
import { loadBrand } from "./src/config.ts"
|
|
import { loadTemplate, buildSvg, computeHash } from "./src/render.ts"
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
|
|
function parseArgs(argv: string[]): Record<string, string> {
|
|
const out: Record<string, string> = {}
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i]
|
|
if (a.startsWith("--")) {
|
|
const key = a.slice(2)
|
|
const val = argv[i + 1] ?? ""
|
|
out[key] = val
|
|
i++
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
function main() {
|
|
const argv = process.argv.slice(2)
|
|
if (argv.length < 2 || argv[0] !== "render") {
|
|
process.stderr.write('usage: node cli.ts render <template> --title "..." [--slots icon=mic] [--out path]\n')
|
|
process.exit(2)
|
|
}
|
|
|
|
const template = argv[1]
|
|
const opts = parseArgs(argv.slice(2))
|
|
const title = opts.title ?? ""
|
|
const subtitle = opts.subtitle
|
|
const out = opts.out ?? "./assets/cover.png"
|
|
|
|
const slots: Record<string, string> = {}
|
|
if (opts.slots) {
|
|
for (const pair of opts.slots.split(",")) {
|
|
const eq = pair.indexOf("=")
|
|
if (eq !== -1) slots[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim()
|
|
}
|
|
}
|
|
|
|
const brand = loadBrand(__dirname)
|
|
const templateSvg = loadTemplate(__dirname, template)
|
|
const hash = computeHash(brand, templateSvg, { template, title, subtitle, slots, out })
|
|
|
|
const outResolved = path.resolve(out)
|
|
const metaPath = outResolved.replace(/\.png$/, ".meta.json")
|
|
if (existsSync(outResolved) && existsSync(metaPath)) {
|
|
try {
|
|
const meta = JSON.parse(readFileSync(metaPath, "utf-8"))
|
|
if (meta.hash === hash) {
|
|
console.log(JSON.stringify({ path: outResolved, hash, status: "skipped" }))
|
|
return
|
|
}
|
|
} catch {
|
|
// stale meta, re-render
|
|
}
|
|
}
|
|
|
|
const svg = buildSvg(templateSvg, brand, { template, title, subtitle, slots, out }, __dirname)
|
|
const tmpSvg = path.join(__dirname, ".tmp-render.svg")
|
|
writeFileSync(tmpSvg, svg)
|
|
|
|
const r = spawnSync("node", [path.join(__dirname, "render.mjs"), tmpSvg, outResolved], {
|
|
encoding: "utf-8",
|
|
})
|
|
if (r.status !== 0) {
|
|
process.stderr.write(r.stderr || r.stdout || "render failed\n")
|
|
process.exit(1)
|
|
}
|
|
|
|
const meta = {
|
|
hash,
|
|
generatedAt: new Date().toISOString(),
|
|
size: 1024,
|
|
}
|
|
writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\n")
|
|
console.log(JSON.stringify({ path: outResolved, hash, status: "rendered" }))
|
|
}
|
|
|
|
main() |