* refactor(draw-image): add tests/fixtures/cover.svg and loadFixture helper * feat(draw-image): add --template-dir flag to cli for test isolation * refactor(draw-image): switch tests from templates/cover to fixtures * chore(draw-image): simplify production cover.svg now decoupled from tests * docs(handoff): add handoff + ADR for issue #196 * docs(handoff): set PR number * docs(project-map): update after structural changes --------- Co-authored-by: opencode-agent <agent@opencode.local>
87 lines
No EOL
2.7 KiB
TypeScript
87 lines
No EOL
2.7 KiB
TypeScript
import { readFileSync } from "node:fs"
|
|
import path from "node:path"
|
|
import { createHash } from "node:crypto"
|
|
import { loadBrand, type Brand } from "./config.ts"
|
|
import { parseSlots, type SlotSpec } from "./slot-parser.ts"
|
|
import { resolveSlotContent, renderSlotSvg, renderSlotBackground } from "./resolve.ts"
|
|
|
|
export type RenderArgs = {
|
|
template: string
|
|
title?: string
|
|
subtitle?: string
|
|
slots?: Record<string, string>
|
|
out?: string
|
|
}
|
|
|
|
export type RenderResult = {
|
|
path: string
|
|
hash: string
|
|
status: "rendered" | "skipped"
|
|
}
|
|
|
|
const SLOT_COMMENT_RE = /<!--\s*slot:[^>]*?-->\n?/g
|
|
|
|
export function buildSvg(
|
|
templateSvg: string,
|
|
brand: Brand,
|
|
args: RenderArgs,
|
|
drawImageDir: string,
|
|
): string {
|
|
const slots = parseSlots(templateSvg)
|
|
let svg = templateSvg
|
|
const slotSvgs: string[] = []
|
|
for (const slot of slots) {
|
|
const value = args.slots?.[slot.name]
|
|
if (value) {
|
|
const bg = renderSlotBackground(slot, brand)
|
|
if (bg) slotSvgs.push(bg)
|
|
const resolved = resolveSlotContent(slot, brand, drawImageDir, value)
|
|
if (resolved) slotSvgs.push(renderSlotSvg(slot, resolved))
|
|
}
|
|
}
|
|
svg = svg.replace(SLOT_COMMENT_RE, "")
|
|
svg = svg.replace(/\{\{base\}\}/g, brand.base)
|
|
svg = svg.replace(/\{\{surface\}\}/g, brand.surface)
|
|
svg = svg.replace(/\{\{fg\}\}/g, brand.fg)
|
|
svg = svg.replace(/\{\{muted\}\}/g, brand.muted)
|
|
svg = svg.replace(/\{\{accent\}\}/g, brand.accent)
|
|
svg = svg.replace(/\{\{line\}\}/g, brand.line)
|
|
svg = svg.replace(/\{\{title\}\}/g, escapeXml(args.title ?? ""))
|
|
if (args.subtitle) {
|
|
svg = svg.replace(/\{\{subtitle\}\}/g, escapeXml(args.subtitle))
|
|
} else {
|
|
svg = svg.replace(/[^\n]*\{\{subtitle\}\}[^\n]*\n?/g, "")
|
|
}
|
|
const insertPoint = svg.indexOf("</svg>")
|
|
if (insertPoint === -1) return svg
|
|
return svg.slice(0, insertPoint) + slotSvgs.join("\n") + "\n" + svg.slice(insertPoint)
|
|
}
|
|
|
|
export function computeHash(brand: Brand, templateSvg: string, args: RenderArgs): string {
|
|
const data = JSON.stringify({
|
|
brand,
|
|
template: templateSvg,
|
|
template_name: args.template,
|
|
title: args.title ?? "",
|
|
subtitle: args.subtitle ?? "",
|
|
slots: args.slots ?? {},
|
|
out: args.out ?? "",
|
|
})
|
|
return createHash("sha256").update(data).digest("hex")
|
|
}
|
|
|
|
export function loadTemplate(drawImageDir: string, name: string, templateDir?: string): string {
|
|
const dir = templateDir ?? "templates"
|
|
const base = path.isAbsolute(dir) ? dir : path.join(drawImageDir, dir)
|
|
const templatePath = path.join(base, `${name}.svg`)
|
|
return readFileSync(templatePath, "utf-8")
|
|
}
|
|
|
|
function escapeXml(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'")
|
|
} |