feat(cli): import DOM/CSS documents
This commit is contained in:
parent
e77a82fb3b
commit
3638fc11ba
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
192
packages/cli/src/commands/dom.ts
Normal file
192
packages/cli/src/commands/dom.ts
Normal 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
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -5,12 +5,9 @@
|
|||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"paths": {
|
||||
"#cli/*": ["./src/*"]
|
||||
},
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export default defineConfig({
|
|||
},
|
||||
platform: 'neutral',
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
dts: false,
|
||||
sourcemap: true,
|
||||
hash: false,
|
||||
clean: true,
|
||||
|
|
|
|||
|
|
@ -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>')
|
||||
|
|
|
|||
Loading…
Reference in a new issue