├── .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 | 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 |
11 |
12 |
13 | 14 | 15 | 19 | 23 | 27 | 28 | 29 |

Acme

30 |
31 |
32 | {user ? ( 33 | <> 34 | 35 | Welcome, {user.name}! 36 | 37 |
46 |
47 |
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 | 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 |
20 |
21 |
22 | 23 | 24 | 28 | 32 | 36 | 37 | 38 |

Acme

39 |
40 |
41 | {user ? ( 42 | <> 43 | 44 | Welcome, {user.name}! 45 | 46 |
55 |
56 |
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 | 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 |
9 |
10 |
11 | 12 | 13 | 17 | 21 | 25 | 26 | 27 |

Acme

28 |
29 |
30 | {user ? ( 31 | <> 32 | 33 | Welcome, {user.name}! 34 | 35 |
44 |
45 |
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 | 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 |
19 |
20 |
21 | 22 | 23 | 27 | 31 | 35 | 36 | 37 |

Acme

38 |
39 |
40 | {user ? ( 41 | <> 42 | 43 | Welcome, {user.name}! 44 | 45 |
54 |
55 |
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 |
334 |
335 |
336 | 337 |
338 |
339 |
340 |
341 |
342 |
343 |
344 |
345 |
346 |
347 |
348 |
349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 367 | 370 | 371 | 372 | 373 | 374 | 380 | 383 | 384 | 385 | 386 | 387 | 393 | 396 | 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 |
417 |

418 |   
419 |
`; 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 |
333 |
334 |
335 | 336 |
337 |
338 |
339 |
340 |
341 |
342 |
343 |
344 |
345 |
346 |
347 |
348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 366 | 369 | 370 | 371 | 372 | 373 | 379 | 382 | 383 | 384 | 385 | 386 | 392 | 395 | 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 |
416 |

417 |   
418 |
`; 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 | --------------------------------------------------------------------------------