├── .changeset
├── README.md
└── config.json
├── .gitattributes
├── .github
└── workflows
│ ├── changesets.yml
│ └── e2e-test.yml
├── .gitignore
├── .npmrc
├── LICENSE
├── README.md
├── package.json
├── packages
├── create-nextjs-storybook
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ ├── addWithStorybook.ts
│ │ ├── cli
│ │ │ ├── HandledError.ts
│ │ │ ├── createLogStream.ts
│ │ │ ├── detect.ts
│ │ │ ├── helpers.ts
│ │ │ └── js-package-manager
│ │ │ │ ├── JsPackageManager.ts
│ │ │ │ ├── JsPackageManagerFactory.ts
│ │ │ │ ├── NPMProxy.ts
│ │ │ │ ├── PNPMProxy.ts
│ │ │ │ ├── PackageJson.ts
│ │ │ │ ├── Yarn1Proxy.ts
│ │ │ │ ├── Yarn2Proxy.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── util.ts
│ │ └── index.ts
│ ├── templates
│ │ ├── app
│ │ │ ├── groupLayouts
│ │ │ │ ├── layout-nested.tsx
│ │ │ │ └── layout-root.tsx
│ │ │ ├── js
│ │ │ │ ├── Button.jsx
│ │ │ │ ├── Button.stories.js
│ │ │ │ ├── Header.jsx
│ │ │ │ ├── Header.stories.js
│ │ │ │ ├── Page.jsx
│ │ │ │ └── Page.stories.js
│ │ │ └── ts
│ │ │ │ ├── Button.stories.ts
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── Header.stories.ts
│ │ │ │ ├── Header.tsx
│ │ │ │ ├── Page.stories.ts
│ │ │ │ └── Page.tsx
│ │ ├── css
│ │ │ ├── button.module.css
│ │ │ ├── header.module.css
│ │ │ └── page.module.css
│ │ ├── pages
│ │ │ ├── js
│ │ │ │ ├── Button.jsx
│ │ │ │ ├── Button.stories.js
│ │ │ │ ├── Header.jsx
│ │ │ │ ├── Header.stories.js
│ │ │ │ ├── Page.jsx
│ │ │ │ └── Page.stories.js
│ │ │ └── ts
│ │ │ │ ├── Button.stories.ts
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── Header.stories.ts
│ │ │ │ ├── Header.tsx
│ │ │ │ ├── Page.stories.ts
│ │ │ │ └── Page.tsx
│ │ └── sb
│ │ │ ├── main.js.ejs
│ │ │ ├── main.ts.ejs
│ │ │ ├── preview.jsx
│ │ │ └── preview.tsx
│ └── tsconfig.json
└── nextjs-server
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── package.json
│ ├── preset.js
│ ├── server.d.ts
│ ├── server.js
│ ├── src
│ ├── index.ts
│ ├── indexers.ts
│ ├── mock.ts
│ ├── next-config.cts
│ ├── null-builder.ts
│ ├── null-renderer.ts
│ ├── pages
│ │ ├── Preview.tsx
│ │ ├── importFn.ts
│ │ ├── index.ts
│ │ └── previewHtml.ts
│ ├── preset.ts
│ ├── reexports
│ │ ├── channels.ts
│ │ ├── core-events.ts
│ │ ├── preview-api.ts
│ │ └── types.ts
│ ├── types.ts
│ ├── typings.d.ts
│ ├── utils.ts
│ └── verifyPort.ts
│ ├── template
│ └── app
│ │ ├── groupLayouts
│ │ ├── layout-nested.tsx
│ │ └── layout-root.tsx
│ │ └── storybook-preview
│ │ ├── components
│ │ ├── Prepare.tsx
│ │ ├── Preview.tsx
│ │ ├── Storybook.tsx
│ │ ├── args.ts
│ │ └── previewHtml.ts
│ │ └── page.tsx
│ └── tsconfig.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── scripts
├── package.json
├── playwright.config.ts
├── prepare
├── bundle.ts
└── check.ts
├── specs
└── basic.spec.ts
├── test.ts
├── tsconfig.json
└── utils
└── exec.ts
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "fixed": [],
6 | "linked": [],
7 | "access": "restricted",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": []
11 | }
12 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/.github/workflows/changesets.yml:
--------------------------------------------------------------------------------
1 | name: Changesets
2 | on:
3 | push:
4 | branches:
5 | - main
6 |
7 | jobs:
8 | version:
9 | timeout-minutes: 15
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v4
14 | with:
15 | fetch-depth: 0
16 |
17 | - name: Setup pnpm
18 | uses: pnpm/action-setup@v2
19 |
20 | - name: Setup Node.js
21 | uses: actions/setup-node@v4
22 | with:
23 | node-version: 18
24 | cache: pnpm
25 |
26 | - name: Install dependencies
27 | run: pnpm install
28 |
29 | - name: Create and publish versions
30 | uses: changesets/action@v1
31 | with:
32 | publish: pnpm ci:publish
33 | env:
34 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
35 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
36 |
37 |
--------------------------------------------------------------------------------
/.github/workflows/e2e-test.yml:
--------------------------------------------------------------------------------
1 | name: End to End Tests
2 |
3 | on: push
4 |
5 | jobs:
6 | test:
7 | runs-on: ubuntu-latest
8 | name: Test
9 | steps:
10 | - name: Checkout
11 | uses: actions/checkout@v4
12 | with:
13 | fetch-depth: 0
14 |
15 | - name: Setup pnpm
16 | uses: pnpm/action-setup@v2
17 |
18 | - name: Setup Node.js
19 | uses: actions/setup-node@v4
20 | with:
21 | node-version: 18
22 | cache: pnpm
23 |
24 | - name: Install dependencies
25 | run: pnpm install
26 |
27 | - name: Build packages
28 | run: pnpm -r build
29 |
30 | - name: Package nextjs-server
31 | run: pnpm pack
32 | working-directory: packages/nextjs-server
33 |
34 | - name: Install Playwright
35 | run: pnpm exec playwright install
36 | working-directory: scripts
37 |
38 | - name: Run E2E Tests (pages dir)
39 | uses: BerniWittmann/background-server-action@v1
40 | with:
41 | cwd: scripts
42 | start: pnpm tsx test.ts
43 | wait-on: 'http://localhost:3000'
44 | wait-on-timeout: 300
45 | command: pnpm playwright test
46 | env:
47 | APP_DIR: false
48 | STORYBOOK_VERIFY_PORT_DELAY: 1000
49 | TMPDIR: ${{ runner.temp }}
50 |
51 | - name: Kill all Node.js processes
52 | run: killall node
53 |
54 | - name: Run E2E Tests (app dir)
55 | uses: BerniWittmann/background-server-action@v1
56 | with:
57 | cwd: scripts
58 | start: pnpm tsx test.ts
59 | wait-on: 'http://localhost:3000'
60 | wait-on-timeout: 300
61 | command: pnpm playwright test
62 | env:
63 | STORYBOOK_VERIFY_PORT_DELAY: 1000
64 | TMPDIR: ${{ runner.temp }}
65 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | # Next.js build output
87 | .next
88 | out
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # vuepress v2.x temp and cache directory
104 | .temp
105 | .cache
106 |
107 | # Serverless directories
108 | .serverless/
109 |
110 | # FuseBox cache
111 | .fusebox/
112 |
113 | # DynamoDB Local files
114 | .dynamodb/
115 |
116 | # TernJS port file
117 | .tern-port
118 |
119 | # Stores VSCode versions used for testing VSCode extensions
120 | .vscode-test
121 |
122 | # yarn v2
123 | .yarn/cache
124 | .yarn/unplugged
125 | .yarn/build-state.yml
126 | .yarn/install-state.gz
127 | .pnp.*
128 | .pnpm-store
129 | .npmrc
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storybookjs/nextjs-server/5f70344ee3a4919b94be4e33d87505487a379555/.npmrc
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Storybook
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
Storybook NextJS Server
2 |
3 | Storybook NextJS server is a **highly experimental** framework to build React components in isolation. Unlike the stable `@storybook/nextjs`, it is embedded **inside** your NextJS app and rendered by NextJS.
4 |
5 | This has a few key benefits:
6 |
7 | 1. You only need to start up one server when you’re developing your app
8 | 2. It is “lightweight” since your app is doing most of the heavy lifting
9 | 3. Your components render identically to how they render in your app
10 | 4. You can use it to develop [React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components)
11 |
12 | - [Install](#install)
13 | - [Start](#start)
14 | - [Develop](#develop)
15 | - [Pages directory](#pages-directory)
16 | - [App directory](#app-directory)
17 | - [Customize](#customize)
18 |
19 | ## Install
20 |
21 | To install, run the following in an existing NextJS project:
22 |
23 | ```bash
24 | npm create nextjs-storybook
25 | ```
26 |
27 | Then update your `next.config.js` to use the `withStorybook` decorator.
28 |
29 | ```diff
30 | + const withStorybook = require('@storybook/nextjs-server/next-config')(/* sb config */);
31 |
32 | /** @type {import('next').NextConfig} */
33 | - module.exports = {/* nextjs config */}
34 | + module.exports = withStorybook({/* nextjs config */});
35 | ```
36 |
37 | ## Start
38 |
39 | The installer adds sample stories to the `/src/stories` or `/stories` directory. To view your Storybook, simply run your NextJS dev server:
40 |
41 | ```sh
42 | npm run dev
43 | ```
44 |
45 | Your app should display as normal, but it should get a new route, `/storybook`, that displays your stories.
46 |
47 | It can fail if your NextJS dev server is not running on the default port, which is `3000` or the `$PORT` environment variable if set. If the specified port is already taken, NextJS will auto-increment to find a free port and this messes up Storybook in the current configuration. You can [customize](#customize) your setup for different ports, routes, etc. as needed.
48 |
49 | ## Develop
50 |
51 | Developing in Storybook is documented in the [official docs](https://storybook.js.org/docs), but there are some nuances to be aware of in Storybook NextJS Server. The behavior is different depending on whether you are running in NextJS's `pages` setup (old) or `app` directory.
52 |
53 | If your app is running in the `pages` directory, Storybook stories are implemented as React Client Components and should behave very similarly to the stable Storybook for NextJS.
54 |
55 | If your app is running in the `app` directory, Storybook's stories are running as [React Server Components (RSC)](https://nextjs.org/docs/app/building-your-application/rendering/server-components), are therefore subject to various RSC constraints.
56 |
57 | ### Pages directory
58 |
59 | FIXME
60 |
61 | ### App directory
62 |
63 | FIXME
64 |
65 | ## Customize
66 |
67 | The `withStorybook` function accepts several configuration options, all of which are optional:
68 |
69 | | Option | Description | Default |
70 | | --------------- | --------------------------------------------- | ------------------------ |
71 | | **port** | Port that the Next.js app will run on. | process.env.PORT ?? 3000 |
72 | | **sbPort** | Internal port that Storybook will run on. | 34567 |
73 | | **managerPath** | URL path to Storybook's "manager" UI. | 'storybook' |
74 | | **previewPath** | URL path to Storybook's story preview iframe. | 'storybook-preview' |
75 | | **configDir** | Directory where Storybook's config files are. | '.storybook' |
76 | | **appDir** | Whether to use the NextJS app directory. | undefined |
77 |
78 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "ci:publish": "pnpm publish -r"
4 | },
5 | "devDependencies": {
6 | "@changesets/cli": "^2.27.1"
7 | },
8 | "packageManager": "pnpm@8.12.0"
9 | }
10 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # create-nextjs-storybook
2 |
3 | ## 0.0.4
4 |
5 | ### Patch Changes
6 |
7 | - Install next version of storybook
8 |
9 | ## 0.0.3
10 |
11 | ### Patch Changes
12 |
13 | - Fix README typo
14 |
15 | ## 0.0.2
16 |
17 | ### Patch Changes
18 |
19 | - Fix publishing of app layout templates
20 |
21 | ## 0.0.1
22 |
23 | ### Patch Changes
24 |
25 | - First version of create-nextjs-storybook installer
26 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/README.md:
--------------------------------------------------------------------------------
1 | ## Create Next.js Storybook
2 |
3 | Automated installer for `@storybook/nextjs-server`, an experimental embedded version of [Storybook](https://storybook.js.org/) that runs **inside** your Next.js dev server.
4 |
5 | To install, run the installer from the root of your Next.js project:
6 |
7 | ```
8 | npm create nextjs-storybook
9 | ```
10 |
11 | For more information, [please see the docs](https://github.com/storybookjs/nextjs-server).
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "create-nextjs-storybook",
3 | "version": "0.0.4",
4 | "description": "Install embedded Storybook route into a NextJS project",
5 | "type": "module",
6 | "license": "MIT",
7 | "bin": {
8 | "init": "dist/index.js"
9 | },
10 | "scripts": {
11 | "test": "echo \"Error: no test specified\" && exit 1",
12 | "build": "tsc --jsx react",
13 | "prepublish": "pnpm build"
14 | },
15 | "keywords": [
16 | "storybook",
17 | "nextjs",
18 | "nextjs-server",
19 | "embedded",
20 | "components",
21 | "installer"
22 | ],
23 | "module": "dist/index.js",
24 | "author": "",
25 | "devDependencies": {
26 | "@storybook/react": "^7.6.0",
27 | "@types/node": "^20.10.0",
28 | "react": "^18.2.0",
29 | "typescript": "^5.3.2"
30 | },
31 | "dependencies": {
32 | "@storybook/types": "^7.5.3",
33 | "@yarnpkg/fslib": "2.10.3",
34 | "@yarnpkg/libzip": "2.3.0",
35 | "boxen": "^7.1.1",
36 | "chalk": "^5.3.0",
37 | "cross-spawn": "^7.0.3",
38 | "ejs": "^3.1.9",
39 | "execa": "^5.0.0",
40 | "find-up": "^7.0.0",
41 | "fs-extra": "^11.2.0",
42 | "ora": "^7.0.1",
43 | "semver": "^7.5.4",
44 | "tempy": "^3.1.0",
45 | "ts-dedent": "^2.2.0"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/addWithStorybook.ts:
--------------------------------------------------------------------------------
1 | export const addWithStorybook = (nextConfig: string) => {
2 | // FIXME
3 | return nextConfig;
4 | };
5 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/cli/HandledError.ts:
--------------------------------------------------------------------------------
1 | export class HandledError extends Error {
2 | public handled = true;
3 |
4 | constructor(messageOrError: string | Error) {
5 | super(typeof messageOrError === 'string' ? messageOrError : messageOrError.message);
6 |
7 | if (typeof messageOrError !== 'string') this.cause = messageOrError;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/cli/createLogStream.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'path';
2 | import type { WriteStream } from 'fs-extra';
3 | import { temporaryFile } from 'tempy';
4 | import { writeFile, readFile } from 'node:fs/promises';
5 | import fse from 'fs-extra';
6 |
7 | const { move, remove, createWriteStream } = fse;
8 |
9 | /**
10 | * Given a file name, creates an object with utilities to manage a log file.
11 | * It creates a temporary log file which you can manage with the returned functions.
12 | * You can then decide whether to move the log file to the users project, or remove it.
13 | *
14 | * @example
15 | * ```
16 | * const { logStream, moveLogFile, removeLogFile, clearLogFile, readLogFile } = await createLogStream('my-log-file.log');
17 | *
18 | * // SCENARIO 1:
19 | * // you can write custom messages to generate a log file
20 | * logStream.write('my log message');
21 | * await moveLogFile();
22 | *
23 | * // SCENARIO 2:
24 | * // or you can pass it to stdio and capture the output of that command
25 | * try {
26 | * await this.executeCommand({
27 | * command: 'pnpm',
28 | * args: ['info', packageName, ...args],
29 | * // do not output to the user, and send stdio and stderr to log file
30 | * stdio: ['ignore', logStream, logStream]
31 | * });
32 | * } catch (err) {
33 | * // do something with the log file content
34 | * const output = await readLogFile();
35 | * // move the log file to the users project
36 | * await moveLogFile();
37 | * }
38 | * // success, no need to keep the log file
39 | * await removeLogFile();
40 | *
41 | * ```
42 | */
43 | export const createLogStream = async (
44 | logFileName = 'storybook.log'
45 | ): Promise<{
46 | moveLogFile: () => Promise;
47 | removeLogFile: () => Promise;
48 | clearLogFile: () => Promise;
49 | readLogFile: () => Promise;
50 | logStream: WriteStream;
51 | }> => {
52 | const finalLogPath = join(process.cwd(), logFileName);
53 | const temporaryLogPath = temporaryFile({ name: logFileName });
54 |
55 | const logStream = createWriteStream(temporaryLogPath, { encoding: 'utf8' });
56 |
57 | return new Promise((resolve, reject) => {
58 | logStream.once('open', () => {
59 | const moveLogFile = async () => move(temporaryLogPath, finalLogPath, { overwrite: true });
60 | const clearLogFile = async () => writeFile(temporaryLogPath, '');
61 | const removeLogFile = async () => remove(temporaryLogPath);
62 | const readLogFile = async () => {
63 | return readFile(temporaryLogPath, 'utf8');
64 | };
65 | resolve({ logStream, moveLogFile, clearLogFile, removeLogFile, readLogFile });
66 | });
67 | logStream.once('error', reject);
68 | });
69 | };
70 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/cli/detect.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import { findUpSync } from 'find-up';
3 | import semver from 'semver';
4 | import { resolve } from 'path';
5 |
6 | const logger = console;
7 |
8 | import type { JsPackageManager } from './js-package-manager/index.js';
9 |
10 | export enum SupportedLanguage {
11 | JAVASCRIPT = 'javascript',
12 | TYPESCRIPT_3_8 = 'typescript-3-8',
13 | TYPESCRIPT_4_9 = 'typescript-4-9',
14 | }
15 |
16 | export function isStorybookInstantiated(configDir = resolve(process.cwd(), '.storybook')) {
17 | return fs.existsSync(configDir);
18 | }
19 |
20 | export async function detectPnp() {
21 | return !!findUpSync(['.pnp.js', '.pnp.cjs']);
22 | }
23 |
24 | export async function detectLanguage(packageManager: JsPackageManager) {
25 | let language = SupportedLanguage.JAVASCRIPT;
26 |
27 | if (fs.existsSync('jsconfig.json')) {
28 | return language;
29 | }
30 |
31 | const isTypescriptDirectDependency = await packageManager
32 | .getAllDependencies()
33 | .then((deps) => Boolean(deps['typescript']));
34 |
35 | const typescriptVersion = await packageManager.getPackageVersion('typescript');
36 | const prettierVersion = await packageManager.getPackageVersion('prettier');
37 | const babelPluginTransformTypescriptVersion = await packageManager.getPackageVersion(
38 | '@babel/plugin-transform-typescript'
39 | );
40 | const typescriptEslintParserVersion = await packageManager.getPackageVersion(
41 | '@typescript-eslint/parser'
42 | );
43 |
44 | const eslintPluginStorybookVersion = await packageManager.getPackageVersion(
45 | 'eslint-plugin-storybook'
46 | );
47 |
48 | if (isTypescriptDirectDependency && typescriptVersion) {
49 | if (
50 | semver.gte(typescriptVersion, '4.9.0') &&
51 | (!prettierVersion || semver.gte(prettierVersion, '2.8.0')) &&
52 | (!babelPluginTransformTypescriptVersion ||
53 | semver.gte(babelPluginTransformTypescriptVersion, '7.20.0')) &&
54 | (!typescriptEslintParserVersion || semver.gte(typescriptEslintParserVersion, '5.44.0')) &&
55 | (!eslintPluginStorybookVersion || semver.gte(eslintPluginStorybookVersion, '0.6.8'))
56 | ) {
57 | language = SupportedLanguage.TYPESCRIPT_4_9;
58 | } else if (semver.gte(typescriptVersion, '3.8.0')) {
59 | language = SupportedLanguage.TYPESCRIPT_3_8;
60 | } else if (semver.lt(typescriptVersion, '3.8.0')) {
61 | logger.warn('Detected TypeScript < 3.8, populating with JavaScript examples');
62 | }
63 | }
64 |
65 | return language;
66 | }
67 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/cli/helpers.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign */
2 | import chalk from 'chalk';
3 | const logger = console;
4 |
5 | export const commandLog = (message: string) => {
6 | process.stdout.write(chalk.cyan(' • ') + message);
7 |
8 | // Need `void` to be able to use this function in a then of a Promise
9 | return (errorMessage?: string | void, errorInfo?: string) => {
10 | if (errorMessage) {
11 | process.stdout.write(`. ${chalk.red('✖')}\n`);
12 | logger.error(`\n ${chalk.red(errorMessage)}`);
13 |
14 | if (!errorInfo) {
15 | return;
16 | }
17 |
18 | const newErrorInfo = errorInfo
19 | .split('\n')
20 | .map((line) => ` ${chalk.dim(line)}`)
21 | .join('\n');
22 | logger.error(`${newErrorInfo}\n`);
23 | return;
24 | }
25 |
26 | process.stdout.write(`. ${chalk.green('✓')}\n`);
27 | };
28 | };
29 |
30 | export function paddedLog(message: string) {
31 | const newMessage = message
32 | .split('\n')
33 | .map((line) => ` ${line}`)
34 | .join('\n');
35 |
36 | logger.log(newMessage);
37 | }
38 |
39 | export function getChars(char: string, amount: number) {
40 | let line = '';
41 | for (let lc = 0; lc < amount; lc += 1) {
42 | line += char;
43 | }
44 |
45 | return line;
46 | }
47 |
48 | export function codeLog(codeLines: string[], leftPadAmount?: number) {
49 | let maxLength = 0;
50 | const newLines = codeLines.map((line) => {
51 | maxLength = line.length > maxLength ? line.length : maxLength;
52 | return line;
53 | });
54 |
55 | const finalResult = newLines
56 | .map((line) => {
57 | const rightPadAmount = maxLength - line.length;
58 | let newLine = line + getChars(' ', rightPadAmount);
59 | newLine = getChars(' ', leftPadAmount || 2) + chalk.inverse(` ${newLine} `);
60 | return newLine;
61 | })
62 | .join('\n');
63 |
64 | logger.log(finalResult);
65 | }
66 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/cli/js-package-manager/JsPackageManager.ts:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk';
2 | import { gt, satisfies } from 'semver';
3 | import type { CommonOptions } from 'execa';
4 | import { command as execaCommand, sync as execaCommandSync } from 'execa';
5 | import path from 'node:path';
6 | import { existsSync, readFileSync } from 'node:fs';
7 | import { readFile, writeFile } from 'node:fs/promises';
8 |
9 | import { dedent } from 'ts-dedent';
10 | import { commandLog } from '../helpers.js';
11 | import type { PackageJson, PackageJsonWithDepsAndDevDeps } from './PackageJson.js';
12 | import type { InstallationMetadata } from './types.js';
13 | import { HandledError } from '../HandledError.js';
14 |
15 | const logger = console;
16 |
17 | export type PackageManagerName = 'npm' | 'yarn1' | 'yarn2' | 'pnpm';
18 |
19 | /**
20 | * Extract package name and version from input
21 | *
22 | * @param pkg A string like `@storybook/cli`, `react` or `react@^16`
23 | * @return A tuple of 2 elements: [packageName, packageVersion]
24 | */
25 | export function getPackageDetails(pkg: string): [string, string?] {
26 | const idx = pkg.lastIndexOf('@');
27 | // If the only `@` is the first character, it is a scoped package
28 | // If it isn't in the string, it will be -1
29 | if (idx <= 0) {
30 | return [pkg, undefined];
31 | }
32 | const packageName = pkg.slice(0, idx);
33 | const packageVersion = pkg.slice(idx + 1);
34 | return [packageName, packageVersion];
35 | }
36 |
37 | interface JsPackageManagerOptions {
38 | cwd?: string;
39 | }
40 | export abstract class JsPackageManager {
41 | public abstract readonly type: PackageManagerName;
42 |
43 | public abstract initPackageJson(): Promise;
44 |
45 | public abstract getRunStorybookCommand(): string;
46 |
47 | public abstract getRunCommand(command: string): string;
48 |
49 | public readonly cwd?: string;
50 |
51 | public abstract getPackageJSON(
52 | packageName: string,
53 | basePath?: string
54 | ): Promise;
55 |
56 | public abstract getPackageVersion(packageName: string, basePath?: string): Promise;
57 |
58 | // NOTE: for some reason yarn prefers the npm registry in
59 | // local development, so always use npm
60 | async setRegistryURL(url: string) {
61 | if (url) {
62 | await this.executeCommand({ command: 'npm', args: ['config', 'set', 'registry', url] });
63 | } else {
64 | await this.executeCommand({ command: 'npm', args: ['config', 'delete', 'registry'] });
65 | }
66 | }
67 |
68 | async getRegistryURL() {
69 | const res = await this.executeCommand({ command: 'npm', args: ['config', 'get', 'registry'] });
70 | const url = res.trim();
71 | return url === 'undefined' ? undefined : url;
72 | }
73 |
74 | constructor(options?: JsPackageManagerOptions) {
75 | this.cwd = options?.cwd || process.cwd();
76 | }
77 |
78 | /** Detect whether Storybook gets initialized in a monorepository/workspace environment
79 | * The cwd doesn't have to be the root of the monorepo, it can be a subdirectory
80 | * @returns true, if Storybook is initialized inside a monorepository/workspace
81 | */
82 | public isStorybookInMonorepo() {
83 | let cwd = process.cwd();
84 |
85 | // eslint-disable-next-line no-constant-condition
86 | while (true) {
87 | try {
88 | const turboJsonPath = `${cwd}/turbo.json`;
89 | const rushJsonPath = `${cwd}/rush.json`;
90 |
91 | if (existsSync(turboJsonPath) || existsSync(rushJsonPath)) {
92 | return true;
93 | }
94 |
95 | const packageJsonPath = require.resolve(`${cwd}/package.json`);
96 |
97 | // read packagejson with readFileSync
98 | const packageJsonFile = readFileSync(packageJsonPath, 'utf8');
99 | const packageJson = JSON.parse(packageJsonFile) as PackageJsonWithDepsAndDevDeps;
100 |
101 | if (packageJson.workspaces) {
102 | return true;
103 | }
104 | } catch (err) {
105 | // Package.json not found or invalid in current directory
106 | }
107 |
108 | // Move up to the parent directory
109 | const parentDir = path.dirname(cwd);
110 |
111 | // Check if we have reached the root of the filesystem
112 | if (parentDir === cwd) {
113 | break;
114 | }
115 |
116 | // Update cwd to the parent directory
117 | cwd = parentDir;
118 | }
119 |
120 | return false;
121 | }
122 |
123 | /**
124 | * Install dependencies listed in `package.json`
125 | */
126 | public async installDependencies() {
127 | let done = commandLog('Preparing to install dependencies');
128 | done();
129 |
130 | logger.log();
131 | logger.log();
132 |
133 | done = commandLog('Installing dependencies');
134 |
135 | try {
136 | await this.runInstall();
137 | done();
138 | } catch (e) {
139 | done('An error occurred while installing dependencies.');
140 | throw new HandledError(e);
141 | }
142 | }
143 |
144 | packageJsonPath(): string {
145 | return path.resolve(this.cwd, 'package.json');
146 | }
147 |
148 | async readPackageJson(): Promise {
149 | const packageJsonPath = this.packageJsonPath();
150 | if (!existsSync(packageJsonPath)) {
151 | throw new Error(`Could not read package.json file at ${packageJsonPath}`);
152 | }
153 |
154 | const jsonContent = await readFile(packageJsonPath, 'utf8');
155 | return JSON.parse(jsonContent);
156 | }
157 |
158 | async writePackageJson(packageJson: PackageJson) {
159 | const packageJsonToWrite = { ...packageJson };
160 | // make sure to not accidentally add empty fields
161 | if (
162 | packageJsonToWrite.dependencies &&
163 | Object.keys(packageJsonToWrite.dependencies).length === 0
164 | ) {
165 | delete packageJsonToWrite.dependencies;
166 | }
167 | if (
168 | packageJsonToWrite.devDependencies &&
169 | Object.keys(packageJsonToWrite.devDependencies).length === 0
170 | ) {
171 | delete packageJsonToWrite.devDependencies;
172 | }
173 | if (
174 | packageJsonToWrite.peerDependencies &&
175 | Object.keys(packageJsonToWrite.peerDependencies).length === 0
176 | ) {
177 | delete packageJsonToWrite.peerDependencies;
178 | }
179 |
180 | const content = `${JSON.stringify(packageJsonToWrite, null, 2)}\n`;
181 | await writeFile(this.packageJsonPath(), content, 'utf8');
182 | }
183 |
184 | /**
185 | * Read the `package.json` file available in the directory the command was call from
186 | * If there is no `package.json` it will create one.
187 | */
188 | public async retrievePackageJson(): Promise {
189 | let packageJson;
190 | try {
191 | packageJson = await this.readPackageJson();
192 | } catch (err) {
193 | if (err.message.includes('Could not read package.json')) {
194 | await this.initPackageJson();
195 | packageJson = await this.readPackageJson();
196 | } else {
197 | throw new Error(
198 | dedent`
199 | There was an error while reading the package.json file at ${this.packageJsonPath()}: ${
200 | err.message
201 | }
202 | Please fix the error and try again.
203 | `
204 | );
205 | }
206 | }
207 |
208 | return {
209 | ...packageJson,
210 | dependencies: { ...packageJson.dependencies },
211 | devDependencies: { ...packageJson.devDependencies },
212 | peerDependencies: { ...packageJson.peerDependencies },
213 | };
214 | }
215 |
216 | public async getAllDependencies(): Promise> {
217 | const { dependencies, devDependencies, peerDependencies } = await this.retrievePackageJson();
218 |
219 | return {
220 | ...dependencies,
221 | ...devDependencies,
222 | ...peerDependencies,
223 | };
224 | }
225 |
226 | /**
227 | * Add dependencies to a project using `yarn add` or `npm install`.
228 | *
229 | * @param {Object} options contains `skipInstall`, `packageJson` and `installAsDevDependencies` which we use to determine how we install packages.
230 | * @param {Array} dependencies contains a list of packages to add.
231 | * @example
232 | * addDependencies(options, [
233 | * `@storybook/react@${storybookVersion}`,
234 | * `@storybook/addon-actions@${actionsVersion}`,
235 | * `@storybook/addon-links@${linksVersion}`,
236 | * `@storybook/preview-api@${addonsVersion}`,
237 | * ]);
238 | */
239 | public async addDependencies(
240 | options: {
241 | skipInstall?: boolean;
242 | installAsDevDependencies?: boolean;
243 | packageJson?: PackageJson;
244 | },
245 | dependencies: string[]
246 | ) {
247 | const { skipInstall } = options;
248 |
249 | if (skipInstall) {
250 | const { packageJson } = options;
251 |
252 | const dependenciesMap = dependencies.reduce((acc, dep) => {
253 | const [packageName, packageVersion] = getPackageDetails(dep);
254 | return { ...acc, [packageName]: packageVersion };
255 | }, {});
256 |
257 | if (options.installAsDevDependencies) {
258 | packageJson.devDependencies = {
259 | ...packageJson.devDependencies,
260 | ...dependenciesMap,
261 | };
262 | } else {
263 | packageJson.dependencies = {
264 | ...packageJson.dependencies,
265 | ...dependenciesMap,
266 | };
267 | }
268 | await this.writePackageJson(packageJson);
269 | } else {
270 | try {
271 | await this.runAddDeps(dependencies, options.installAsDevDependencies);
272 | } catch (e) {
273 | logger.error('\nAn error occurred while installing dependencies:');
274 | logger.log(e.message);
275 | throw new HandledError(e);
276 | }
277 | }
278 | }
279 |
280 | /**
281 | * Remove dependencies from a project using `yarn remove` or `npm uninstall`.
282 | *
283 | * @param {Object} options contains `skipInstall`, `packageJson` and `installAsDevDependencies` which we use to determine how we install packages.
284 | * @param {Array} dependencies contains a list of packages to remove.
285 | * @example
286 | * removeDependencies(options, [
287 | * `@storybook/react`,
288 | * `@storybook/addon-actions`,
289 | * ]);
290 | */
291 | public async removeDependencies(
292 | options: {
293 | skipInstall?: boolean;
294 | packageJson?: PackageJson;
295 | },
296 | dependencies: string[]
297 | ): Promise {
298 | const { skipInstall } = options;
299 |
300 | if (skipInstall) {
301 | const { packageJson } = options;
302 |
303 | dependencies.forEach((dep) => {
304 | if (packageJson.devDependencies) {
305 | delete packageJson.devDependencies[dep];
306 | }
307 | if (packageJson.dependencies) {
308 | delete packageJson.dependencies[dep];
309 | }
310 | });
311 |
312 | await this.writePackageJson(packageJson);
313 | } else {
314 | try {
315 | await this.runRemoveDeps(dependencies);
316 | } catch (e) {
317 | logger.error('An error occurred while removing dependencies.');
318 | logger.log(e.message);
319 | throw new HandledError(e);
320 | }
321 | }
322 | }
323 |
324 | /**
325 | * Return an array of strings matching following format: `@`
326 | *
327 | * @param packages
328 | */
329 | public getVersionedPackages(packages: string[]): Promise {
330 | return Promise.all(
331 | packages.map(async (pkg) => {
332 | const [packageName, packageVersion] = getPackageDetails(pkg);
333 | return `${packageName}@${await this.getVersion(packageName, packageVersion)}`;
334 | })
335 | );
336 | }
337 |
338 | /**
339 | * Return an array of string standing for the latest version of the input packages.
340 | * To be able to identify which version goes with which package the order of the input array is keep.
341 | *
342 | * @param packageNames
343 | */
344 | public getVersions(...packageNames: string[]): Promise {
345 | return Promise.all(
346 | packageNames.map((packageName) => {
347 | return this.getVersion(packageName);
348 | })
349 | );
350 | }
351 |
352 | /**
353 | * Return the latest version of the input package available on npmjs registry.
354 | * If constraint are provided it return the latest version matching the constraints.
355 | *
356 | * For `@storybook/*` packages the latest version is retrieved from `cli/src/versions.json` file directly
357 | *
358 | * @param packageName The name of the package
359 | * @param constraint A valid semver constraint, example: '1.x || >=2.5.0 || 5.0.0 - 7.2.3'
360 | */
361 | public async getVersion(packageName: string, constraint?: string): Promise {
362 | let current: string;
363 |
364 | if (/(@storybook|^sb$|^storybook$)/.test(packageName)) {
365 | // @ts-expect-error (Converted from ts-ignore)
366 | current = storybookPackagesVersions[packageName];
367 | }
368 |
369 | let latest;
370 | try {
371 | latest = await this.latestVersion(packageName, constraint);
372 | } catch (e) {
373 | if (current) {
374 | logger.warn(`\n ${chalk.yellow(e.message)}`);
375 | return current;
376 | }
377 |
378 | logger.error(`\n ${chalk.red(e.message)}`);
379 | throw new HandledError(e);
380 | }
381 |
382 | const versionToUse =
383 | current && (!constraint || satisfies(current, constraint)) && gt(current, latest)
384 | ? current
385 | : latest;
386 | return `^${versionToUse}`;
387 | }
388 |
389 | /**
390 | * Get the latest version of the package available on npmjs.com.
391 | * If constraint is set then it returns a version satisfying it, otherwise the latest version available is returned.
392 | *
393 | * @param packageName Name of the package
394 | * @param constraint Version range to use to constraint the returned version
395 | */
396 | public async latestVersion(packageName: string, constraint?: string): Promise {
397 | if (!constraint) {
398 | return this.runGetVersions(packageName, false);
399 | }
400 |
401 | const versions = await this.runGetVersions(packageName, true);
402 |
403 | // Get the latest version satisfying the constraint
404 | return versions.reverse().find((version) => satisfies(version, constraint));
405 | }
406 |
407 | public async addStorybookCommandInScripts(options?: { port: number; preCommand?: string }) {
408 | const sbPort = options?.port ?? 6006;
409 | const storybookCmd = `storybook dev -p ${sbPort}`;
410 |
411 | const buildStorybookCmd = `storybook build`;
412 |
413 | const preCommand = options?.preCommand ? this.getRunCommand(options.preCommand) : undefined;
414 | await this.addScripts({
415 | storybook: [preCommand, storybookCmd].filter(Boolean).join(' && '),
416 | 'build-storybook': [preCommand, buildStorybookCmd].filter(Boolean).join(' && '),
417 | });
418 | }
419 |
420 | public async addScripts(scripts: Record) {
421 | const packageJson = await this.retrievePackageJson();
422 | await this.writePackageJson({
423 | ...packageJson,
424 | scripts: {
425 | ...packageJson.scripts,
426 | ...scripts,
427 | },
428 | });
429 | }
430 |
431 | public async addPackageResolutions(versions: Record) {
432 | const packageJson = await this.retrievePackageJson();
433 | const resolutions = this.getResolutions(packageJson, versions);
434 | this.writePackageJson({ ...packageJson, ...resolutions });
435 | }
436 |
437 | protected abstract runInstall(): Promise;
438 |
439 | protected abstract runAddDeps(
440 | dependencies: string[],
441 | installAsDevDependencies: boolean
442 | ): Promise;
443 |
444 | protected abstract runRemoveDeps(dependencies: string[]): Promise;
445 |
446 | protected abstract getResolutions(
447 | packageJson: PackageJson,
448 | versions: Record
449 | ): Record;
450 |
451 | /**
452 | * Get the latest or all versions of the input package available on npmjs.com
453 | *
454 | * @param packageName Name of the package
455 | * @param fetchAllVersions Should return
456 | */
457 | protected abstract runGetVersions(
458 | packageName: string,
459 | fetchAllVersions: T
460 | ): // Use generic and conditional type to force `string[]` if fetchAllVersions is true and `string` if false
461 | Promise;
462 |
463 | public abstract runPackageCommand(
464 | command: string,
465 | args: string[],
466 | cwd?: string,
467 | stdio?: string
468 | ): Promise;
469 | public abstract runPackageCommandSync(
470 | command: string,
471 | args: string[],
472 | cwd?: string,
473 | stdio?: 'inherit' | 'pipe'
474 | ): string;
475 | public abstract findInstallations(pattern?: string[]): Promise;
476 | public abstract parseErrorFromLogs(logs?: string): string;
477 |
478 | public executeCommandSync({
479 | command,
480 | args = [],
481 | stdio,
482 | cwd,
483 | ignoreError = false,
484 | env,
485 | ...execaOptions
486 | }: CommonOptions & {
487 | command: string;
488 | args: string[];
489 | cwd?: string;
490 | ignoreError?: boolean;
491 | }): string {
492 | try {
493 | const commandResult = execaCommandSync(command, args, {
494 | cwd: cwd ?? this.cwd,
495 | stdio: stdio ?? 'pipe',
496 | encoding: 'utf-8',
497 | shell: true,
498 | cleanup: true,
499 | env,
500 | ...execaOptions,
501 | });
502 |
503 | return commandResult.stdout ?? '';
504 | } catch (err) {
505 | if (ignoreError !== true) {
506 | throw err;
507 | }
508 | return '';
509 | }
510 | }
511 |
512 | public async executeCommand({
513 | command,
514 | args = [],
515 | stdio,
516 | cwd,
517 | ignoreError = false,
518 | env,
519 | ...execaOptions
520 | }: CommonOptions & {
521 | command: string;
522 | args: string[];
523 | cwd?: string;
524 | ignoreError?: boolean;
525 | }): Promise {
526 | try {
527 | const commandResult = await execaCommand([command, ...args].join(' '), {
528 | cwd: cwd ?? this.cwd,
529 | stdio: stdio ?? 'pipe',
530 | encoding: 'utf-8',
531 | shell: true,
532 | cleanup: true,
533 | env,
534 | ...execaOptions,
535 | });
536 |
537 | return commandResult.stdout ?? '';
538 | } catch (err) {
539 | if (ignoreError !== true) {
540 | throw err;
541 | }
542 | return '';
543 | }
544 | }
545 | }
546 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/cli/js-package-manager/JsPackageManagerFactory.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import { sync as spawnSync } from 'cross-spawn';
3 | import { findUpSync } from 'find-up';
4 |
5 | import { NPMProxy } from './NPMProxy.js';
6 | import { PNPMProxy } from './PNPMProxy.js';
7 |
8 | import type { JsPackageManager, PackageManagerName } from './JsPackageManager.js';
9 |
10 | import { Yarn2Proxy } from './Yarn2Proxy.js';
11 | import { Yarn1Proxy } from './Yarn1Proxy.js';
12 |
13 | const NPM_LOCKFILE = 'package-lock.json';
14 | const PNPM_LOCKFILE = 'pnpm-lock.yaml';
15 | const YARN_LOCKFILE = 'yarn.lock';
16 |
17 | export class JsPackageManagerFactory {
18 | public static getPackageManager(
19 | { force }: { force?: PackageManagerName } = {},
20 | cwd?: string
21 | ): JsPackageManager {
22 | if (force === 'npm') {
23 | return new NPMProxy({ cwd });
24 | }
25 | if (force === 'pnpm') {
26 | return new PNPMProxy({ cwd });
27 | }
28 | if (force === 'yarn1') {
29 | return new Yarn1Proxy({ cwd });
30 | }
31 | if (force === 'yarn2') {
32 | return new Yarn2Proxy({ cwd });
33 | }
34 |
35 | const yarnVersion = getYarnVersion(cwd);
36 |
37 | const closestLockfilePath = findUpSync([YARN_LOCKFILE, PNPM_LOCKFILE, NPM_LOCKFILE], {
38 | cwd,
39 | });
40 | const closestLockfile = closestLockfilePath && path.basename(closestLockfilePath);
41 |
42 | const hasNPMCommand = hasNPM(cwd);
43 | const hasPNPMCommand = hasPNPM(cwd);
44 |
45 | if (yarnVersion && (closestLockfile === YARN_LOCKFILE || (!hasNPMCommand && !hasPNPMCommand))) {
46 | return yarnVersion === 1 ? new Yarn1Proxy({ cwd }) : new Yarn2Proxy({ cwd });
47 | }
48 |
49 | if (hasPNPMCommand && closestLockfile === PNPM_LOCKFILE) {
50 | return new PNPMProxy({ cwd });
51 | }
52 |
53 | if (hasNPMCommand) {
54 | return new NPMProxy({ cwd });
55 | }
56 |
57 | throw new Error('Unable to find a usable package manager within NPM, PNPM, Yarn and Yarn 2');
58 | }
59 | }
60 |
61 | function hasNPM(cwd?: string) {
62 | const npmVersionCommand = spawnSync('npm', ['--version'], { cwd, shell: true });
63 | return npmVersionCommand.status === 0;
64 | }
65 |
66 | function hasPNPM(cwd?: string) {
67 | const pnpmVersionCommand = spawnSync('pnpm', ['--version'], { cwd, shell: true });
68 | return pnpmVersionCommand.status === 0;
69 | }
70 |
71 | function getYarnVersion(cwd?: string): 1 | 2 | undefined {
72 | const yarnVersionCommand = spawnSync('yarn', ['--version'], { cwd, shell: true });
73 |
74 | if (yarnVersionCommand.status !== 0) {
75 | return undefined;
76 | }
77 |
78 | const yarnVersion = yarnVersionCommand.output.toString().replace(/,/g, '').replace(/"/g, '');
79 |
80 | return /^1\.+/.test(yarnVersion) ? 1 : 2;
81 | }
82 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/cli/js-package-manager/NPMProxy.ts:
--------------------------------------------------------------------------------
1 | import sort from 'semver/functions/sort.js';
2 | import { platform } from 'os';
3 | import { dedent } from 'ts-dedent';
4 | import { findUpSync } from 'find-up';
5 | import { existsSync, readFileSync } from 'node:fs';
6 | import path from 'node:path';
7 | import semver from 'semver';
8 | import { JsPackageManager } from './JsPackageManager.js';
9 | import type { PackageJson } from './PackageJson.js';
10 | import type { InstallationMetadata, PackageMetadata } from './types.js';
11 | import { createLogStream } from '../createLogStream.js';
12 |
13 | type NpmDependency = {
14 | version: string;
15 | resolved?: string;
16 | overridden?: boolean;
17 | dependencies?: NpmDependencies;
18 | };
19 |
20 | type NpmDependencies = {
21 | [key: string]: NpmDependency;
22 | };
23 |
24 | export type NpmListOutput = {
25 | dependencies: NpmDependencies;
26 | };
27 |
28 | const NPM_ERROR_REGEX = /npm ERR! code (\w+)/;
29 | const NPM_ERROR_CODES = {
30 | E401: 'Authentication failed or is required.',
31 | E403: 'Access to the resource is forbidden.',
32 | E404: 'Requested resource not found.',
33 | EACCES: 'Permission issue.',
34 | EAI_FAIL: 'DNS lookup failed.',
35 | EBADENGINE: 'Engine compatibility check failed.',
36 | EBADPLATFORM: 'Platform not supported.',
37 | ECONNREFUSED: 'Connection refused.',
38 | ECONNRESET: 'Connection reset.',
39 | EEXIST: 'File or directory already exists.',
40 | EINVALIDTYPE: 'Invalid type encountered.',
41 | EISGIT: 'Git operation failed or conflicts with an existing file.',
42 | EJSONPARSE: 'Error parsing JSON data.',
43 | EMISSINGARG: 'Required argument missing.',
44 | ENEEDAUTH: 'Authentication needed.',
45 | ENOAUDIT: 'No audit available.',
46 | ENOENT: 'File or directory does not exist.',
47 | ENOGIT: 'Git not found or failed to run.',
48 | ENOLOCK: 'Lockfile missing.',
49 | ENOSPC: 'Insufficient disk space.',
50 | ENOTFOUND: 'Resource not found.',
51 | EOTP: 'One-time password required.',
52 | EPERM: 'Permission error.',
53 | EPUBLISHCONFLICT: 'Conflict during package publishing.',
54 | ERESOLVE: 'Dependency resolution error.',
55 | EROFS: 'File system is read-only.',
56 | ERR_SOCKET_TIMEOUT: 'Socket timed out.',
57 | ETARGET: 'Package target not found.',
58 | ETIMEDOUT: 'Operation timed out.',
59 | ETOOMANYARGS: 'Too many arguments provided.',
60 | EUNKNOWNTYPE: 'Unknown type encountered.',
61 | };
62 |
63 | export class NPMProxy extends JsPackageManager {
64 | readonly type = 'npm';
65 |
66 | installArgs: string[] | undefined;
67 |
68 | async initPackageJson() {
69 | await this.executeCommand({ command: 'npm', args: ['init', '-y'] });
70 | }
71 |
72 | getRunStorybookCommand(): string {
73 | return 'npm run storybook';
74 | }
75 |
76 | getRunCommand(command: string): string {
77 | return `npm run ${command}`;
78 | }
79 |
80 | async getNpmVersion(): Promise {
81 | return this.executeCommand({ command: 'npm', args: ['--version'] });
82 | }
83 |
84 | public async getPackageJSON(
85 | packageName: string,
86 | basePath = this.cwd
87 | ): Promise {
88 | const packageJsonPath = await findUpSync(
89 | (dir) => {
90 | const possiblePath = path.join(dir, 'node_modules', packageName, 'package.json');
91 | return existsSync(possiblePath) ? possiblePath : undefined;
92 | },
93 | { cwd: basePath }
94 | );
95 |
96 | if (!packageJsonPath) {
97 | return null;
98 | }
99 |
100 | const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
101 | return packageJson;
102 | }
103 |
104 | public async getPackageVersion(packageName: string, basePath = this.cwd): Promise {
105 | const packageJson = await this.getPackageJSON(packageName, basePath);
106 | return packageJson ? semver.coerce(packageJson.version)?.version ?? null : null;
107 | }
108 |
109 | getInstallArgs(): string[] {
110 | if (!this.installArgs) {
111 | this.installArgs = [];
112 | }
113 | return this.installArgs;
114 | }
115 |
116 | public runPackageCommandSync(
117 | command: string,
118 | args: string[],
119 | cwd?: string,
120 | stdio?: 'pipe' | 'inherit'
121 | ): string {
122 | return this.executeCommandSync({
123 | command: 'npm',
124 | args: ['exec', '--', command, ...args],
125 | cwd,
126 | stdio,
127 | });
128 | }
129 |
130 | public async runPackageCommand(command: string, args: string[], cwd?: string): Promise {
131 | return this.executeCommand({
132 | command: 'npm',
133 | args: ['exec', '--', command, ...args],
134 | cwd,
135 | });
136 | }
137 |
138 | public async findInstallations() {
139 | const pipeToNull = platform() === 'win32' ? '2>NUL' : '2>/dev/null';
140 | const commandResult = await this.executeCommand({
141 | command: 'npm',
142 | args: ['ls', '--json', '--depth=99', pipeToNull],
143 | // ignore errors, because npm ls will exit with code 1 if there are e.g. unmet peer dependencies
144 | ignoreError: true,
145 | env: {
146 | FORCE_COLOR: 'false',
147 | },
148 | });
149 |
150 | try {
151 | const parsedOutput = JSON.parse(commandResult);
152 | return this.mapDependencies(parsedOutput);
153 | } catch (e) {
154 | return undefined;
155 | }
156 | }
157 |
158 | protected getResolutions(packageJson: PackageJson, versions: Record) {
159 | return {
160 | overrides: {
161 | ...packageJson.overrides,
162 | ...versions,
163 | },
164 | };
165 | }
166 |
167 | protected async runInstall() {
168 | await this.executeCommand({
169 | command: 'npm',
170 | args: ['install', ...this.getInstallArgs()],
171 | stdio: 'inherit',
172 | });
173 | }
174 |
175 | protected async runAddDeps(dependencies: string[], installAsDevDependencies: boolean) {
176 | const { logStream, readLogFile, moveLogFile, removeLogFile } = await createLogStream();
177 | let args = [...dependencies];
178 |
179 | if (installAsDevDependencies) {
180 | args = ['-D', ...args];
181 | }
182 |
183 | try {
184 | await this.executeCommand({
185 | command: 'npm',
186 | args: ['install', ...args, ...this.getInstallArgs()],
187 | stdio: process.env.CI ? 'inherit' : ['ignore', logStream, logStream],
188 | });
189 | } catch (err) {
190 | const stdout = await readLogFile();
191 |
192 | const errorMessage = this.parseErrorFromLogs(stdout);
193 |
194 | await moveLogFile();
195 |
196 | throw new Error(
197 | dedent`${errorMessage}
198 |
199 | Please check the logfile generated at ./storybook.log for troubleshooting and try again.`
200 | );
201 | }
202 |
203 | await removeLogFile();
204 | }
205 |
206 | protected async runRemoveDeps(dependencies: string[]) {
207 | const args = [...dependencies];
208 |
209 | await this.executeCommand({
210 | command: 'npm',
211 | args: ['uninstall', ...this.getInstallArgs(), ...args],
212 | stdio: 'inherit',
213 | });
214 | }
215 |
216 | protected async runGetVersions(
217 | packageName: string,
218 | fetchAllVersions: T
219 | ): Promise {
220 | const args = [fetchAllVersions ? 'versions' : 'version', '--json'];
221 |
222 | const commandResult = await this.executeCommand({
223 | command: 'npm',
224 | args: ['info', packageName, ...args],
225 | });
226 |
227 | try {
228 | const parsedOutput = JSON.parse(commandResult);
229 |
230 | if (parsedOutput.error) {
231 | // FIXME: improve error handling
232 | throw new Error(parsedOutput.error.summary);
233 | } else {
234 | return parsedOutput;
235 | }
236 | } catch (e) {
237 | throw new Error(`Unable to find versions of ${packageName} using npm`);
238 | }
239 | }
240 |
241 | protected mapDependencies(input: NpmListOutput): InstallationMetadata {
242 | const acc: Record = {};
243 | const existingVersions: Record = {};
244 | const duplicatedDependencies: Record = {};
245 |
246 | const recurse = ([name, packageInfo]: [string, NpmDependency]): void => {
247 | if (!name || !name.includes('storybook')) return;
248 |
249 | const value = {
250 | version: packageInfo.version,
251 | location: '',
252 | };
253 |
254 | if (!existingVersions[name]?.includes(value.version)) {
255 | if (acc[name]) {
256 | acc[name].push(value);
257 | } else {
258 | acc[name] = [value];
259 | }
260 | existingVersions[name] = sort([...(existingVersions[name] || []), value.version]);
261 |
262 | if (existingVersions[name].length > 1) {
263 | duplicatedDependencies[name] = existingVersions[name];
264 | }
265 | }
266 |
267 | if (packageInfo.dependencies) {
268 | Object.entries(packageInfo.dependencies).forEach(recurse);
269 | }
270 | };
271 |
272 | Object.entries(input.dependencies).forEach(recurse);
273 |
274 | return {
275 | dependencies: acc,
276 | duplicatedDependencies,
277 | infoCommand: 'npm ls --depth=1',
278 | dedupeCommand: 'npm dedupe',
279 | };
280 | }
281 |
282 | public parseErrorFromLogs(logs: string): string {
283 | let finalMessage = 'NPM error';
284 | const match = logs.match(NPM_ERROR_REGEX);
285 |
286 | if (match) {
287 | const errorCode = match[1] as keyof typeof NPM_ERROR_CODES;
288 | if (errorCode) {
289 | finalMessage = `${finalMessage} ${errorCode}`;
290 | }
291 |
292 | const errorMessage = NPM_ERROR_CODES[errorCode];
293 | if (errorMessage) {
294 | finalMessage = `${finalMessage} - ${errorMessage}`;
295 | }
296 | }
297 |
298 | return finalMessage.trim();
299 | }
300 | }
301 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/cli/js-package-manager/PNPMProxy.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'node:path';
2 | import { readFileSync, existsSync } from 'node:fs';
3 | import { dedent } from 'ts-dedent';
4 | import { findUpSync } from 'find-up';
5 | import semver from 'semver';
6 | import { JsPackageManager } from './JsPackageManager.js';
7 | import type { PackageJson } from './PackageJson.js';
8 | import type { InstallationMetadata, PackageMetadata } from './types.js';
9 | import { createLogStream } from '../createLogStream.js';
10 |
11 | type PnpmDependency = {
12 | from: string;
13 | version: string;
14 | resolved: string;
15 | dependencies?: PnpmDependencies;
16 | };
17 |
18 | type PnpmDependencies = {
19 | [key: string]: PnpmDependency;
20 | };
21 |
22 | type PnpmListItem = {
23 | dependencies: PnpmDependencies;
24 | peerDependencies: PnpmDependencies;
25 | devDependencies: PnpmDependencies;
26 | };
27 |
28 | export type PnpmListOutput = PnpmListItem[];
29 |
30 | const PNPM_ERROR_REGEX = /(ELIFECYCLE|ERR_PNPM_[A-Z_]+)\s+(.*)/i;
31 |
32 | export class PNPMProxy extends JsPackageManager {
33 | readonly type = 'pnpm';
34 |
35 | installArgs: string[] | undefined;
36 |
37 | detectWorkspaceRoot() {
38 | const CWD = process.cwd();
39 |
40 | const pnpmWorkspaceYaml = `${CWD}/pnpm-workspace.yaml`;
41 | return existsSync(pnpmWorkspaceYaml);
42 | }
43 |
44 | async initPackageJson() {
45 | await this.executeCommand({
46 | command: 'pnpm',
47 | args: ['init'],
48 | });
49 | }
50 |
51 | getRunStorybookCommand(): string {
52 | return 'pnpm run storybook';
53 | }
54 |
55 | getRunCommand(command: string): string {
56 | return `pnpm run ${command}`;
57 | }
58 |
59 | async getPnpmVersion(): Promise {
60 | return this.executeCommand({
61 | command: 'pnpm',
62 | args: ['--version'],
63 | });
64 | }
65 |
66 | getInstallArgs(): string[] {
67 | if (!this.installArgs) {
68 | this.installArgs = [];
69 |
70 | if (this.detectWorkspaceRoot()) {
71 | this.installArgs.push('-w');
72 | }
73 | }
74 | return this.installArgs;
75 | }
76 |
77 | public runPackageCommandSync(
78 | command: string,
79 | args: string[],
80 | cwd?: string,
81 | stdio?: 'pipe' | 'inherit'
82 | ): string {
83 | return this.executeCommandSync({
84 | command: 'pnpm',
85 | args: ['exec', command, ...args],
86 | cwd,
87 | stdio,
88 | });
89 | }
90 |
91 | async runPackageCommand(command: string, args: string[], cwd?: string): Promise {
92 | return this.executeCommand({
93 | command: 'pnpm',
94 | args: ['exec', command, ...args],
95 | cwd,
96 | });
97 | }
98 |
99 | public async findInstallations(pattern: string[]) {
100 | const commandResult = await this.executeCommand({
101 | command: 'pnpm',
102 | args: ['list', pattern.map((p) => `"${p}"`).join(' '), '--json', '--depth=99'],
103 | env: {
104 | FORCE_COLOR: 'false',
105 | },
106 | });
107 |
108 | try {
109 | const parsedOutput = JSON.parse(commandResult);
110 | return this.mapDependencies(parsedOutput);
111 | } catch (e) {
112 | return undefined;
113 | }
114 | }
115 |
116 | public async getPackageJSON(
117 | packageName: string,
118 | basePath = this.cwd
119 | ): Promise {
120 | const pnpapiPath = findUpSync(['.pnp.js', '.pnp.cjs'], { cwd: basePath });
121 |
122 | if (pnpapiPath) {
123 | try {
124 | // eslint-disable-next-line import/no-dynamic-require, global-require
125 | const pnpApi = require(pnpapiPath);
126 |
127 | const resolvedPath = await pnpApi.resolveToUnqualified(packageName, basePath, {
128 | considerBuiltins: false,
129 | });
130 |
131 | const pkgLocator = pnpApi.findPackageLocator(resolvedPath);
132 | const pkg = pnpApi.getPackageInformation(pkgLocator);
133 |
134 | const packageJSON = JSON.parse(
135 | readFileSync(join(pkg.packageLocation, 'package.json'), 'utf-8')
136 | );
137 |
138 | return packageJSON;
139 | } catch (error) {
140 | if (error.code !== 'MODULE_NOT_FOUND') {
141 | console.error('Error while fetching package version in PNPM PnP mode:', error);
142 | }
143 | return null;
144 | }
145 | }
146 |
147 | const packageJsonPath = await findUpSync(
148 | (dir) => {
149 | const possiblePath = join(dir, 'node_modules', packageName, 'package.json');
150 | return existsSync(possiblePath) ? possiblePath : undefined;
151 | },
152 | { cwd: basePath }
153 | );
154 |
155 | if (!packageJsonPath) {
156 | return null;
157 | }
158 |
159 | return JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
160 | }
161 |
162 | async getPackageVersion(packageName: string, basePath = this.cwd): Promise {
163 | const packageJSON = await this.getPackageJSON(packageName, basePath);
164 |
165 | return packageJSON ? semver.coerce(packageJSON.version)?.version ?? null : null;
166 | }
167 |
168 | protected getResolutions(packageJson: PackageJson, versions: Record) {
169 | return {
170 | overrides: {
171 | ...packageJson.overrides,
172 | ...versions,
173 | },
174 | };
175 | }
176 |
177 | protected async runInstall() {
178 | await this.executeCommand({
179 | command: 'pnpm',
180 | args: ['install', ...this.getInstallArgs()],
181 | stdio: 'inherit',
182 | });
183 | }
184 |
185 | protected async runAddDeps(dependencies: string[], installAsDevDependencies: boolean) {
186 | let args = [...dependencies];
187 |
188 | if (installAsDevDependencies) {
189 | args = ['-D', ...args];
190 | }
191 | const { logStream, readLogFile, moveLogFile, removeLogFile } = await createLogStream();
192 |
193 | try {
194 | await this.executeCommand({
195 | command: 'pnpm',
196 | args: ['add', ...args, ...this.getInstallArgs()],
197 | stdio: process.env.CI ? 'inherit' : ['ignore', logStream, logStream],
198 | });
199 | } catch (err) {
200 | const stdout = await readLogFile();
201 |
202 | const errorMessage = this.parseErrorFromLogs(stdout);
203 |
204 | await moveLogFile();
205 |
206 | throw new Error(
207 | dedent`${errorMessage}
208 |
209 | Please check the logfile generated at ./storybook.log for troubleshooting and try again.`
210 | );
211 | }
212 |
213 | await removeLogFile();
214 | }
215 |
216 | protected async runRemoveDeps(dependencies: string[]) {
217 | const args = [...dependencies];
218 |
219 | await this.executeCommand({
220 | command: 'pnpm',
221 | args: ['remove', ...args, ...this.getInstallArgs()],
222 | stdio: 'inherit',
223 | });
224 | }
225 |
226 | protected async runGetVersions(
227 | packageName: string,
228 | fetchAllVersions: T
229 | ): Promise {
230 | const args = [fetchAllVersions ? 'versions' : 'version', '--json'];
231 |
232 | const commandResult = await this.executeCommand({
233 | command: 'pnpm',
234 | args: ['info', packageName, ...args],
235 | });
236 |
237 | try {
238 | const parsedOutput = JSON.parse(commandResult);
239 |
240 | if (parsedOutput.error) {
241 | // FIXME: improve error handling
242 | throw new Error(parsedOutput.error.summary);
243 | } else {
244 | return parsedOutput;
245 | }
246 | } catch (e) {
247 | throw new Error(`Unable to find versions of ${packageName} using pnpm`);
248 | }
249 | }
250 |
251 | protected mapDependencies(input: PnpmListOutput): InstallationMetadata {
252 | const acc: Record = {};
253 | const existingVersions: Record = {};
254 | const duplicatedDependencies: Record = {};
255 | const items: PnpmDependencies = input.reduce((curr, item) => {
256 | const { devDependencies, dependencies, peerDependencies } = item;
257 | const allDependencies = { ...devDependencies, ...dependencies, ...peerDependencies };
258 | return Object.assign(curr, allDependencies);
259 | }, {} as PnpmDependencies);
260 |
261 | const recurse = ([name, packageInfo]: [string, PnpmDependency]): void => {
262 | if (!name || !name.includes('storybook')) return;
263 |
264 | const value = {
265 | version: packageInfo.version,
266 | location: '',
267 | };
268 |
269 | if (!existingVersions[name]?.includes(value.version)) {
270 | if (acc[name]) {
271 | acc[name].push(value);
272 | } else {
273 | acc[name] = [value];
274 | }
275 | existingVersions[name] = [...(existingVersions[name] || []), value.version];
276 |
277 | if (existingVersions[name].length > 1) {
278 | duplicatedDependencies[name] = existingVersions[name];
279 | }
280 | }
281 |
282 | if (packageInfo.dependencies) {
283 | Object.entries(packageInfo.dependencies).forEach(recurse);
284 | }
285 | };
286 | Object.entries(items).forEach(recurse);
287 |
288 | return {
289 | dependencies: acc,
290 | duplicatedDependencies,
291 | infoCommand: 'pnpm list --depth=1',
292 | dedupeCommand: 'pnpm dedupe',
293 | };
294 | }
295 |
296 | public parseErrorFromLogs(logs: string): string {
297 | let finalMessage = 'PNPM error';
298 | const match = logs.match(PNPM_ERROR_REGEX);
299 | if (match) {
300 | const [errorCode] = match;
301 | if (errorCode) {
302 | finalMessage = `${finalMessage} ${errorCode}`;
303 | }
304 | }
305 |
306 | return finalMessage.trim();
307 | }
308 | }
309 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/cli/js-package-manager/PackageJson.ts:
--------------------------------------------------------------------------------
1 | import type { PackageJson } from '@storybook/types';
2 |
3 | export type { PackageJson } from '@storybook/types';
4 | export type PackageJsonWithDepsAndDevDeps = PackageJson &
5 | Required>;
6 |
7 | export type PackageJsonWithMaybeDeps = Partial<
8 | Pick
9 | >;
10 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/cli/js-package-manager/Yarn1Proxy.ts:
--------------------------------------------------------------------------------
1 | import { dedent } from 'ts-dedent';
2 | import { findUpSync } from 'find-up';
3 | import { existsSync, readFileSync } from 'node:fs';
4 | import { join } from 'node:path';
5 | import semver from 'semver';
6 | import { createLogStream } from '../createLogStream.js';
7 | import { JsPackageManager } from './JsPackageManager.js';
8 | import type { PackageJson } from './PackageJson.js';
9 | import type { InstallationMetadata, PackageMetadata } from './types.js';
10 | import { parsePackageData } from './util.js';
11 |
12 | type Yarn1ListItem = {
13 | name: string;
14 | children: Yarn1ListItem[];
15 | };
16 |
17 | type Yarn1ListData = {
18 | type: 'list';
19 | trees: Yarn1ListItem[];
20 | };
21 |
22 | export type Yarn1ListOutput = {
23 | type: 'tree';
24 | data: Yarn1ListData;
25 | };
26 |
27 | const YARN1_ERROR_REGEX = /^error\s(.*)$/gm;
28 |
29 | export class Yarn1Proxy extends JsPackageManager {
30 | readonly type = 'yarn1';
31 |
32 | installArgs: string[] | undefined;
33 |
34 | getInstallArgs(): string[] {
35 | if (!this.installArgs) {
36 | this.installArgs = ['--ignore-workspace-root-check'];
37 | }
38 | return this.installArgs;
39 | }
40 |
41 | async initPackageJson() {
42 | await this.executeCommand({ command: 'yarn', args: ['init', '-y'] });
43 | }
44 |
45 | getRunStorybookCommand(): string {
46 | return 'yarn storybook';
47 | }
48 |
49 | getRunCommand(command: string): string {
50 | return `yarn ${command}`;
51 | }
52 |
53 | public runPackageCommandSync(
54 | command: string,
55 | args: string[],
56 | cwd?: string,
57 | stdio?: 'pipe' | 'inherit'
58 | ): string {
59 | return this.executeCommandSync({ command: `yarn`, args: [command, ...args], cwd, stdio });
60 | }
61 |
62 | async runPackageCommand(command: string, args: string[], cwd?: string): Promise {
63 | return this.executeCommand({ command: `yarn`, args: [command, ...args], cwd });
64 | }
65 |
66 | public async getPackageJSON(
67 | packageName: string,
68 | basePath = this.cwd
69 | ): Promise {
70 | const packageJsonPath = await findUpSync(
71 | (dir) => {
72 | const possiblePath = join(dir, 'node_modules', packageName, 'package.json');
73 | return existsSync(possiblePath) ? possiblePath : undefined;
74 | },
75 | { cwd: basePath }
76 | );
77 |
78 | if (!packageJsonPath) {
79 | return null;
80 | }
81 |
82 | return JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as Record;
83 | }
84 |
85 | public async getPackageVersion(packageName: string, basePath = this.cwd): Promise {
86 | const packageJson = await this.getPackageJSON(packageName, basePath);
87 | return packageJson ? semver.coerce(packageJson.version)?.version ?? null : null;
88 | }
89 |
90 | public async findInstallations(pattern: string[]) {
91 | const commandResult = await this.executeCommand({
92 | command: 'yarn',
93 | args: ['list', '--pattern', pattern.map((p) => `"${p}"`).join(' '), '--recursive', '--json'],
94 | env: {
95 | FORCE_COLOR: 'false',
96 | },
97 | });
98 |
99 | try {
100 | const parsedOutput = JSON.parse(commandResult);
101 | return this.mapDependencies(parsedOutput);
102 | } catch (e) {
103 | return undefined;
104 | }
105 | }
106 |
107 | protected getResolutions(packageJson: PackageJson, versions: Record) {
108 | return {
109 | resolutions: {
110 | ...packageJson.resolutions,
111 | ...versions,
112 | },
113 | };
114 | }
115 |
116 | protected async runInstall() {
117 | await this.executeCommand({
118 | command: 'yarn',
119 | args: ['install', ...this.getInstallArgs()],
120 | stdio: 'inherit',
121 | });
122 | }
123 |
124 | protected async runAddDeps(dependencies: string[], installAsDevDependencies: boolean) {
125 | let args = [...dependencies];
126 |
127 | if (installAsDevDependencies) {
128 | args = ['-D', ...args];
129 | }
130 |
131 | const { logStream, readLogFile, moveLogFile, removeLogFile } = await createLogStream();
132 |
133 | try {
134 | await this.executeCommand({
135 | command: 'yarn',
136 | args: ['add', ...this.getInstallArgs(), ...args],
137 | stdio: process.env.CI ? 'inherit' : ['ignore', logStream, logStream],
138 | });
139 | } catch (err) {
140 | const stdout = await readLogFile();
141 |
142 | const errorMessage = this.parseErrorFromLogs(stdout);
143 |
144 | await moveLogFile();
145 |
146 | throw new Error(
147 | dedent`${errorMessage}
148 |
149 | Please check the logfile generated at ./storybook.log for troubleshooting and try again.`
150 | );
151 | }
152 |
153 | await removeLogFile();
154 | }
155 |
156 | protected async runRemoveDeps(dependencies: string[]) {
157 | const args = [...dependencies];
158 |
159 | await this.executeCommand({
160 | command: 'yarn',
161 | args: ['remove', ...this.getInstallArgs(), ...args],
162 | stdio: 'inherit',
163 | });
164 | }
165 |
166 | protected async runGetVersions(
167 | packageName: string,
168 | fetchAllVersions: T
169 | ): Promise {
170 | const args = [fetchAllVersions ? 'versions' : 'version', '--json'];
171 |
172 | const commandResult = await this.executeCommand({
173 | command: 'yarn',
174 | args: ['info', packageName, ...args],
175 | });
176 |
177 | try {
178 | const parsedOutput = JSON.parse(commandResult);
179 | if (parsedOutput.type === 'inspect') {
180 | return parsedOutput.data;
181 | }
182 | throw new Error(`Unable to find versions of ${packageName} using yarn`);
183 | } catch (e) {
184 | throw new Error(`Unable to find versions of ${packageName} using yarn`);
185 | }
186 | }
187 |
188 | protected mapDependencies(input: Yarn1ListOutput): InstallationMetadata {
189 | if (input.type === 'tree') {
190 | const { trees } = input.data;
191 | const acc: Record = {};
192 | const existingVersions: Record = {};
193 | const duplicatedDependencies: Record = {};
194 |
195 | const recurse = (tree: (typeof trees)[0]) => {
196 | const { children } = tree;
197 | const { name, value } = parsePackageData(tree.name);
198 | if (!name || !name.includes('storybook')) return;
199 | if (!existingVersions[name]?.includes(value.version)) {
200 | if (acc[name]) {
201 | acc[name].push(value);
202 | } else {
203 | acc[name] = [value];
204 | }
205 | existingVersions[name] = [...(existingVersions[name] || []), value.version];
206 |
207 | if (existingVersions[name].length > 1) {
208 | duplicatedDependencies[name] = existingVersions[name];
209 | }
210 | }
211 |
212 | children.forEach(recurse);
213 | };
214 |
215 | trees.forEach(recurse);
216 |
217 | return {
218 | dependencies: acc,
219 | duplicatedDependencies,
220 | infoCommand: 'yarn why',
221 | dedupeCommand: 'yarn dedupe',
222 | };
223 | }
224 |
225 | throw new Error('Something went wrong while parsing yarn output');
226 | }
227 |
228 | public parseErrorFromLogs(logs: string): string {
229 | let finalMessage = 'YARN1 error';
230 | const match = logs.match(YARN1_ERROR_REGEX);
231 |
232 | if (match) {
233 | const errorMessage = match[0]?.replace(/^error\s(.*)$/, '$1');
234 | if (errorMessage) {
235 | finalMessage = `${finalMessage}: ${errorMessage}`;
236 | }
237 | }
238 |
239 | return finalMessage.trim();
240 | }
241 | }
242 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/cli/js-package-manager/Yarn2Proxy.ts:
--------------------------------------------------------------------------------
1 | import { dedent } from 'ts-dedent';
2 | import { findUpSync } from 'find-up';
3 | import { existsSync, readFileSync } from 'node:fs';
4 | import { join } from 'node:path';
5 | import { PosixFS, VirtualFS, ZipOpenFS } from '@yarnpkg/fslib';
6 | import { getLibzipSync } from '@yarnpkg/libzip';
7 | import semver from 'semver';
8 | import { createLogStream } from '../createLogStream.js';
9 | import { JsPackageManager } from './JsPackageManager.js';
10 | import type { PackageJson } from './PackageJson.js';
11 | import type { InstallationMetadata, PackageMetadata } from './types.js';
12 | import { parsePackageData } from './util.js';
13 |
14 | const YARN2_ERROR_REGEX = /(YN\d{4}):.*?Error:\s+(.*)/i;
15 | const YARN2_ERROR_CODES = {
16 | YN0000: 'UNNAMED',
17 | YN0001: 'EXCEPTION',
18 | YN0002: 'MISSING_PEER_DEPENDENCY',
19 | YN0003: 'CYCLIC_DEPENDENCIES',
20 | YN0004: 'DISABLED_BUILD_SCRIPTS',
21 | YN0005: 'BUILD_DISABLED',
22 | YN0006: 'SOFT_LINK_BUILD',
23 | YN0007: 'MUST_BUILD',
24 | YN0008: 'MUST_REBUILD',
25 | YN0009: 'BUILD_FAILED',
26 | YN0010: 'RESOLVER_NOT_FOUND',
27 | YN0011: 'FETCHER_NOT_FOUND',
28 | YN0012: 'LINKER_NOT_FOUND',
29 | YN0013: 'FETCH_NOT_CACHED',
30 | YN0014: 'YARN_IMPORT_FAILED',
31 | YN0015: 'REMOTE_INVALID',
32 | YN0016: 'REMOTE_NOT_FOUND',
33 | YN0017: 'RESOLUTION_PACK',
34 | YN0018: 'CACHE_CHECKSUM_MISMATCH',
35 | YN0019: 'UNUSED_CACHE_ENTRY',
36 | YN0020: 'MISSING_LOCKFILE_ENTRY',
37 | YN0021: 'WORKSPACE_NOT_FOUND',
38 | YN0022: 'TOO_MANY_MATCHING_WORKSPACES',
39 | YN0023: 'CONSTRAINTS_MISSING_DEPENDENCY',
40 | YN0024: 'CONSTRAINTS_INCOMPATIBLE_DEPENDENCY',
41 | YN0025: 'CONSTRAINTS_EXTRANEOUS_DEPENDENCY',
42 | YN0026: 'CONSTRAINTS_INVALID_DEPENDENCY',
43 | YN0027: 'CANT_SUGGEST_RESOLUTIONS',
44 | YN0028: 'FROZEN_LOCKFILE_EXCEPTION',
45 | YN0029: 'CROSS_DRIVE_VIRTUAL_LOCAL',
46 | YN0030: 'FETCH_FAILED',
47 | YN0031: 'DANGEROUS_NODE_MODULES',
48 | YN0032: 'NODE_GYP_INJECTED',
49 | YN0046: 'AUTOMERGE_FAILED_TO_PARSE',
50 | YN0047: 'AUTOMERGE_IMMUTABLE',
51 | YN0048: 'AUTOMERGE_SUCCESS',
52 | YN0049: 'AUTOMERGE_REQUIRED',
53 | YN0050: 'DEPRECATED_CLI_SETTINGS',
54 | YN0059: 'INVALID_RANGE_PEER_DEPENDENCY',
55 | YN0060: 'INCOMPATIBLE_PEER_DEPENDENCY',
56 | YN0061: 'DEPRECATED_PACKAGE',
57 | YN0062: 'INCOMPATIBLE_OS',
58 | YN0063: 'INCOMPATIBLE_CPU',
59 | YN0068: 'UNUSED_PACKAGE_EXTENSION',
60 | YN0069: 'REDUNDANT_PACKAGE_EXTENSION',
61 | YN0071: 'NM_CANT_INSTALL_EXTERNAL_SOFT_LINK',
62 | YN0072: 'NM_PRESERVE_SYMLINKS_REQUIRED',
63 | YN0074: 'NM_HARDLINKS_MODE_DOWNGRADED',
64 | YN0075: 'PROLOG_INSTANTIATION_ERROR',
65 | YN0076: 'INCOMPATIBLE_ARCHITECTURE',
66 | YN0077: 'GHOST_ARCHITECTURE',
67 | };
68 |
69 | // This encompasses both yarn 2 and yarn 3
70 | export class Yarn2Proxy extends JsPackageManager {
71 | readonly type = 'yarn2';
72 |
73 | installArgs: string[] | undefined;
74 |
75 | getInstallArgs(): string[] {
76 | if (!this.installArgs) {
77 | this.installArgs = [];
78 | }
79 | return this.installArgs;
80 | }
81 |
82 | async initPackageJson() {
83 | await this.executeCommand({ command: 'yarn', args: ['init'] });
84 | }
85 |
86 | getRunStorybookCommand(): string {
87 | return 'yarn storybook';
88 | }
89 |
90 | getRunCommand(command: string): string {
91 | return `yarn ${command}`;
92 | }
93 |
94 | public runPackageCommandSync(
95 | command: string,
96 | args: string[],
97 | cwd?: string,
98 | stdio?: 'pipe' | 'inherit'
99 | ) {
100 | return this.executeCommandSync({ command: 'yarn', args: [command, ...args], cwd, stdio });
101 | }
102 |
103 | async runPackageCommand(command: string, args: string[], cwd?: string) {
104 | return this.executeCommand({ command: 'yarn', args: [command, ...args], cwd });
105 | }
106 |
107 | public async findInstallations(pattern: string[]) {
108 | const commandResult = await this.executeCommand({
109 | command: 'yarn',
110 | args: [
111 | 'info',
112 | '--name-only',
113 | '--recursive',
114 | pattern.map((p) => `"${p}"`).join(' '),
115 | `"${pattern}"`,
116 | ],
117 | env: {
118 | FORCE_COLOR: 'false',
119 | },
120 | });
121 |
122 | try {
123 | return this.mapDependencies(commandResult);
124 | } catch (e) {
125 | return undefined;
126 | }
127 | }
128 |
129 | async getPackageJSON(packageName: string, basePath = this.cwd): Promise {
130 | const pnpapiPath = findUpSync(['.pnp.js', '.pnp.cjs'], { cwd: basePath });
131 |
132 | if (pnpapiPath) {
133 | try {
134 | // eslint-disable-next-line import/no-dynamic-require, global-require
135 | const pnpApi = require(pnpapiPath);
136 |
137 | const resolvedPath = await pnpApi.resolveToUnqualified(packageName, basePath, {
138 | considerBuiltins: false,
139 | });
140 |
141 | const pkgLocator = pnpApi.findPackageLocator(resolvedPath);
142 | const pkg = pnpApi.getPackageInformation(pkgLocator);
143 |
144 | const zipOpenFs = new ZipOpenFS({
145 | libzip: getLibzipSync(),
146 | });
147 |
148 | const virtualFs = new VirtualFS({ baseFs: zipOpenFs });
149 | const crossFs = new PosixFS(virtualFs);
150 |
151 | const virtualPath = join(pkg.packageLocation, 'package.json');
152 |
153 | return crossFs.readJsonSync(virtualPath);
154 | } catch (error) {
155 | if (error.code !== 'MODULE_NOT_FOUND') {
156 | console.error('Error while fetching package version in Yarn PnP mode:', error);
157 | }
158 | return null;
159 | }
160 | }
161 |
162 | const packageJsonPath = await findUpSync(
163 | (dir) => {
164 | const possiblePath = join(dir, 'node_modules', packageName, 'package.json');
165 | return existsSync(possiblePath) ? possiblePath : undefined;
166 | },
167 | { cwd: basePath }
168 | );
169 |
170 | if (!packageJsonPath) {
171 | return null;
172 | }
173 |
174 | const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
175 | return packageJson;
176 | }
177 |
178 | async getPackageVersion(packageName: string, basePath = this.cwd): Promise {
179 | const packageJSON = await this.getPackageJSON(packageName, basePath);
180 | return packageJSON ? semver.coerce(packageJSON.version)?.version ?? null : null;
181 | }
182 |
183 | protected getResolutions(packageJson: PackageJson, versions: Record) {
184 | return {
185 | resolutions: {
186 | ...packageJson.resolutions,
187 | ...versions,
188 | },
189 | };
190 | }
191 |
192 | protected async runInstall() {
193 | await this.executeCommand({
194 | command: 'yarn',
195 | args: ['install', ...this.getInstallArgs()],
196 | stdio: 'inherit',
197 | });
198 | }
199 |
200 | protected async runAddDeps(dependencies: string[], installAsDevDependencies: boolean) {
201 | let args = [...dependencies];
202 |
203 | if (installAsDevDependencies) {
204 | args = ['-D', ...args];
205 | }
206 |
207 | const { logStream, readLogFile, moveLogFile, removeLogFile } = await createLogStream();
208 |
209 | try {
210 | await this.executeCommand({
211 | command: 'yarn',
212 | args: ['add', ...this.getInstallArgs(), ...args],
213 | stdio: process.env.CI ? 'inherit' : ['ignore', logStream, logStream],
214 | });
215 | } catch (err) {
216 | const stdout = await readLogFile();
217 |
218 | const errorMessage = this.parseErrorFromLogs(stdout);
219 |
220 | await moveLogFile();
221 |
222 | throw new Error(
223 | dedent`${errorMessage}
224 |
225 | Please check the logfile generated at ./storybook.log for troubleshooting and try again.`
226 | );
227 | }
228 |
229 | await removeLogFile();
230 | }
231 |
232 | protected async runRemoveDeps(dependencies: string[]) {
233 | const args = [...dependencies];
234 |
235 | await this.executeCommand({
236 | command: 'yarn',
237 | args: ['remove', ...this.getInstallArgs(), ...args],
238 | stdio: 'inherit',
239 | });
240 | }
241 |
242 | protected async runGetVersions(
243 | packageName: string,
244 | fetchAllVersions: T
245 | ): Promise {
246 | const field = fetchAllVersions ? 'versions' : 'version';
247 | const args = ['--fields', field, '--json'];
248 |
249 | const commandResult = await this.executeCommand({
250 | command: 'yarn',
251 | args: ['npm', 'info', packageName, ...args],
252 | });
253 |
254 | try {
255 | const parsedOutput = JSON.parse(commandResult);
256 | return parsedOutput[field];
257 | } catch (e) {
258 | throw new Error(`Unable to find versions of ${packageName} using yarn 2`);
259 | }
260 | }
261 |
262 | protected mapDependencies(input: string): InstallationMetadata {
263 | const lines = input.split('\n');
264 | const acc: Record = {};
265 | const existingVersions: Record = {};
266 | const duplicatedDependencies: Record = {};
267 |
268 | lines.forEach((packageName) => {
269 | if (!packageName || !packageName.includes('storybook')) {
270 | return;
271 | }
272 |
273 | const { name, value } = parsePackageData(packageName.replaceAll(`"`, ''));
274 | if (!existingVersions[name]?.includes(value.version)) {
275 | if (acc[name]) {
276 | acc[name].push(value);
277 | } else {
278 | acc[name] = [value];
279 | }
280 |
281 | existingVersions[name] = [...(existingVersions[name] || []), value.version];
282 | if (existingVersions[name].length > 1) {
283 | duplicatedDependencies[name] = existingVersions[name];
284 | }
285 | }
286 | });
287 |
288 | return {
289 | dependencies: acc,
290 | duplicatedDependencies,
291 | infoCommand: 'yarn why',
292 | dedupeCommand: 'yarn dedupe',
293 | };
294 | }
295 |
296 | public parseErrorFromLogs(logs: string): string {
297 | let finalMessage = 'YARN2 error';
298 | const match = logs.match(YARN2_ERROR_REGEX);
299 |
300 | if (match) {
301 | const errorCode = match[1] as keyof typeof YARN2_ERROR_CODES;
302 | if (errorCode) {
303 | finalMessage = `${finalMessage} ${errorCode}`;
304 | }
305 |
306 | const errorType = YARN2_ERROR_CODES[errorCode];
307 | if (errorType) {
308 | finalMessage = `${finalMessage} - ${errorType}`;
309 | }
310 |
311 | const errorMessage = match[2];
312 | if (errorMessage) {
313 | finalMessage = `${finalMessage}: ${errorMessage}`;
314 | }
315 | }
316 |
317 | return finalMessage.trim();
318 | }
319 | }
320 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/cli/js-package-manager/index.ts:
--------------------------------------------------------------------------------
1 | export * from './JsPackageManagerFactory.js';
2 | export * from './JsPackageManager.js';
3 | export * from './PackageJson.js';
4 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/cli/js-package-manager/types.ts:
--------------------------------------------------------------------------------
1 | export type PackageMetadata = { version: string; location?: string; reasons?: string[] };
2 | export type InstallationMetadata = {
3 | dependencies: Record;
4 | duplicatedDependencies: Record;
5 | infoCommand: string;
6 | dedupeCommand: string;
7 | };
8 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/cli/js-package-manager/util.ts:
--------------------------------------------------------------------------------
1 | // input: @storybook/addon-essentials@npm:7.0.0
2 | // output: { name: '@storybook/addon-essentials', value: { version : '7.0.0', location: '' } }
3 | export const parsePackageData = (packageName = '') => {
4 | const [first, second, third] = packageName.trim().split('@');
5 | const version = (third || second).replace('npm:', '');
6 | const name = third ? `@${second}` : first;
7 |
8 | const value = { version, location: '' };
9 | return { name, value };
10 | };
11 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/src/index.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import { existsSync } from 'node:fs';
4 | import { cp, readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
5 | import { dedent } from 'ts-dedent';
6 | import boxen from 'boxen';
7 | import chalk from 'chalk';
8 | import { join } from 'path';
9 | import { render } from 'ejs';
10 | import ora from 'ora';
11 |
12 | import { JsPackageManagerFactory } from './cli/js-package-manager/index.js';
13 | import type { PackageManagerName } from './cli/js-package-manager/index.js';
14 | import { HandledError } from './cli/HandledError.js';
15 | import { SupportedLanguage, detectLanguage } from './cli/detect.js';
16 | import { addWithStorybook } from './addWithStorybook.js';
17 | import { codeLog } from './cli/helpers.js';
18 |
19 | const logger = console;
20 |
21 | const DIRNAME = new URL('.', import.meta.url).pathname;
22 | const VERSION = 'next';
23 |
24 | const ensureDirShallow = async (path: string) => mkdir(path).catch(() => {});
25 |
26 | const getEmptyDirMessage = (packageManagerType: PackageManagerName) => {
27 | const generatorCommandsMap = {
28 | npm: 'npm create',
29 | yarn1: 'yarn create',
30 | yarn2: 'yarn create',
31 | pnpm: 'pnpm create',
32 | };
33 |
34 | const create = generatorCommandsMap[packageManagerType];
35 |
36 | return dedent`
37 | Storybook-NextJS-Server cannot be installed into an empty project.
38 |
39 | Please create a new NextJS project using ${chalk.green(
40 | create + ' next-app'
41 | )} or any other tooling of your choice.
42 |
43 | Once you've created the project, please re-run ${chalk.green(
44 | create + 'storybook-nextjs'
45 | )} inside the project root.
46 |
47 | For more information, see ${chalk.yellowBright('https://storybook.js.org/docs')}.
48 | Good luck! 🚀
49 | `;
50 | };
51 |
52 | const isAppDir = (projectRoot: string) => {
53 | return existsSync(join(projectRoot, 'app')) || existsSync(join(projectRoot, 'src', 'app'));
54 | };
55 |
56 | interface CreateOptions {
57 | srcDir: string;
58 | appDir: boolean;
59 | language: SupportedLanguage;
60 | addons: string[];
61 | }
62 |
63 | const formatArray = (arr: string[]) => `[${arr.map((item) => `'${item}'`).join(', ')}]`;
64 |
65 | const createConfig = async ({ appDir, language, srcDir, addons }: CreateOptions) => {
66 | const templateDir = join(DIRNAME, '..', 'templates', 'sb');
67 | const configDir = join(process.cwd(), '.storybook');
68 |
69 | await ensureDirShallow(configDir);
70 |
71 | if (!appDir) {
72 | const previewFile = language == SupportedLanguage.JAVASCRIPT ? 'preview.jsx' : 'preview.tsx';
73 | await cp(join(templateDir, previewFile), join(configDir, previewFile));
74 | }
75 |
76 | const mainExt = language === SupportedLanguage.JAVASCRIPT ? 'js' : 'ts';
77 | const stories = formatArray([`../${srcDir}**/*.stories.@(js|jsx|ts|tsx)`]);
78 | const extras =
79 | "framework: '@storybook/nextjs-server',\n" + (appDir ? '' : " docs: { autodocs: 'tag' },\n");
80 |
81 | const mainTemplate = await readFile(join(templateDir, `main.${mainExt}.ejs`), 'utf-8');
82 | const main = render(mainTemplate, { stories, addons: formatArray(addons), extras });
83 | await writeFile(join(configDir, `main.${mainExt}`), main);
84 | };
85 |
86 | const createStories = async ({ srcDir, appDir, language }: CreateOptions) => {
87 | const ext = language === SupportedLanguage.JAVASCRIPT ? 'js' : 'ts';
88 | const templateDir = join(DIRNAME, '..', 'templates');
89 | const storiesDir = join(templateDir, appDir ? 'app' : 'pages', ext);
90 | const outputDir = join(process.cwd(), srcDir, 'stories');
91 |
92 | await cp(storiesDir, outputDir, { recursive: true });
93 |
94 | await Promise.all(
95 | ['page', 'header', 'button'].map((fname) =>
96 | cp(join(templateDir, 'css', `${fname}.module.css`), join(outputDir, `${fname}.module.css`))
97 | )
98 | );
99 | };
100 |
101 | /**
102 | * NextJS app router has problems if the routes are created dynamically on
103 | * first startup, so let's try to create them on install.
104 | */
105 | const createRoutes = async ({ srcDir, appDir, language }: CreateOptions) => {
106 | if (!appDir) return;
107 | const templateDir = join(DIRNAME, '..', 'templates', 'app', 'groupLayouts');
108 |
109 | const groupDir = join(srcDir, 'app', '(sb)');
110 | await ensureDirShallow(groupDir);
111 | await cp(join(templateDir, 'layout-root.tsx'), join(groupDir, 'layout.tsx'));
112 |
113 | const previewDir = join(groupDir, 'storybook-preview');
114 | await ensureDirShallow(previewDir);
115 | await cp(join(templateDir, 'layout-nested.tsx'), join(previewDir, 'layout.tsx'));
116 | };
117 |
118 | const updateNextConfig = async () => {
119 | const nextConfigPath = join(process.cwd(), 'next.config.js');
120 |
121 | let nextConfig = 'module.exports = {}';
122 | if (existsSync(nextConfigPath)) {
123 | nextConfig = await readFile(nextConfigPath, 'utf-8');
124 | }
125 | const updatedConfig = addWithStorybook(nextConfig);
126 | await writeFile(nextConfigPath, updatedConfig);
127 | };
128 |
129 | const _version = (pkgs: string[]) => pkgs.map((pkg) => `${pkg}@${VERSION}`);
130 |
131 | const init = async () => {
132 | let done = false;
133 | const spinner = ora('Adding Storybook').start();
134 |
135 | // add a slight delay so that user can see what's going on
136 | const status = (msg: string, delay: number) => {
137 | setTimeout(() => {
138 | if (!done) spinner.text = msg;
139 | }, delay);
140 | };
141 |
142 | // FIXME:
143 | // - telemetry
144 | // - force package manager
145 | // - force install
146 | // - pnp
147 | const entries = await readdir(process.cwd());
148 | const isEmptyDir = entries.length === 0 || entries.every((entry) => entry.startsWith('.'));
149 | const packageManager = JsPackageManagerFactory.getPackageManager();
150 |
151 | if (isEmptyDir) {
152 | logger.log(
153 | boxen(getEmptyDirMessage(packageManager.type), {
154 | borderStyle: 'round',
155 | padding: 1,
156 | borderColor: '#F1618C',
157 | })
158 | );
159 | throw new HandledError('Project was initialized in an empty directory.');
160 | }
161 |
162 | const language = await detectLanguage(packageManager);
163 |
164 | const appDir = isAppDir(process.cwd());
165 | const corePackages = ['storybook', '@storybook/react'];
166 | const addons = appDir
167 | ? ['@storybook/addon-controls']
168 | : [
169 | '@storybook/addon-essentials',
170 | '@storybook/blocks',
171 | '@storybook/addon-interactions',
172 | '@storybook/test',
173 | ];
174 |
175 | const options = {
176 | srcDir: existsSync(join(process.cwd(), 'src')) ? 'src/' : '',
177 | appDir,
178 | language,
179 | addons,
180 | };
181 | status('Creating .storybook config', 500);
182 | await createConfig(options);
183 | status('Creating example stories', 1000);
184 | await createStories(options);
185 | await createRoutes(options);
186 | await updateNextConfig();
187 | status('Installing package dependencies', 1500);
188 | await packageManager.addDependencies({ installAsDevDependencies: true }, [
189 | '@storybook/nextjs-server',
190 | ..._version([...corePackages, ...addons]),
191 | ]);
192 |
193 | done = true;
194 | spinner.succeed('Done!');
195 |
196 | logger.log(`\n1️⃣ Update ${chalk.bold(chalk.cyan('next.config.js'))}:\n`);
197 | codeLog([
198 | "const withStorybook = require('@storybook/nextjs-server/next-config')({/* sb config */});",
199 | 'module.exports = withStorybook({/* next config */});',
200 | ]);
201 | logger.log('\n2️⃣ Run your NextJS app:\n');
202 | codeLog([packageManager.getRunCommand('dev')]);
203 | logger.log('\n3️⃣ View your Storybook:\n');
204 | logger.log(chalk.bold(chalk.cyan(' https://localhost:3000/storybook')));
205 | logger.log();
206 | };
207 |
208 | init().catch((e) => {
209 | console.error(e);
210 | });
211 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/app/groupLayouts/layout-nested.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren } from 'react';
2 | import React from 'react';
3 |
4 | export default function NestedLayout({ children }: PropsWithChildren<{}>) {
5 | return <>{children}>;
6 | }
7 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/app/groupLayouts/layout-root.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren } from 'react';
2 | import React from 'react';
3 |
4 | export default function RootLayout({ children }: PropsWithChildren<{}>) {
5 | return (
6 |
7 |
8 |
9 |
10 | <>{children}>
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/app/js/Button.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import PropTypes from 'prop-types';
5 | import styles from './button.module.css';
6 |
7 | /**
8 | * Primary UI component for user interaction
9 | */
10 | export const Button = ({ primary, backgroundColor, size, label, ...props }) => {
11 | const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
12 | return (
13 |
18 | {label}
19 |
24 |
25 | );
26 | };
27 |
28 | Button.propTypes = {
29 | /**
30 | * Is this the principal call to action on the page?
31 | */
32 | primary: PropTypes.bool,
33 | /**
34 | * What background color to use
35 | */
36 | backgroundColor: PropTypes.string,
37 | /**
38 | * How large should the button be?
39 | */
40 | size: PropTypes.oneOf(['small', 'medium', 'large']),
41 | /**
42 | * Button contents
43 | */
44 | label: PropTypes.string.isRequired,
45 | /**
46 | * Optional click handler
47 | */
48 | onClick: PropTypes.func,
49 | };
50 |
51 | Button.defaultProps = {
52 | backgroundColor: null,
53 | primary: false,
54 | size: 'medium',
55 | onClick: undefined,
56 | };
57 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/app/js/Button.stories.js:
--------------------------------------------------------------------------------
1 | import { Button } from './Button';
2 |
3 | // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
4 | export default {
5 | title: 'Example/Button',
6 | component: Button,
7 | parameters: {
8 | // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
9 | layout: 'centered',
10 | },
11 | // More on argTypes: https://storybook.js.org/docs/api/argtypes
12 | argTypes: {
13 | backgroundColor: { control: 'color' },
14 | },
15 | };
16 |
17 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
18 | export const Primary = {
19 | args: {
20 | primary: true,
21 | label: 'Button',
22 | },
23 | };
24 |
25 | export const Secondary = {
26 | args: {
27 | label: 'Button',
28 | },
29 | };
30 |
31 | export const Large = {
32 | args: {
33 | size: 'large',
34 | label: 'Button',
35 | },
36 | };
37 |
38 | export const Small = {
39 | args: {
40 | size: 'small',
41 | label: 'Button',
42 | },
43 | };
44 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/app/js/Header.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import PropTypes from 'prop-types';
5 |
6 | import { Button } from './Button';
7 | import styles from './header.module.css';
8 |
9 | export const Header = ({ user, onLogin, onLogout, onCreateAccount }) => (
10 |
48 | );
49 |
50 | Header.propTypes = {
51 | user: PropTypes.shape({
52 | name: PropTypes.string.isRequired,
53 | }),
54 | onLogin: PropTypes.func.isRequired,
55 | onLogout: PropTypes.func.isRequired,
56 | onCreateAccount: PropTypes.func.isRequired,
57 | };
58 |
59 | Header.defaultProps = {
60 | user: null,
61 | };
62 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/app/js/Header.stories.js:
--------------------------------------------------------------------------------
1 | import { Header } from './Header';
2 |
3 | export default {
4 | title: 'Example/Header',
5 | component: Header,
6 | parameters: {
7 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
8 | layout: 'fullscreen',
9 | },
10 | };
11 | export const LoggedIn = {
12 | args: {
13 | user: {
14 | name: 'Jane Doe',
15 | },
16 | },
17 | };
18 |
19 | export const LoggedOut = {
20 | args: {},
21 | };
22 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/app/js/Page.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import React from 'react';
3 |
4 | import { Header } from './Header';
5 | import styles from './page.module.css';
6 |
7 | export const Page = () => {
8 | const [user, setUser] = React.useState();
9 |
10 | return (
11 |
12 | setUser({ name: 'Jane Doe' })}
15 | onLogout={() => setUser(undefined)}
16 | onCreateAccount={() => setUser({ name: 'Jane Doe' })}
17 | />
18 |
19 | Pages in Storybook
20 |
21 | We recommend building UIs with a{' '}
22 |
23 | component-driven
24 | {' '}
25 | process starting with atomic components and ending with pages.
26 |
27 |
28 | Render pages with mock data. This makes it easy to build and review page states without
29 | needing to navigate to them in your app. Here are some handy patterns for managing page
30 | data in Storybook:
31 |
32 |
33 |
34 | Use a higher-level connected component. Storybook helps you compose such data from the
35 | "args" of child component stories
36 |
37 |
38 | Assemble data in the page component from your services. You can mock these services out
39 | using Storybook.
40 |
41 |
42 |
43 | Get a guided tutorial on component-driven development at{' '}
44 |
45 | Storybook tutorials
46 |
47 | . Read more in the{' '}
48 |
49 | docs
50 |
51 | .
52 |
53 |
54 |
Tip Adjust the width of the canvas with the{' '}
55 |
56 |
57 |
62 |
63 |
64 | Viewports addon in the toolbar
65 |
66 |
67 |
68 | );
69 | };
70 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/app/js/Page.stories.js:
--------------------------------------------------------------------------------
1 | import { Page } from './Page';
2 |
3 | export default {
4 | title: 'Example/Page',
5 | component: Page,
6 | parameters: {
7 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
8 | layout: 'fullscreen',
9 | },
10 | };
11 |
12 | export const LoggedOut = {};
13 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/app/ts/Button.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Button } from './Button';
4 |
5 | // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
6 | const meta = {
7 | title: 'Example/Button',
8 | component: Button,
9 | parameters: {
10 | // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
11 | layout: 'centered',
12 | },
13 | // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
14 | tags: ['autodocs'],
15 | // More on argTypes: https://storybook.js.org/docs/api/argtypes
16 | argTypes: {
17 | backgroundColor: { control: 'color' },
18 | },
19 | } satisfies Meta;
20 |
21 | export default meta;
22 | type Story = StoryObj;
23 |
24 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
25 | export const Primary: Story = {
26 | args: {
27 | primary: true,
28 | label: 'Button',
29 | },
30 | };
31 |
32 | export const Secondary: Story = {
33 | args: {
34 | label: 'Button',
35 | },
36 | };
37 |
38 | export const Large: Story = {
39 | args: {
40 | size: 'large',
41 | label: 'Button',
42 | },
43 | };
44 |
45 | export const Small: Story = {
46 | args: {
47 | size: 'small',
48 | label: 'Button',
49 | },
50 | };
51 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/app/ts/Button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import React from 'react';
3 | import styles from './button.module.css';
4 |
5 | interface ButtonProps {
6 | /**
7 | * Is this the principal call to action on the page?
8 | */
9 | primary?: boolean;
10 | /**
11 | * What background color to use
12 | */
13 | backgroundColor?: string;
14 | /**
15 | * How large should the button be?
16 | */
17 | size?: 'small' | 'medium' | 'large';
18 | /**
19 | * Button contents
20 | */
21 | label: string;
22 | /**
23 | * Optional click handler
24 | */
25 | onClick?: () => void;
26 | }
27 |
28 | /**
29 | * Primary UI component for user interaction
30 | */
31 | export const Button = ({
32 | primary = false,
33 | size = 'medium',
34 | backgroundColor,
35 | label,
36 | ...props
37 | }: ButtonProps) => {
38 | const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
39 | return (
40 |
45 | {label}
46 |
51 |
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/app/ts/Header.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { Header } from './Header';
3 |
4 | const meta = {
5 | title: 'Example/Header',
6 | component: Header,
7 | parameters: {
8 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
9 | layout: 'fullscreen',
10 | },
11 | } satisfies Meta;
12 |
13 | export default meta;
14 | type Story = StoryObj;
15 |
16 | export const LoggedIn: Story = {
17 | args: {
18 | user: {
19 | name: 'Jane Doe',
20 | },
21 | },
22 | };
23 |
24 | export const LoggedOut: Story = {};
25 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/app/ts/Header.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import React from 'react';
3 |
4 | import { Button } from './Button';
5 | import styles from './header.module.css';
6 |
7 | type User = {
8 | name: string;
9 | };
10 |
11 | interface HeaderProps {
12 | user?: User;
13 | onLogin: () => void;
14 | onLogout: () => void;
15 | onCreateAccount: () => void;
16 | }
17 |
18 | export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => (
19 |
57 | );
58 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/app/ts/Page.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { Page } from './Page';
3 |
4 | const meta = {
5 | title: 'Example/Page',
6 | component: Page,
7 | parameters: {
8 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
9 | layout: 'fullscreen',
10 | },
11 | } satisfies Meta;
12 |
13 | export default meta;
14 | type Story = StoryObj;
15 |
16 | export const LoggedOut: Story = {};
17 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/app/ts/Page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import React from 'react';
3 |
4 | import { Header } from './Header';
5 | import styles from './page.module.css';
6 |
7 | type User = {
8 | name: string;
9 | };
10 |
11 | export const Page: React.FC = () => {
12 | const [user, setUser] = React.useState();
13 |
14 | return (
15 |
16 | setUser({ name: 'Jane Doe' })}
19 | onLogout={() => setUser(undefined)}
20 | onCreateAccount={() => setUser({ name: 'Jane Doe' })}
21 | />
22 |
23 |
24 | Pages in Storybook
25 |
26 | We recommend building UIs with a{' '}
27 |
28 | component-driven
29 | {' '}
30 | process starting with atomic components and ending with pages.
31 |
32 |
33 | Render pages with mock data. This makes it easy to build and review page states without
34 | needing to navigate to them in your app. Here are some handy patterns for managing page
35 | data in Storybook:
36 |
37 |
38 |
39 | Use a higher-level connected component. Storybook helps you compose such data from the
40 | "args" of child component stories
41 |
42 |
43 | Assemble data in the page component from your services. You can mock these services out
44 | using Storybook.
45 |
46 |
47 |
48 | Get a guided tutorial on component-driven development at{' '}
49 |
50 | Storybook tutorials
51 |
52 | . Read more in the{' '}
53 |
54 | docs
55 |
56 | .
57 |
58 |
59 |
Tip Adjust the width of the canvas with the{' '}
60 |
61 |
62 |
67 |
68 |
69 | Viewports addon in the toolbar
70 |
71 |
72 |
73 | );
74 | };
75 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/css/button.module.css:
--------------------------------------------------------------------------------
1 | .storybook-button {
2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
3 | font-weight: 700;
4 | border: 0;
5 | border-radius: 3em;
6 | cursor: pointer;
7 | display: inline-block;
8 | line-height: 1;
9 | }
10 | .storybook-button--primary {
11 | color: white;
12 | background-color: #1ea7fd;
13 | }
14 | .storybook-button--secondary {
15 | color: #333;
16 | background-color: transparent;
17 | box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
18 | }
19 | .storybook-button--small {
20 | font-size: 12px;
21 | padding: 10px 16px;
22 | }
23 | .storybook-button--medium {
24 | font-size: 14px;
25 | padding: 11px 20px;
26 | }
27 | .storybook-button--large {
28 | font-size: 16px;
29 | padding: 12px 24px;
30 | }
31 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/css/header.module.css:
--------------------------------------------------------------------------------
1 | .storybook-header {
2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
3 | border-bottom: 1px solid rgba(0, 0, 0, 0.1);
4 | padding: 15px 20px;
5 | display: flex;
6 | align-items: center;
7 | justify-content: space-between;
8 | }
9 |
10 | .storybook-header svg {
11 | display: inline-block;
12 | vertical-align: top;
13 | }
14 |
15 | .storybook-header h1 {
16 | font-weight: 700;
17 | font-size: 20px;
18 | line-height: 1;
19 | margin: 6px 0 6px 10px;
20 | display: inline-block;
21 | vertical-align: top;
22 | }
23 |
24 | .storybook-header button + button {
25 | margin-left: 10px;
26 | }
27 |
28 | .storybook-header .welcome {
29 | color: #333;
30 | font-size: 14px;
31 | margin-right: 10px;
32 | }
33 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/css/page.module.css:
--------------------------------------------------------------------------------
1 | .storybook-page {
2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
3 | font-size: 14px;
4 | line-height: 24px;
5 | padding: 48px 20px;
6 | margin: 0 auto;
7 | max-width: 600px;
8 | color: #333;
9 | }
10 |
11 | .storybook-page h2 {
12 | font-weight: 700;
13 | font-size: 32px;
14 | line-height: 1;
15 | margin: 0 0 4px;
16 | display: inline-block;
17 | vertical-align: top;
18 | }
19 |
20 | .storybook-page p {
21 | margin: 1em 0;
22 | }
23 |
24 | .storybook-page a {
25 | text-decoration: none;
26 | color: #1ea7fd;
27 | }
28 |
29 | .storybook-page ul {
30 | padding-left: 30px;
31 | margin: 1em 0;
32 | }
33 |
34 | .storybook-page li {
35 | margin-bottom: 8px;
36 | }
37 |
38 | .storybook-page .tip {
39 | display: inline-block;
40 | border-radius: 1em;
41 | font-size: 11px;
42 | line-height: 12px;
43 | font-weight: 700;
44 | background: #e7fdd8;
45 | color: #66bf3c;
46 | padding: 4px 12px;
47 | margin-right: 10px;
48 | vertical-align: top;
49 | }
50 |
51 | .storybook-page .tip-wrapper {
52 | font-size: 13px;
53 | line-height: 20px;
54 | margin-top: 40px;
55 | margin-bottom: 40px;
56 | }
57 |
58 | .storybook-page .tip-wrapper svg {
59 | display: inline-block;
60 | height: 12px;
61 | width: 12px;
62 | margin-right: 4px;
63 | vertical-align: top;
64 | margin-top: 3px;
65 | }
66 |
67 | .storybook-page .tip-wrapper svg path {
68 | fill: #1ea7fd;
69 | }
70 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/pages/js/Button.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styles from './button.module.css';
4 |
5 | /**
6 | * Primary UI component for user interaction
7 | */
8 | export const Button = ({ primary, backgroundColor, size, label, ...props }) => {
9 | const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
10 | return (
11 |
16 | {label}
17 |
22 |
23 | );
24 | };
25 |
26 | Button.propTypes = {
27 | /**
28 | * Is this the principal call to action on the page?
29 | */
30 | primary: PropTypes.bool,
31 | /**
32 | * What background color to use
33 | */
34 | backgroundColor: PropTypes.string,
35 | /**
36 | * How large should the button be?
37 | */
38 | size: PropTypes.oneOf(['small', 'medium', 'large']),
39 | /**
40 | * Button contents
41 | */
42 | label: PropTypes.string.isRequired,
43 | /**
44 | * Optional click handler
45 | */
46 | onClick: PropTypes.func,
47 | };
48 |
49 | Button.defaultProps = {
50 | backgroundColor: null,
51 | primary: false,
52 | size: 'medium',
53 | onClick: undefined,
54 | };
55 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/pages/js/Button.stories.js:
--------------------------------------------------------------------------------
1 | import { Button } from './Button';
2 |
3 | // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
4 | export default {
5 | title: 'Example/Button',
6 | component: Button,
7 | parameters: {
8 | // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
9 | layout: 'centered',
10 | },
11 | // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
12 | tags: ['autodocs'],
13 | // More on argTypes: https://storybook.js.org/docs/api/argtypes
14 | argTypes: {
15 | backgroundColor: { control: 'color' },
16 | },
17 | };
18 |
19 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
20 | export const Primary = {
21 | args: {
22 | primary: true,
23 | label: 'Button',
24 | },
25 | };
26 |
27 | export const Secondary = {
28 | args: {
29 | label: 'Button',
30 | },
31 | };
32 |
33 | export const Large = {
34 | args: {
35 | size: 'large',
36 | label: 'Button',
37 | },
38 | };
39 |
40 | export const Small = {
41 | args: {
42 | size: 'small',
43 | label: 'Button',
44 | },
45 | };
46 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/pages/js/Header.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import { Button } from './Button';
5 | import styles from './header.module.css';
6 |
7 | export const Header = ({ user, onLogin, onLogout, onCreateAccount }) => (
8 |
46 | );
47 |
48 | Header.propTypes = {
49 | user: PropTypes.shape({
50 | name: PropTypes.string.isRequired,
51 | }),
52 | onLogin: PropTypes.func.isRequired,
53 | onLogout: PropTypes.func.isRequired,
54 | onCreateAccount: PropTypes.func.isRequired,
55 | };
56 |
57 | Header.defaultProps = {
58 | user: null,
59 | };
60 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/pages/js/Header.stories.js:
--------------------------------------------------------------------------------
1 | import { Header } from './Header';
2 |
3 | export default {
4 | title: 'Example/Header',
5 | component: Header,
6 | // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
7 | tags: ['autodocs'],
8 | parameters: {
9 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
10 | layout: 'fullscreen',
11 | },
12 | };
13 | export const LoggedIn = {
14 | args: {
15 | user: {
16 | name: 'Jane Doe',
17 | },
18 | },
19 | };
20 |
21 | export const LoggedOut = {
22 | args: {},
23 | };
24 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/pages/js/Page.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Header } from './Header';
4 | import styles from './page.module.css';
5 |
6 | export const Page = () => {
7 | const [user, setUser] = React.useState();
8 |
9 | return (
10 |
11 | setUser({ name: 'Jane Doe' })}
14 | onLogout={() => setUser(undefined)}
15 | onCreateAccount={() => setUser({ name: 'Jane Doe' })}
16 | />
17 |
18 | Pages in Storybook
19 |
20 | We recommend building UIs with a{' '}
21 |
22 | component-driven
23 | {' '}
24 | process starting with atomic components and ending with pages.
25 |
26 |
27 | Render pages with mock data. This makes it easy to build and review page states without
28 | needing to navigate to them in your app. Here are some handy patterns for managing page
29 | data in Storybook:
30 |
31 |
32 |
33 | Use a higher-level connected component. Storybook helps you compose such data from the
34 | "args" of child component stories
35 |
36 |
37 | Assemble data in the page component from your services. You can mock these services out
38 | using Storybook.
39 |
40 |
41 |
42 | Get a guided tutorial on component-driven development at{' '}
43 |
44 | Storybook tutorials
45 |
46 | . Read more in the{' '}
47 |
48 | docs
49 |
50 | .
51 |
52 |
53 |
Tip Adjust the width of the canvas with the{' '}
54 |
55 |
56 |
61 |
62 |
63 | Viewports addon in the toolbar
64 |
65 |
66 |
67 | );
68 | };
69 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/pages/js/Page.stories.js:
--------------------------------------------------------------------------------
1 | import { within, userEvent, expect } from '@storybook/test';
2 | import { Page } from './Page';
3 |
4 | export default {
5 | title: 'Example/Page',
6 | component: Page,
7 | parameters: {
8 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
9 | layout: 'fullscreen',
10 | },
11 | };
12 |
13 | export const LoggedOut = {};
14 |
15 | // More on interaction testing: https://storybook.js.org/docs/writing-tests/interaction-testing
16 | export const LoggedIn = {
17 | play: async ({ canvasElement }) => {
18 | const canvas = within(canvasElement);
19 | const loginButton = canvas.getByRole('button', { name: /Log in/i });
20 | await expect(loginButton).toBeInTheDocument();
21 | await userEvent.click(loginButton);
22 | await expect(loginButton).not.toBeInTheDocument();
23 |
24 | const logoutButton = canvas.getByRole('button', { name: /Log out/i });
25 | await expect(logoutButton).toBeInTheDocument();
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/pages/ts/Button.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import { Button } from './Button';
4 |
5 | // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
6 | const meta = {
7 | title: 'Example/Button',
8 | component: Button,
9 | parameters: {
10 | // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
11 | layout: 'centered',
12 | },
13 | // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
14 | tags: ['autodocs'],
15 | // More on argTypes: https://storybook.js.org/docs/api/argtypes
16 | argTypes: {
17 | backgroundColor: { control: 'color' },
18 | },
19 | } satisfies Meta;
20 |
21 | export default meta;
22 | type Story = StoryObj;
23 |
24 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
25 | export const Primary: Story = {
26 | args: {
27 | primary: true,
28 | label: 'Button',
29 | },
30 | };
31 |
32 | export const Secondary: Story = {
33 | args: {
34 | label: 'Button',
35 | },
36 | };
37 |
38 | export const Large: Story = {
39 | args: {
40 | size: 'large',
41 | label: 'Button',
42 | },
43 | };
44 |
45 | export const Small: Story = {
46 | args: {
47 | size: 'small',
48 | label: 'Button',
49 | },
50 | };
51 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/pages/ts/Button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styles from './button.module.css';
3 |
4 | interface ButtonProps {
5 | /**
6 | * Is this the principal call to action on the page?
7 | */
8 | primary?: boolean;
9 | /**
10 | * What background color to use
11 | */
12 | backgroundColor?: string;
13 | /**
14 | * How large should the button be?
15 | */
16 | size?: 'small' | 'medium' | 'large';
17 | /**
18 | * Button contents
19 | */
20 | label: string;
21 | /**
22 | * Optional click handler
23 | */
24 | onClick?: () => void;
25 | }
26 |
27 | /**
28 | * Primary UI component for user interaction
29 | */
30 | export const Button = ({
31 | primary = false,
32 | size = 'medium',
33 | backgroundColor,
34 | label,
35 | ...props
36 | }: ButtonProps) => {
37 | const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
38 | return (
39 |
44 | {label}
45 |
50 |
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/pages/ts/Header.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { Header } from './Header';
3 |
4 | const meta = {
5 | title: 'Example/Header',
6 | component: Header,
7 | // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
8 | tags: ['autodocs'],
9 | parameters: {
10 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
11 | layout: 'fullscreen',
12 | },
13 | } satisfies Meta;
14 |
15 | export default meta;
16 | type Story = StoryObj;
17 |
18 | export const LoggedIn: Story = {
19 | args: {
20 | user: {
21 | name: 'Jane Doe',
22 | },
23 | },
24 | };
25 |
26 | export const LoggedOut: Story = {};
27 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/pages/ts/Header.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Button } from './Button';
4 | import styles from './header.module.css';
5 |
6 | type User = {
7 | name: string;
8 | };
9 |
10 | interface HeaderProps {
11 | user?: User;
12 | onLogin: () => void;
13 | onLogout: () => void;
14 | onCreateAccount: () => void;
15 | }
16 |
17 | export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => (
18 |
56 | );
57 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/pages/ts/Page.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { within, userEvent, expect } from '@storybook/test';
3 |
4 | import { Page } from './Page';
5 |
6 | const meta = {
7 | title: 'Example/Page',
8 | component: Page,
9 | parameters: {
10 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
11 | layout: 'fullscreen',
12 | },
13 | } satisfies Meta;
14 |
15 | export default meta;
16 | type Story = StoryObj;
17 |
18 | export const LoggedOut: Story = {};
19 |
20 | // More on interaction testing: https://storybook.js.org/docs/writing-tests/interaction-testing
21 | export const LoggedIn: Story = {
22 | play: async ({ canvasElement }) => {
23 | const canvas = within(canvasElement);
24 | const loginButton = canvas.getByRole('button', { name: /Log in/i });
25 | await expect(loginButton).toBeInTheDocument();
26 | await userEvent.click(loginButton);
27 | await expect(loginButton).not.toBeInTheDocument();
28 |
29 | const logoutButton = canvas.getByRole('button', { name: /Log out/i });
30 | await expect(logoutButton).toBeInTheDocument();
31 | },
32 | };
33 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/pages/ts/Page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Header } from './Header';
4 | import styles from './page.module.css';
5 |
6 | type User = {
7 | name: string;
8 | };
9 |
10 | export const Page: React.FC = () => {
11 | const [user, setUser] = React.useState();
12 |
13 | return (
14 |
15 | setUser({ name: 'Jane Doe' })}
18 | onLogout={() => setUser(undefined)}
19 | onCreateAccount={() => setUser({ name: 'Jane Doe' })}
20 | />
21 |
22 |
23 | Pages in Storybook
24 |
25 | We recommend building UIs with a{' '}
26 |
27 | component-driven
28 | {' '}
29 | process starting with atomic components and ending with pages.
30 |
31 |
32 | Render pages with mock data. This makes it easy to build and review page states without
33 | needing to navigate to them in your app. Here are some handy patterns for managing page
34 | data in Storybook:
35 |
36 |
37 |
38 | Use a higher-level connected component. Storybook helps you compose such data from the
39 | "args" of child component stories
40 |
41 |
42 | Assemble data in the page component from your services. You can mock these services out
43 | using Storybook.
44 |
45 |
46 |
47 | Get a guided tutorial on component-driven development at{' '}
48 |
49 | Storybook tutorials
50 |
51 | . Read more in the{' '}
52 |
53 | docs
54 |
55 | .
56 |
57 |
58 |
Tip Adjust the width of the canvas with the{' '}
59 |
60 |
61 |
66 |
67 |
68 | Viewports addon in the toolbar
69 |
70 |
71 |
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/sb/main.js.ejs:
--------------------------------------------------------------------------------
1 | /** @type { import('@storybook/nextjs-server').StorybookConfig } */
2 | const config = {
3 | stories: <%- stories %>,
4 | addons: <%- addons %>,
5 | framework: { name: '@storybook/nextjs-server', options: {} },
6 | <%- extras %>};
7 |
8 | export default config;
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/sb/main.ts.ejs:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig } from '@storybook/nextjs-server';
2 |
3 | const config: StorybookConfig = {
4 | stories: <%- stories %>,
5 | addons: <%- addons %>,
6 | <%- extras %>};
7 |
8 | export default config;
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/sb/preview.jsx:
--------------------------------------------------------------------------------
1 | /** @type { import('${rendererPackage}').Preview } */
2 | const preview = {
3 | parameters: {
4 | actions: { argTypesRegex: '^on[A-Z].*' },
5 | controls: {
6 | matchers: {
7 | color: /(background|color)$/i,
8 | date: /Date$/i,
9 | },
10 | }
11 | },
12 | };
13 | export default preview;
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/templates/sb/preview.tsx:
--------------------------------------------------------------------------------
1 | import type { Preview } from '@storybook/react';
2 |
3 | const preview: Preview = {
4 | parameters: {
5 | actions: { argTypesRegex: '^on[A-Z].*' },
6 | controls: {
7 | matchers: {
8 | color: /(background|color)$/i,
9 | date: /Date$/i,
10 | },
11 | }
12 | },
13 | };
14 | export default preview;
--------------------------------------------------------------------------------
/packages/create-nextjs-storybook/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "moduleResolution": "NodeNext",
4 | "target": "es2022",
5 | "module": "NodeNext",
6 | "outDir": "./dist",
7 | // thanks yarn
8 | "skipLibCheck": true,
9 | "esModuleInterop": true,
10 | },
11 | "exclude": [
12 | "node_modules",
13 | "dist",
14 | "templates"
15 | ]
16 | }
--------------------------------------------------------------------------------
/packages/nextjs-server/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @storybook/nextjs-server
2 |
3 | ## 0.0.4
4 |
5 | ### Patch Changes
6 |
7 | - 2c61a7b: Clean up src dir support
8 |
9 | ## 0.0.3
10 |
11 | ### Patch Changes
12 |
13 | - b097bb8: Fix src dir support
14 |
15 | ## 0.0.2
16 |
17 | ### Patch Changes
18 |
19 | - Update for storybook 8.0-alpha
20 |
21 | ## 0.0.1
22 |
23 | ### Patch Changes
24 |
25 | - Initial release
26 |
--------------------------------------------------------------------------------
/packages/nextjs-server/README.md:
--------------------------------------------------------------------------------
1 | # Storybook Next.js Server
2 |
3 | An experimental embedded version of [Storybook](https://storybook.js.org/) that runs **inside** your Next.js dev server.
4 |
5 | To install, run the installer from the root of your Next.js project:
6 |
7 | ```
8 | npm create nextjs-storybook
9 | ```
10 |
11 | For more information, [please see the docs](https://github.com/storybookjs/nextjs-server).
--------------------------------------------------------------------------------
/packages/nextjs-server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@storybook/nextjs-server",
3 | "version": "0.0.4",
4 | "description": "Storybook for NextJS Server: Embedded server-side rendering",
5 | "keywords": [
6 | "storybook",
7 | "nextjs"
8 | ],
9 | "homepage": "https://github.com/storybookjs/nextjs-server/tree/next/packages/nextjs-server",
10 | "bugs": {
11 | "url": "https://github.com/storybookjs/nextjs-server/issues"
12 | },
13 | "repository": {
14 | "type": "git",
15 | "url": "https://github.com/storybookjs/nextjs-server.git",
16 | "directory": "code/frameworks/nextjs-server"
17 | },
18 | "funding": {
19 | "type": "opencollective",
20 | "url": "https://opencollective.com/storybook"
21 | },
22 | "license": "MIT",
23 | "exports": {
24 | ".": {
25 | "types": "./dist/index.d.ts",
26 | "node": "./dist/index.js",
27 | "require": "./dist/index.js",
28 | "import": "./dist/index.mjs"
29 | },
30 | "./preset": {
31 | "types": "./dist/preset.d.ts",
32 | "require": "./dist/preset.js"
33 | },
34 | "./plugin": {
35 | "types": "./dist/plugin.d.ts",
36 | "require": "./dist/plugin.js"
37 | },
38 | "./mock": {
39 | "types": "./dist/mock.d.ts",
40 | "node": "./dist/mock.js",
41 | "require": "./dist/mock.js",
42 | "import": "./dist/mock.mjs"
43 | },
44 | "./next-config": {
45 | "node": "./dist/next-config.js"
46 | },
47 | "./pages": {
48 | "types": "./dist/pages/index.d.ts",
49 | "import": "./dist/pages/index.mjs"
50 | },
51 | "./channels": {
52 | "types": "./dist/reexports/channels.d.ts",
53 | "node": "./dist/reexports/channels.js",
54 | "require": "./dist/reexports/channels.js",
55 | "import": "./dist/reexports/channels.mjs"
56 | },
57 | "./core-events": {
58 | "types": "./dist/reexports/core-events.d.ts",
59 | "node": "./dist/reexports/core-events.js",
60 | "require": "./dist/reexports/core-events.js",
61 | "import": "./dist/reexports/core-events.mjs"
62 | },
63 | "./preview-api": {
64 | "types": "./dist/reexports/preview-api.d.ts",
65 | "node": "./dist/reexports/preview-api.js",
66 | "require": "./dist/reexports/preview-api.js",
67 | "import": "./dist/reexports/preview-api.mjs"
68 | },
69 | "./types": {
70 | "types": "./dist/reexports/types.d.ts"
71 | },
72 | "./package.json": "./package.json"
73 | },
74 | "main": "dist/index.js",
75 | "module": "dist/index.mjs",
76 | "types": "dist/index.d.ts",
77 | "files": [
78 | "dist/**/*",
79 | "template/**/*",
80 | "README.md",
81 | "*.js",
82 | "*.d.ts"
83 | ],
84 | "scripts": {
85 | "check": "node --loader ../../scripts/node_modules/esbuild-register/loader.js -r ../../scripts/node_modules/esbuild-register/register.js ../../scripts/prepare/check.ts",
86 | "build": "node --loader ../../scripts/node_modules/esbuild-register/loader.js -r ../../scripts/node_modules/esbuild-register/register.js ../../scripts/prepare/bundle.ts",
87 | "prepublish": "pnpm build"
88 | },
89 | "dependencies": {
90 | "@babel/core": "^7.22.9",
91 | "@babel/types": "^7.22.5",
92 | "@storybook/channels": "0.0.0-pr-25086-sha-fa16f873",
93 | "@storybook/core-common": "0.0.0-pr-25086-sha-fa16f873",
94 | "@storybook/core-events": "0.0.0-pr-25086-sha-fa16f873",
95 | "@storybook/core-server": "0.0.0-pr-25086-sha-fa16f873",
96 | "@storybook/csf-tools": "0.0.0-pr-25086-sha-fa16f873",
97 | "@storybook/node-logger": "0.0.0-pr-25086-sha-fa16f873",
98 | "@storybook/preview-api": "0.0.0-pr-25086-sha-fa16f873",
99 | "@storybook/react": "0.0.0-pr-25086-sha-fa16f873",
100 | "@storybook/types": "0.0.0-pr-25086-sha-fa16f873",
101 | "@storybook/csf": "^0.1.2",
102 | "@types/node": "^18.0.0",
103 | "@types/react": "^18",
104 | "fs-extra": "^11.1.0",
105 | "ts-dedent": "^2.0.0",
106 | "unplugin": "^1.3.1"
107 | },
108 | "devDependencies": {
109 | "@types/fs-extra": "^11.0.1",
110 | "typescript": "^5.3.2"
111 | },
112 | "peerDependencies": {
113 | "next": "^14.0.4",
114 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
115 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
116 | },
117 | "engines": {
118 | "node": ">=16.0.0"
119 | },
120 | "publishConfig": {
121 | "access": "public"
122 | },
123 | "bundler": {
124 | "entries": [
125 | "./src/index.ts",
126 | "./src/null-builder.ts",
127 | "./src/null-renderer.ts",
128 | "./src/preset.ts",
129 | "./src/mock.ts",
130 | "./src/next-config.cts",
131 | "./src/pages/index.ts",
132 | "./src/reexports/channels.ts",
133 | "./src/reexports/core-events.ts",
134 | "./src/reexports/preview-api.ts",
135 | "./src/reexports/types.ts"
136 | ],
137 | "platform": "node"
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/packages/nextjs-server/preset.js:
--------------------------------------------------------------------------------
1 | module.exports = require('./dist/preset');
2 |
--------------------------------------------------------------------------------
/packages/nextjs-server/server.d.ts:
--------------------------------------------------------------------------------
1 | export * from './dist/server/index.d';
2 |
--------------------------------------------------------------------------------
/packages/nextjs-server/server.js:
--------------------------------------------------------------------------------
1 | export * from './dist/server';
2 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './types';
2 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/indexers.ts:
--------------------------------------------------------------------------------
1 | import { cp, readFile, writeFile } from 'fs/promises';
2 | import { ensureDir, exists } from 'fs-extra';
3 | import { join, relative, resolve } from 'path';
4 | import { dedent } from 'ts-dedent';
5 | import type { Indexer, PreviewAnnotation } from '@storybook/types';
6 | import { loadCsf } from '@storybook/csf-tools';
7 | import type { StorybookNextJSOptions } from './types';
8 | import { getAppDir, getPagesDir } from './utils';
9 |
10 | const LAYOUT_FILES = ['layout.tsx', 'layout.jsx'];
11 |
12 | export const appIndexer = (
13 | allPreviewAnnotations: PreviewAnnotation[],
14 | { previewPath }: StorybookNextJSOptions
15 | ): Indexer => {
16 | return {
17 | test: /(stories|story)\.[tj]sx?$/,
18 | createIndex: async (fileName, opts) => {
19 | console.log('indexing', fileName);
20 | const code = (await readFile(fileName, 'utf-8')).toString();
21 | const csf = await loadCsf(code, { ...opts, fileName }).parse();
22 |
23 | const inputAppDir = resolve(__dirname, '../template/app');
24 | const inputGroupDir = join(inputAppDir, 'groupLayouts');
25 | const inputStorybookDir = join(inputAppDir, 'storybook-preview');
26 | let appDir = getAppDir({ createIfMissing: true })!;
27 | const sbGroupDir = join(appDir, '(sb)');
28 | const storybookDir = join(sbGroupDir, previewPath);
29 | await ensureDir(storybookDir);
30 |
31 | try {
32 | await cp(inputStorybookDir, storybookDir, { recursive: true });
33 | const hasRootLayout = await Promise.any(
34 | LAYOUT_FILES.map((file) => exists(join(appDir, file)))
35 | );
36 | const inputLayout = hasRootLayout ? 'layout-nested.tsx' : 'layout-root.tsx';
37 | await cp(`${inputGroupDir}/${inputLayout}`, join(sbGroupDir, 'layout.tsx'));
38 |
39 | const routeLayoutTsx = dedent`import type { PropsWithChildren } from 'react';
40 | import React from 'react';
41 | import { Storybook } from './components/Storybook';
42 |
43 | export default function NestedLayout({ children }: PropsWithChildren<{}>) {
44 | return {children} ;
45 | }`;
46 | const routeLayoutFile = join(storybookDir, 'layout.tsx');
47 | await writeFile(routeLayoutFile, routeLayoutTsx);
48 | } catch (err) {
49 | // FIXME: assume we've copied already
50 | // console.log({ err });
51 | }
52 |
53 | await Promise.all(
54 | Object.entries(csf._stories).map(async ([exportName, story]) => {
55 | const storyDir = join(storybookDir, story.id);
56 | await ensureDir(storyDir);
57 | const relativeStoryPath = relative(storyDir, fileName).replace(/\.tsx?$/, '');
58 |
59 | const pageTsx = dedent`
60 | import React from 'react';
61 | import { composeStory } from '@storybook/react';
62 | import { getArgs } from '../components/args';
63 | import { Prepare, StoryAnnotations } from '../components/Prepare';
64 | import { Args } from '@storybook/react';
65 |
66 | const page = async () => {
67 | const stories = await import('${relativeStoryPath}');
68 | const projectAnnotations = {};
69 | const Composed = composeStory(stories.${exportName}, stories.default, projectAnnotations?.default || {}, '${exportName}');
70 | const extraArgs = await getArgs(Composed.id);
71 |
72 | const { id, parameters, argTypes, args: initialArgs } = Composed;
73 | const args = { ...initialArgs, ...extraArgs };
74 |
75 | const storyAnnotations: StoryAnnotations = {
76 | id,
77 | parameters,
78 | argTypes,
79 | initialArgs,
80 | args,
81 | };
82 | return (
83 | <>
84 |
85 | {/* @ts-ignore TODO -- why? */}
86 |
87 | >
88 | );
89 | };
90 | export default page;
91 | `;
92 |
93 | const pageFile = join(storyDir, 'page.tsx');
94 | await writeFile(pageFile, pageTsx);
95 | })
96 | );
97 |
98 | return csf.indexInputs;
99 | },
100 | };
101 | };
102 |
103 | export const pagesIndexer = (
104 | allPreviewAnnotations: PreviewAnnotation[],
105 | { previewPath }: StorybookNextJSOptions
106 | ): Indexer => {
107 | const workingDir = process.cwd(); // TODO we should probably put this on the preset options
108 |
109 | return {
110 | test: /(stories|story)\.[tj]sx?$/,
111 | createIndex: async (fileName, opts) => {
112 | console.log('indexing', fileName);
113 | const code = (await readFile(fileName, 'utf-8')).toString();
114 | const csf = await loadCsf(code, { ...opts, fileName }).parse();
115 |
116 | const pagesDir = getPagesDir({ createIfMissing: true })!;
117 | const storybookDir = join(pagesDir, previewPath);
118 | await ensureDir(storybookDir);
119 |
120 | const indexTsx = dedent`
121 | import React from 'react';
122 | import { Storybook } from './components/Storybook';
123 |
124 | const page = () => ;
125 | export default page;
126 | `;
127 | const indexFile = join(storybookDir, 'index.tsx');
128 | await writeFile(indexFile, indexTsx);
129 |
130 | const projectAnnotationImports = allPreviewAnnotations
131 | .map((path, index) => `const projectAnnotations${index} = await import('${path}');`)
132 | .join('\n');
133 |
134 | const projectAnnotationArray = allPreviewAnnotations
135 | .map((_, index) => `projectAnnotations${index}`)
136 | .join(',');
137 |
138 | const storybookTsx = dedent`
139 | import React, { useEffect } from 'react';
140 | import { composeConfigs } from '@storybook/preview-api';
141 | import { Preview } from '@storybook/nextjs-server/pages';
142 |
143 | const getProjectAnnotations = async () => {
144 | ${projectAnnotationImports}
145 | return composeConfigs([${projectAnnotationArray}]);
146 | }
147 |
148 | export const Storybook = () => (
149 |
150 | );
151 | `;
152 |
153 | const componentsDir = join(storybookDir, 'components');
154 | await ensureDir(componentsDir);
155 | const storybookFile = join(componentsDir, 'Storybook.tsx');
156 | await writeFile(storybookFile, storybookTsx);
157 |
158 | const componentId = csf.stories[0].id.split('--')[0];
159 | const relativeStoryPath = relative(storybookDir, fileName).replace(/\.tsx?$/, '');
160 | const importPath = relative(workingDir, fileName).replace(/^([^./])/, './$1');
161 |
162 | const csfImportTsx = dedent`
163 | import React from 'react';
164 | import { Storybook } from './components/Storybook';
165 | import * as stories from '${relativeStoryPath}';
166 |
167 | if (typeof window !== 'undefined') {
168 | window._storybook_onImport('${importPath}', stories);
169 | }
170 |
171 | const page = () => ;
172 | export default page;
173 | `;
174 |
175 | const csfImportFile = join(storybookDir, `${componentId}.tsx`);
176 | await writeFile(csfImportFile, csfImportTsx);
177 |
178 | return csf.indexInputs;
179 | },
180 | };
181 | };
182 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/mock.ts:
--------------------------------------------------------------------------------
1 | // @ts-expect-error wrong react version
2 | import React, { cache } from 'react';
3 |
4 | const requestId = cache(() => Math.random());
5 | type Exports = Record;
6 |
7 | export const Mock = {
8 | cache: {} as Record,
9 | storybookRequests: {} as Record,
10 | set(data: any) {
11 | const id = requestId();
12 | console.log('Mock.set', { id, data });
13 | this.cache[id] = data;
14 | this.storybookRequests[id] = true;
15 | },
16 | get() {
17 | const id = requestId();
18 | const data = this.cache[id];
19 | console.log('Mock.get', { id, data });
20 | return data;
21 | },
22 | fn(original: any, mock: any) {
23 | return (...args: any) => {
24 | const id = requestId();
25 | const isStorybook = !!this.storybookRequests[id];
26 | return isStorybook ? mock(...args) : original(...args);
27 | };
28 | },
29 | module(original: Exports, mocks: Exports) {
30 | const result = { ...original };
31 | Object.keys(mocks).forEach((key) => {
32 | result[key] = Mock.fn(original[key], mocks[key]);
33 | });
34 | return result;
35 | },
36 | };
37 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/next-config.cts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from 'next';
2 | import type { ChildProcess } from 'child_process';
3 | import { spawn } from 'child_process';
4 | import type { StorybookNextJSOptions } from './types';
5 | import { verifyPort } from './verifyPort';
6 | import { getAppDir } from './utils';
7 |
8 | const logger = console;
9 | let childProcess: ChildProcess | undefined;
10 |
11 | [
12 | 'SIGHUP',
13 | 'SIGINT',
14 | 'SIGQUIT',
15 | 'SIGILL',
16 | 'SIGTRAP',
17 | 'SIGABRT',
18 | 'SIGBUS',
19 | 'SIGFPE',
20 | 'SIGUSR1',
21 | 'SIGSEGV',
22 | 'SIGUSR2',
23 | 'SIGTERM',
24 | ].forEach((sig) => {
25 | process.on(sig, () => {
26 | if (childProcess) {
27 | logger.log('Stopping storybook');
28 | childProcess.kill();
29 | }
30 | });
31 | });
32 |
33 | function addRewrites(
34 | existing: NextConfig['rewrites'] | undefined,
35 | ourRewrites: { source: string; destination: string }[]
36 | ): NextConfig['rewrites'] {
37 | if (!existing) return async () => ourRewrites;
38 |
39 | return async () => {
40 | const existingRewrites = await existing();
41 |
42 | if (Array.isArray(existingRewrites)) return [...existingRewrites, ...ourRewrites];
43 |
44 | return {
45 | ...existingRewrites,
46 | fallback: [...existingRewrites.fallback, ...ourRewrites],
47 | };
48 | };
49 | }
50 |
51 | interface WithStorybookOptions {
52 | /**
53 | * Port that the Next.js app will run on.
54 | * @default 3000
55 | */
56 | port: string | number;
57 |
58 | /**
59 | * Internal port that Storybook will run on.
60 | * @default 34567
61 | */
62 | sbPort: string | number;
63 |
64 | /**
65 | * URL path to Storybook's "manager" UI.
66 | * @default 'storybook'
67 | */
68 | managerPath: string;
69 |
70 | /**
71 | * URL path to Storybook's story preview iframe.
72 | * @default 'storybook-preview'
73 | */
74 | previewPath: string;
75 |
76 | /**
77 | * Directory where Storybook's config files are located.
78 | * @default '.storybook'
79 | */
80 | configDir: string;
81 |
82 | /**
83 | * Whether to use the NextJS app directory.
84 | * @default undefined
85 | */
86 | appDir: boolean;
87 | }
88 |
89 | const withStorybook = ({
90 | port = process.env.PORT ?? 3000,
91 | sbPort = 34567,
92 | managerPath = 'storybook',
93 | previewPath = 'storybook-preview',
94 | configDir = '.storybook',
95 | appDir = undefined,
96 | }: Partial = {}) => {
97 | const isAppDir = appDir ?? !!getAppDir({ createIfMissing: false });
98 | const storybookNextJSOptions: StorybookNextJSOptions = {
99 | appDir: isAppDir,
100 | managerPath,
101 | previewPath,
102 | };
103 |
104 | childProcess = spawn(
105 | 'npm',
106 | [
107 | 'exec',
108 | 'storybook',
109 | '--',
110 | 'dev',
111 | '--preview-url',
112 | `http://localhost:${port}/${previewPath}`,
113 | '-p',
114 | sbPort.toString(),
115 | '--ci',
116 | // NOTE that this is still a race condition. However, if two instances of SB use the exact port,
117 | // the second will fail and the first will still be running, which is what we want. There must be
118 | // a more graceful way to handle this.
119 | '--exact-port',
120 | '--config-dir',
121 | configDir,
122 | ],
123 | {
124 | stdio: 'inherit',
125 | env: { ...process.env, STORYBOOK_NEXTJS_OPTIONS: JSON.stringify(storybookNextJSOptions) },
126 | }
127 | );
128 |
129 | verifyPort(port, { appDir: isAppDir, previewPath });
130 |
131 | return (config: NextConfig) => ({
132 | ...config,
133 | rewrites: addRewrites(config.rewrites, [
134 | {
135 | source: '/logo.svg',
136 | destination: `http://localhost:${sbPort}/logo.svg`,
137 | },
138 | {
139 | source: `/${managerPath}/:path*`,
140 | destination: `http://localhost:${sbPort}/:path*`,
141 | },
142 | {
143 | source: '/sb-manager/:path*',
144 | destination: `http://localhost:${sbPort}/sb-manager/:path*`,
145 | },
146 | {
147 | source: '/sb-common-assets/:path*',
148 | destination: `http://localhost:${sbPort}/sb-common-assets/:path*`,
149 | },
150 | {
151 | source: '/sb-preview/:path*',
152 | destination: `http://localhost:${sbPort}/sb-preview/:path*`,
153 | },
154 | {
155 | source: '/sb-addons/:path*',
156 | destination: `http://localhost:${sbPort}/sb-addons/:path*`,
157 | },
158 | {
159 | source: '/storybook-server-channel',
160 | destination: `http://localhost:${sbPort}/storybook-server-channel`,
161 | },
162 | {
163 | source: '/index.json',
164 | destination: `http://localhost:${sbPort}/index.json`,
165 | },
166 | {
167 | source: `/${previewPath}/index.json`,
168 | destination: `http://localhost:${sbPort}/index.json`,
169 | },
170 | ]),
171 | });
172 | };
173 |
174 | module.exports = withStorybook;
175 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/null-builder.ts:
--------------------------------------------------------------------------------
1 | import type { Builder } from '@storybook/types';
2 |
3 | interface NullStats {
4 | toJson: () => any;
5 | }
6 | export type NullBuilder = Builder<{}, NullStats>;
7 |
8 | export const start: NullBuilder['start'] = async () => {};
9 |
10 | export const build: NullBuilder['build'] = async () => {};
11 |
12 | export const bail: NullBuilder['bail'] = async () => {};
13 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/null-renderer.ts:
--------------------------------------------------------------------------------
1 | export const renderToCanvas = () => {
2 | throw new Error('This should never be called');
3 | };
4 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/pages/Preview.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | import React from 'react';
3 | import { useRouter } from 'next/navigation.js';
4 | import type { Renderer } from '@storybook/csf';
5 | import { createBrowserChannel } from '@storybook/channels';
6 | import { PreviewWithSelection, addons, UrlStore, WebView } from '@storybook/preview-api';
7 |
8 | import { previewHtml } from './previewHtml';
9 | import { importFn } from './importFn';
10 |
11 | // A version of the URL store that doesn't change route when the selection changes
12 | // (as we change the URL as part of rendering the story)
13 | class StaticUrlStore extends UrlStore {
14 | setSelection(selection: Parameters[0]) {
15 | this.selection = selection;
16 | }
17 | }
18 |
19 | type GetProjectAnnotations = Parameters<
20 | PreviewWithSelection['initialize']
21 | >[0]['getProjectAnnotations'];
22 |
23 | export const Preview = ({
24 | getProjectAnnotations,
25 | previewPath,
26 | }: {
27 | getProjectAnnotations: GetProjectAnnotations;
28 | previewPath: string;
29 | }) => {
30 | const router = useRouter();
31 |
32 | // We can't use React's useEffect in the monorepo because of dependency issues,
33 | // but we only need to ensure code runs *once* on the client only, so let's just make
34 | // our own version of that
35 | if (typeof window !== 'undefined') {
36 | if (!window.__STORYBOOK_PREVIEW__) {
37 | console.log('creating preview');
38 | const channel = createBrowserChannel({ page: 'preview' });
39 | addons.setChannel(channel);
40 | window.__STORYBOOK_ADDONS_CHANNEL__ = channel;
41 |
42 | const preview = new PreviewWithSelection(new StaticUrlStore(), new WebView());
43 |
44 | preview.initialize({
45 | importFn: (path) =>
46 | importFn(preview.storyStore.storyIndex!.entries, router, previewPath, path),
47 | getProjectAnnotations,
48 | });
49 | window.__STORYBOOK_PREVIEW__ = preview;
50 | }
51 |
52 | // Render the the SB UI (ie iframe.html / preview.ejs) in a non-react way to ensure
53 | // it doesn't get ripped down when a new route renders
54 | if (!document.querySelector('#storybook-root')) {
55 | document.body.innerHTML += previewHtml;
56 | }
57 | }
58 |
59 | return <>>;
60 | };
61 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/pages/importFn.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | import type { ModuleExports, StoryIndex } from '@storybook/types';
3 | import type { useRouter } from 'next/navigation';
4 |
5 | type Path = string;
6 |
7 | const csfFiles: Record = {};
8 | const csfResolvers: Record void> = {};
9 | const csfPromises: Record> = {};
10 | // @ts-ignore FIXME
11 | let useEffect = (_1: any, _2: any) => {};
12 | if (typeof window !== 'undefined') {
13 | window.FEATURES = { storyStoreV7: true };
14 |
15 | window._storybook_onImport = (path: Path, moduleExports: ModuleExports) => {
16 | console.log('_storybook_onImport', path, Object.keys(csfFiles), Object.keys(csfResolvers));
17 | csfFiles[path] = moduleExports;
18 | csfResolvers[path]?.(moduleExports);
19 | };
20 |
21 | useEffect = (cb, _) => cb();
22 | }
23 |
24 | export const importFn = async (
25 | allEntries: StoryIndex['entries'],
26 | router: ReturnType,
27 | previewPath: Path,
28 | path: Path
29 | ) => {
30 | console.log('importing', path);
31 |
32 | if (csfFiles[path]) {
33 | console.log('got it already, short circuiting');
34 | return csfFiles[path];
35 | }
36 |
37 | // @ts-expect-error TS is confused, this is not a bug
38 | if (csfPromises[path]) {
39 | console.log('got promise, short circuiting');
40 | return csfPromises[path];
41 | }
42 |
43 | // Find all index entries for this import path, to find a story id
44 | const entries = Object.values(allEntries || []).filter(
45 | ({ importPath }: any) => importPath === path
46 | ) as { id: string; name: string; title: string }[];
47 |
48 | if (entries.length === 0) throw new Error(`Couldn't find import path ${path}, this is odd`);
49 |
50 | const firstStoryId = entries[0].id;
51 | const componentId = firstStoryId.split('--')[0];
52 |
53 | csfPromises[path] = new Promise((resolve) => {
54 | csfResolvers[path] = resolve;
55 | });
56 |
57 | router.push(`/${previewPath}/${componentId}`);
58 |
59 | return csfPromises[path];
60 | };
61 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Preview';
2 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/pages/previewHtml.ts:
--------------------------------------------------------------------------------
1 | export const previewHtml = `
2 |
314 |
315 |
329 |
330 |
331 |
332 |
333 |
336 |
337 |
338 |
349 |
350 |
351 |
352 | Name
353 | Description
354 | Default
355 | Control
356 |
357 |
358 |
359 |
360 | propertyName *
361 |
362 | This is a short description
363 |
366 |
367 |
368 | defaultValue
369 |
370 | Set string
371 |
372 |
373 | propertyName *
374 |
375 | This is a short description
376 |
379 |
380 |
381 | defaultValue
382 |
383 | Set string
384 |
385 |
386 | propertyName *
387 |
388 | This is a short description
389 |
392 |
393 |
394 | defaultValue
395 |
396 | Set string
397 |
398 |
399 |
400 |
401 |
402 |
403 |
404 |
No Preview
405 |
Sorry, but you either have no stories or none are selected somehow.
406 |
407 | Please check the Storybook config.
408 | Try reloading the page.
409 |
410 |
411 | If the problem persists, check the browser console, or the terminal you've run Storybook from.
412 |
413 |
414 |
415 |
416 | `;
420 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/preset.ts:
--------------------------------------------------------------------------------
1 | import { dirname, join } from 'path';
2 |
3 | import type { PresetProperty, PreviewAnnotation } from '@storybook/types';
4 | import type { StorybookConfig, StorybookNextJSOptions } from './types';
5 | import { appIndexer, pagesIndexer } from './indexers';
6 |
7 | const wrapForPnP = (input: string) => dirname(require.resolve(join(input, 'package.json')));
8 |
9 | const nextJsOptions: StorybookNextJSOptions = process.env.STORYBOOK_NEXTJS_OPTIONS
10 | ? JSON.parse(process.env.STORYBOOK_NEXTJS_OPTIONS)
11 | : {};
12 |
13 | // eslint-disable-next-line @typescript-eslint/naming-convention
14 | export const experimental_indexers: StorybookConfig['experimental_indexers'] = async (
15 | existingIndexers,
16 | { presets, configDir }
17 | ) => {
18 | console.log('experimental_indexers');
19 |
20 | const allPreviewAnnotations = [
21 | ...(await presets.apply('previewAnnotations', [])).map((entry) => {
22 | if (typeof entry === 'object') {
23 | return entry.absolute;
24 | }
25 | return entry;
26 | }),
27 | join(configDir, 'preview'), // FIXME is :point_down: better?
28 | // loadPreviewOrConfigFile(options),
29 | ].filter(Boolean);
30 |
31 | const rewritingIndexer = nextJsOptions.appDir
32 | ? appIndexer(allPreviewAnnotations, nextJsOptions)
33 | : pagesIndexer(allPreviewAnnotations, nextJsOptions);
34 | return [rewritingIndexer, ...(existingIndexers || [])];
35 | };
36 |
37 | export const core: PresetProperty<'core'> = async (config) => {
38 | return {
39 | ...config,
40 | builder: {
41 | name: require.resolve('./null-builder') as '@storybook/builder-vite',
42 | options: {},
43 | },
44 | renderer: nextJsOptions.appDir
45 | ? require.resolve('./null-renderer')
46 | : wrapForPnP('@storybook/react'),
47 | };
48 | };
49 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/reexports/channels.ts:
--------------------------------------------------------------------------------
1 | export * from '@storybook/channels';
2 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/reexports/core-events.ts:
--------------------------------------------------------------------------------
1 | export * from '@storybook/core-events';
2 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/reexports/preview-api.ts:
--------------------------------------------------------------------------------
1 | export * from '@storybook/preview-api';
2 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/reexports/types.ts:
--------------------------------------------------------------------------------
1 | export * from '@storybook/types';
2 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig as StorybookConfigBase } from '@storybook/types';
2 |
3 | type FrameworkName = '@storybook/nextjs-server';
4 |
5 | export type FrameworkOptions = {
6 | builder?: {};
7 | };
8 |
9 | type StorybookConfigFramework = {
10 | framework:
11 | | FrameworkName
12 | | {
13 | name: FrameworkName;
14 | options: FrameworkOptions;
15 | };
16 | };
17 |
18 | /**
19 | * The interface for Storybook configuration in `main.ts` files.
20 | */
21 | export type StorybookConfig = StorybookConfigBase & StorybookConfigFramework;
22 |
23 | export interface StorybookNextJSOptions {
24 | appDir: boolean;
25 | managerPath: string;
26 | previewPath: string;
27 | }
28 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/typings.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | /* eslint-disable @typescript-eslint/naming-convention */
3 |
4 | declare var FEATURES: import('@storybook/types').StorybookConfig['features'];
5 |
6 | declare var __STORYBOOK_PREVIEW__: any;
7 | declare var __STORYBOOK_ADDONS_CHANNEL__: any;
8 | declare var _storybook_onImport: any;
9 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { existsSync } from 'fs';
2 | import { ensureDirSync } from 'fs-extra';
3 | import { join } from 'path';
4 |
5 | interface Options {
6 | /**
7 | * Create the directory if it doesn't exist. This is useful if
8 | * we've already determined what type of project we're running in
9 | * (pages vs app), but we want to handle the case where the
10 | * directory has been deleted out from under us.
11 | *
12 | * When creating the directory, we prefer `src/{pages,app}` if
13 | * a `src` directory exists.
14 | */
15 | createIfMissing?: boolean;
16 | }
17 |
18 | const getDirOrSrcDir = (pagesOrApp: string, { createIfMissing }: Options) => {
19 | const cwd = process.cwd();
20 |
21 | const rootDir = join(cwd, pagesOrApp);
22 | if (existsSync(rootDir)) return rootDir;
23 |
24 | const srcDir = join(cwd, 'src');
25 | const srcRootDir = join(srcDir, pagesOrApp);
26 | if (existsSync(srcRootDir)) return srcRootDir;
27 |
28 | if (createIfMissing) {
29 | const toCreate = existsSync(srcDir) ? srcRootDir : rootDir;
30 | ensureDirSync(toCreate);
31 | return toCreate;
32 | }
33 |
34 | return undefined;
35 | };
36 |
37 | export const getAppDir = (options: Options) => getDirOrSrcDir('app', options);
38 |
39 | export const getPagesDir = (options: Options) => getDirOrSrcDir('pages', options);
40 |
--------------------------------------------------------------------------------
/packages/nextjs-server/src/verifyPort.ts:
--------------------------------------------------------------------------------
1 | import { join, dirname } from 'path';
2 | import { ensureDir, exists, writeFile } from 'fs-extra';
3 | import { getAppDir, getPagesDir } from './utils';
4 |
5 | interface VerifyOptions {
6 | pid: number;
7 | ppid: number;
8 | port: string | number;
9 | appDir: boolean;
10 | previewPath: string;
11 | }
12 |
13 | const writePidFilePages = async ({ previewPath }: VerifyOptions) => {
14 | const pagesDir = getPagesDir({ createIfMissing: true })!;
15 | const pidFile = join(pagesDir, previewPath, 'pid.tsx');
16 |
17 | if (await exists(pidFile)) return;
18 |
19 | await ensureDir(dirname(pidFile));
20 | const pidTsx = `
21 | import type { InferGetServerSidePropsType, GetServerSideProps } from 'next'
22 |
23 | export const getServerSideProps = (async () => {
24 | return { props: { ppid: process.ppid } }
25 | }) satisfies GetServerSideProps<{ ppid: number }>;
26 |
27 | export default function Page(
28 | { ppid }: InferGetServerSidePropsType
29 | ) {
30 | const ppidTag = '__ppid_' + ppid + '__';
31 | return <>{ppidTag}>;
32 | };
33 | `;
34 | await writeFile(pidFile, pidTsx);
35 | };
36 |
37 | const writePidFileApp = async ({ previewPath }: VerifyOptions) => {
38 | const appDir = getAppDir({ createIfMissing: true })!;
39 | const pidFile = join(appDir, '(sb)', previewPath, 'pid', 'page.tsx');
40 |
41 | if (await exists(pidFile)) return;
42 |
43 | await ensureDir(dirname(pidFile));
44 | const pidTsx = `
45 | const page = () => {
46 | const ppidTag = '__ppid_' + process.ppid + '__';
47 | return <>{ppidTag}>;
48 | };
49 | export default page;`;
50 | await writeFile(pidFile, pidTsx);
51 | };
52 |
53 | const PPID_RE = /__ppid_(\d+)__/;
54 | const checkPidRoute = async ({ pid, ppid, port, previewPath }: VerifyOptions) => {
55 | const res = await fetch(`http://localhost:${port}/${previewPath}/pid`);
56 | const pidHtml = await res.text();
57 | const match = PPID_RE.exec(pidHtml);
58 | const pidMatch = match?.[1].toString();
59 |
60 | if (pidMatch === pid.toString() || pidMatch === ppid.toString()) {
61 | console.log(`Verified NextJS pid ${pidMatch} is running on port ${port}`);
62 | } else {
63 | console.error(`NextJS server failed to start on port ${port}`);
64 | console.error(`Wanted pid ${pid} or parent ${ppid}, got ${pidMatch}`);
65 | console.error(`${pid.toString() === pidMatch} || ${ppid.toString() === pidMatch}`);
66 | process.exit(1);
67 | }
68 | };
69 |
70 | /**
71 | * Helper function to verify that the NextJS
72 | * server is actually running on the port we
73 | * requested. Since NextJS can run multiple
74 | * processes, defer to the parent process if
75 | * it has already written to the pid file.
76 | */
77 | export const verifyPort = (
78 | port: string | number,
79 | { appDir, previewPath }: { appDir: boolean; previewPath: string }
80 | ) => {
81 | const { pid, ppid } = process;
82 |
83 | setTimeout(async () => {
84 | try {
85 | const writePidFile = appDir ? writePidFileApp : writePidFilePages;
86 | await writePidFile({ pid, ppid, port, appDir, previewPath });
87 | setTimeout(
88 | () => checkPidRoute({ pid, ppid, port, appDir, previewPath }),
89 | parseInt(process.env.STORYBOOK_VERIFY_PORT_DELAY ?? '100', 10)
90 | );
91 | } catch (e) {
92 | console.error(e);
93 | }
94 | }, 200);
95 | };
96 |
--------------------------------------------------------------------------------
/packages/nextjs-server/template/app/groupLayouts/layout-nested.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren } from 'react';
2 | import React from 'react';
3 |
4 | export default function NestedLayout({ children }: PropsWithChildren<{}>) {
5 | return <>{children}>;
6 | }
7 |
--------------------------------------------------------------------------------
/packages/nextjs-server/template/app/groupLayouts/layout-root.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren } from 'react';
2 | import React from 'react';
3 |
4 | export default function RootLayout({ children }: PropsWithChildren<{}>) {
5 | return (
6 |
7 |
8 |
9 |
10 | <>{children}>
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/packages/nextjs-server/template/app/storybook-preview/components/Prepare.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useEffect } from 'react';
4 | import { STORY_PREPARED } from '@storybook/nextjs-server/core-events';
5 | import type { Args, Parameters, StoryId, StrictArgTypes } from '@storybook/nextjs-server/types';
6 | import { addons } from '@storybook/nextjs-server/preview-api';
7 |
8 | export type StoryAnnotations = {
9 | id: StoryId;
10 | parameters: Parameters;
11 | argTypes: StrictArgTypes;
12 | initialArgs: TArgs;
13 | args: TArgs;
14 | };
15 |
16 | // A component to emit the prepared event
17 | export function Prepare({ story }: { story: StoryAnnotations }) {
18 | const channel = addons.getChannel();
19 | useEffect(() => {
20 | if (story) {
21 | channel.emit(STORY_PREPARED, { ...story });
22 | }
23 | }, [channel, story]);
24 |
25 | return <>>;
26 | }
27 |
--------------------------------------------------------------------------------
/packages/nextjs-server/template/app/storybook-preview/components/Preview.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 |
3 | 'use client';
4 |
5 | import React, { useEffect } from 'react';
6 | import { useRouter } from 'next/navigation';
7 | import {
8 | PreviewWithSelection,
9 | addons,
10 | UrlStore,
11 | WebView,
12 | } from '@storybook/nextjs-server/preview-api';
13 | import { createBrowserChannel } from '@storybook/nextjs-server/channels';
14 | import type { StoryIndex } from '@storybook/nextjs-server/types';
15 | import { setArgs } from './args';
16 | import { previewHtml } from './previewHtml';
17 |
18 | global.FEATURES = { storyStoreV7: true };
19 |
20 | // A version of the URL store that doesn't change route when the selection changes
21 | // (as we change the URL as part of rendering the story)
22 | class StaticUrlStore extends UrlStore {
23 | setSelection(selection: Parameters[0]) {
24 | this.selection = selection;
25 | }
26 | }
27 |
28 | // Construct a CSF file from all the index entries on a path
29 | function pathToCSFile(allEntries: StoryIndex['entries'], path: string) {
30 | const entries = Object.values(allEntries || []).filter(
31 | ({ importPath }: any) => importPath === path
32 | ) as { id: string; name: string; title: string }[];
33 |
34 | if (entries.length === 0) throw new Error(`Couldn't find import path ${path}, this is odd`);
35 |
36 | const mappedEntries: [string, { name: string }][] = entries.map(({ id, name }) => [
37 | id.split('--')[1],
38 | { name },
39 | ]);
40 |
41 | return Object.fromEntries([['default', { title: entries[0].title }] as const, ...mappedEntries]);
42 | }
43 |
44 | export const Preview = ({ previewPath }: { previewPath: string }) => {
45 | const router = useRouter();
46 | useEffect(() => {
47 | if (!window.__STORYBOOK_ADDONS_CHANNEL__) {
48 | const channel = createBrowserChannel({ page: 'preview' });
49 | addons.setChannel(channel);
50 | window.__STORYBOOK_ADDONS_CHANNEL__ = channel;
51 | }
52 |
53 | if (!window.__STORYBOOK_PREVIEW__) {
54 | const preview = new PreviewWithSelection(new StaticUrlStore(), new WebView());
55 | preview.initialize({
56 | importFn: async (path) => pathToCSFile(preview.storyStore.storyIndex!.entries, path),
57 | getProjectAnnotations: () => ({
58 | render: () => {},
59 | renderToCanvas: async ({ id, showMain, storyContext: { args } }) => {
60 | setArgs(previewPath, id, args);
61 | await router.push(`/${previewPath}/${id}`);
62 | showMain();
63 | },
64 | }),
65 | });
66 | window.__STORYBOOK_PREVIEW__ = preview;
67 | }
68 |
69 | // Render the the SB UI (ie iframe.html / preview.ejs) in a non-react way to ensure
70 | // it doesn't get ripped down when a new route renders
71 | if (!document.querySelector('#storybook-docs')) {
72 | document.body.insertAdjacentHTML('beforeend', previewHtml);
73 | }
74 |
75 | return () => {};
76 | }, []);
77 | return <>>;
78 | };
79 |
--------------------------------------------------------------------------------
/packages/nextjs-server/template/app/storybook-preview/components/Storybook.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { PropsWithChildren } from 'react';
4 | import React, { useRef } from 'react';
5 | import { Preview } from './Preview';
6 |
7 | export const Storybook = ({
8 | previewPath,
9 | children,
10 | }: PropsWithChildren<{ previewPath: string }>) => {
11 | const ref = useRef(null); // To be used by the play fn?
12 | return (
13 |
14 |
15 |
16 | {children}
17 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/packages/nextjs-server/template/app/storybook-preview/components/args.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import type { Args } from '@storybook/react';
4 | import { revalidatePath } from 'next/cache';
5 | import { cookies } from 'next/headers';
6 |
7 | const cookieName = 'sbSessionId';
8 |
9 | type SessionId = string;
10 | const args: Record = {};
11 |
12 | function getSessionId() {
13 | return cookies().get(cookieName)?.value;
14 | }
15 |
16 | export async function getArgs(storyId: string): Promise {
17 | const sessionId = await getSessionId();
18 | if (!sessionId) return {};
19 | return args[sessionId]?.[storyId] || {};
20 | }
21 |
22 | export async function setArgs(previewPath: string, storyId: string, newArgs: any) {
23 | revalidatePath(`/${previewPath}/${storyId}`);
24 |
25 | let sessionId = getSessionId();
26 | if (!sessionId) {
27 | sessionId = Math.random().toString();
28 | cookies().set(cookieName, sessionId);
29 | }
30 |
31 | console.log(`[${sessionId}]: setting '${storyId}' args:`, { newArgs });
32 | args[sessionId] ||= {};
33 | args[sessionId][storyId] = newArgs;
34 | }
35 |
--------------------------------------------------------------------------------
/packages/nextjs-server/template/app/storybook-preview/components/previewHtml.ts:
--------------------------------------------------------------------------------
1 | export const previewHtml = `
2 |
314 |
315 |
329 |
330 |
331 |
332 |
335 |
336 |
337 |
348 |
349 |
350 |
351 | Name
352 | Description
353 | Default
354 | Control
355 |
356 |
357 |
358 |
359 | propertyName *
360 |
361 | This is a short description
362 |
365 |
366 |
367 | defaultValue
368 |
369 | Set string
370 |
371 |
372 | propertyName *
373 |
374 | This is a short description
375 |
378 |
379 |
380 | defaultValue
381 |
382 | Set string
383 |
384 |
385 | propertyName *
386 |
387 | This is a short description
388 |
391 |
392 |
393 | defaultValue
394 |
395 | Set string
396 |
397 |
398 |
399 |
400 |
401 |
402 |
403 |
No Preview
404 |
Sorry, but you either have no stories or none are selected somehow.
405 |
406 | Please check the Storybook config.
407 | Try reloading the page.
408 |
409 |
410 | If the problem persists, check the browser console, or the terminal you've run Storybook from.
411 |
412 |
413 |
414 |
415 | `;
419 |
--------------------------------------------------------------------------------
/packages/nextjs-server/template/app/storybook-preview/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const Page = () => <>>;
4 |
5 | export default Page;
6 |
--------------------------------------------------------------------------------
/packages/nextjs-server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": false,
3 | "compilerOptions": {
4 | "ignoreDeprecations": "5.0",
5 | "baseUrl": ".",
6 | "incremental": false,
7 | "noImplicitAny": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "jsx": "react",
10 | "moduleResolution": "Node",
11 | "target": "ES2020",
12 | "module": "CommonJS",
13 | "skipLibCheck": true,
14 | "allowSyntheticDefaultImports": true,
15 | "esModuleInterop": true,
16 | "isolatedModules": true,
17 | "strictBindCallApply": true,
18 | "lib": ["dom", "dom.iterable", "esnext"],
19 | "noUnusedLocals": true,
20 | "types": ["jest"],
21 | "strict": true
22 | },
23 | "exclude": ["dist", "**/dist", "node_modules", "**/node_modules", "**/setup-jest.ts"],
24 | "include": ["src/**/*"]
25 | }
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | # all packages in direct subdirs of packages/
3 | - 'packages/*'
4 | # exclude scripts
5 | - 'scripts'
6 |
--------------------------------------------------------------------------------
/scripts/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@storybook/nextjs-server-scripts",
3 | "private": true,
4 | "license": "MIT",
5 | "type": "module",
6 | "devDependencies": {
7 | "@playwright/test": "^1.40.0",
8 | "@types/fs-extra": "^11.0.4",
9 | "@types/node": "^20.10.0",
10 | "@types/react": "^18",
11 | "@types/react-dom": "^18",
12 | "chalk": "^5.3.0",
13 | "esbuild": "^0.18.0",
14 | "esbuild-plugin-alias": "^0.2.1",
15 | "esbuild-register": "^3.5.0",
16 | "execa": "^8.0.1",
17 | "fs-extra": "^11.2.0",
18 | "playwright": "^1.40.0",
19 | "slash": "^3.0.0",
20 | "tempy": "^3.1.0",
21 | "ts-dedent": "^2.2.0",
22 | "tsup": "^6.7.0",
23 | "tsx": "^4.6.2",
24 | "type-fest": "~2.19",
25 | "typescript": "^5.3.2"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/scripts/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from '@playwright/test';
2 |
3 | export default defineConfig({
4 | retries: process.env.CI ? 2 : 0,
5 | });
6 |
--------------------------------------------------------------------------------
/scripts/prepare/bundle.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs-extra';
2 | import path, { dirname, join, relative } from 'path';
3 | import type { Options } from 'tsup';
4 | import type { PackageJson } from 'type-fest';
5 | import { build } from 'tsup';
6 | import aliasPlugin from 'esbuild-plugin-alias';
7 | import dedent from 'ts-dedent';
8 | import slash from 'slash';
9 | import { exec } from '../utils/exec';
10 |
11 | /* TYPES */
12 |
13 | type Formats = 'esm' | 'cjs';
14 | type BundlerConfig = {
15 | entries: string[];
16 | externals: string[];
17 | noExternal: string[];
18 | platform: Options['platform'];
19 | pre: string;
20 | post: string;
21 | formats: Formats[];
22 | };
23 | type PackageJsonWithBundlerConfig = PackageJson & {
24 | bundler: BundlerConfig;
25 | };
26 | type DtsConfigSection = Pick;
27 |
28 | /* MAIN */
29 |
30 | const run = async ({ cwd, flags }: { cwd: string; flags: string[] }) => {
31 | const {
32 | name,
33 | dependencies,
34 | peerDependencies,
35 | bundler: {
36 | entries = [],
37 | externals: extraExternals = [],
38 | noExternal: extraNoExternal = [],
39 | platform,
40 | pre,
41 | post,
42 | formats = ['esm', 'cjs'],
43 | },
44 | } = (await fs.readJson(join(cwd, 'package.json'))) as PackageJsonWithBundlerConfig;
45 |
46 | if (pre) {
47 | await exec(`node -r ${__dirname}/../node_modules/esbuild-register/register.js ${pre}`, { cwd });
48 | }
49 |
50 | const reset = hasFlag(flags, 'reset');
51 | const watch = hasFlag(flags, 'watch');
52 | const optimized = hasFlag(flags, 'optimized');
53 |
54 | if (reset) {
55 | await fs.emptyDir(join(process.cwd(), 'dist'));
56 | }
57 |
58 | const tasks: Promise[] = [];
59 |
60 | const outDir = join(process.cwd(), 'dist');
61 | const externals = [
62 | name,
63 | ...extraExternals,
64 | ...Object.keys(dependencies || {}),
65 | ...Object.keys(peerDependencies || {}),
66 | ];
67 |
68 | const allEntries = entries.map((e: string) => slash(join(cwd, e)));
69 |
70 | const { dtsBuild, dtsConfig, tsConfigExists } = await getDTSConfigs({
71 | formats,
72 | entries,
73 | optimized,
74 | });
75 |
76 | /* preset files are always CJS only.
77 | * Generating an ESM file for them anyway is problematic because they often have a reference to `require`.
78 | * TSUP generated code will then have a `require` polyfill/guard in the ESM files, which causes issues for webpack.
79 | */
80 | const nonPresetEntries = allEntries.filter((f) => !path.parse(f).name.includes('preset'));
81 |
82 | const noExternal = [/^@vitest\/.+$/, ...extraNoExternal];
83 |
84 | if (formats.includes('esm')) {
85 | tasks.push(
86 | build({
87 | noExternal,
88 | silent: true,
89 | treeshake: true,
90 | entry: nonPresetEntries,
91 | shims: false,
92 | watch,
93 | outDir,
94 | sourcemap: false,
95 | format: ['esm'],
96 | target: ['chrome100', 'safari15', 'firefox91'],
97 | clean: false,
98 | ...(dtsBuild === 'esm' ? dtsConfig : {}),
99 | platform: platform || 'browser',
100 | esbuildPlugins: [
101 | aliasPlugin({
102 | process: path.resolve('../node_modules/process/browser.js'),
103 | util: path.resolve('../node_modules/util/util.js'),
104 | }),
105 | ],
106 | external: externals,
107 |
108 | esbuildOptions: (c) => {
109 | /* eslint-disable no-param-reassign */
110 | c.conditions = ['module'];
111 | c.platform = platform || 'browser';
112 | Object.assign(c, getESBuildOptions(optimized));
113 | /* eslint-enable no-param-reassign */
114 | },
115 | })
116 | );
117 | }
118 |
119 | if (formats.includes('cjs')) {
120 | tasks.push(
121 | build({
122 | noExternal,
123 | silent: true,
124 | entry: allEntries,
125 | watch,
126 | outDir,
127 | sourcemap: false,
128 | format: ['cjs'],
129 | target: 'node16',
130 | ...(dtsBuild === 'cjs' ? dtsConfig : {}),
131 | platform: 'node',
132 | clean: false,
133 | external: externals,
134 |
135 | esbuildOptions: (c) => {
136 | /* eslint-disable no-param-reassign */
137 | c.platform = 'node';
138 | Object.assign(c, getESBuildOptions(optimized));
139 | /* eslint-enable no-param-reassign */
140 | },
141 | })
142 | );
143 | }
144 |
145 | if (tsConfigExists && !optimized) {
146 | tasks.push(...entries.map(generateDTSMapperFile));
147 | }
148 |
149 | await Promise.all(tasks);
150 |
151 | if (post) {
152 | await exec(
153 | `node -r ${__dirname}/../node_modules/esbuild-register/register.js ${post}`,
154 | { cwd },
155 | { debug: true }
156 | );
157 | }
158 |
159 | if (process.env.CI !== 'true') {
160 | console.log('done');
161 | }
162 | };
163 |
164 | /* UTILS */
165 |
166 | async function getDTSConfigs({
167 | formats,
168 | entries,
169 | optimized,
170 | }: {
171 | formats: Formats[];
172 | entries: string[];
173 | optimized: boolean;
174 | }) {
175 | const tsConfigPath = join(cwd, 'tsconfig.json');
176 | const tsConfigExists = await fs.pathExists(tsConfigPath);
177 |
178 | const dtsBuild = optimized && formats[0] && tsConfigExists ? formats[0] : undefined;
179 |
180 | const dtsConfig: DtsConfigSection = {
181 | tsconfig: tsConfigPath,
182 | dts: {
183 | entry: entries,
184 | resolve: true,
185 | },
186 | };
187 |
188 | return { dtsBuild, dtsConfig, tsConfigExists };
189 | }
190 |
191 | function getESBuildOptions(optimized: boolean) {
192 | return {
193 | logLevel: 'error',
194 | legalComments: 'none',
195 | minifyWhitespace: optimized,
196 | minifyIdentifiers: false,
197 | minifySyntax: optimized,
198 | };
199 | }
200 |
201 | async function generateDTSMapperFile(file: string) {
202 | const { name: entryName, dir } = path.parse(file);
203 |
204 | const pathName = join(process.cwd(), dir.replace('./src', 'dist'), `${entryName}.d.ts`);
205 | const srcName = join(process.cwd(), file);
206 | const rel = relative(dirname(pathName), dirname(srcName)).split(path.sep).join(path.posix.sep);
207 |
208 | await fs.ensureFile(pathName);
209 | await fs.writeFile(
210 | pathName,
211 | dedent`
212 | // dev-mode
213 | export * from '${rel}/${entryName}';
214 | `,
215 | { encoding: 'utf-8' }
216 | );
217 | }
218 |
219 | const hasFlag = (flags: string[], name: string) => !!flags.find((s) => s.startsWith(`--${name}`));
220 |
221 | /* SELF EXECUTION */
222 |
223 | const flags = process.argv.slice(2);
224 | const cwd = process.cwd();
225 |
226 | run({ cwd, flags }).catch((err: unknown) => {
227 | // We can't let the stack try to print, it crashes in a way that sets the exit code to 0.
228 | // Seems to have something to do with running JSON.parse() on binary / base64 encoded sourcemaps
229 | // in @cspotcode/source-map-support
230 | if (err instanceof Error) {
231 | console.error(err.stack);
232 | }
233 | process.exit(1);
234 | });
235 |
--------------------------------------------------------------------------------
/scripts/prepare/check.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'path';
2 | import fs from 'fs-extra';
3 | import ts from 'typescript';
4 |
5 | const run = async ({ cwd }: { cwd: string }) => {
6 | const {
7 | bundler: { tsConfig: tsconfigPath = 'tsconfig.json' },
8 | } = await fs.readJson(join(cwd, 'package.json'));
9 |
10 | const { options, fileNames } = getTSFilesAndConfig(tsconfigPath);
11 | const { program, host } = getTSProgramAndHost(fileNames, options);
12 |
13 | const tsDiagnostics = getTSDiagnostics(program, cwd, host);
14 | if (tsDiagnostics.length > 0) {
15 | console.log(tsDiagnostics);
16 | process.exit(1);
17 | } else {
18 | console.log('no type errors');
19 | }
20 |
21 | // TODO, add more package checks here, like:
22 | // - check for missing dependencies/peerDependencies
23 | // - check for unused exports
24 |
25 | if (process.env.CI !== 'true') {
26 | console.log('done');
27 | }
28 | };
29 |
30 | run({ cwd: process.cwd() }).catch((err: unknown) => {
31 | // We can't let the stack try to print, it crashes in a way that sets the exit code to 0.
32 | // Seems to have something to do with running JSON.parse() on binary / base64 encoded sourcemaps
33 | // in @cspotcode/source-map-support
34 | if (err instanceof Error) {
35 | console.error(err.message);
36 | }
37 | process.exit(1);
38 | });
39 |
40 | function getTSDiagnostics(program: ts.Program, cwd: string, host: ts.CompilerHost): any {
41 | return ts.formatDiagnosticsWithColorAndContext(
42 | ts.getPreEmitDiagnostics(program).filter((d) => d.file?.fileName?.startsWith(cwd)),
43 | host
44 | );
45 | }
46 |
47 | function getTSProgramAndHost(fileNames: string[], options: ts.CompilerOptions) {
48 | const program = ts.createProgram({
49 | rootNames: fileNames,
50 | options: {
51 | module: ts.ModuleKind.CommonJS,
52 | ...options,
53 | declaration: false,
54 | noEmit: true,
55 | },
56 | });
57 |
58 | const host = ts.createCompilerHost(program.getCompilerOptions());
59 | return { program, host };
60 | }
61 |
62 | function getTSFilesAndConfig(tsconfigPath: string) {
63 | const content = ts.readJsonConfigFile(tsconfigPath, ts.sys.readFile);
64 | return ts.parseJsonSourceFileConfigFileContent(
65 | content,
66 | {
67 | useCaseSensitiveFileNames: true,
68 | readDirectory: ts.sys.readDirectory,
69 | fileExists: ts.sys.fileExists,
70 | readFile: ts.sys.readFile,
71 | },
72 | process.cwd(),
73 | {
74 | noEmit: true,
75 | outDir: join(process.cwd(), 'types'),
76 | target: ts.ScriptTarget.ES2022,
77 | declaration: false,
78 | }
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/scripts/specs/basic.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 | import { Page } from 'playwright';
3 | import process from 'process';
4 |
5 | const storybookUrl = process.env.STORYBOOK_URL || 'http://localhost:3000/storybook';
6 | const buttonId = 'example-button--primary';
7 |
8 | async function goToStorybook(page: Page, storyId = buttonId) {
9 | await page.goto(`${storybookUrl}?path=/story/${storyId}`);
10 |
11 | const preview = page.frameLocator('#storybook-preview-iframe');
12 | const root = preview.locator('#storybook-root:visible, #storybook-docs:visible');
13 |
14 | await expect(preview.locator('.sb-show-main')).toBeVisible();
15 |
16 | return root;
17 | }
18 |
19 | test('should render button story when visited directly', async ({ page }) => {
20 | const storybookRoot = await goToStorybook(page);
21 | await expect(storybookRoot.locator('button')).toContainText('Button');
22 | });
23 |
24 | test('should change story within a component', async ({ page }) => {
25 | const storybookRoot = await goToStorybook(page);
26 |
27 | await page.locator('#example-button--large').click();
28 |
29 | await expect(storybookRoot.locator('button')).toHaveClass(/storybook-button--large/);
30 | });
31 |
32 | test('should change component', async ({ page }) => {
33 | const storybookRoot = await goToStorybook(page);
34 |
35 | await page.locator('#example-header').click();
36 | await page.locator('#example-header--logged-in').click();
37 |
38 | await expect(storybookRoot.locator('button')).toHaveText('Log out');
39 | });
40 |
41 | test('should change args', async ({ page }) => {
42 | const storybookRoot = await goToStorybook(page);
43 |
44 | const label = page
45 | .locator('#storybook-panel-root #panel-tab-content')
46 | .locator('textarea[name=label]');
47 | await label.fill('Changed');
48 |
49 | await expect(storybookRoot.locator('button')).toContainText('Changed');
50 | });
51 |
--------------------------------------------------------------------------------
/scripts/test.ts:
--------------------------------------------------------------------------------
1 | import fsExtra from 'fs-extra';
2 | import { temporaryDirectory } from 'tempy';
3 | import { execa } from 'execa';
4 | import { join, resolve } from 'path';
5 | import { dedent } from 'ts-dedent';
6 |
7 | const { remove, outputFile, readJson } = fsExtra;
8 |
9 | const initCommandPath = resolve('../packages/create-nextjs-storybook/dist/index.js');
10 | const packagePath = resolve('../packages/nextjs-server');
11 |
12 | async function createSandbox({
13 | dirName,
14 | appDir = true,
15 | srcDir = false,
16 | }: {
17 | dirName: string;
18 | appDir?: boolean;
19 | srcDir?: boolean;
20 | }) {
21 | await remove(join(process.cwd(), dirName));
22 |
23 | const app = appDir ? '--app' : '--no-app';
24 | const src = srcDir ? '--src-dir' : '--no-src-dir';
25 | const createCommand = `pnpm create next-app ${dirName} --typescript --no-eslint --no-tailwind --import-alias=@/* ${app} ${src}`;
26 | await execa(createCommand.split(' ')[0], createCommand.split(' ').slice(1), { stdio: 'inherit' });
27 |
28 | await execa('node', [initCommandPath], {
29 | cwd: dirName,
30 | stdio: 'inherit',
31 | });
32 |
33 | const { version } = await readJson('../packages/nextjs-server/package.json');
34 | const installTarballCommand = `pnpm add -D ${packagePath}/storybook-nextjs-server-${version}.tgz`;
35 | await execa(installTarballCommand.split(' ')[0], installTarballCommand.split(' ').slice(1), {
36 | cwd: dirName,
37 | stdio: 'inherit',
38 | });
39 |
40 | const nextConfig = dedent`const withStorybook = require('@storybook/nextjs-server/next-config')();
41 | const nextConfig = withStorybook({
42 | /* your custom config here */
43 | });
44 | module.exports = nextConfig;`;
45 | outputFile(`${dirName}/next.config.js`, nextConfig);
46 | }
47 |
48 | async function startSandbox({ dirName }: { dirName: string }) {
49 | execa('pnpm', ['dev'], {
50 | cwd: dirName,
51 | stdio: 'inherit',
52 | });
53 | }
54 |
55 | async function go() {
56 | const appDir = process.env.APP_DIR !== 'false';
57 | const srcDir = process.env.SRC_DIR && process.env.SRC_DIR !== 'false';
58 |
59 | const dirName = temporaryDirectory();
60 | await createSandbox({ dirName, appDir, srcDir });
61 | await startSandbox({ dirName });
62 | }
63 |
64 | go()
65 | .then(() => console.log('done'))
66 | .catch((err) => console.log(err));
67 |
--------------------------------------------------------------------------------
/scripts/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "module": "esnext",
5 | "module": "ESNext",
6 | "lib": ["es2020"],
7 | "esModuleInterop": true,
8 | "strict": true,
9 | "moduleResolution": "node"
10 | },
11 | "include": ["/**/*.ts"],
12 | "exclude": ["node_modules"]
13 | }
14 |
--------------------------------------------------------------------------------
/scripts/utils/exec.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-await-in-loop, no-restricted-syntax */
2 | import type { ExecaChildProcess, Options } from 'execa';
3 | import chalk from 'chalk';
4 | import { execa } from 'execa';
5 |
6 | const logger = console;
7 |
8 | type StepOptions = {
9 | startMessage?: string;
10 | errorMessage?: string;
11 | dryRun?: boolean;
12 | debug?: boolean;
13 | signal?: AbortSignal;
14 | };
15 |
16 | export const exec = async (
17 | command: string | string[],
18 | options: Options = {},
19 | { startMessage, errorMessage, dryRun, debug, signal }: StepOptions = {}
20 | ): Promise => {
21 | logger.info();
22 | if (startMessage) logger.info(startMessage);
23 |
24 | if (dryRun) {
25 | logger.info(`\n> ${command}\n`);
26 | return undefined;
27 | }
28 |
29 | const defaultOptions: Options = {
30 | shell: true,
31 | stdout: debug ? 'inherit' : 'pipe',
32 | stderr: debug ? 'inherit' : 'pipe',
33 | signal,
34 | };
35 | let currentChild: ExecaChildProcess;
36 |
37 | try {
38 | if (typeof command === 'string') {
39 | logger.debug(`> ${command}`);
40 | currentChild = execa(command, { ...defaultOptions, ...options });
41 | await currentChild;
42 | } else {
43 | for (const subcommand of command) {
44 | logger.debug(`> ${subcommand}`);
45 | currentChild = execa(subcommand, { ...defaultOptions, ...options });
46 | await currentChild;
47 | }
48 | }
49 | } catch (err) {
50 | if (!(typeof err === 'object' && 'killed' in err && err.killed)) {
51 | logger.error(chalk.red(`An error occurred while executing: \`${command}\``));
52 | logger.log(`${errorMessage}\n`);
53 | }
54 |
55 | throw err;
56 | }
57 |
58 | return undefined;
59 | };
60 |
--------------------------------------------------------------------------------