feat(cli): import DOM/CSS documents

This commit is contained in:
Danila Poyarkov 2026-06-03 20:36:27 +03:00
parent e77a82fb3b
commit 3638fc11ba
10 changed files with 339 additions and 11 deletions

View file

@ -80,6 +80,14 @@ openpencil export design.fig -f jpg -s 2 -q 90 # JPG at 2x, quality 90
openpencil export design.fig -f fig --page "Page 1" # Export a page as .fig
openpencil export design.fig -f jsx --style tailwind # Tailwind JSX
openpencil convert design.pen output.fig # Convert between document formats
openpencil dom page.html --css styles.css -o page.fig # HTML/CSS → editable .fig
```
DOM/CSS input flows through `@open-pencil/dom-css`, so HTML, authored CSS, and Tailwind utility CSS can become editable OpenPencil layers:
```sh
openpencil dom card.html --css card.css -o card.fig
openpencil dom card.html --tailwind "flex flex-col gap-3 w-80 p-6 rounded-xl bg-white" -o card.fig
```
```html

View file

@ -28,6 +28,7 @@
},
"dependencies": {
"@open-pencil/core": "workspace:*",
"@open-pencil/dom-css": "workspace:*",
"agentfmt": "^0.1.3",
"canvaskit-wasm": "^0.40.0",
"citty": "^0.1.6"

View file

@ -0,0 +1,192 @@
import { basename, extname, resolve } from 'node:path'
import { defineCommand } from 'citty'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
import {
createHeadlessCSSRuntime,
htmlToDesignDocument,
htmlToSceneGraph,
serializeHTML,
tailwindHTMLToDesignDocument,
tailwindHTMLToSceneGraph,
type DesignDocument
} from '@open-pencil/dom-css'
import { requireFile } from '#cli/app-client'
import { fmtList, ok, printError } from '#cli/format'
const io = new IORegistry(BUILTIN_IO_FORMATS)
const OUTPUT_FORMATS = new Set(['fig', 'html', 'json'])
interface DomArgs {
file?: string
output?: string
format: string
css?: string
cssText?: string
tailwind?: string
tailwindFile?: string
pageName: string
json?: boolean
}
function defaultOutput(input: string, format: string): string {
const base = basename(input, extname(input))
return resolve(`${base}.${format}`)
}
async function readTextFile(path: string): Promise<string> {
return Bun.file(requireFile(path)).text()
}
async function cssTextForArgs(args: DomArgs): Promise<string | undefined> {
const cssParts = []
if (args.css) cssParts.push(await readTextFile(args.css))
if (args.cssText) cssParts.push(args.cssText)
return cssParts.length > 0 ? cssParts.join('\n') : undefined
}
async function tailwindCandidatesForArgs(args: DomArgs): Promise<string[] | undefined> {
const parts = []
if (args.tailwind) parts.push(args.tailwind)
if (args.tailwindFile) parts.push(await readTextFile(args.tailwindFile))
const classes = parts
.flatMap((part) => part.split(/\s+/))
.filter((className) => className.length > 0)
return classes.length > 0 ? classes : undefined
}
function childCount(document: DesignDocument): number {
return document.children.length
}
async function convertDom(args: DomArgs) {
const file = requireFile(args.file)
const html = await readTextFile(file)
const runtime = createHeadlessCSSRuntime()
const tailwind = await tailwindCandidatesForArgs(args)
const cssText = await cssTextForArgs(args)
if (tailwind) {
const options = { ...args, css: cssText, runtime }
return {
document: await tailwindHTMLToDesignDocument(html, tailwind, options),
graph: await tailwindHTMLToSceneGraph(html, tailwind, options)
}
}
const options = { cssText, runtime, pageName: args.pageName }
return {
document: await htmlToDesignDocument(html, options),
graph: await htmlToSceneGraph(html, options)
}
}
async function writeOutput(
args: DomArgs,
document: DesignDocument,
graph: Awaited<ReturnType<typeof htmlToSceneGraph>>
) {
const format = args.format.toLowerCase()
const output = args.output ? resolve(args.output) : defaultOutput(requireFile(args.file), format)
if (format === 'json') {
await Bun.write(output, `${JSON.stringify(document, null, 2)}\n`)
return output
}
if (format === 'html') {
await Bun.write(output, serializeHTML(document))
return output
}
const result = await io.writeDocument('fig', graph)
await Bun.write(output, result.data as Uint8Array)
return output
}
export default defineCommand({
meta: { description: 'Convert HTML/CSS/Tailwind into an OpenPencil document' },
args: {
file: {
type: 'positional',
description: 'Input HTML file path',
required: true
},
output: {
type: 'string',
alias: 'o',
description: 'Output file path (default: <name>.<format>)',
required: false
},
format: {
type: 'string',
alias: 'f',
description: 'Output format: fig, html, json (default: fig)',
default: 'fig'
},
css: {
type: 'string',
description: 'CSS file to apply before conversion',
required: false
},
cssText: {
type: 'string',
description: 'Inline CSS text to apply before conversion',
required: false
},
tailwind: {
type: 'string',
description: 'Tailwind utility candidates to compile and apply',
required: false
},
tailwindFile: {
type: 'string',
description: 'File containing Tailwind utility candidates',
required: false
},
pageName: {
type: 'string',
description: 'Scene graph page name (default: DOM/CSS)',
default: 'DOM/CSS'
},
json: {
type: 'boolean',
description: 'Print a machine-readable summary to stdout'
}
},
async run({ args }) {
const format = args.format.toLowerCase()
if (!OUTPUT_FORMATS.has(format)) {
printError(`Invalid format "${args.format}". Use fig, html, or json.`)
process.exit(1)
}
const { document, graph } = await convertDom(args)
const output = await writeOutput(args, document, graph)
const pages = graph.getPages()
const summary = {
input: requireFile(args.file),
output,
format,
pages: pages.length,
rootElements: childCount(document)
}
if (args.json) {
console.log(JSON.stringify(summary, null, 2))
return
}
console.log(ok(`Converted ${summary.input}${summary.output}`))
console.log(
fmtList([
{
header: 'DOM/CSS conversion',
details: summary
}
])
)
}
})

View file

@ -3,6 +3,7 @@ import { defineCommand, runMain } from 'citty'
import analyze from './commands/analyze'
import convert from './commands/convert'
import dom from './commands/dom'
import evalCmd from './commands/eval'
import exportCmd from './commands/export'
import find from './commands/find'
@ -27,6 +28,7 @@ const main = defineCommand({
subCommands: {
analyze,
convert,
dom,
eval: evalCmd,
export: exportCmd,
find,

View file

@ -5,12 +5,9 @@
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"paths": {
"#cli/*": ["./src/*"]
},
"outDir": "dist",
"rootDir": "src"
}
},
"include": ["src"]
}

View file

@ -64,6 +64,40 @@ Minimum exit criteria:
- Heavy Figma fixture coverage still available at repo level.
- Public API supports CLI/MCP/app document I/O without private path imports.
## Initial inventory
Likely `@open-pencil/kiwi` candidates:
- `packages/core/src/kiwi/schema-runtime/**`
- `packages/core/src/kiwi/fig/codec/**`
- Generated Kiwi schema modules under the Figma codec directory, as long as they stay scene-graph agnostic
Likely `@open-pencil/fig` candidates:
- `packages/core/src/kiwi/fig/file.ts`
- `packages/core/src/kiwi/fig/container/**`
- `packages/core/src/kiwi/fig/parse/**`
- `packages/core/src/kiwi/fig/import.ts`
- `packages/core/src/kiwi/fig/node-change/**`
- `packages/core/src/kiwi/fig/instance-overrides/**`
- `packages/core/src/io/formats/fig/**`
Keep in `@open-pencil/core` unless proven otherwise:
- `SceneGraph` and node type definitions
- Renderer/editor fallback behavior
- Layout, text measurement, and canvas-specific code
- Generic IO registry contracts that other formats use
## Migration checklist
1. Add package-local tests before moving files.
2. Confirm every moved module imports only allowed public package exports.
3. Preserve existing `@open-pencil/core/kiwi` re-exports during the first migration step.
4. Move one boundary at a time: schema runtime first, generated codec second, `.fig` policy last.
5. Keep fixture/oracle tests in the repo-level suite even after package-local tests exist.
6. Run package smoke checks from a temporary consumer project before publishing.
## Migration order
1. Keep `@open-pencil/dom-css` standalone and stabilize its browser/headless runtime split.

View file

@ -109,6 +109,32 @@ openpencil export [file] [options]
| `--width` | | Thumbnail width (default: 1920) |
| `--height` | | Thumbnail height (default: 1080) |
## dom
Convert HTML/CSS/Tailwind into an editable OpenPencil document.
```sh
openpencil dom page.html [options]
```
| Option | Alias | Description |
|--------|-------|-------------|
| `--format` | `-f` | Output format: `fig` (default), `html`, `json` |
| `--output` | `-o` | Output file path (default: `<name>.<format>`) |
| `--css` | | CSS file to apply before conversion |
| `--css-text` | | Inline CSS text to apply before conversion |
| `--tailwind` | | Tailwind utility candidates to compile and apply |
| `--tailwind-file` | | File containing Tailwind utility candidates |
| `--page-name` | | Scene graph page name (default: `DOM/CSS`) |
| `--json` | | Print a machine-readable summary |
Examples:
```sh
openpencil dom card.html --css card.css -o card.fig
openpencil dom card.html --tailwind "flex flex-col gap-3 w-80 p-6 rounded-xl bg-white" -o card.fig
```
## eval
Execute JavaScript with the Figma Plugin API.

View file

@ -12,39 +12,39 @@
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"types": "./dist/src/index.d.ts",
"bun": "./src/index.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./browser": {
"types": "./dist/browser.d.ts",
"types": "./dist/src/browser.d.ts",
"bun": "./src/browser.ts",
"import": "./dist/browser.js",
"default": "./dist/browser.js"
},
"./jsx-runtime": {
"types": "./dist/jsx-runtime.d.ts",
"types": "./dist/src/jsx/runtime.d.ts",
"bun": "./src/jsx/runtime.ts",
"import": "./dist/jsx-runtime.js",
"default": "./dist/jsx-runtime.js"
},
"./jsx-dev-runtime": {
"types": "./dist/jsx-dev-runtime.d.ts",
"types": "./dist/src/jsx/dev-runtime.d.ts",
"bun": "./src/jsx/dev-runtime.ts",
"import": "./dist/jsx-dev-runtime.js",
"default": "./dist/jsx-dev-runtime.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"types": "./dist/src/index.d.ts",
"sideEffects": false,
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "bunx tsdown --config tsdown.config.ts",
"build": "bunx tsdown --config tsdown.config.ts && bunx tsc --emitDeclarationOnly -p tsconfig.json",
"check": "bun run typecheck && bun run test && bun run build && bun run smoke:dist",
"prepack": "bun run build",
"smoke:dist": "bun scripts/smoke-dist.ts",

View file

@ -9,7 +9,7 @@ export default defineConfig({
},
platform: 'neutral',
format: ['esm'],
dts: true,
dts: false,
sourcemap: true,
hash: false,
clean: true,

View file

@ -351,6 +351,74 @@ test.describe('@open-pencil/dom-css browser CSS runtime oracle', () => {
expect(styles.width).toBe('176px')
})
test('resolves flex wrap, self alignment, absolute positioning, and clipping', async ({
page
}) => {
await setStyledContent(
page,
`
.wrap {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
overflow: clip;
position: relative;
width: 240px;
height: 120px;
}
.chip {
align-self: center;
position: absolute;
left: 16px;
top: 24px;
min-width: 48px;
max-width: 96px;
width: 80px;
height: 32px;
}
`,
'<section class="wrap"><div class="chip">Chip</div></section>'
)
const wrap = await computedStyleProperties(page, '.wrap', [
'column-gap',
'display',
'flex-wrap',
'height',
'overflow',
'position',
'row-gap',
'width'
])
expect(wrap.display).toBe('flex')
expect(wrap['flex-wrap']).toBe('wrap')
expect(wrap['column-gap']).toBe('20px')
expect(wrap['row-gap']).toBe('12px')
expect(wrap.overflow).toBe('clip')
expect(wrap.position).toBe('relative')
expect(wrap.width).toBe('240px')
expect(wrap.height).toBe('120px')
const chip = await computedStyleProperties(page, '.chip', [
'align-self',
'height',
'left',
'max-width',
'min-width',
'position',
'top',
'width'
])
expect(chip['align-self']).toBe('center')
expect(chip.position).toBe('absolute')
expect(chip.left).toBe('16px')
expect(chip.top).toBe('24px')
expect(chip['min-width']).toBe('48px')
expect(chip['max-width']).toBe('96px')
expect(chip.width).toBe('80px')
expect(chip.height).toBe('32px')
})
test('computes styles through the browser runtime sandbox', async ({ page }) => {
await page.goto('/')
await setStyledContent(page, '.card { width: 20px; }', '<article class="card">Host</article>')