From 3638fc11ba3d8b1350c5bd89d50da2fe448fbb0b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 3 Jun 2026 20:36:27 +0300 Subject: [PATCH] feat(cli): import DOM/CSS documents --- README.md | 8 + packages/cli/package.json | 1 + packages/cli/src/commands/dom.ts | 192 +++++++++++++++++++++ packages/cli/src/index.ts | 2 + packages/cli/tsconfig.json | 5 +- packages/docs/development/package-split.md | 34 ++++ packages/docs/reference/cli.md | 26 +++ packages/dom-css/package.json | 12 +- packages/dom-css/tsdown.config.ts | 2 +- tests/e2e/dom-css/browser-runtime.spec.ts | 68 ++++++++ 10 files changed, 339 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/commands/dom.ts diff --git a/README.md b/README.md index 66ee42f89..82687034e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/cli/package.json b/packages/cli/package.json index 18d8c8e84..1b7f99036 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" diff --git a/packages/cli/src/commands/dom.ts b/packages/cli/src/commands/dom.ts new file mode 100644 index 000000000..114c57ead --- /dev/null +++ b/packages/cli/src/commands/dom.ts @@ -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 { + return Bun.file(requireFile(path)).text() +} + +async function cssTextForArgs(args: DomArgs): Promise { + 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 { + 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> +) { + 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: .)', + 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 + } + ]) + ) + } +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fcb9d5a2e..d57b14eff 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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, diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 09a27eaca..6f425b4f8 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -5,12 +5,9 @@ "moduleResolution": "bundler", "strict": true, "skipLibCheck": true, - "declaration": true, "paths": { "#cli/*": ["./src/*"] - }, - "outDir": "dist", - "rootDir": "src" + } }, "include": ["src"] } diff --git a/packages/docs/development/package-split.md b/packages/docs/development/package-split.md index 7fc98fe6f..230b2f7d9 100644 --- a/packages/docs/development/package-split.md +++ b/packages/docs/development/package-split.md @@ -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. diff --git a/packages/docs/reference/cli.md b/packages/docs/reference/cli.md index 7335121f8..0646e0936 100644 --- a/packages/docs/reference/cli.md +++ b/packages/docs/reference/cli.md @@ -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: `.`) | +| `--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. diff --git a/packages/dom-css/package.json b/packages/dom-css/package.json index 2be96250a..01a6709da 100644 --- a/packages/dom-css/package.json +++ b/packages/dom-css/package.json @@ -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", diff --git a/packages/dom-css/tsdown.config.ts b/packages/dom-css/tsdown.config.ts index 606eff70c..0267c3d80 100644 --- a/packages/dom-css/tsdown.config.ts +++ b/packages/dom-css/tsdown.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ }, platform: 'neutral', format: ['esm'], - dts: true, + dts: false, sourcemap: true, hash: false, clean: true, diff --git a/tests/e2e/dom-css/browser-runtime.spec.ts b/tests/e2e/dom-css/browser-runtime.spec.ts index 6cc57801c..f4ce9ed9d 100644 --- a/tests/e2e/dom-css/browser-runtime.spec.ts +++ b/tests/e2e/dom-css/browser-runtime.spec.ts @@ -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; + } + `, + '
Chip
' + ) + + 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; }', '
Host
')