opencode-config/.opencode/draw-image/src/render.ts
Sergey 0d16b028fa
fix(draw-image): optional badge/subtitle + self-healing docker entrypoint (#144)
* fix(draw-image): conditional slot bg + optional subtitle in buildSvg

* test(draw-image): optional badge/subtitle unit+integration+e2e

* feat(docker): self-healing entrypoint shim + .dockerignore

* test(draw-image): pytest assert --subtitle omitted when absent

* docs(handoff): optional badge/subtitle + docker entrypoint handoff+ADR

* docs(handoff): set PR number 144

* docs(project-map): fix PR refs 143 to 144

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-30 01:23:25 +03:00

85 lines
No EOL
2.6 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): string {
const templatePath = path.join(drawImageDir, "templates", `${name}.svg`)
return readFileSync(templatePath, "utf-8")
}
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;")
}