From 8c72b62da07ea1f7e82de84c7c837c3c78dfbf95 Mon Sep 17 00:00:00 2001 From: Marc Went Date: Wed, 30 Sep 2026 20:05:51 +0200 Subject: [PATCH] feat(cli): export Storybook stories beside many documents (#761) * feat(cli): export Storybook stories beside many documents Accept several documents, or a quoted glob such as 'src/**/*.pen', and add --beside to write each document's stories, design images, and manifest into the document's own folder, next to the component's code. Documents export one after another, since documents in one folder share its manifest; a failed document is reported and the rest still export. --watch covers every matched document through one queue. Several documents need --beside or --output, and --page takes a single document. Refs #727 * fix(cli): resolve Storybook export documents by existence, not glob syntax Deciding between a path and a pattern by looking for glob characters missed extglobs, so 'src/+(a|b).pen' was opened as a literal filename, and it flagged an escaped star, so a file genuinely named that way went to the matcher. The character list also could not agree with Node's matcher: is-glob rejects a bare '?', picomatch accepts a parenthesised directory name. An existing path is now that file, and everything else goes to glob(), which matches a plain path to itself and expands every pattern it supports. --------- Co-authored-by: Danila Poyarkov --- CHANGELOG.md | 2 +- packages/cli/src/commands/export/index.ts | 7 +- .../src/commands/export/storybook/index.ts | 134 ++++++++++++--- packages/docs/programmable/cli/exporting.md | 7 + packages/docs/reference/cli.md | 1 + skills/open-pencil/SKILL.md | 1 + tests/engine/cli/export/storybook.test.ts | 152 ++++++++++++++++++ 7 files changed, 277 insertions(+), 27 deletions(-) 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'])