16 KiB
16 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 41-diagrams-icons-theme-engine | 02 | execute | 2 |
|
|
true |
|
|
Purpose: Server-side rendering engines for diagrams and icons, consumed by the job runner. Output: Two renderer files with tests covering LLM synthesis, security, bundle structure, and rasterization.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/41-diagrams-icons-theme-engine/41-RESEARCH.md @.planning/phases/41-diagrams-icons-theme-engine/41-01-SUMMARY.md ```typescript export interface RenderResult { filename: string; contentType: string; buffer: Buffer; }export interface DiagramBundle { type: "diagram-bundle"; svgBase64: string; pngBase64: string; mermaidSource: string; stripped: boolean; }
export interface IconSetBundle { type: "icon-set-bundle"; style: "outline" | "filled" | "rounded"; icons: Array<{ name: string; svgSource: string; pngs: Record<string, string>; }>; }
<!-- Existing pattern from server/src/routes/org-chart-svg.ts -->
```typescript
// sharp(svgBuffer, { density: 144 }).resize(width).png().toBuffer()
import { JSDOM } from "jsdom";
import DOMPurify from "dompurify";
const { window } = new JSDOM("");
const purify = DOMPurify(window as unknown as Window);
const cleanSvg = purify.sanitize(rawSvg, { USE_PROFILES: { svg: true } });
Task 1: Diagram renderer with LLM synthesis + Playwright headless Mermaid + security stripping + tests
server/src/services/renderers/diagram-renderer.ts, server/src/__tests__/diagram-renderer.test.ts
server/src/services/renderers/types.ts, server/src/routes/org-chart-svg.ts, ui/src/components/MarkdownBody.tsx, server/src/services/content-job-runner.ts, server/src/services/chat.ts
- stripUnsafeDirectives("graph TD\n A-->B\n click A \"https://evil.com\"") returns { cleaned: "graph TD\n A-->B", stripped: true }
- stripUnsafeDirectives("%%{init: {\"theme\": \"dark\"}}%%\ngraph TD\n A-->B") returns cleaned without %%{init}%% block, stripped: true
- stripUnsafeDirectives("graph TD\n A-->B") returns { cleaned: "graph TD\n A-->B", stripped: false }
- buildDiagramPrompt("A login flow with validation", "flowchart") returns a system+user prompt instructing the LLM to output valid Mermaid flowchart syntax
- buildDiagramPrompt with diagramType "architecture" includes architecture-specific preamble hints
- renderDiagram({ prompt: "A login flow", diagramType: "flowchart", darkMode: false }) calls LLM to get Mermaid source, then renders via Playwright, returns RenderResult with DiagramBundle JSON
- renderDiagram with LLM-generated source containing click + init directives sets stripped=true in bundle
1. Create `server/src/__tests__/diagram-renderer.test.ts`:
- Unit tests for `stripUnsafeDirectives` (pure function, no mocks needed):
- Test strips `%%{init}%%` blocks
- Test strips `click NodeId "url"` lines
- Test strips `click NodeId call fn()` lines
- Test leaves clean source unchanged, stripped=false
- Test strips both init and click simultaneously
- Unit tests for `buildDiagramPrompt`:
- Test returns string containing "flowchart" when diagramType is "flowchart"
- Test returns string containing "architecture" when diagramType is "architecture"
- Test includes the user's natural language description in the prompt
- Test instructs LLM to output ONLY valid Mermaid syntax (no markdown fences, no explanation)
- Integration test for `renderDiagram` (mock BOTH the LLM inference call AND playwright-core's chromium.launch):
- Mock the LLM/chat inference function to return a Mermaid source string like `"graph TD\n A[Login]-->B[Validate]\n B-->C[Dashboard]"`
- Mock `chromium.launch` to return `{ newPage: () => page, close: async () => {} }`
- Mock `page.setContent`, `page.waitForSelector`, `page.$eval` to return a simple SVG string ``
- Assert result is JSON with `type: "diagram-bundle"`, has `svgBase64`, `pngBase64`, `mermaidSource`, `stripped`
- Assert the mermaidSource in the bundle is the LLM-generated Mermaid (not the original natural language prompt)
- Assert `browser.close()` is called (cleanup verification)
- Create
server/src/services/renderers/diagram-renderer.ts:stripUnsafeDirectives(source: string): { cleaned: string; stripped: boolean }:INIT_BLOCK_RE = /%%\{[\s\S]*?\}%%/gCLICK_LINE_RE = /^\s*click\s+.*/gim- Apply both regexes, trim, compare to determine stripped
buildDiagramPrompt(description: string, diagramType: string): { system: string; user: string }:- System prompt: "You are a Mermaid diagram generator. Output ONLY valid Mermaid syntax. No markdown fences, no explanation, no comments. The output must be directly parseable by mermaid.render()."
- Include diagram-type-specific preamble hints:
- "flowchart": "Use graph TD or graph LR syntax"
- "sequence": "Use sequenceDiagram syntax with participant declarations"
- "erd": "Use erDiagram syntax with entity relationships"
- "architecture": "Use architecture-beta syntax with groups and services"
- "mindmap": "Use mindmap syntax with indented hierarchy"
- User prompt: the natural language description from the user
resolveBrowserPath(): string:- Check env
PLAYWRIGHT_BROWSERS_PATHfirst - Fall back to glob
~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome - Throw clear error if not found: "Playwright Chromium not found. Run npx playwright install chromium"
- Check env
buildMermaidHtml(source: string, darkMode: boolean): string:- Returns HTML page that loads mermaid from CDN (
https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js) - Calls
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: darkMode ? "dark" : "default" }) - Calls
mermaid.render("render", source)and sets innerHTML of#renderdiv
- Returns HTML page that loads mermaid from CDN (
renderDiagram(input: Record<string, unknown>): Promise<RenderResult>:- Extract
prompt(string, natural language description),diagramType(string, default "flowchart"),darkMode(boolean, default false) from input - LLM SYNTHESIS STEP (DIAG-01): Call
buildDiagramPrompt(prompt, diagramType)to construct the LLM prompt, then call the LLM via the existing chat inference pattern (model after how renderIconSet calls the LLM — look at chat.ts or similar). The LLM returns Mermaid syntax from the natural language description. - If input contains a
sourcefield (string) instead ofprompt, skip the LLM step and use the provided Mermaid source directly (this is the re-render path from DiagramSourcePanel where the user edits Mermaid source and re-submits) - Strip unsafe directives from the Mermaid source (whether LLM-generated or user-provided)
- Launch Playwright chromium (headless, executablePath from resolveBrowserPath)
- Set page content with buildMermaidHtml, wait for
#render svg(15s timeout) - Extract SVG innerHTML via page.$eval
- Sanitize SVG with DOMPurify (USE_PROFILES: { svg: true })
- Rasterize to PNG via
new Resvg(svgClean, { dpi: 144 }).render().asPng() - Build DiagramBundle JSON: svgBase64, pngBase64, mermaidSource (the cleaned Mermaid syntax, NOT the original prompt), stripped
- Return
{ filename: "diagram-bundle.json", contentType: "application/json", buffer: Buffer.from(JSON.stringify(bundle)) } - ALWAYS close browser in finally block cd /opt/nexus && pnpm --filter server exec vitest run src/tests/diagram-renderer.test.ts <acceptance_criteria>
- Extract
grep -c "buildDiagramPrompt" server/src/services/renderers/diagram-renderer.tsreturns at least 2 (definition + usage)grep -c "stripUnsafeDirectives" server/src/services/renderers/diagram-renderer.tsreturns at least 1grep -c "DOMPurify" server/src/services/renderers/diagram-renderer.tsreturns at least 1grep -c "Resvg" server/src/services/renderers/diagram-renderer.tsreturns at least 1grep "securityLevel.*strict" server/src/services/renderers/diagram-renderer.tsmatchesgrep "finally" server/src/services/renderers/diagram-renderer.tsmatches (browser cleanup)grep "diagram-bundle" server/src/services/renderers/diagram-renderer.tsmatches- The LLM inference call exists in renderDiagram (grep for the chat/inference function name)
- All tests in diagram-renderer.test.ts pass </acceptance_criteria> Diagram renderer synthesizes Mermaid syntax from natural language via LLM (DIAG-01), strips unsafe directives (DIAG-05), renders to SVG+PNG via Playwright (DIAG-02), supports all 5 diagram types via prompt hints (DIAG-04); browser always cleaned up; all tests green
- Create
server/src/services/renderers/icon-renderer.ts:validateAndCleanSvg(raw: string): { svg: string; valid: boolean; error?: string }:- Run SVGO optimize with preset-default
- Check for viewBox, add
viewBox="0 0 24 24"if missing - Check for xmlns, add
xmlns="http://www.w3.org/2000/svg"if missing - Validate at least one
<path>,<circle>, or<rect>exists - Return cleaned SVG or error
buildIconPrompt(description: string, style: string, count: number): string:- System prompt enforcing: 24x24 viewBox, stroke-width=1.5 for outline, currentColor fill, no text elements, no embedded images
- Include style constraint (outline: stroke only; filled: fill only; rounded: stroke with stroke-linecap="round" stroke-linejoin="round")
- Request exactly
counticons as a JSON array of{ name: string, svg: string }objects
renderIconSet(input: Record<string, unknown>): Promise<RenderResult>:- Extract description (string), style ("outline"|"filled"|"rounded", default "outline"), count (1|4|8|16, default 4)
- Call LLM via the existing chat inference pattern (look at how chat.ts or similar calls the active provider)
- Parse LLM JSON response (try/catch — if parse fails, retry once with explicit "respond with JSON only" instruction)
- For each icon SVG: validateAndCleanSvg, then generate PNG variants at 16, 32, 64 via sharp:
sharp(Buffer.from(svgString), { density: 96 }).resize(size).png().toBuffer() - Build IconSetBundle JSON with all icons, base64-encode PNG buffers
- Return
{ filename: "icon-set-bundle.json", contentType: "application/json", buffer: Buffer.from(JSON.stringify(bundle)) }
- Handle partial failures: if some icons fail validation, include only valid ones and note count in bundle metadata cd /opt/nexus && pnpm --filter server exec vitest run src/tests/icon-renderer.test.ts <acceptance_criteria>
grep "icon-set-bundle" server/src/services/renderers/icon-renderer.tsmatchesgrep "optimize" server/src/services/renderers/icon-renderer.tsmatches (SVGO)grep "viewBox" server/src/services/renderers/icon-renderer.tsmatchesgrep "sharp" server/src/services/renderers/icon-renderer.tsmatches (PNG rasterization)- All tests in icon-renderer.test.ts pass </acceptance_criteria> Icon renderer generates SVG icons via LLM, cleans with SVGO, validates, rasterizes to PNG at 3 sizes; all tests green
<success_criteria>
- Diagram renderer synthesizes Mermaid from natural language via LLM (DIAG-01), strips unsafe directives (DIAG-05), renders to SVG+PNG via Playwright (DIAG-02)
- Diagram renderer supports all 5 diagram types via LLM prompt hints (DIAG-04)
- Diagram renderer supports direct Mermaid source input for the re-render path (DiagramSourcePanel edits)
- Icon renderer produces cohesive SVG icon sets from LLM (ICON-01, ICON-02) with multi-size PNG export (ICON-03)
- Both renderers produce JSON bundles stored as single assets (content_jobs.resultAssetId pattern)
- All tests pass </success_criteria>