feat(draw-image): SVG template renderer for on-brand covers (#134)
* 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>
This commit is contained in:
parent
e72d435b82
commit
56b67d2d5c
40 changed files with 4107 additions and 2 deletions
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
|
|
@ -6,7 +6,12 @@ updates:
|
|||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/config"
|
||||
directory: "/.opencode"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/.opencode/draw-image"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
|
|
|
|||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -77,6 +77,10 @@ jobs:
|
|||
- run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||
- run: npm ci
|
||||
working-directory: .opencode
|
||||
- run: npm ci
|
||||
working-directory: .opencode/draw-image
|
||||
- run: npm test
|
||||
working-directory: .opencode/draw-image
|
||||
- uses: astral-sh/setup-uv@v3
|
||||
- run: uv sync --extra dev --python ${{ matrix.python }}
|
||||
- run: uv run --python ${{ matrix.python }} pytest
|
||||
|
|
|
|||
5
.opencode/draw-image/.gitignore
vendored
Normal file
5
.opencode/draw-image/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
.tmp-render.svg
|
||||
icons/lucide/*
|
||||
!icons/lucide/mic.svg
|
||||
!icons/lucide/play.svg
|
||||
0
.opencode/draw-image/brand-logos/.gitkeep
Normal file
0
.opencode/draw-image/brand-logos/.gitkeep
Normal file
8
.opencode/draw-image/brand.json
Normal file
8
.opencode/draw-image/brand.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"base": "#0a0a0a",
|
||||
"surface": "#121212",
|
||||
"fg": "#ededed",
|
||||
"muted": "#a1a1aa",
|
||||
"accent": "#ccff00",
|
||||
"line": "rgba(255,255,255,0.06)"
|
||||
}
|
||||
84
.opencode/draw-image/cli.ts
Normal file
84
.opencode/draw-image/cli.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
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()
|
||||
BIN
.opencode/draw-image/fonts/Geist-Bold.ttf
Normal file
BIN
.opencode/draw-image/fonts/Geist-Bold.ttf
Normal file
Binary file not shown.
BIN
.opencode/draw-image/fonts/Geist-Regular.ttf
Normal file
BIN
.opencode/draw-image/fonts/Geist-Regular.ttf
Normal file
Binary file not shown.
17
.opencode/draw-image/icons/lucide/mic.svg
Normal file
17
.opencode/draw-image/icons/lucide/mic.svg
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<!-- @license lucide-static v1.27.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-mic"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 19v3" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<rect x="9" y="2" width="6" height="13" rx="3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 400 B |
15
.opencode/draw-image/icons/lucide/play.svg
Normal file
15
.opencode/draw-image/icons/lucide/play.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!-- @license lucide-static v1.27.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-play"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 381 B |
2577
.opencode/draw-image/package-lock.json
generated
Normal file
2577
.opencode/draw-image/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
.opencode/draw-image/package.json
Normal file
20
.opencode/draw-image/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "draw-image",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "SVG template renderer for on-brand covers (sharp + lucide-static)",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"postinstall": "node scripts/sync-lucide.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-static": "^1.27.0",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
44
.opencode/draw-image/render.mjs
Normal file
44
.opencode/draw-image/render.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import sharp from "sharp"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
if (args.length < 2) {
|
||||
process.stderr.write("usage: node render.mjs <svg-file> <out-path>\n")
|
||||
process.exit(2)
|
||||
}
|
||||
const svgFile = args[0]
|
||||
const outPath = args[1]
|
||||
|
||||
const svg = readFileSync(svgFile, "utf-8")
|
||||
|
||||
const outDir = path.dirname(outPath)
|
||||
if (outDir && !existsSync(outDir)) {
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
}
|
||||
|
||||
const fontDir = path.join(__dirname, "fonts")
|
||||
const fontFiles = []
|
||||
const sansTtf = path.join(fontDir, "Geist-Regular.ttf")
|
||||
const sansBoldTtf = path.join(fontDir, "Geist-Bold.ttf")
|
||||
if (existsSync(sansTtf)) fontFiles.push(sansTtf)
|
||||
if (existsSync(sansBoldTtf)) fontFiles.push(sansBoldTtf)
|
||||
|
||||
const pngOpts = { compressionLevel: 9 }
|
||||
if (fontFiles.length > 0) {
|
||||
pngOpts.fontFiles = fontFiles
|
||||
}
|
||||
|
||||
await sharp(Buffer.from(svg), { density: 96 })
|
||||
.png(pngOpts)
|
||||
.toFile(outPath)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`render failed: ${err instanceof Error ? err.message : String(err)}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
23
.opencode/draw-image/scripts/sync-lucide.mjs
Normal file
23
.opencode/draw-image/scripts/sync-lucide.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { existsSync, mkdirSync, readdirSync, copyFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const drawImageDir = path.resolve(__dirname, "..")
|
||||
const lucideSrc = path.join(drawImageDir, "node_modules", "lucide-static", "icons")
|
||||
const lucideDst = path.join(drawImageDir, "icons", "lucide")
|
||||
|
||||
if (!existsSync(lucideSrc)) {
|
||||
console.log("lucide-static not installed, skipping icon sync")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
mkdirSync(lucideDst, { recursive: true })
|
||||
let count = 0
|
||||
for (const file of readdirSync(lucideSrc)) {
|
||||
if (file.endsWith(".svg")) {
|
||||
copyFileSync(path.join(lucideSrc, file), path.join(lucideDst, file))
|
||||
count++
|
||||
}
|
||||
}
|
||||
console.log(`synced ${count} lucide icons`)
|
||||
56
.opencode/draw-image/src/config.ts
Normal file
56
.opencode/draw-image/src/config.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
export type Brand = {
|
||||
base: string
|
||||
surface: string
|
||||
fg: string
|
||||
muted: string
|
||||
accent: string
|
||||
line: string
|
||||
}
|
||||
|
||||
const HEX_RE = /^#[0-9a-fA-F]{6}$/
|
||||
|
||||
type BrandKey = keyof Brand
|
||||
|
||||
const REQUIRED_KEYS: BrandKey[] = ["base", "surface", "fg", "muted", "accent"]
|
||||
|
||||
export function validateBrand(raw: unknown): Brand {
|
||||
if (typeof raw !== "object" || raw === null) {
|
||||
throw new Error("brand.json must be a JSON object")
|
||||
}
|
||||
const obj = raw as Record<string, unknown>
|
||||
for (const key of REQUIRED_KEYS) {
|
||||
if (!(key in obj)) {
|
||||
throw new Error(`brand.json missing required key: ${key}`)
|
||||
}
|
||||
const val = obj[key]
|
||||
if (typeof val !== "string" || !HEX_RE.test(val)) {
|
||||
throw new Error(`brand.json key '${key}' must be a 6-digit hex color (got: ${String(val)})`)
|
||||
}
|
||||
}
|
||||
if ("line" in obj && typeof obj.line !== "string") {
|
||||
throw new Error("brand.json key 'line' must be a string")
|
||||
}
|
||||
return {
|
||||
base: obj.base as string,
|
||||
surface: obj.surface as string,
|
||||
fg: obj.fg as string,
|
||||
muted: obj.muted as string,
|
||||
accent: obj.accent as string,
|
||||
line: (obj.line as string | undefined) ?? "rgba(255,255,255,0.06)",
|
||||
}
|
||||
}
|
||||
|
||||
export function loadBrand(dir: string): Brand {
|
||||
const brandPath = path.join(dir, "brand.json")
|
||||
const raw = JSON.parse(readFileSync(brandPath, "utf-8"))
|
||||
return validateBrand(raw)
|
||||
}
|
||||
|
||||
export function resolveColor(brand: Brand, name: string): string {
|
||||
if (name === "none") return "none"
|
||||
if (name in brand) return (brand as Record<string, string>)[name]
|
||||
throw new Error(`unknown color alias: ${name}`)
|
||||
}
|
||||
71
.opencode/draw-image/src/fit.ts
Normal file
71
.opencode/draw-image/src/fit.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { FitMode } from "./slot-parser.ts"
|
||||
|
||||
export type FitResult = {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
viewBox: string
|
||||
}
|
||||
|
||||
export function computeFit(
|
||||
fit: FitMode,
|
||||
slotX: number,
|
||||
slotY: number,
|
||||
slotW: number,
|
||||
slotH: number,
|
||||
contentW: number,
|
||||
contentH: number,
|
||||
): FitResult {
|
||||
if (fit === "stretch") {
|
||||
return {
|
||||
x: slotX,
|
||||
y: slotY,
|
||||
width: slotW,
|
||||
height: slotH,
|
||||
viewBox: `0 0 ${contentW} ${contentH}`,
|
||||
}
|
||||
}
|
||||
if (fit === "cover") {
|
||||
const scale = Math.max(slotW / contentW, slotH / contentH)
|
||||
const renderedW = contentW * scale
|
||||
const renderedH = contentH * scale
|
||||
const offsetX = slotX + (slotW - renderedW) / 2
|
||||
const offsetY = slotY + (slotH - renderedH) / 2
|
||||
return {
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
width: renderedW,
|
||||
height: renderedH,
|
||||
viewBox: `0 0 ${contentW} ${contentH}`,
|
||||
}
|
||||
}
|
||||
const scale = Math.min(slotW / contentW, slotH / contentH)
|
||||
const renderedW = contentW * scale
|
||||
const renderedH = contentH * scale
|
||||
const offsetX = slotX + (slotW - renderedW) / 2
|
||||
const offsetY = slotY + (slotH - renderedH) / 2
|
||||
return {
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
width: renderedW,
|
||||
height: renderedH,
|
||||
viewBox: `0 0 ${contentW} ${contentH}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseViewBox(svg: string): { width: number; height: number } {
|
||||
const m = svg.match(/viewBox=["']([^"']+)["']/)
|
||||
if (m) {
|
||||
const parts = m[1].split(/[\s,]+/).map(Number)
|
||||
if (parts.length === 4 && parts.every((n) => !Number.isNaN(n))) {
|
||||
return { width: parts[2], height: parts[3] }
|
||||
}
|
||||
}
|
||||
const wMatch = svg.match(/\swidth=["'](\d+)["']/)
|
||||
const hMatch = svg.match(/\sheight=["'](\d+)["']/)
|
||||
if (wMatch && hMatch) {
|
||||
return { width: parseInt(wMatch[1], 10), height: parseInt(hMatch[1], 10) }
|
||||
}
|
||||
return { width: 24, height: 24 }
|
||||
}
|
||||
26
.opencode/draw-image/src/recolor.ts
Normal file
26
.opencode/draw-image/src/recolor.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export function recolorSvg(svg: string, color: string): string {
|
||||
if (color === "none") return svg
|
||||
let out = svg
|
||||
out = out.replace(/\bstroke=["'](?!none)([^"']+)["']/g, `stroke="${color}"`)
|
||||
out = out.replace(/\bfill=["'](?!none)([^"']+)["']/g, (match, _val) => {
|
||||
if (match.includes(`fill="none"`)) return match
|
||||
return `fill="${color}"`
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
export function stripSvgWrapper(svg: string): string {
|
||||
const m = svg.match(/<svg[^>]*>([\s\S]*)<\/svg>/)
|
||||
return m ? m[1].trim() : svg
|
||||
}
|
||||
|
||||
export function extractRootAttrs(svg: string): { stroke?: string; fill?: string; strokeWidth?: string } {
|
||||
const attrs: { stroke?: string; fill?: string; strokeWidth?: string } = {}
|
||||
const strokeMatch = svg.match(/<svg[^>]*\bstroke=["']([^"']+)["']/)
|
||||
if (strokeMatch) attrs.stroke = strokeMatch[1]
|
||||
const fillMatch = svg.match(/<svg[^>]*\bfill=["']([^"']+)["']/)
|
||||
if (fillMatch) attrs.fill = fillMatch[1]
|
||||
const swMatch = svg.match(/<svg[^>]*\bstroke-width=["']([^"']+)["']/)
|
||||
if (swMatch) attrs.strokeWidth = swMatch[1]
|
||||
return attrs
|
||||
}
|
||||
81
.opencode/draw-image/src/render.ts
Normal file
81
.opencode/draw-image/src/render.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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 bg = renderSlotBackground(slot, brand)
|
||||
if (bg) slotSvgs.push(bg)
|
||||
const value = args.slots?.[slot.name]
|
||||
if (value) {
|
||||
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 ?? ""))
|
||||
svg = svg.replace(/\{\{subtitle\}\}/g, escapeXml(args.subtitle ?? ""))
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
78
.opencode/draw-image/src/resolve.ts
Normal file
78
.opencode/draw-image/src/resolve.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { existsSync, readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { recolorSvg, stripSvgWrapper, extractRootAttrs } from "./recolor.ts"
|
||||
import { computeFit, parseViewBox } from "./fit.ts"
|
||||
import type { SlotSpec } from "./slot-parser.ts"
|
||||
import type { Brand } from "./config.ts"
|
||||
import { resolveColor } from "./config.ts"
|
||||
|
||||
export type ResolveResult = {
|
||||
inner: string
|
||||
contentW: number
|
||||
contentH: number
|
||||
rootAttrs: { stroke?: string; fill?: string; strokeWidth?: string }
|
||||
}
|
||||
|
||||
export function resolveSlotContent(
|
||||
slot: SlotSpec,
|
||||
brand: Brand,
|
||||
drawImageDir: string,
|
||||
slotValue: string,
|
||||
): ResolveResult | null {
|
||||
const raw = loadSlotSvg(slotValue, drawImageDir)
|
||||
if (!raw) return null
|
||||
const color = resolveColor(brand, slot.recolor)
|
||||
const recolored = recolorSvg(raw, color)
|
||||
const rootAttrs = extractRootAttrs(recolored)
|
||||
const inner = stripSvgWrapper(recolored)
|
||||
const { width, height } = parseViewBox(raw)
|
||||
return { inner, contentW: width, contentH: height, rootAttrs }
|
||||
}
|
||||
|
||||
export function loadSlotSvg(value: string, drawImageDir: string): string | null {
|
||||
if (value.startsWith("./") || value.startsWith("/") || value.startsWith("../")) {
|
||||
if (!existsSync(value)) return null
|
||||
return readFileSync(value, "utf-8")
|
||||
}
|
||||
const lucidePath = path.join(drawImageDir, "icons", "lucide", `${value}.svg`)
|
||||
if (existsSync(lucidePath)) {
|
||||
return readFileSync(lucidePath, "utf-8")
|
||||
}
|
||||
const brandPath = path.join(drawImageDir, "brand-logos", `${value}.svg`)
|
||||
if (existsSync(brandPath)) {
|
||||
return readFileSync(brandPath, "utf-8")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function renderSlotSvg(slot: SlotSpec, resolved: ResolveResult): string {
|
||||
const fit = computeFit(
|
||||
slot.fit,
|
||||
slot.x,
|
||||
slot.y,
|
||||
slot.w,
|
||||
slot.h,
|
||||
resolved.contentW,
|
||||
resolved.contentH,
|
||||
)
|
||||
const attrs = resolved.rootAttrs
|
||||
const strokeAttr = attrs.stroke ? ` stroke="${attrs.stroke}"` : ""
|
||||
const fillAttr = attrs.fill ? ` fill="${attrs.fill}"` : ""
|
||||
const swAttr = attrs.strokeWidth ? ` stroke-width="${attrs.strokeWidth}"` : ""
|
||||
return `<svg x="${fit.x}" y="${fit.y}" width="${fit.width}" height="${fit.height}" viewBox="${fit.viewBox}" xmlns="http://www.w3.org/2000/svg"${strokeAttr}${fillAttr}${swAttr}>${resolved.inner}</svg>`
|
||||
}
|
||||
|
||||
export function renderSlotBackground(slot: SlotSpec, brand: Brand): string {
|
||||
const parts: string[] = []
|
||||
if (slot.bg !== "none") {
|
||||
const bg = resolveColor(brand, slot.bg)
|
||||
const r = slot.radius ? Math.min(slot.w, slot.h) * slot.radius / 2 : 0
|
||||
parts.push(`<rect x="${slot.x}" y="${slot.y}" width="${slot.w}" height="${slot.h}" rx="${r}" fill="${bg}" />`)
|
||||
}
|
||||
if (slot.border !== "none") {
|
||||
const border = resolveColor(brand, slot.border)
|
||||
const r = slot.radius ? Math.min(slot.w, slot.h) * slot.radius / 2 : 0
|
||||
parts.push(`<rect x="${slot.x}" y="${slot.y}" width="${slot.w}" height="${slot.h}" rx="${r}" fill="none" stroke="${border}" stroke-width="4" />`)
|
||||
}
|
||||
return parts.join("\n")
|
||||
}
|
||||
57
.opencode/draw-image/src/slot-parser.ts
Normal file
57
.opencode/draw-image/src/slot-parser.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
export type FitMode = "contain" | "cover" | "stretch"
|
||||
|
||||
export type SlotSpec = {
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
fit: FitMode
|
||||
recolor: string
|
||||
bg: string
|
||||
border: string
|
||||
radius: number
|
||||
}
|
||||
|
||||
const SLOT_RE = /<!--\s*slot:\s*([^>]*?)-->/g
|
||||
|
||||
export function parseSlots(svg: string): SlotSpec[] {
|
||||
const slots: SlotSpec[] = []
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = SLOT_RE.exec(svg)) !== null) {
|
||||
const body = m[1].trim()
|
||||
const spec = parseSlotBody(body)
|
||||
if (spec) slots.push(spec)
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
function parseSlotBody(body: string): SlotSpec | null {
|
||||
const fields = new Map<string, string>()
|
||||
for (const part of body.split(",")) {
|
||||
const eq = part.indexOf("=")
|
||||
if (eq === -1) continue
|
||||
const key = part.slice(0, eq).trim()
|
||||
const val = part.slice(eq + 1).trim()
|
||||
fields.set(key, val)
|
||||
}
|
||||
const name = fields.get("name")
|
||||
if (!name) return null
|
||||
const x = num(fields, "x")
|
||||
const y = num(fields, "y")
|
||||
const w = num(fields, "w")
|
||||
const h = num(fields, "h")
|
||||
if ([x, y, w, h].some((v) => Number.isNaN(v))) return null
|
||||
const fit = (fields.get("fit") as FitMode) ?? "contain"
|
||||
const recolor = fields.get("recolor") ?? "none"
|
||||
const bg = fields.get("bg") ?? "none"
|
||||
const border = fields.get("border") ?? "none"
|
||||
const radius = num(fields, "radius") || 0
|
||||
return { name, x, y, w, h, fit, recolor, bg, border, radius }
|
||||
}
|
||||
|
||||
function num(fields: Map<string, string>, key: string): number {
|
||||
const v = fields.get(key)
|
||||
if (v === undefined) return NaN
|
||||
return parseFloat(v)
|
||||
}
|
||||
8
.opencode/draw-image/templates/cover.svg
Normal file
8
.opencode/draw-image/templates/cover.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<!-- slot: name=icon, x=312, y=180, w=400, h=400, fit=contain, recolor=accent -->
|
||||
<!-- slot: name=sub-icon, x=412, y=600, w=200, h=200, fit=contain, recolor=accent -->
|
||||
<!-- slot: name=badge, x=780, y=780, w=180, h=180, fit=contain, recolor=none, bg=surface, border=accent, radius=0.5 -->
|
||||
<rect width="1024" height="1024" fill="{{base}}" />
|
||||
<text x="512" y="870" font-family="Geist Sans, sans-serif" font-size="72" font-weight="700" fill="{{fg}}" text-anchor="middle">{{title}}</text>
|
||||
<text x="512" y="930" font-family="Geist Sans, sans-serif" font-size="36" font-weight="400" fill="{{muted}}" text-anchor="middle">{{subtitle}}</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 744 B |
57
.opencode/draw-image/tests/cli.test.ts
Normal file
57
.opencode/draw-image/tests/cli.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, test, expect } from "vitest"
|
||||
|
||||
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 parseSlotsArg(slotsStr: string | undefined): Record<string, string> {
|
||||
const slots: Record<string, string> = {}
|
||||
if (slotsStr) {
|
||||
for (const pair of slotsStr.split(",")) {
|
||||
const eq = pair.indexOf("=")
|
||||
if (eq !== -1) slots[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim()
|
||||
}
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
describe("cli — arg parsing", () => {
|
||||
test("parses title and out", () => {
|
||||
const opts = parseArgs(["--title", "Hello", "--out", "/tmp/x.png"])
|
||||
expect(opts.title).toBe("Hello")
|
||||
expect(opts.out).toBe("/tmp/x.png")
|
||||
})
|
||||
|
||||
test("default out is ./assets/cover.png", () => {
|
||||
const opts = parseArgs(["--title", "Test"])
|
||||
const out = opts.out ?? "./assets/cover.png"
|
||||
expect(out).toBe("./assets/cover.png")
|
||||
})
|
||||
|
||||
test("parses slots string into object", () => {
|
||||
const slots = parseSlotsArg("icon=mic,sub-icon=opencode,badge=./x.svg")
|
||||
expect(slots.icon).toBe("mic")
|
||||
expect(slots["sub-icon"]).toBe("opencode")
|
||||
expect(slots.badge).toBe("./x.svg")
|
||||
})
|
||||
|
||||
test("empty slots string yields empty object", () => {
|
||||
expect(parseSlotsArg(undefined)).toEqual({})
|
||||
expect(parseSlotsArg("")).toEqual({})
|
||||
})
|
||||
|
||||
test("subtitle is optional", () => {
|
||||
const opts = parseArgs(["--title", "Test"])
|
||||
expect(opts.subtitle).toBeUndefined()
|
||||
})
|
||||
})
|
||||
42
.opencode/draw-image/tests/config.test.ts
Normal file
42
.opencode/draw-image/tests/config.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, test, expect } from "vitest"
|
||||
import { validateBrand } from "../src/config"
|
||||
|
||||
describe("config — validateBrand", () => {
|
||||
test("valid brand passes", () => {
|
||||
const brand = validateBrand({
|
||||
base: "#0a0a0a",
|
||||
surface: "#121212",
|
||||
fg: "#ededed",
|
||||
muted: "#a1a1aa",
|
||||
accent: "#ccff00",
|
||||
line: "rgba(255,255,255,0.06)",
|
||||
})
|
||||
expect(brand.accent).toBe("#ccff00")
|
||||
expect(brand.base).toBe("#0a0a0a")
|
||||
})
|
||||
|
||||
test("missing accent rejected", () => {
|
||||
expect(() => validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa" }))
|
||||
.toThrow(/accent/)
|
||||
})
|
||||
|
||||
test("non-hex accent rejected", () => {
|
||||
expect(() => validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa", accent: "green" }))
|
||||
.toThrow(/hex/)
|
||||
})
|
||||
|
||||
test("short hex rejected", () => {
|
||||
expect(() => validateBrand({ base: "#0a0", surface: "#121212", fg: "#ededed", muted: "#a1a1aa", accent: "#ccff00" }))
|
||||
.toThrow(/hex/)
|
||||
})
|
||||
|
||||
test("non-object rejected", () => {
|
||||
expect(() => validateBrand("not an object")).toThrow(/object/)
|
||||
expect(() => validateBrand(null)).toThrow(/object/)
|
||||
})
|
||||
|
||||
test("line defaults when missing", () => {
|
||||
const brand = validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa", accent: "#ccff00" })
|
||||
expect(brand.line).toBe("rgba(255,255,255,0.06)")
|
||||
})
|
||||
})
|
||||
36
.opencode/draw-image/tests/e2e.bad-input.test.ts
Normal file
36
.opencode/draw-image/tests/e2e.bad-input.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, test, expect, beforeAll } from "vitest"
|
||||
import { existsSync, rmSync, mkdirSync, writeFileSync, copyFileSync, readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { validateBrand } from "../src/config"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||
const TMP = "/tmp/draw-image-bad-input"
|
||||
|
||||
beforeAll(() => {
|
||||
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||
mkdirSync(TMP, { recursive: true })
|
||||
})
|
||||
|
||||
describe("e2e — bad input (unit-level, no file mutation)", () => {
|
||||
test("invalid brand.json rejected by validateBrand", () => {
|
||||
expect(() => validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa" }))
|
||||
.toThrow(/accent/)
|
||||
})
|
||||
|
||||
test("non-hex accent rejected", () => {
|
||||
expect(() => validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa", accent: "green" }))
|
||||
.toThrow(/hex/)
|
||||
})
|
||||
|
||||
test("cli exits non-zero with missing template file", () => {
|
||||
const out = path.join(TMP, "bad.png")
|
||||
const r = spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), "render", "nonexistent", "--title", "Bad", "--out", out], {
|
||||
encoding: "utf-8",
|
||||
cwd: DRAW_IMAGE_DIR,
|
||||
})
|
||||
expect(r.status).not.toBe(0)
|
||||
})
|
||||
})
|
||||
33
.opencode/draw-image/tests/e2e.no-icon.test.ts
Normal file
33
.opencode/draw-image/tests/e2e.no-icon.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, test, expect, beforeAll } from "vitest"
|
||||
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { spawnSync } from "node:child_process"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||
const TMP = "/tmp/draw-image-e2e-no-icon"
|
||||
|
||||
beforeAll(() => {
|
||||
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||
mkdirSync(TMP, { recursive: true })
|
||||
})
|
||||
|
||||
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
|
||||
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
|
||||
encoding: "utf-8",
|
||||
cwd: DRAW_IMAGE_DIR,
|
||||
})
|
||||
}
|
||||
|
||||
describe("e2e — no icon render", () => {
|
||||
test("exit 0 and valid PNG without icon slot", () => {
|
||||
const out = path.join(TMP, "no-icon.png")
|
||||
const r = runCli(["render", "cover", "--title", "Default Brand", "--out", out])
|
||||
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
||||
expect(existsSync(out)).toBe(true)
|
||||
const buf = readFileSync(out)
|
||||
expect(buf[0]).toBe(0x89)
|
||||
expect(buf[1]).toBe(0x50)
|
||||
})
|
||||
})
|
||||
60
.opencode/draw-image/tests/e2e.test.ts
Normal file
60
.opencode/draw-image/tests/e2e.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, test, expect, beforeAll, afterAll } from "vitest"
|
||||
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { spawnSync } from "node:child_process"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||
const TMP = "/tmp/draw-image-e2e"
|
||||
|
||||
beforeAll(() => {
|
||||
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||
mkdirSync(TMP, { recursive: true })
|
||||
})
|
||||
|
||||
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
|
||||
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
|
||||
encoding: "utf-8",
|
||||
cwd: DRAW_IMAGE_DIR,
|
||||
})
|
||||
}
|
||||
|
||||
function assertPng1024(filePath: string) {
|
||||
expect(existsSync(filePath), `PNG not found: ${filePath}`).toBe(true)
|
||||
const buf = readFileSync(filePath)
|
||||
expect(buf.length).toBeGreaterThan(1000)
|
||||
expect(buf[0]).toBe(0x89)
|
||||
expect(buf[1]).toBe(0x50)
|
||||
expect(buf[2]).toBe(0x4e)
|
||||
expect(buf[3]).toBe(0x47)
|
||||
}
|
||||
|
||||
describe("e2e — full CLI render", () => {
|
||||
test("render cover with title and icon", () => {
|
||||
const out = path.join(TMP, "e2e.png")
|
||||
const r = runCli(["render", "cover", "--title", "E2E", "--slots", "icon=mic", "--out", out])
|
||||
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
||||
const result = JSON.parse(r.stdout)
|
||||
expect(result.status).toBe("rendered")
|
||||
assertPng1024(out)
|
||||
const metaPath = out.replace(/\.png$/, ".meta.json")
|
||||
expect(existsSync(metaPath)).toBe(true)
|
||||
const meta = JSON.parse(readFileSync(metaPath, "utf-8"))
|
||||
expect(meta.hash).toHaveLength(64)
|
||||
})
|
||||
|
||||
test("render without icon succeeds", () => {
|
||||
const out = path.join(TMP, "e2e-no-icon.png")
|
||||
const r = runCli(["render", "cover", "--title", "No Icon", "--out", out])
|
||||
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
||||
assertPng1024(out)
|
||||
})
|
||||
|
||||
test("render with subtitle", () => {
|
||||
const out = path.join(TMP, "e2e-sub.png")
|
||||
const r = runCli(["render", "cover", "--title", "Main", "--subtitle", "Sub", "--out", out])
|
||||
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
||||
assertPng1024(out)
|
||||
})
|
||||
})
|
||||
64
.opencode/draw-image/tests/fit.test.ts
Normal file
64
.opencode/draw-image/tests/fit.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, test, expect } from "vitest"
|
||||
import { computeFit, parseViewBox } from "../src/fit"
|
||||
|
||||
describe("fit — contain", () => {
|
||||
test("square content into wider slot centers horizontally", () => {
|
||||
const r = computeFit("contain", 100, 100, 400, 200, 100, 100)
|
||||
expect(r.width).toBe(200)
|
||||
expect(r.height).toBe(200)
|
||||
expect(r.x).toBe(200)
|
||||
expect(r.y).toBe(100)
|
||||
expect(r.viewBox).toBe("0 0 100 100")
|
||||
})
|
||||
|
||||
test("square content into taller slot centers vertically", () => {
|
||||
const r = computeFit("contain", 100, 100, 200, 400, 100, 100)
|
||||
expect(r.width).toBe(200)
|
||||
expect(r.height).toBe(200)
|
||||
expect(r.x).toBe(100)
|
||||
expect(r.y).toBe(200)
|
||||
})
|
||||
|
||||
test("horizontal content into square slot fits by width", () => {
|
||||
const r = computeFit("contain", 0, 0, 200, 200, 400, 100)
|
||||
expect(r.width).toBe(200)
|
||||
expect(r.height).toBe(50)
|
||||
expect(r.x).toBe(0)
|
||||
expect(r.y).toBe(75)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fit — cover", () => {
|
||||
test("square content into wide slot fills height", () => {
|
||||
const r = computeFit("cover", 0, 0, 400, 200, 100, 100)
|
||||
expect(r.width).toBe(400)
|
||||
expect(r.height).toBe(400)
|
||||
expect(r.x).toBe(0)
|
||||
expect(r.y).toBe(-100)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fit — stretch", () => {
|
||||
test("stretches to slot dimensions ignoring aspect", () => {
|
||||
const r = computeFit("stretch", 10, 20, 300, 150, 100, 100)
|
||||
expect(r.x).toBe(10)
|
||||
expect(r.y).toBe(20)
|
||||
expect(r.width).toBe(300)
|
||||
expect(r.height).toBe(150)
|
||||
expect(r.viewBox).toBe("0 0 100 100")
|
||||
})
|
||||
})
|
||||
|
||||
describe("fit — parseViewBox", () => {
|
||||
test("extracts from viewBox attribute", () => {
|
||||
expect(parseViewBox('<svg viewBox="0 0 24 24">')).toEqual({ width: 24, height: 24 })
|
||||
})
|
||||
|
||||
test("extracts from width/height attributes", () => {
|
||||
expect(parseViewBox('<svg width="48" height="48">')).toEqual({ width: 48, height: 48 })
|
||||
})
|
||||
|
||||
test("defaults to 24x24 when nothing found", () => {
|
||||
expect(parseViewBox("<svg></svg>")).toEqual({ width: 24, height: 24 })
|
||||
})
|
||||
})
|
||||
51
.opencode/draw-image/tests/idempotency.test.ts
Normal file
51
.opencode/draw-image/tests/idempotency.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, test, expect, beforeAll } from "vitest"
|
||||
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { loadBrand } from "../src/config"
|
||||
import { loadTemplate, buildSvg, computeHash } from "../src/render"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||
const TMP = "/tmp/draw-image-idempotency"
|
||||
|
||||
beforeAll(() => {
|
||||
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||
mkdirSync(TMP, { recursive: true })
|
||||
})
|
||||
|
||||
function renderCli(template: string, title: string, out: string, slots?: string): { status: number; stdout: string; stderr: string } {
|
||||
const args = ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), "render", template, "--title", title, "--out", out]
|
||||
if (slots) args.push("--slots", slots)
|
||||
return spawnSync("node", args, { encoding: "utf-8", cwd: DRAW_IMAGE_DIR })
|
||||
}
|
||||
|
||||
describe("integration — idempotency", () => {
|
||||
test("re-render with same args skips", () => {
|
||||
const out = path.join(TMP, "cover.png")
|
||||
const r1 = renderCli("cover", "Same Title", out)
|
||||
expect(r1.status).toBe(0)
|
||||
const result1 = JSON.parse(r1.stdout)
|
||||
expect(result1.status).toBe("rendered")
|
||||
|
||||
const r2 = renderCli("cover", "Same Title", out)
|
||||
expect(r2.status).toBe(0)
|
||||
const result2 = JSON.parse(r2.stdout)
|
||||
expect(result2.status).toBe("skipped")
|
||||
})
|
||||
|
||||
test("changed title re-renders", () => {
|
||||
const out = path.join(TMP, "cover2.png")
|
||||
const r1 = renderCli("cover", "Title A", out)
|
||||
const result1 = JSON.parse(r1.stdout)
|
||||
expect(result1.status).toBe("rendered")
|
||||
|
||||
const r2 = renderCli("cover", "Title B", out)
|
||||
const result2 = JSON.parse(r2.stdout)
|
||||
expect(result2.status).toBe("rendered")
|
||||
|
||||
const meta = JSON.parse(readFileSync(out.replace(/\.png$/, ".meta.json"), "utf-8"))
|
||||
expect(meta.hash).toHaveLength(64)
|
||||
})
|
||||
})
|
||||
48
.opencode/draw-image/tests/recolor.test.ts
Normal file
48
.opencode/draw-image/tests/recolor.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, test, expect } from "vitest"
|
||||
import { recolorSvg, stripSvgWrapper } from "../src/recolor"
|
||||
|
||||
const SAMPLE = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19v3" /></svg>`
|
||||
|
||||
describe("recolor — recolorSvg", () => {
|
||||
test("replaces stroke=currentColor with accent", () => {
|
||||
const out = recolorSvg(SAMPLE, "#ccff00")
|
||||
expect(out).toContain('stroke="#ccff00"')
|
||||
expect(out).not.toContain('stroke="currentColor"')
|
||||
})
|
||||
|
||||
test("replaces fill (non-none) with target color", () => {
|
||||
const svg = `<svg fill="#000000" stroke="currentColor"><path fill="#ff0000" /></svg>`
|
||||
const out = recolorSvg(svg, "#ccff00")
|
||||
expect(out).toContain('fill="#ccff00"')
|
||||
expect(out).not.toContain('fill="#000000"')
|
||||
expect(out).not.toContain('fill="#ff0000"')
|
||||
})
|
||||
|
||||
test("preserves fill=none", () => {
|
||||
const svg = `<svg fill="none" stroke="currentColor"><path fill="none" /></svg>`
|
||||
const out = recolorSvg(svg, "#ccff00")
|
||||
expect(out).toContain('fill="none"')
|
||||
})
|
||||
|
||||
test("none color returns unchanged", () => {
|
||||
expect(recolorSvg(SAMPLE, "none")).toBe(SAMPLE)
|
||||
})
|
||||
|
||||
test("replaces stroke with fg color", () => {
|
||||
const out = recolorSvg(SAMPLE, "#ededed")
|
||||
expect(out).toContain('stroke="#ededed"')
|
||||
})
|
||||
|
||||
test("replaces stroke with muted color", () => {
|
||||
const out = recolorSvg(SAMPLE, "#a1a1aa")
|
||||
expect(out).toContain('stroke="#a1a1aa"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("recolor — stripSvgWrapper", () => {
|
||||
test("extracts inner content", () => {
|
||||
const inner = stripSvgWrapper(SAMPLE)
|
||||
expect(inner).toContain("<path")
|
||||
expect(inner).not.toContain("<svg")
|
||||
})
|
||||
})
|
||||
57
.opencode/draw-image/tests/render.integration.test.ts
Normal file
57
.opencode/draw-image/tests/render.integration.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, test, expect, beforeAll } from "vitest"
|
||||
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { loadBrand } from "../src/config"
|
||||
import { loadTemplate, buildSvg, computeHash } from "../src/render"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||
const TMP = "/tmp/draw-image-integration"
|
||||
|
||||
beforeAll(() => {
|
||||
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||
mkdirSync(TMP, { recursive: true })
|
||||
})
|
||||
|
||||
function renderToPng(svg: string, outPath: string): void {
|
||||
const tmpSvg = path.join(TMP, "input.svg")
|
||||
require("node:fs").writeFileSync(tmpSvg, svg)
|
||||
const r = spawnSync("node", [path.join(DRAW_IMAGE_DIR, "render.mjs"), tmpSvg, outPath], {
|
||||
encoding: "utf-8",
|
||||
})
|
||||
if (r.status !== 0) throw new Error(`render.mjs failed: ${r.stderr || r.stdout}`)
|
||||
}
|
||||
|
||||
describe("integration — render pipeline", () => {
|
||||
test("renders valid PNG 1024x1024", () => {
|
||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
||||
const args = { template: "cover", title: "Test", out: path.join(TMP, "cover.png") }
|
||||
const hash = computeHash(brand, templateSvg, args)
|
||||
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
|
||||
|
||||
const outPath = path.join(TMP, "cover.png")
|
||||
renderToPng(svg, outPath)
|
||||
|
||||
expect(existsSync(outPath)).toBe(true)
|
||||
const buf = readFileSync(outPath)
|
||||
expect(buf[0]).toBe(0x89)
|
||||
expect(buf[1]).toBe(0x50)
|
||||
expect(buf[2]).toBe(0x4e)
|
||||
expect(buf[3]).toBe(0x47)
|
||||
|
||||
expect(hash).toHaveLength(64)
|
||||
})
|
||||
|
||||
test("creates output directory recursively", () => {
|
||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
||||
const args = { template: "cover", title: "Deep", out: path.join(TMP, "a", "b", "c", "cover.png") }
|
||||
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
|
||||
const outPath = path.join(TMP, "a", "b", "c", "cover.png")
|
||||
renderToPng(svg, outPath)
|
||||
expect(existsSync(outPath)).toBe(true)
|
||||
})
|
||||
})
|
||||
58
.opencode/draw-image/tests/slot-parser.test.ts
Normal file
58
.opencode/draw-image/tests/slot-parser.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, test, expect } from "vitest"
|
||||
import { parseSlots } from "../src/slot-parser"
|
||||
|
||||
describe("slot-parser — parseSlots", () => {
|
||||
test("parses icon slot with all defaults", () => {
|
||||
const svg = `<!-- slot: name=icon, x=312, y=200, w=400, h=400, fit=contain, recolor=accent -->`
|
||||
const slots = parseSlots(svg)
|
||||
expect(slots).toHaveLength(1)
|
||||
expect(slots[0].name).toBe("icon")
|
||||
expect(slots[0].x).toBe(312)
|
||||
expect(slots[0].y).toBe(200)
|
||||
expect(slots[0].w).toBe(400)
|
||||
expect(slots[0].h).toBe(400)
|
||||
expect(slots[0].fit).toBe("contain")
|
||||
expect(slots[0].recolor).toBe("accent")
|
||||
expect(slots[0].bg).toBe("none")
|
||||
expect(slots[0].border).toBe("none")
|
||||
expect(slots[0].radius).toBe(0)
|
||||
})
|
||||
|
||||
test("parses badge slot with bg+border+radius", () => {
|
||||
const svg = `<!-- slot: name=badge, x=780, y=780, w=180, h=180, fit=contain, recolor=none, bg=surface, border=accent, radius=0.5 -->`
|
||||
const slots = parseSlots(svg)
|
||||
expect(slots).toHaveLength(1)
|
||||
expect(slots[0].name).toBe("badge")
|
||||
expect(slots[0].recolor).toBe("none")
|
||||
expect(slots[0].bg).toBe("surface")
|
||||
expect(slots[0].border).toBe("accent")
|
||||
expect(slots[0].radius).toBe(0.5)
|
||||
})
|
||||
|
||||
test("parses multiple slots", () => {
|
||||
const svg = `
|
||||
<!-- slot: name=icon, x=312, y=180, w=400, h=400, fit=contain, recolor=accent -->
|
||||
<!-- slot: name=sub-icon, x=412, y=600, w=200, h=200, fit=contain, recolor=accent -->
|
||||
<!-- slot: name=badge, x=780, y=780, w=180, h=180, fit=contain, recolor=none, bg=surface, border=accent, radius=0.5 -->
|
||||
`
|
||||
const slots = parseSlots(svg)
|
||||
expect(slots).toHaveLength(3)
|
||||
expect(slots.map((s) => s.name)).toEqual(["icon", "sub-icon", "badge"])
|
||||
})
|
||||
|
||||
test("defaults fit to contain when omitted", () => {
|
||||
const svg = `<!-- slot: name=icon, x=0, y=0, w=100, h=100 -->`
|
||||
const slots = parseSlots(svg)
|
||||
expect(slots[0].fit).toBe("contain")
|
||||
expect(slots[0].recolor).toBe("none")
|
||||
})
|
||||
|
||||
test("returns empty for no slots", () => {
|
||||
expect(parseSlots("<svg></svg>")).toEqual([])
|
||||
})
|
||||
|
||||
test("skips slot without name", () => {
|
||||
const svg = `<!-- slot: x=0, y=0, w=100, h=100 -->`
|
||||
expect(parseSlots(svg)).toEqual([])
|
||||
})
|
||||
})
|
||||
75
.opencode/draw-image/tests/slot.test.ts
Normal file
75
.opencode/draw-image/tests/slot.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, test, expect, beforeAll } from "vitest"
|
||||
import { existsSync, rmSync, mkdirSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { loadBrand } from "../src/config"
|
||||
import { loadTemplate, buildSvg } from "../src/render"
|
||||
import { parseSlots } from "../src/slot-parser"
|
||||
import { resolveSlotContent } from "../src/resolve"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||
|
||||
beforeAll(() => {
|
||||
const tmp = "/tmp/draw-image-slot"
|
||||
if (existsSync(tmp)) rmSync(tmp, { recursive: true, force: true })
|
||||
mkdirSync(tmp, { recursive: true })
|
||||
})
|
||||
|
||||
describe("integration — slot resolution", () => {
|
||||
test("icon=mic resolves lucide icon and recolors to accent", () => {
|
||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
||||
const slots = parseSlots(templateSvg)
|
||||
const iconSlot = slots.find((s) => s.name === "icon")!
|
||||
expect(iconSlot).toBeDefined()
|
||||
|
||||
const resolved = resolveSlotContent(iconSlot, brand, DRAW_IMAGE_DIR, "mic")
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved!.inner).toContain("<path")
|
||||
expect(resolved!.rootAttrs.stroke).toBe("#ccff00")
|
||||
expect(resolved!.contentW).toBe(24)
|
||||
expect(resolved!.contentH).toBe(24)
|
||||
})
|
||||
|
||||
test("icon=play resolves lucide icon", () => {
|
||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
||||
const slots = parseSlots(templateSvg)
|
||||
const iconSlot = slots.find((s) => s.name === "icon")!
|
||||
const resolved = resolveSlotContent(iconSlot, brand, DRAW_IMAGE_DIR, "play")
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved!.inner).toContain("<path")
|
||||
})
|
||||
|
||||
test("badge slot has bg=surface and border=accent", () => {
|
||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
||||
const slots = parseSlots(templateSvg)
|
||||
const badgeSlot = slots.find((s) => s.name === "badge")!
|
||||
expect(badgeSlot.bg).toBe("surface")
|
||||
expect(badgeSlot.border).toBe("accent")
|
||||
expect(badgeSlot.radius).toBe(0.5)
|
||||
})
|
||||
|
||||
test("buildSvg inserts slot content into final SVG", () => {
|
||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
||||
const svg = buildSvg(templateSvg, brand, {
|
||||
template: "cover",
|
||||
title: "With Icon",
|
||||
slots: { icon: "mic" },
|
||||
}, DRAW_IMAGE_DIR)
|
||||
expect(svg).toContain("<path")
|
||||
expect(svg).toContain('stroke="#ccff00"')
|
||||
expect(svg).not.toContain("<!-- slot:")
|
||||
expect(svg).toContain("With Icon")
|
||||
})
|
||||
|
||||
test("buildSvg without slots renders template defaults", () => {
|
||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
||||
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Icon" }, DRAW_IMAGE_DIR)
|
||||
expect(svg).toContain("No Icon")
|
||||
expect(svg).not.toContain("<!-- slot:")
|
||||
})
|
||||
})
|
||||
15
.opencode/draw-image/tsconfig.json
Normal file
15
.opencode/draw-image/tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "cli.ts", "tests/**/*.ts"]
|
||||
}
|
||||
9
.opencode/draw-image/vitest.config.ts
Normal file
9
.opencode/draw-image/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 30000,
|
||||
},
|
||||
})
|
||||
32
.opencode/tools/draw-image.ts
Normal file
32
.opencode/tools/draw-image.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { spawnSync } from "child_process"
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import path from "path"
|
||||
|
||||
export default tool({
|
||||
description: "Render an on-brand cover PNG from an SVG template. Resolves Lucide icons and brand logos into slots, applies brand palette, renders via sharp. Returns { path, hash, status }.",
|
||||
args: {
|
||||
template: tool.schema.string().describe("Template name (file in templates/ without .svg extension, e.g. 'cover')"),
|
||||
title: tool.schema.string().describe("Title text rendered at the bottom of the cover"),
|
||||
subtitle: tool.schema.string().optional().describe("Optional subtitle text below the title"),
|
||||
slots: tool.schema.string().optional().describe("Comma-separated slot assignments, e.g. 'icon=mic,sub-icon=opencode,badge=./x.svg'"),
|
||||
out: tool.schema.string().optional().describe("Output PNG path (default: ./assets/cover.png)"),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const drawImageDir = path.resolve(import.meta.dir, "..", "draw-image")
|
||||
const cliPath = path.join(drawImageDir, "cli.ts")
|
||||
|
||||
const cliArgs = ["render", args.template, "--title", args.title]
|
||||
if (args.subtitle) cliArgs.push("--subtitle", args.subtitle)
|
||||
if (args.slots) cliArgs.push("--slots", args.slots)
|
||||
if (args.out) cliArgs.push("--out", args.out)
|
||||
|
||||
const r = spawnSync("node", ["--experimental-strip-types", cliPath, ...cliArgs], {
|
||||
encoding: "utf-8",
|
||||
cwd: context.worktree,
|
||||
})
|
||||
if (r.status !== 0) {
|
||||
return `⚠️ draw-image failed (exit ${r.status}): ${r.stderr || r.stdout}`
|
||||
}
|
||||
return r.stdout.trim()
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# ADR-060: SVG template renderer for on-brand covers
|
||||
|
||||
## Статус
|
||||
Accepted (2026-07-29)
|
||||
|
||||
## Контекст
|
||||
В продуктовых репо slaid098 нет ни одного cover-ассета. Обложки для slaid098.dev рисовались вручную или генерировались fallback-функцией (svgFallback в route.ts — HSL-градиент + буква, off-brand). Нужна утилита, которая берёт SVG-шаблон со слотами, подставляет палитру из brand.json + контент из args, рендерит PNG через sharp с bundled Geist TTF — always on-brand.
|
||||
|
||||
Архитектурный выбор: размещение в `.opencode/` (opencode plugin + render-движок) через volume bind-mount → видна во всех воркспейсах глобально. Отдельный package.json в `.opencode/draw-image/` (deps: sharp, lucide-static) — не загрязняет `.opencode/package.json` (используется для tools/*.ts).
|
||||
|
||||
## Решение
|
||||
1. **opencode plugin** (`.opencode/tools/draw-image.ts`) — spawnSync wrapper вокруг `node cli.ts render ...`, возвращает `{ path, hash, status }`
|
||||
2. **Слот-система** — HTML-комментарии в SVG-шаблоне парсятся в SlotSpec (name/x/y/w/h/fit/recolor/bg/border/radius). Контент резолвится: Lucide lookup → brand-logos lookup → file path
|
||||
3. **Render pipeline** — buildSvg (assemble) → render.mjs (sharp → PNG 1024×1024, fontFiles: Geist TTF)
|
||||
4. **Идемпотентность** — cover.meta.json с sha256(brand+template+args), skip при совпадении хеша
|
||||
5. **Lucide-иконки** — npm-пакет lucide-static, postinstall sync в icons/lucide/, recolor stroke → accent
|
||||
6. **Шрифты** — Geist Sans TTF bundled в fonts/, переданы в sharp через fontFiles (без этого fallback = off-brand)
|
||||
7. **Тесты** — vitest (48: unit + integration + e2e) + pytest (5: tool wrapper via _ts_loader.mjs)
|
||||
|
||||
## Альтернативы
|
||||
- **@resvg/resvg-js вместо sharp** — sharp уже используется в экосистеме, fontFiles опция покрывает потребность. resvg был бы отдельной dep.
|
||||
- **Шрифты через fontconfig (системная установка)** — негибко, требует root в CI. Bundle TTF в fonts/ + fontFiles опция sharp — self-contained.
|
||||
- **Шаблоны в коде (не SVG-файлы)** — менее гибко, требует правок кода для нового шаблона. SVG-файлы в templates/ — ноль правок кода.
|
||||
- **Отдельный package.json vs расширить .opencode/package.json** — отдельный package.json изолирует deps (sharp тяжёлая native dep) от tools/*.ts.
|
||||
29
docs/handoff/pr-134-draw-image-svg-template-renderer.md
Normal file
29
docs/handoff/pr-134-draw-image-svg-template-renderer.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
pr: 134
|
||||
title: feat(draw-image): SVG template renderer for on-brand covers
|
||||
---
|
||||
|
||||
## Что сделано
|
||||
Реализован плагин `draw-image` для генерации on-brand обложек (covers) в стиле slaid098.dev. Структура:
|
||||
- `.opencode/tools/draw-image.ts` — opencode plugin (spawnSync → cli.ts)
|
||||
- `.opencode/draw-image/` — render-движок: render.mjs (sharp → PNG), cli.ts, src/ (config/slot-parser/fit/recolor/resolve/render), templates/cover.svg, brand.json, fonts/ (Geist TTF), icons/lucide/ (2007 иконок), brand-logos/ (пусто v1)
|
||||
- Слот-система: HTML-комментарии в SVG → SlotSpec (name/x/y/w/h/fit/recolor/bg/border/radius)
|
||||
- Lucide-иконки: npm-пакет lucide-static, postinstall sync в icons/lucide/, recolor stroke → accent
|
||||
- Шрифты: Geist Sans TTF bundled, переданы в sharp через fontFiles
|
||||
- Идемпотентность: cover.meta.json с sha256(brand+template+args)
|
||||
- Тесты: 48 vitest (unit + integration + e2e) + 5 pytest (tool wrapper via _ts_loader.mjs)
|
||||
- CI: npm ci + npm test в .opencode/draw-image добавлены в test job
|
||||
- dependabot.yml: битый `/config` → `/.opencode` + `/.opencode/draw-image`
|
||||
- docs/project-map/README.md обновлён
|
||||
|
||||
## Почему
|
||||
В продуктовых репо slaid098 нет ни одного cover-ассета — обложки рисовались вручную или отсутствовали. Утилита обеспечивает always on-brand генерацию: цвета захардкожены в шаблоне, Geist TTF bundled, Lucide line-icons recolor в accent. Эталон — covers из slaid098-dev/src/apps/.
|
||||
|
||||
## Pending
|
||||
—
|
||||
|
||||
## Watch out
|
||||
- `import.meta.dir` в draw-image.ts (не `import.meta.url`) — _ts_loader.mjs заменяет только `.dir`
|
||||
- CLI использует `--experimental-strip-types` (Node 22+) для запуска .ts напрямую
|
||||
- lucide-static postinstall копирует 2007 SVG в icons/lucide/ (committим только mic/play, остальные через npm)
|
||||
- sharp fontFiles требует Geist TTF в fonts/ (committим Regular + Bold)
|
||||
|
|
@ -46,9 +46,10 @@ opencode-config/
|
|||
│ │ ├── create-issue.ts # create-issue tool wrapper (3 args, validates format+labels; optional repo?: string) — PR#38, PR#65
|
||||
│ │ ├── create-pr.ts # create-pr tool wrapper (3 args, validates format+Closes #N; optional repo?: string) — PR#38, PR#65
|
||||
│ │ ├── create-readme.ts # create-readme tool (TS plugin, modes: create/validate; standardized bilingual README with features table, include_clone/development_en/ru optional params, RU heading 'Русский' + anchor checks, 4 delimiter pairs for slaid098.dev; local fs + remote gh api) — PR#112, PR#116, PR#118, PR#130
|
||||
│ │ ├── draw-image.ts # draw-image tool wrapper (opencode plugin, 5 args: template/title/subtitle?/slots?/out?; spawnSync node cli.ts render → sharp PNG) — PR#133
|
||||
│ │ ├── merge-pr.ts # merge-pr tool wrapper (orchestrator-safe gh pr merge; optional repo?: string) — PR#30, PR#65
|
||||
│ │ ├── memory-access.ts # memory-access tool (bump frontmatter last_accessed/access_count, regex replace, atomic write tmp+rename) — PR#101
|
||||
│ │ ├── memory-doctor.ts # memory-doctor tool (read-only diagnostics: rg {lines, rgWorks} G9, allowSystemFallback:true G5, arch-mismatch detection G2, npm-missing hint G3; markdown ✅/❌ report) — PR#101, PR#124
|
||||
│ │ ├── memory-doctor.ts # memory-doctor tool (read-only diagnostics: rg {lines, rgWorks} G9, allowSystemFallback:true G5, arch-mismatch detection G2; npm-missing hint G3; markdown ✅/❌ report) — PR#101, PR#124
|
||||
│ │ ├── memory-list.ts # memory-list tool (pure TS, categories count .md or files in category with frontmatter) — PR#101
|
||||
│ │ ├── memory-save.ts # memory-save tool (auto-setup mkdir+git init/clone+hook+7 categories, git commit, async reindex via spawn detached+unref) — PR#101
|
||||
│ │ ├── memory-search.ts # memory-search tool (ripgrep keyword + Python semantic via spawnSync + scoring port + cross-category fallback; stderr warning при rgBin===null) — PR#101, PR#124
|
||||
|
|
@ -57,6 +58,37 @@ opencode-config/
|
|||
│ │ ├── post-review.ts # post-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Code Review Summary heading; optional repo?: string) — PR#46, PR#65
|
||||
│ │ ├── spec-status.ts # spec-status tool wrapper
|
||||
│ │ └── tunnel.ts # Cloudflare tunnel toggle tool (start/stop без args) — PR#34
|
||||
│ ├── draw-image/ # SVG template renderer for on-brand covers (sharp + lucide-static) — PR#133
|
||||
│ │ ├── package.json # deps: sharp, lucide-static; devDeps: vitest, typescript; postinstall sync-lucide
|
||||
│ │ ├── tsconfig.json # ES2022 Bundler, strict, noEmit
|
||||
│ │ ├── vitest.config.ts # node env, tests/**/*.test.ts
|
||||
│ │ ├── render.mjs # SVG string → PNG via sharp (fontFiles: Geist TTF bundle)
|
||||
│ │ ├── cli.ts # CLI entry: render <template> --title --slots --out
|
||||
│ │ ├── brand.json # default palette slaid098 (base/surface/fg/muted/accent/line)
|
||||
│ │ ├── templates/cover.svg # 1024×1024 cover template (slots: icon + sub-icon + badge, {{title}}/{{subtitle}})
|
||||
│ │ ├── fonts/ # Geist Sans TTF (Regular + Bold) bundled for sharp fontFiles
|
||||
│ │ ├── icons/lucide/ # 2007 Lucide SVG icons (synced from npm lucide-static via postinstall)
|
||||
│ │ ├── brand-logos/ # brand SVG logos (empty in v1, .gitkeep)
|
||||
│ │ ├── scripts/sync-lucide.mjs # postinstall: copy icons from node_modules/lucide-static → icons/lucide/
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── config.ts # loadBrand + validateBrand (zod-style hex validation)
|
||||
│ │ │ ├── slot-parser.ts # parseSlots: HTML comments → SlotSpec objects
|
||||
│ │ │ ├── fit.ts # computeFit (contain/cover/stretch) + parseViewBox
|
||||
│ │ │ ├── recolor.ts # recolorSvg (stroke/fill → target) + stripSvgWrapper + extractRootAttrs
|
||||
│ │ │ ├── resolve.ts # resolveSlotContent (lucide/brand-logo/file lookup) + renderSlotSvg
|
||||
│ │ │ └── render.ts # buildSvg (assemble final SVG) + computeHash (sha256 idempotency)
|
||||
│ │ └── tests/ # vitest: 48 tests (unit + integration + e2e)
|
||||
│ │ ├── config.test.ts
|
||||
│ │ ├── slot-parser.test.ts
|
||||
│ │ ├── fit.test.ts
|
||||
│ │ ├── recolor.test.ts
|
||||
│ │ ├── cli.test.ts
|
||||
│ │ ├── render.integration.test.ts
|
||||
│ │ ├── idempotency.test.ts
|
||||
│ │ ├── slot.test.ts
|
||||
│ │ ├── e2e.test.ts
|
||||
│ │ ├── e2e.no-icon.test.ts
|
||||
│ │ └── e2e.bad-input.test.ts
|
||||
│ ├── scripts/
|
||||
│ │ ├── check-adr-refs.py # ADR cross-reference validator (adr-check.yml)
|
||||
│ │ ├── check-permissions.py # Permissions validator (permissions-check.yml)
|
||||
|
|
@ -94,6 +126,8 @@ opencode-config/
|
|||
│ ├── test_create_issue_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#38, PR#65
|
||||
│ ├── test_create_pr_tool.py # .opencode/tools/create-pr.ts (via _ts_loader.mjs exec_stub_json; +repo explicit/omitted/invalid) — PR#38, PR#65
|
||||
│ ├── test_create_pr_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#38, PR#65
|
||||
│ ├── test_draw_image_tool.py # .opencode/tools/draw-image.ts (via _ts_loader.mjs exec_stub_json; 5 tests: load/valid/failure/cwd/all-args) — PR#133
|
||||
│ ├── test_draw_image_tool.ts # TS wrapper test (mjs loader; 3 tests: valid/failure/cwd) — PR#133
|
||||
│ ├── test_embedder.py # src/memory/embedder.py (mocks OPENAI_BASE_URL)
|
||||
│ ├── test_embedder_live.py # Live embed tests (skip without RUN_LIVE=1)
|
||||
│ ├── test_index.py # src/memory/index.py (chunking + env override + .rag skip)
|
||||
|
|
|
|||
132
tests/test_draw_image_tool.py
Normal file
132
tests/test_draw_image_tool.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""Tests for .opencode/tools/draw-image.ts — the draw-image custom tool.
|
||||
|
||||
Mirrors tests/test_commit_tool.py: exercises the tool's ``execute()`` function
|
||||
via ``tests/_ts_loader.mjs`` using the ``exec_stub_json`` mode (multi-arg tools).
|
||||
|
||||
The loader is parameterized via the ``TS_FILE`` env var. These tests set
|
||||
``TS_FILE=.opencode/tools/draw-image.ts``.
|
||||
|
||||
Modes used:
|
||||
- ``load`` — sanity-check that the tool loads and declares args.
|
||||
- ``exec_stub_json`` — call execute with a stubbed spawnSync to verify:
|
||||
(a) success path: valid args + spawnSync exit 0 → JSON result string,
|
||||
(b) failure path: spawnSync exit 1 → error string with "draw-image failed",
|
||||
(c) cwd propagation: spawnSync opts contain cwd=context.worktree.
|
||||
|
||||
draw-image.ts makes 1 spawnSync call (node cli.ts render ...). The stub
|
||||
sequencer returns responses in order per call.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs"
|
||||
TS_FILE = REPO_ROOT / ".opencode" / "tools" / "draw-image.ts"
|
||||
TS_FILE_REL = ".opencode/tools/draw-image.ts"
|
||||
|
||||
TMP_OUT = os.path.join(tempfile.gettempdir(), "out.png")
|
||||
TMP_CUSTOM = os.path.join(tempfile.gettempdir(), "custom.png")
|
||||
|
||||
OK_RESPONSE = {
|
||||
"status": 0,
|
||||
"stdout": '{"path":"/tmp/out.png","hash":"abc123","status":"rendered"}',
|
||||
"stderr": "",
|
||||
}
|
||||
FAIL_RESPONSE = {"status": 1, "stdout": "", "stderr": "render failed"}
|
||||
|
||||
|
||||
def _run_loader(*args: str) -> dict:
|
||||
"""Invoke the loader with TS_FILE env set to draw-image.ts and parse JSON stdout."""
|
||||
env = {**os.environ, "TS_FILE": TS_FILE_REL}
|
||||
proc = subprocess.run(
|
||||
["node", str(LOADER), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=str(REPO_ROOT),
|
||||
timeout=60,
|
||||
env=env,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"_ts_loader.mjs {' '.join(args)} failed (exit {proc.returncode}):\n"
|
||||
f"stdout: {proc.stdout}\nstderr: {proc.stderr}"
|
||||
)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _run_exec(args: dict, responses: list[dict]) -> dict:
|
||||
"""Helper: exec_stub_json mode with JSON args + sequential stub responses."""
|
||||
return _run_loader("exec_stub_json", json.dumps(args), json.dumps(responses))
|
||||
|
||||
|
||||
def test_loader_can_load_tool():
|
||||
"""Sanity: draw-image.ts loads and declares the expected arguments."""
|
||||
if not TS_FILE.exists():
|
||||
pytest.skip("draw-image.ts not present")
|
||||
out = _run_loader("load")
|
||||
assert "description" in out
|
||||
assert "template" in out["args"]
|
||||
assert "title" in out["args"]
|
||||
assert "subtitle" in out["args"]
|
||||
assert "slots" in out["args"]
|
||||
assert "out" in out["args"]
|
||||
|
||||
|
||||
def test_valid_render():
|
||||
"""execute() with valid args + spawnSync exit 0 returns JSON result string.
|
||||
|
||||
draw-image.ts makes 1 spawnSync call: node cli.ts render ...
|
||||
"""
|
||||
out = _run_exec({"template": "cover", "title": "Test"}, [OK_RESPONSE])
|
||||
result = out["result"]
|
||||
assert "rendered" in result, f"expected rendered status, got: {result!r}"
|
||||
assert TMP_OUT in result, f"expected path in result, got: {result!r}"
|
||||
|
||||
|
||||
def test_render_failure():
|
||||
"""execute() with spawnSync exit 1 returns error string."""
|
||||
out = _run_exec({"template": "cover", "title": "Test"}, [FAIL_RESPONSE])
|
||||
result = out["result"]
|
||||
assert "draw-image failed" in result, f"expected failure error, got: {result!r}"
|
||||
|
||||
|
||||
def test_execute_uses_cwd_from_context():
|
||||
"""execute passes cwd=context.worktree to spawnSync (ADR-023 pattern)."""
|
||||
out = _run_exec({"template": "cover", "title": "Test"}, [OK_RESPONSE])
|
||||
calls = out["calls"]
|
||||
assert len(calls) >= 1, f"expected >=1 spawnSync call, got {len(calls)}"
|
||||
opts = calls[0]["opts"]
|
||||
assert opts is not None, "spawnSync called without opts — expected cwd kwarg"
|
||||
assert "cwd" in opts, f"opts missing 'cwd' key — got: {opts}"
|
||||
assert opts["cwd"] == str(REPO_ROOT), (
|
||||
f"cwd must equal context.worktree ({REPO_ROOT}), got: {opts['cwd']!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_passes_all_args_to_cli():
|
||||
"""execute builds CLI args from all provided fields."""
|
||||
args = {
|
||||
"template": "cover",
|
||||
"title": "My Title",
|
||||
"subtitle": "Sub",
|
||||
"slots": "icon=mic",
|
||||
"out": TMP_CUSTOM,
|
||||
}
|
||||
out = _run_exec(args, [OK_RESPONSE])
|
||||
calls = out["calls"]
|
||||
assert len(calls) >= 1
|
||||
cli_args = calls[0]["args"]
|
||||
cli_args_str = " ".join(cli_args)
|
||||
assert "render" in cli_args_str
|
||||
assert "cover" in cli_args_str
|
||||
assert "My Title" in cli_args_str
|
||||
assert "Sub" in cli_args_str
|
||||
assert "icon=mic" in cli_args_str
|
||||
assert TMP_CUSTOM in cli_args_str
|
||||
70
tests/test_draw_image_tool.ts
Normal file
70
tests/test_draw_image_tool.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Tests for .opencode/tools/draw-image.ts — the draw-image custom tool.
|
||||
*
|
||||
* Mirror of tests/test_commit_tool.ts: the tool is a spawnSync wrapper
|
||||
* around `node cli.ts render ...`. Runtime note: opencode ships a standalone
|
||||
* binary with Bun bundled inside; there is no separate `bun` CLI on the host
|
||||
* (CI runner uses node + pytest). The CI runs the equivalent Python tests in
|
||||
* tests/test_draw_image_tool.py via the JS loader tests/_ts_loader.mjs
|
||||
* (exec_stub_json mode for multi-arg tools). This file documents the intended
|
||||
* TS-side test cases and is runnable under `bun test` once a bun runtime is
|
||||
* available on the host.
|
||||
*
|
||||
* Test cases (mirror tests/test_draw_image_tool.py):
|
||||
* - test_loader_can_load_tool — tool loads, declares args
|
||||
* - test_valid_render — valid args + spawnSync exit 0 → JSON result
|
||||
* - test_render_failure — spawnSync exit 1 → error string
|
||||
* - test_execute_uses_cwd_from_context — cwd=context.worktree propagated
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock } from "bun:test" with { type: "'bun-test'" }
|
||||
import { spawnSync } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
const TOOL_SRC = path.resolve(import.meta.dir, "..", ".opencode", "tools", "draw-image.ts")
|
||||
|
||||
function ctx() {
|
||||
return {
|
||||
sessionID: "t", messageID: "t", agent: "t",
|
||||
directory: ".", worktree: ".",
|
||||
abort: new AbortController().signal,
|
||||
metadata() {}, async ask() {},
|
||||
}
|
||||
}
|
||||
|
||||
describe("draw-image tool", () => {
|
||||
test("test_valid_render — valid args + spawnSync exit 0 returns JSON", async () => {
|
||||
const okResponse = '{"path":"/tmp/out.png","hash":"abc123","status":"rendered"}'
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: () => ({ status: 0, stdout: okResponse, stderr: "" }),
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
const result = await mod.default.execute({ template: "cover", title: "Test" }, ctx())
|
||||
expect(result).toContain("rendered")
|
||||
expect(result).toContain("/tmp/out.png")
|
||||
})
|
||||
|
||||
test("test_render_failure — spawnSync exit 1 returns error", async () => {
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: () => ({ status: 1, stdout: "", stderr: "render failed" }),
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
const result = await mod.default.execute({ template: "cover", title: "Test" }, ctx())
|
||||
expect(result).toContain("draw-image failed")
|
||||
})
|
||||
|
||||
test("test_execute_uses_cwd_from_context — cwd propagated to spawnSync", async () => {
|
||||
const okResponse = '{"path":"./out.png","hash":"abc","status":"rendered"}'
|
||||
let capturedOpts: any = null
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: (_cmd: string, _args: string[], opts: any) => {
|
||||
capturedOpts = opts
|
||||
return { status: 0, stdout: okResponse, stderr: "" }
|
||||
},
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
await mod.default.execute({ template: "cover", title: "Test" }, ctx())
|
||||
expect(capturedOpts).not.toBeNull()
|
||||
expect(capturedOpts.cwd).toBe(".")
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue