From 4aec5b99d6db8b7863930348d452dd3a41d1f546 Mon Sep 17 00:00:00 2001 From: lojhan Date: Thu, 19 Mar 2026 14:00:06 -0300 Subject: [PATCH 1/2] feature: Add SVG export support --- src/commands/export.ts | 17 +- src/index.ts | 2 +- src/render.ts | 56 +++++-- src/runtime.ts | 33 ++++ src/types.ts | 8 +- test/fixtures/svg-dashboard.tsx | 264 ++++++++++++++++++++++++++++++++ test/unit/cli.test.ts | 5 +- test/unit/render.test.ts | 143 +++++++++++++++++ 8 files changed, 501 insertions(+), 27 deletions(-) create mode 100644 test/fixtures/svg-dashboard.tsx diff --git a/src/commands/export.ts b/src/commands/export.ts index 4d8d2cd..0c6389d 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -7,13 +7,13 @@ Usage: grafex export --file [options] Options: --file, -f Path to the composition .tsx file (required) - --out, -o Output file path (default: ./output.png) + --out, -o Output file path (default: ./output.png or ./output.svg) --props Props to pass as JSON (default: {}) --width Override composition width (pixels) --height Override composition height (pixels) - --scale Device scale factor, e.g. 2 for retina (default: 1) - --format Output format, must be "png" (default: png) - --browser Browser engine: webkit or chromium (default: webkit) + --scale Scale factor, e.g. 2 for retina PNG or larger SVG canvas (default: 1) + --format Output format: "png" or "svg" (default: png) + --browser Browser engine: webkit or chromium (default: webkit, PNG only) --help, -h Show this help text `.trim(); @@ -44,14 +44,14 @@ export async function runExport(args: string[]): Promise { process.exit(1); } - const outPath = values.out ?? './output.png'; - const format = values.format ?? 'png'; - if (format !== 'png') { - process.stderr.write('Only PNG format is supported in this version.\n'); + if (format !== 'png' && format !== 'svg') { + process.stderr.write(`Error: --format must be "png" or "svg", got "${format}".\n`); process.exit(1); } + const outPath = values.out ?? (format === 'svg' ? './output.svg' : './output.png'); + const browser = values.browser ?? 'webkit'; if (browser !== 'webkit' && browser !== 'chromium') { process.stderr.write(`Error: --browser must be "webkit" or "chromium", got "${browser}".\n`); @@ -107,6 +107,7 @@ export async function runExport(args: string[]): Promise { width, height, scale, + format: format as 'png' | 'svg', browser: browser as 'webkit' | 'chromium', }); try { diff --git a/src/index.ts b/src/index.ts index 58219a5..4f068bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { pipeline, browserManager as defaultBrowserManager } from './render.js'; import { BrowserManager } from './browser.js'; import type { RenderOptions, RenderResult } from './types.js'; -export { h, Fragment, renderToHTML } from './runtime.js'; +export { h, Fragment, renderToHTML, renderToSVG } from './runtime.js'; export { BrowserManager } from './browser.js'; export type { RenderOptions, RenderResult } from './types.js'; diff --git a/src/render.ts b/src/render.ts index 10c0f88..d01d128 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,11 +1,49 @@ import { resolve } from 'node:path'; import { transpile } from './transpile.js'; -import { renderToHTML } from './runtime.js'; +import { renderToHTML, renderToSVG } from './runtime.js'; import { BrowserManager } from './browser.js'; import type { RenderOptions, RenderResult, CompositionConfig } from './types.js'; export type { RenderOptions, RenderResult, CompositionConfig }; +interface FormatContext { + componentHtml: string; + width: number; + height: number; + scale: number; + fonts?: string[]; + manager: Pick; +} + +type FormatHandler = (ctx: FormatContext) => Promise; + +const formatHandlers: Record = { + png: async ({ componentHtml, width, height, scale, fonts, manager }) => { + const html = renderToHTML(componentHtml, { width, height }, fonts); + const buffer = await manager.render(html, { width, height }, scale); + return { + buffer, + width: Math.round(width * scale), + height: Math.round(height * scale), + scale, + format: 'png', + }; + }, + + svg: async ({ componentHtml, width, height, scale, fonts }) => { + const svgString = renderToSVG(componentHtml, { width, height }, scale, fonts); + return { + buffer: Buffer.from(svgString), + width: Math.round(width * scale), + height: Math.round(height * scale), + scale, + format: 'svg', + }; + }, +}; + +// ── Pipeline ────────────────────────────────────────────────────────────────── + export const browserManager = new BrowserManager(); export async function pipeline( @@ -26,17 +64,11 @@ export async function pipeline( const width = options.width ?? config.width ?? 1200; const height = options.height ?? config.height ?? 630; const scale = options.scale ?? config.scale ?? 1; + const format = options.format ?? config.format ?? 'png'; - const componentHtml = String(component(options.props ?? {})); - const html = renderToHTML(componentHtml, { width, height }, config.fonts); - - const buffer = await manager.render(html, { width, height }, scale); + const handler = formatHandlers[format]; + if (!handler) throw new Error(`Unsupported format: "${format}"`); - return { - buffer, - width: Math.round(width * scale), - height: Math.round(height * scale), - scale, - format: 'png', - }; + const componentHtml = String(component(options.props ?? {})); + return handler({ componentHtml, width, height, scale, fonts: config.fonts, manager }); } diff --git a/src/runtime.ts b/src/runtime.ts index 797a792..8e20274 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -136,3 +136,36 @@ ${componentHtml} `; } + +export function renderToSVG( + componentHtml: string, + viewport: { width: number; height: number }, + scale: number = 1, + fonts?: string[], +): string { + const { width, height } = viewport; + const physicalWidth = Math.round(width * scale); + const physicalHeight = Math.round(height * scale); + const fontLinks = + fonts && fonts.length > 0 + ? `${fonts + .map((url) => ``) + .join('\n')}\n` + : ''; + return ` + + + + +${fontLinks} + + +${componentHtml} + + + +`; +} diff --git a/src/types.ts b/src/types.ts index 24d89d6..75c1379 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,11 @@ +export type Format = 'png' | 'svg'; + export interface RenderOptions { props?: Record; width?: number; height?: number; scale?: number; - format?: 'png'; + format?: Format; browser?: 'webkit' | 'chromium'; } @@ -12,13 +14,13 @@ export interface RenderResult { width: number; height: number; scale: number; - format: 'png'; + format: Format; } export interface CompositionConfig { width?: number; height?: number; scale?: number; - format?: 'png'; + format?: Format; fonts?: string[]; } diff --git a/test/fixtures/svg-dashboard.tsx b/test/fixtures/svg-dashboard.tsx new file mode 100644 index 0000000..6202d80 --- /dev/null +++ b/test/fixtures/svg-dashboard.tsx @@ -0,0 +1,264 @@ +/** + * A complex SVG output demo: a stats dashboard card. + * + * Run: + * npx tsx src/cli.ts export \ + * --file test/fixtures/svg-dashboard.tsx \ + * --format svg \ + * --out dashboard.svg + * + * Because format is "svg", no browser is needed — the output is + * produced entirely from the JSX runtime, wrapped in a . + */ +import type { CompositionConfig } from '../../src/types.js'; + +export const config: CompositionConfig = { + width: 900, + height: 540, + format: 'svg', +}; + +// ── tiny helpers ────────────────────────────────────────────────────────────── + +function Badge({ label, color }: { label: string; color: string }) { + return ( + + {label} + + ); +} + +function StatCard({ + value, + label, + delta, + positive, +}: { + value: string; + label: string; + delta: string; + positive: boolean; +}) { + return ( +
+ + {label} + + + {value} + + + {positive ? '▲' : '▼'} {delta} + +
+ ); +} + +function BarChart({ bars }: { bars: Array<{ label: string; pct: number; color: string }> }) { + return ( +
+ {bars.map((b) => ( +
+ + {b.label} + +
+
+
+ + {b.pct}% + +
+ ))} +
+ ); +} + +// ── main composition ────────────────────────────────────────────────────────── + +export default function SVGDashboard() { + const bars = [ + { label: 'Mon', pct: 72, color: '#6366f1' }, + { label: 'Tue', pct: 58, color: '#6366f1' }, + { label: 'Wed', pct: 91, color: '#818cf8' }, + { label: 'Thu', pct: 44, color: '#6366f1' }, + { label: 'Fri', pct: 83, color: '#818cf8' }, + { label: 'Sat', pct: 27, color: '#4f46e5' }, + { label: 'Sun', pct: 15, color: '#4f46e5' }, + ]; + + return ( +
+ {/* header */} +
+
+ + Weekly Overview + + March 13 – 19, 2026 +
+
+ + +
+
+ + {/* stat row */} +
+ + + + +
+ + {/* chart + legend */} +
+ {/* bar chart */} +
+ + Daily sessions + + +
+ + {/* top pages */} +
+ + Top pages + + {[ + { path: '/', views: '14 820' }, + { path: '/docs', views: '9 340' }, + { path: '/examples', views: '6 110' }, + { path: '/blog', views: '4 880' }, + { path: '/changelog', views: '2 040' }, + ].map((p, i) => ( +
+ {p.path} + + {p.views} + +
+ ))} +
+
+ + {/* footer */} +
+ + Generated by grafex · SVG + format · no browser required + + grafex.dev +
+
+ ); +} diff --git a/test/unit/cli.test.ts b/test/unit/cli.test.ts index 9fd956e..09ac5fe 100644 --- a/test/unit/cli.test.ts +++ b/test/unit/cli.test.ts @@ -76,10 +76,9 @@ describe('export command — validation', () => { expect(result.stderr.length).toBeGreaterThan(0); }); - test('--format svg prints unsupported format error to stderr and exits 1', () => { + test('--format svg is accepted (does not error on format validation)', () => { const result = runCli(['export', '--file', 'test/fixtures/simple.tsx', '--format', 'svg']); - expect(result.status).toBe(1); - expect(result.stderr).toContain('Only PNG format is supported in this version.'); + expect(result.stderr).not.toContain('--format must be'); }); test('--browser firefox prints error to stderr and exits 1', () => { diff --git a/test/unit/render.test.ts b/test/unit/render.test.ts index 6552870..fe0141a 100644 --- a/test/unit/render.test.ts +++ b/test/unit/render.test.ts @@ -323,3 +323,146 @@ describe('pipeline() — with-components fixture', () => { expect(result.format).toBe('png'); }); }); + +describe('pipeline() — SVG format', () => { + let mockManager: ReturnType; + + beforeEach(() => { + mockManager = makeMockManager(); + }); + + test('format "svg" returns result with format "svg"', async () => { + const { pipeline } = await import('../../src/render.js'); + const result = await pipeline( + resolve(fixturesDir, 'simple.tsx'), + { format: 'svg' }, + mockManager as any, + ); + expect(result.format).toBe('svg'); + }); + + test('SVG output does not call manager.render (no browser needed)', async () => { + const { pipeline } = await import('../../src/render.js'); + await pipeline(resolve(fixturesDir, 'simple.tsx'), { format: 'svg' }, mockManager as any); + expect(mockManager.render).not.toHaveBeenCalled(); + }); + + test('SVG buffer contains valid SVG markup', async () => { + const { pipeline } = await import('../../src/render.js'); + const result = await pipeline( + resolve(fixturesDir, 'simple.tsx'), + { format: 'svg' }, + mockManager as any, + ); + const svg = result.buffer.toString('utf-8'); + expect(svg).toContain(''); + }); + + test('SVG buffer contains component HTML inside foreignObject', async () => { + const { pipeline } = await import('../../src/render.js'); + const result = await pipeline( + resolve(fixturesDir, 'simple.tsx'), + { format: 'svg' }, + mockManager as any, + ); + const svg = result.buffer.toString('utf-8'); + expect(svg).toContain('Hello, Grafex'); + }); + + test('SVG width/height attributes match composition dimensions', async () => { + const { pipeline } = await import('../../src/render.js'); + // simple.tsx: width 800, height 400 + const result = await pipeline( + resolve(fixturesDir, 'simple.tsx'), + { format: 'svg' }, + mockManager as any, + ); + const svg = result.buffer.toString('utf-8'); + expect(svg).toContain('width="800"'); + expect(svg).toContain('height="400"'); + expect(result.width).toBe(800); + expect(result.height).toBe(400); + }); + + test('SVG scale applies to intrinsic width/height attributes', async () => { + const { pipeline } = await import('../../src/render.js'); + const result = await pipeline( + resolve(fixturesDir, 'simple.tsx'), + { format: 'svg', scale: 2 }, + mockManager as any, + ); + const svg = result.buffer.toString('utf-8'); + // Physical canvas is 2x, viewBox stays at logical size + expect(svg).toContain('width="1600"'); + expect(svg).toContain('height="800"'); + expect(svg).toContain('viewBox="0 0 800 400"'); + expect(result.width).toBe(1600); + expect(result.height).toBe(800); + expect(result.scale).toBe(2); + }); + + test('SVG format includes fonts link tags when config.fonts is set', async () => { + const { pipeline } = await import('../../src/render.js'); + const result = await pipeline( + resolve(fixturesDir, 'with-fonts.tsx'), + { format: 'svg' }, + mockManager as any, + ); + const svg = result.buffer.toString('utf-8'); + expect(svg).toContain(' { + const { pipeline } = await import('../../src/render.js'); + const result = await pipeline( + resolve(fixturesDir, 'simple.tsx'), + { format: 'svg' }, + mockManager as any, + ); + expect(Buffer.isBuffer(result.buffer)).toBe(true); + }); +}); + +describe('runtime.ts — renderToSVG', () => { + test('renderToSVG is exported from runtime', async () => { + const runtime = await import('../../src/runtime.js'); + expect(typeof runtime.renderToSVG).toBe('function'); + }); + + test('renderToSVG returns a string containing svg element', async () => { + const { renderToSVG } = await import('../../src/runtime.js'); + const svg = renderToSVG('
hello
', { width: 100, height: 50 }); + expect(svg).toContain(''); + }); + + test('renderToSVG embeds content inside foreignObject', async () => { + const { renderToSVG } = await import('../../src/runtime.js'); + const svg = renderToSVG('

test content

', { width: 200, height: 100 }); + expect(svg).toContain('test content

'); + }); + + test('renderToSVG includes viewBox with logical dimensions', async () => { + const { renderToSVG } = await import('../../src/runtime.js'); + const svg = renderToSVG('
', { width: 400, height: 200 }); + expect(svg).toContain('viewBox="0 0 400 200"'); + }); + + test('renderToSVG scale multiplies physical width/height', async () => { + const { renderToSVG } = await import('../../src/runtime.js'); + const svg = renderToSVG('
', { width: 400, height: 200 }, 2); + expect(svg).toContain('width="800"'); + expect(svg).toContain('height="400"'); + expect(svg).toContain('viewBox="0 0 400 200"'); + }); + + test('renderToSVG is exported from index', async () => { + const idx = await import('../../src/index.js'); + expect(typeof (idx as any).renderToSVG).toBe('function'); + }); +}); From e25bc08abed551bdab780567e87cfdadd0a4ba5e Mon Sep 17 00:00:00 2001 From: lojhan Date: Thu, 19 Mar 2026 14:05:38 -0300 Subject: [PATCH 2/2] test: fix integration tests for svg support --- test/integration/cli.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/integration/cli.test.ts b/test/integration/cli.test.ts index 4544b00..7c6c23b 100644 --- a/test/integration/cli.test.ts +++ b/test/integration/cli.test.ts @@ -58,7 +58,8 @@ describe('export command — error cases', () => { expect(result.stderr.trim().length).toBeGreaterThan(0); }); - test('--format svg exits 1 with unsupported format message in stderr', () => { + test('--format svg exits 0 and writes a valid SVG file', () => { + const outPath = '/tmp/grafex-svg-test.svg'; const result = runCli([ 'export', '--file', @@ -66,10 +67,13 @@ describe('export command — error cases', () => { '--format', 'svg', '--out', - '/tmp/grafex-svg-test.png', + outPath, ]); - expect(result.status).toBe(1); - expect(result.stderr).toContain('Only PNG format is supported in this version.'); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(outPath); + const content = readFileSync(outPath, 'utf-8'); + expect(content).toContain(''); }); });