diff --git a/CHANGELOG.md b/CHANGELOG.md index b055185da..b716b66a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ - Swap the component behind an instance with `instance.swapComponent(component)` in the plugin API, as in Figma. - Detach an instance from its component with `detachInstance()` in the plugin API, as in Figma, from scripts run through `eval`. - Run scripts written for Figma's dynamic-page mode that call `figma.getNodeByIdAsync()` or `getMainComponentAsync()`; both resolve to the same nodes as their synchronous forms. -- Export components to Storybook with `openpencil export -f storybook`: one CSF3 story file per component set or component for React, Vue, or HTML, with a story and `select` controls per variant, a design image per variant, and an `openpencil://` link that opens the variant in OpenPencil. `--watch` re-exports on every save and removes stories of deleted components (#727). +- Export components to Storybook with `openpencil export -f storybook`: one CSF3 story file per component set or component for React, Vue, or HTML, with a story and `select` controls per variant, a design image per variant, and an `openpencil://` link that opens the variant in OpenPencil. `--watch` re-exports on every save and removes stories of deleted components, and `--beside` writes each document's stories next to it, for many documents at once (#727). - Export HTML and Tailwind JSX from the app's export options and through the IO registry, and export Tailwind JSX from the CLI with `-f tailwind-jsx` (`-f jsx --style tailwind` still works). HTML export of a single layer now includes the layer itself, as other formats do. - Choose PPTX in the Export panel's format list, alongside PNG, JPG, WEBP, SVG, and PDF. diff --git a/packages/cli/src/commands/export/index.ts b/packages/cli/src/commands/export/index.ts index 5adcc2553..88dce8304 100644 --- a/packages/cli/src/commands/export/index.ts +++ b/packages/cli/src/commands/export/index.ts @@ -239,7 +239,8 @@ export default defineCommand({ args: { file: { type: 'positional', - description: 'Document file path (omit to connect to running app)', + description: + 'Document file path (omit to connect to running app); for storybook, several files or a quoted glob', required: false }, output: { @@ -312,6 +313,10 @@ export default defineCommand({ type: 'boolean', description: 'Storybook: re-export whenever the document changes' }, + beside: { + type: 'boolean', + description: "Storybook: write each document's stories into the document's own folder" + }, 'font-policy': { type: 'string', description: 'Raster/PDF font policy: warn, strict, or allow (default: warn)', diff --git a/packages/cli/src/commands/export/storybook/index.ts b/packages/cli/src/commands/export/storybook/index.ts index b288893e6..226e4cc69 100644 --- a/packages/cli/src/commands/export/storybook/index.ts +++ b/packages/cli/src/commands/export/storybook/index.ts @@ -1,5 +1,5 @@ import { existsSync, watch } from 'node:fs' -import { lstat, mkdir, mkdtemp, rename, rm, rmdir, writeFile } from 'node:fs/promises' +import { glob, lstat, mkdir, mkdtemp, rename, rm, rmdir, writeFile } from 'node:fs/promises' import { basename, dirname, @@ -29,7 +29,10 @@ import { readManifest, writeManifest, type StoryManifest, type StoryOwner } from interface StorybookArgs { file?: string + /** Every positional argument: a shell-expanded glob arrives as several files. */ + _?: string[] output?: string + beside?: boolean page?: string node?: string framework: string @@ -189,17 +192,68 @@ async function writeStories( return files.filter((story) => story.path.endsWith('.stories.ts')).length } -/** Re-exports on every save; editors that save by rename are why the folder is watched. */ -function watchDocument(file: string, onChange: () => Promise): void { - let timer: ReturnType | undefined +/** + * Re-exports a document on every save. One folder watch covers all its documents, since + * editors that save by rename replace the file, and every export runs through one queue: + * documents in a folder share its manifest, so two exports must never overlap. + */ +function watchDocuments(files: string[], onChange: (file: string) => Promise): void { + const timers = new Map>() let running = Promise.resolve() - watch(dirname(resolve(file)), (_event, name) => { - if (name !== basename(file)) return - clearTimeout(timer) - timer = setTimeout(() => { - running = running.then(onChange) - }, 200) - }) + const enqueue = (file: string) => { + running = running.then(() => onChange(file)) + } + const folders = new Map() + for (const file of files) { + const folder = dirname(resolve(file)) + folders.set(folder, [...(folders.get(folder) ?? []), file]) + } + for (const [folder, documents] of folders) { + watch(folder, (_event, name) => { + const file = documents.find((document) => basename(document) === name) + if (!file) return + clearTimeout(timers.get(file)) + timers.set( + file, + setTimeout(() => enqueue(file), 200) + ) + }) + } +} + +/** + * The documents to export: each argument is a file, or a quoted glob pattern + * for many. + * + * An existing path is that file, so a name containing glob syntax still opens + * directly. Anything else goes to `glob()`, which matches a plain path to + * itself and expands every pattern it supports, including the extglobs and + * escapes that inspecting the string for glob characters gets wrong. + */ +async function resolveDocuments(args: StorybookArgs): Promise { + const patterns = args._?.length ? args._ : [requireFile(args.file)] + const documents: string[] = [] + for (const pattern of patterns) { + if (existsSync(pattern)) { + documents.push(pattern) + continue + } + if (typeof glob !== 'function') throw new Error('Glob patterns need Node.js 22 or later.') + const matches: string[] = [] + for await (const match of glob(pattern)) matches.push(match) + if (matches.length === 0) throw new Error(`No documents match ${pattern}.`) + documents.push(...matches.sort()) + } + return [...new Set(documents)] +} + +function outputFor(args: StorybookArgs, file: string): string { + if (args.beside) return dirname(resolve(file)) + return resolve(args.output ?? `${basename(file, extname(file))}-stories`) +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error) } export async function exportStorybookFromFile(args: StorybookArgs): Promise { @@ -207,6 +261,10 @@ export async function exportStorybookFromFile(args: StorybookArgs): Promise { - const count = await writeStories(args, file, framework, outputDir) - console.log(ok(`Exported ${count} story files to ${outputDir}`)) - } - + let documents: string[] try { - await run() + documents = await resolveDocuments(args) } catch (error) { - printError(error instanceof Error ? error.message : String(error)) + printError(message(error)) process.exit(1) } - if (!args.watch) return + if (documents.length > 1 && !args.beside && !args.output) { + printError( + `${documents.length} documents need --beside, to write stories next to each, or --output.` + ) + process.exit(1) + } + if (documents.length > 1 && args.page) { + printError('--page names a page of one document; export a single document with it.') + process.exit(1) + } + + const run = async (file: string) => { + const outputDir = outputFor(args, file) + const count = await writeStories(args, file, framework, outputDir) + console.log(ok(`Exported ${count} story files from ${file} to ${outputDir}`)) + } + + // One after another: documents that share an output folder share its manifest. + const failed: string[] = [] + for (const file of documents) { + try { + await run(file) + } catch (error) { + printError(`${file}: ${message(error)}`) + failed.push(file) + } + } + if (!args.watch) { + if (failed.length > 0) process.exit(1) + return + } // A save can land mid-write or break the document; report it and keep watching. - watchDocument(file, () => - run().catch((error: unknown) => - printError(error instanceof Error ? error.message : String(error)) + watchDocuments(documents, (file) => + run(file).catch((error: unknown) => printError(`${file}: ${message(error)}`)) + ) + console.log( + ok( + `Watching ${documents.length === 1 ? documents[0] : `${documents.length} documents`} for changes` ) ) - console.log(ok(`Watching ${file} for changes`)) } diff --git a/packages/docs/programmable/cli/exporting.md b/packages/docs/programmable/cli/exporting.md index 6d3d3c731..317e608f8 100644 --- a/packages/docs/programmable/cli/exporting.md +++ b/packages/docs/programmable/cli/exporting.md @@ -97,8 +97,15 @@ openpencil export design.fig -f storybook # React stories i openpencil export design.fig -f storybook --framework vue -o src/stories openpencil export design.fig -f storybook --framework html --page "Components" openpencil export design.pen -f storybook -o src/stories --watch # re-export on every save +openpencil export 'src/**/*.pen' -f storybook --beside --watch # stories next to each design ``` +### Designs next to their stories + +Keep each component's design file in the component's folder and export with `--beside`: each document's stories, design images, and `.openpencil-stories.json` manifest go into that document's own folder, next to the component's code. A Storybook `stories` glob such as `../src/**/*.stories.ts` in `.storybook/main.ts` then picks them up without further configuration. + +Pass several documents, or a quoted glob such as `'src/**/*.pen'` that OpenPencil expands itself (Node.js 22 or later). Several documents need `--beside` or `--output`; `--page` works with one document only. Documents are exported one after another, and when one fails the rest are still exported before the command exits with an error. `--watch` watches every matched document; a document created after the watch started needs another run. + Each variant of a component set becomes a story, and its variant properties become `select` controls, so switching a control shows the matching variant. Standalone components named with slashes, such as `Button/Primary` and `Button/Secondary`, are grouped into one `Button` file with a `Variant` control. A combination the design has no variant for throws a named error in Storybook rather than showing a different variant. Stories render the component as HTML with inline styles, like `-f html`, so they need no OpenPencil runtime; `--framework` (`react`, `vue`, or `html`) only changes the wrapper and the `Meta`/`StoryObj` import from `@storybook/react-vite`, `@storybook/vue3-vite`, or `@storybook/html-vite`. Text uses the document's font families, which Storybook has to load itself. Text, boolean, and instance-swap properties are not exported yet. diff --git a/packages/docs/reference/cli.md b/packages/docs/reference/cli.md index 1d57809de..f50da8458 100644 --- a/packages/docs/reference/cli.md +++ b/packages/docs/reference/cli.md @@ -112,6 +112,7 @@ openpencil export [file] [options] | `--framework` | | Storybook framework: `react` (default), `vue`, `html` | | `--design-images` | | Storybook: render a PNG per variant for the Design panel (default: on; `--no-design-images` to skip) | | `--watch` | | Storybook: re-export whenever the document is saved | +| `--beside` | | Storybook: write each document's stories into the document's own folder; the file argument can then be several files or a quoted glob | | `--thumbnail` | | Export page thumbnail instead of full render | | `--width` | | Thumbnail width (default: 1920) | | `--height` | | Thumbnail height (default: 1080) | diff --git a/skills/open-pencil/SKILL.md b/skills/open-pencil/SKILL.md index 0e1978ac0..14575a6af 100644 --- a/skills/open-pencil/SKILL.md +++ b/skills/open-pencil/SKILL.md @@ -106,6 +106,7 @@ openpencil export design.fig -f fig -o roundtrip.fig openpencil export design.fig -f jsx -o component.jsx openpencil export design.fig -f jsx --style tailwind -o component.tsx openpencil export design.pen -f storybook -o src/stories --watch # stories + design images per component, re-exported on save +openpencil export 'src/**/*.pen' -f storybook --beside # stories next to each design file openpencil export design.fig --thumbnail --width 1920 --height 1080 openpencil export --page "Components" -o components.png diff --git a/tests/engine/cli/export/storybook.test.ts b/tests/engine/cli/export/storybook.test.ts index c93867ce0..aafb384f3 100644 --- a/tests/engine/cli/export/storybook.test.ts +++ b/tests/engine/cli/export/storybook.test.ts @@ -257,6 +257,158 @@ test('export CLI refuses a design folder that links outside the output', async ( expect(await Bun.file(join(output, 'Badge.stories.ts')).exists()).toBe(true) }) +test('export CLI --beside writes the stories of every matched document next to it', async () => { + const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-')) + await mkdir(join(dir, 'button')) + await mkdir(join(dir, 'forms/input'), { recursive: true }) + await writeComponentFixture(join(dir, 'button'), ['Button'], 'button.fig') + await writeComponentFixture(join(dir, 'forms/input'), ['Input'], 'input.fig') + + const { stdout, stderr, exitCode } = await runOpenPencilCLI([ + 'export', + join(dir, '**/*.fig'), + '--format', + 'storybook', + '--no-design-images', + '--beside' + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toContain('Exported 1 story files from') + expect(await outputEntries(join(dir, 'button'))).toEqual(['Button.stories.ts', 'button.fig']) + expect(await outputEntries(join(dir, 'forms/input'))).toEqual(['Input.stories.ts', 'input.fig']) + const manifest: unknown = JSON.parse(await Bun.file(join(dir, 'button', MANIFEST)).text()) + expect(manifest).toEqual({ + version: 1, + files: { 'Button.stories.ts': { source: 'button.fig', page: 'Library' } } + }) +}) + +test('export CLI expands an extglob pattern', async () => { + const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-')) + await writeComponentFixture(dir, ['Card'], 'card.fig') + await writeComponentFixture(dir, ['Chip'], 'chip.fig') + await writeComponentFixture(dir, ['Draft'], 'draft.fig') + + const { stderr, exitCode } = await runOpenPencilCLI([ + 'export', + join(dir, '+(card|chip).fig'), + '--format', + 'storybook', + '--no-design-images', + '--beside' + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + const manifest: unknown = JSON.parse(await Bun.file(join(dir, MANIFEST)).text()) + expect(manifest).toEqual({ + version: 1, + files: { + 'Card.stories.ts': { source: 'card.fig', page: 'Library' }, + 'Chip.stories.ts': { source: 'chip.fig', page: 'Library' } + } + }) +}) + +test('export CLI opens a document whose name contains glob syntax', async () => { + const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-')) + const file = await writeComponentFixture(dir, ['Card'], 'card[1].fig') + + const { stderr, exitCode } = await runOpenPencilCLI([ + 'export', + file, + '--format', + 'storybook', + '--no-design-images', + '--beside' + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + const manifest: unknown = JSON.parse(await Bun.file(join(dir, MANIFEST)).text()) + expect(manifest).toEqual({ + version: 1, + files: { 'Card.stories.ts': { source: 'card[1].fig', page: 'Library' } } + }) +}) + +test('export CLI --beside records documents that share a folder in one manifest', async () => { + const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-')) + const card = await writeComponentFixture(dir, ['Card'], 'card.fig') + const chip = await writeComponentFixture(dir, ['Chip'], 'chip.fig') + + // A shell expands an unquoted glob into several arguments. + const { exitCode } = await runOpenPencilCLI([ + 'export', + card, + chip, + '--format', + 'storybook', + '--no-design-images', + '--beside' + ]) + + expect(exitCode).toBe(0) + const manifest: unknown = JSON.parse(await Bun.file(join(dir, MANIFEST)).text()) + expect(manifest).toEqual({ + version: 1, + files: { + 'Card.stories.ts': { source: 'card.fig', page: 'Library' }, + 'Chip.stories.ts': { source: 'chip.fig', page: 'Library' } + } + }) +}) + +test('export CLI needs an output choice and one document for --page', async () => { + const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-')) + await writeComponentFixture(dir, ['Card'], 'card.fig') + await writeComponentFixture(dir, ['Chip'], 'chip.fig') + const pattern = join(dir, '*.fig') + const storybook = ['--format', 'storybook', '--no-design-images'] + + const noOutput = await runOpenPencilCLI(['export', pattern, ...storybook]) + expect(noOutput.exitCode).toBe(1) + expect(noOutput.stderr).toContain('2 documents need --beside') + + const page = await runOpenPencilCLI([ + 'export', + pattern, + ...storybook, + '--beside', + '--page', + 'Library' + ]) + expect(page.exitCode).toBe(1) + expect(page.stderr).toContain('--page names a page of one document') + + const none = await runOpenPencilCLI(['export', join(dir, '*.pen'), ...storybook, '--beside']) + expect(none.exitCode).toBe(1) + expect(none.stderr).toContain('No documents match') + + expect(await outputEntries(dir)).toEqual(['card.fig', 'chip.fig']) +}) + +test('export CLI exports the other documents when one fails, then exits 1', async () => { + const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-')) + await writeComponentFixture(dir, ['Card'], 'a.fig') + await writeComponentFixture(dir, [], 'b.fig') + + const { stderr, exitCode } = await runOpenPencilCLI([ + 'export', + join(dir, '*.fig'), + '--format', + 'storybook', + '--no-design-images', + '--beside' + ]) + + expect(exitCode).toBe(1) + expect(stderr).toContain('b.fig: No components found') + expect(await Bun.file(join(dir, 'Card.stories.ts')).exists()).toBe(true) +}) + test('export CLI --watch re-exports stories when the document changes', async () => { const dir = await mkdtemp(join(tmpdir(), 'open-pencil-storybook-cli-')) const figPath = await writeComponentFixture(dir, ['First'])