openpencil/packages/cli/src/commands/export.ts
Danila Poyarkov 5ae6234e40
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format

- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
  fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass

* Extract escapeJSXText — replace nested ternary with entity map lookup

* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers

* Update changelog

* Tighten Tailwind JSX export fidelity
2026-03-06 18:04:11 +03:00

111 lines
4.3 KiB
TypeScript

import { defineCommand } from 'citty'
import { basename, extname, resolve } from 'node:path'
import { renderNodesToSVG, sceneNodeToJSX, selectionToJSX } from '@open-pencil/core'
import { loadDocument, loadFonts, exportNodes, exportThumbnail } from '../headless'
import { ok, printError } from '../format'
import type { ExportFormat, JSXFormat } from '@open-pencil/core'
const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP']
const ALL_FORMATS = [...RASTER_FORMATS, 'SVG', 'JSX']
const JSX_STYLES = ['openpencil', 'tailwind']
export default defineCommand({
meta: { description: 'Export a .fig file to PNG, JPG, WEBP, SVG, or JSX' },
args: {
file: { type: 'positional', description: '.fig file path', required: true },
output: { type: 'string', alias: 'o', description: 'Output file path (default: <name>.<format>)' },
format: { type: 'string', alias: 'f', description: 'Export format: png, jpg, webp, svg, jsx (default: png)', default: 'png' },
scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' },
quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)' },
page: { type: 'string', description: 'Page name (default: first page)' },
node: { type: 'string', description: 'Node ID to export (default: all top-level nodes)' },
style: { type: 'string', description: 'JSX style: openpencil, tailwind (default: openpencil)', default: 'openpencil' },
thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' },
width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' }
},
async run({ args }) {
const format = args.format.toUpperCase() as ExportFormat | 'JSX'
if (!ALL_FORMATS.includes(format)) {
printError(`Invalid format "${args.format}". Use png, jpg, webp, svg, or jsx.`)
process.exit(1)
}
if (format === 'JSX' && !JSX_STYLES.includes(args.style)) {
printError(`Invalid JSX style "${args.style}". Use openpencil or tailwind.`)
process.exit(1)
}
const graph = await loadDocument(args.file)
await loadFonts(graph)
const pages = graph.getPages()
const page = args.page
? pages.find((p) => p.name === args.page)
: pages[0]
if (!page) {
printError(`Page "${args.page}" not found.`)
process.exit(1)
}
const defaultName = basename(args.file, extname(args.file))
if (format === 'JSX') {
const jsxFormat = args.style as JSXFormat
const nodeIds = args.node ? [args.node] : page.childIds
const jsxStr = nodeIds.length === 1
? sceneNodeToJSX(nodeIds[0], graph, jsxFormat)
: selectionToJSX(nodeIds, graph, jsxFormat)
if (!jsxStr) {
printError('Nothing to export (empty page or no visible nodes).')
process.exit(1)
}
const output = resolve(args.output ?? `${defaultName}.jsx`)
await Bun.write(output, jsxStr)
console.log(ok(`Exported ${output} (${(jsxStr.length / 1024).toFixed(1)} KB)`))
return
}
const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
const output = resolve(args.output ?? `${defaultName}.${ext}`)
if (format === 'SVG') {
const nodeIds = args.node ? [args.node] : page.childIds
const svgStr = renderNodesToSVG(graph, page.id, nodeIds)
if (!svgStr) {
printError('Nothing to export (empty page or no visible nodes).')
process.exit(1)
}
await Bun.write(output, svgStr)
console.log(ok(`Exported ${output} (${(svgStr.length / 1024).toFixed(1)} KB)`))
return
}
let data: Uint8Array | null
if (args.thumbnail) {
data = await exportThumbnail(graph, page.id, Number(args.width), Number(args.height))
} else {
const nodeIds = args.node ? [args.node] : page.childIds
data = await exportNodes(graph, page.id, nodeIds, {
scale: Number(args.scale),
format,
quality: args.quality ? Number(args.quality) : undefined
})
}
if (!data) {
printError('Nothing to export (empty page or no visible nodes).')
process.exit(1)
}
await Bun.write(output, data)
console.log(ok(`Exported ${output} (${(data.length / 1024).toFixed(1)} KB)`))
}
})