├── .github └── workflows │ ├── check.yml │ ├── chromatic.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── biome.json ├── example ├── .env ├── .gitignore ├── .storybook │ ├── global.css │ ├── main.ts │ ├── preview.tsx │ └── vitest.setup.ts ├── README.md ├── env.d.ts ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── public │ ├── next.svg │ └── vercel.svg ├── src │ ├── app │ │ ├── components │ │ │ ├── BootstrapButton │ │ │ │ ├── BootstrapButton.stories.tsx │ │ │ │ └── BootstrapButton.tsx │ │ │ ├── CSS │ │ │ │ ├── CSSModules.module.css │ │ │ │ ├── CSSModules.module.scss │ │ │ │ ├── CSSModules.tsx │ │ │ │ ├── Sass.module.scss │ │ │ │ ├── Sass.tsx │ │ │ │ ├── Tailwind.stories.tsx │ │ │ │ └── Tailwind.tsx │ │ │ ├── DynamicImport │ │ │ │ ├── DynamicImport.stories.tsx │ │ │ │ └── DynamicImport.tsx │ │ │ ├── EnvironmentVariables │ │ │ │ ├── EnvironmentVariables.stories.tsx │ │ │ │ └── EnvironmentVariables.tsx │ │ │ ├── Font │ │ │ │ ├── Font.stories.tsx │ │ │ │ ├── Font.test.tsx │ │ │ │ ├── Font.tsx │ │ │ │ └── fonts │ │ │ │ │ ├── Playwrite │ │ │ │ │ ├── OFL.txt │ │ │ │ │ ├── PlaywriteBEVLG-ExtraLight.ttf │ │ │ │ │ └── PlaywriteBEVLG-Regular.ttf │ │ │ │ │ └── RubikStorm │ │ │ │ │ ├── OFL.txt │ │ │ │ │ └── RubikStorm-Regular.ttf │ │ │ ├── Header │ │ │ │ ├── Header.stories.tsx │ │ │ │ └── Header.tsx │ │ │ ├── Image │ │ │ │ ├── GetImageProps.stories.tsx │ │ │ │ ├── Image.stories.tsx │ │ │ │ ├── ImageLegacy.stories.tsx │ │ │ │ └── assets │ │ │ │ │ ├── accessibility.svg │ │ │ │ │ ├── avif-test-image.avif │ │ │ │ │ └── testing.png │ │ │ └── Link │ │ │ │ ├── Link.stories.module.css │ │ │ │ ├── Link.stories.tsx │ │ │ │ └── __snapshots__ │ │ │ │ ├── Link.test.tsx.snap │ │ │ │ └── index.test.tsx.snap │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ └── page.tsx │ ├── env.test.ts │ └── stories │ │ ├── Button.stories.ts │ │ ├── Button.tsx │ │ ├── Configure.mdx │ │ ├── Header.stories.ts │ │ ├── Header.tsx │ │ ├── Page.stories.ts │ │ ├── Page.tsx │ │ ├── assets │ │ ├── accessibility.png │ │ ├── accessibility.svg │ │ ├── addon-library.png │ │ ├── assets.png │ │ ├── avif-test-image.avif │ │ ├── context.png │ │ ├── discord.svg │ │ ├── docs.png │ │ ├── figma-plugin.png │ │ ├── github.svg │ │ ├── share.png │ │ ├── styling.png │ │ ├── testing.png │ │ ├── theming.png │ │ ├── tutorials.svg │ │ └── youtube.svg │ │ ├── button.css │ │ ├── header.css │ │ └── page.css ├── tailwind.config.ts ├── tsconfig.json └── vitest.config.ts ├── lefthook.yml ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src ├── index.ts ├── mocks │ └── storybook.global.ts ├── plugins │ ├── next-dynamic │ │ └── plugin.ts │ ├── next-env │ │ └── plugin.ts │ ├── next-font │ │ ├── google │ │ │ └── get-font-face-declarations.ts │ │ ├── local │ │ │ └── get-font-face-declarations.ts │ │ ├── plugin.ts │ │ └── utils │ │ │ ├── get-css-meta.ts │ │ │ └── set-font-declarations-in-head.ts │ ├── next-image │ │ ├── alias │ │ │ ├── image-context.tsx │ │ │ ├── image-default-loader.tsx │ │ │ ├── index.tsx │ │ │ ├── next-image.tsx │ │ │ └── next-legacy-image.tsx │ │ └── plugin.ts │ ├── next-mocks │ │ ├── alias │ │ │ ├── cache │ │ │ │ └── index.ts │ │ │ ├── dynamic │ │ │ │ └── index.tsx │ │ │ ├── headers │ │ │ │ ├── cookies.ts │ │ │ │ ├── headers.ts │ │ │ │ └── index.ts │ │ │ ├── navigation │ │ │ │ └── index.ts │ │ │ ├── router │ │ │ │ └── index.ts │ │ │ └── rsc │ │ │ │ └── server-only.ts │ │ ├── compatibility │ │ │ ├── compatibility-map.ts │ │ │ ├── draft-mode.compat.ts │ │ │ └── utils.ts │ │ └── plugin.ts │ └── next-swc │ │ └── plugin.ts ├── polyfills │ └── promise-with-resolvers.ts ├── types.d.ts ├── utils.ts ├── utils │ ├── nextjs.test.ts │ ├── nextjs.ts │ ├── swc │ │ ├── options.ts │ │ ├── styles.ts │ │ └── transform.ts │ └── typescript.ts └── vitest.d.ts ├── tsconfig.json └── tsup.config.ts /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | checks: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | with: 12 | node-version: 22 13 | - uses: pnpm/action-setup@v4 14 | with: 15 | version: 9.4.0 16 | run_install: true 17 | - name: Build the plugin 18 | run: pnpm build 19 | 20 | - name: Check if Storybook builds successfully 21 | run: pnpm build-storybook 22 | working-directory: ./example 23 | 24 | - name: Install Playwright 25 | working-directory: ./example 26 | run: pnpm exec playwright install 27 | 28 | - name: Run all tests 29 | run: pnpm test:all 30 | working-directory: ./example 31 | 32 | # - name: Run Portable stories tests 33 | # run: pnpm test:storybook 34 | # working-directory: ./example 35 | 36 | - name: Biome checks 37 | run: pnpm check 38 | -------------------------------------------------------------------------------- /.github/workflows/chromatic.yml: -------------------------------------------------------------------------------- 1 | name: "Chromatic" 2 | 3 | on: push 4 | 5 | jobs: 6 | chromatic: 7 | name: Run Chromatic 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 22 17 | - uses: pnpm/action-setup@v4 18 | with: 19 | version: 9.4.0 20 | run_install: true 21 | - name: Install dependencies 22 | run: pnpm install 23 | - name: Build the plugin 24 | run: pnpm build 25 | - name: Run Chromatic 26 | uses: chromaui/action@latest 27 | with: 28 | # ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret 29 | projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} 30 | workingDir: example 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: [push] 4 | 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | if: "!contains(github.event.head_commit.message, 'ci skip') && !contains(github.event.head_commit.message, 'skip ci')" 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Prepare repository 12 | run: git fetch --unshallow --tags 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 22 16 | - uses: pnpm/action-setup@v4 17 | with: 18 | version: 9.4.0 19 | run_install: true 20 | - run: pnpm build 21 | - name: Release 22 | run: pnpm release 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | hoist-workspace-packages=false -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | 1. Clone the repository 4 | 2. Install dependencies 5 | 6 | ```bash 7 | pnpm install 8 | ``` 9 | 10 | ## vite-plugin-storybook-nextjs development 11 | 12 | Run tsup in dev mode to watch for changes 13 | 14 | ```bash 15 | pnpm dev 16 | ``` 17 | 18 | Run the tests in `./example` to test the plugin 19 | 20 | ```bash 21 | pnpm test:all 22 | ``` 23 | 24 | OR 25 | 26 | Run Storybook in `./example` to test the plugin 27 | 28 | ```bash 29 | pnpm storybook 30 | ``` 31 | 32 | Make sure to restart the Storybook server when you make changes to the plugin 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 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 | # vite-plugin-storybook-nextjs 2 | 3 | This is a Vite plugin that allows you to use Next.js features in Vite. It is the basis for `@storybook/experimental-nextjs-vite` and should be used when running portable stories in Vitest. 4 | 5 | ## Features 6 | 7 | - **Next.js Integration**: Seamlessly integrate Next.js features into your Vite project. 8 | - **Storybook Compatibility**: Acts as the foundation for [the `@storybook/experimental-nextjs-vite` framework](https://storybook.js.org/docs/get-started/frameworks/nextjs#with-vite), enabling you to use Storybook with Next.js in a Vite environment. 9 | - **Portable Stories**: Ideal for running portable stories in Vitest, ensuring your components are tested in an environment that closely mirrors production. 10 | 11 | ## Requirements 12 | 13 | - Next.js v14.1.0 or higher 14 | - Storybook 9 or higher 15 | 16 | ## Installation 17 | 18 | Install the plugin using your preferred package manager: 19 | 20 | ```sh 21 | npm install vite-plugin-storybook-nextjs 22 | # or 23 | yarn add vite-plugin-storybook-nextjs 24 | # or 25 | pnpm add vite-plugin-storybook-nextjs 26 | ``` 27 | 28 | ## Usage 29 | 30 | ### Set up Vitest 31 | 32 | To use the plugin, you need to set up Vitest in your project. You can do this by following the instructions in the [Vitest documentation](https://vitest.dev/guide/). 33 | 34 | ### Add the plugin to your Vitest configuration 35 | 36 | Add the plugin to your Vitest configuration file. This ensures that Vitest is aware of the Next.js features provided by the plugin. 37 | 38 | ```ts 39 | // vitest.config.ts 40 | import { defineConfig } from "vite"; 41 | import nextjs from "vite-plugin-storybook-nextjs"; 42 | 43 | export default defineConfig({ 44 | plugins: [nextjs()], 45 | }); 46 | ``` 47 | 48 | If you are using `@storybook/experimental-nextjs-vite` you don't have to install `vite-plugin-storybook-nextjs`, since `@storybook/experimental-nextjs-vite` already re-exports it. 49 | 50 | ```ts 51 | // vitest.config.ts 52 | import { defineConfig } from "vite"; 53 | import { storybookNextJsPlugin } from "@storybook/experimental-nextjs-vite/vite-plugin"; 54 | 55 | export default defineConfig({ 56 | plugins: [storybookNextJsPlugin()], 57 | }); 58 | ``` 59 | 60 | ## Configuration Options 61 | 62 | You can configure the plugin using the following options: 63 | 64 | ```ts 65 | type VitePluginOptions = { 66 | /** 67 | * Provide the path to your Next.js project directory 68 | * @default process.cwd() 69 | */ 70 | dir?: string; 71 | }; 72 | ``` 73 | 74 | ## Usage with portable stories 75 | 76 | [Portable stories](https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest) are Storybook stories which can be used in external environments, such as Vitest. 77 | 78 | This plugin is necessary to run portable stories in Vitest, as it provides the necessary Next.js features to ensure that your components are tested in an environment that closely mirrors production. 79 | 80 | ## Automatic story transformation 81 | 82 | The addon `@storybook/addon-vitest` can be used to automatically transform your stories at Vitest runtime to in-memory test files. This allows you to run your stories in a Vitest environment without needing to manually transform your stories. Please visit https://storybook.js.org/docs/8.3/writing-tests/test-runner-with-vitest for more information. 83 | 84 | ## Limitations and differences to the Webpack5-based integration of Next.js in Storybook 85 | 86 | ### `next/font` `staticDir` mapping obsolete 87 | 88 | You don't need to map your custom font directory in Storybook's `staticDir` configuration. Instead, Vite will automatically serve the files in the `public` directory and provide assets during production builds. 89 | 90 | ### CSS/SASS 91 | 92 | The `sassOptions` property in `next.config.js` is not supported. Please use Vite's configuration options to configure the Sass compiler: 93 | 94 | ```js 95 | css: { 96 | preprocessorOptions: { 97 | scss: { 98 | quietDeps: true 99 | }, 100 | } 101 | }, 102 | ``` 103 | 104 | ### Next.js: Server Actions 105 | 106 | When testing components that rely on Next.js Server Actions, you need to ensure that your story files are [set up to use the `jsdom` environment in Vitest](https://vitest.dev/config/#environment). This can be done in two ways: 107 | 108 | 1. To apply it to individual story files, add a special comment at the top of each file: 109 | 110 | ```js 111 | // @vitest-environment jsdom 112 | ``` 113 | 114 | 2. To apply it to all tests, adjust your Vitest configuration: 115 | 116 | ```ts 117 | // vitest.config.ts 118 | import { defineConfig } from "vitest/config"; 119 | import nextjs from "vite-plugin-storybook-nextjs"; 120 | 121 | export default defineConfig({ 122 | plugins: [nextjs()], 123 | test: { 124 | environment: "jsdom", // 👈 Add this 125 | }, 126 | }); 127 | ``` 128 | 129 | ### SWC Mode 130 | 131 | Only [Next.js in SWC mode](https://nextjs.org/docs/architecture/nextjs-compiler) is supported. If your project was forced to opt out of Babel for some reason, you will very likely encounter issues with this plugin (e.g., Emotion support in SWC is still lagging behind). 132 | 133 | ## License 134 | 135 | This project is licensed under the MIT License. 136 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "formatter": { 7 | "indentStyle": "space" 8 | }, 9 | "linter": { 10 | "enabled": true, 11 | "rules": { 12 | "recommended": true 13 | } 14 | }, 15 | "files": { 16 | "ignore": [ 17 | "dist/**/*", 18 | "example/.next/**/*", 19 | ".vite-inspect/**/*", 20 | "**/node_modules/**/*", 21 | "example/storybook-static/**/*", 22 | ".tsup/**", 23 | "package.json" 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /example/.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_EXAMPLE1=example1 2 | EXAMPLE2=example2 3 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | *storybook.log 39 | storybook-static 40 | 41 | .vite-inspect -------------------------------------------------------------------------------- /example/.storybook/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | background: lightgray 7 | } 8 | -------------------------------------------------------------------------------- /example/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/nextjs-vite"; 2 | 3 | const config: StorybookConfig = { 4 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], 5 | addons: [ 6 | "@storybook/addon-docs", 7 | "@chromatic-com/storybook", 8 | "@storybook/addon-vitest", 9 | ], 10 | framework: { 11 | name: "@storybook/nextjs-vite", 12 | options: {}, 13 | }, 14 | features: { 15 | experimentalRSC: true, 16 | }, 17 | staticDirs: ["../public"], 18 | }; 19 | 20 | export default config; 21 | -------------------------------------------------------------------------------- /example/.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import type { Preview } from "@storybook/nextjs-vite"; 2 | 3 | import "./global.css"; 4 | /** 5 | * This external stylesheet import checks Next.js' external stylesheet support 6 | * by referencing Bootstrap's css in ./src/app/components/BootstrapButton/BootstrapButton.tsx 7 | * https://nextjs.org/docs/app/building-your-application/styling/css-modules#external-stylesheets 8 | */ 9 | import "bootstrap/dist/css/bootstrap.css"; 10 | 11 | const preview: Preview = { 12 | parameters: { 13 | controls: { 14 | matchers: { 15 | color: /(background|color)$/i, 16 | date: /Date$/i, 17 | }, 18 | }, 19 | react: { rsc: true }, 20 | }, 21 | }; 22 | 23 | export default preview; 24 | -------------------------------------------------------------------------------- /example/.storybook/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { setProjectAnnotations } from "@storybook/nextjs-vite"; 2 | import { beforeAll } from "vitest"; 3 | import * as projectAnnotations from "./preview"; 4 | 5 | // This is an important step to apply the right configuration when testing your stories. 6 | // More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations 7 | const project = setProjectAnnotations([projectAnnotations]); 8 | 9 | beforeAll(project.beforeAll); 10 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /example/env.d.ts: -------------------------------------------------------------------------------- 1 | interface ImportMetaEnv { 2 | readonly NEXT_PUBLIC_EXAMPLE1: string; 3 | readonly MODE: string; 4 | readonly PROD: string; 5 | readonly DEV: string; 6 | readonly BASE_URL: string; 7 | } 8 | 9 | interface ImportMeta { 10 | readonly env: ImportMetaEnv; 11 | } 12 | -------------------------------------------------------------------------------- /example/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | env: { 4 | nextConfigEnv: "next-config-env", 5 | }, 6 | }; 7 | 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "vitest --project=next", 11 | "test:storybook": "vitest --project=storybook --reporter=verbose", 12 | "test:all": "vitest", 13 | "storybook": "storybook dev -p 6006", 14 | "build-storybook": "storybook build", 15 | "inspect": "serve .storybook/.vite-inspect" 16 | }, 17 | "dependencies": { 18 | "next": "^15.3.0", 19 | "react": "19.1.0", 20 | "react-dom": "19.1.0", 21 | "styled-jsx": "^5.1.6" 22 | }, 23 | "devDependencies": { 24 | "@chromatic-com/storybook": "^4.0.0-next.2", 25 | "@storybook/addon-docs": "^9.0.0-0", 26 | "@storybook/addon-vitest": "^9.0.0-0", 27 | "@storybook/nextjs-vite": "^9.0.0-0", 28 | "@storybook/react": "^9.0.0-0", 29 | "@storybook/nextjs": "^9.0.0-0", 30 | "@testing-library/dom": "^10.3.2", 31 | "@testing-library/jest-dom": "^6.4.6", 32 | "@testing-library/react": "^16.0.0", 33 | "@types/node": "^20", 34 | "@types/react": "^18.3.12", 35 | "@types/react-dom": "^18.3.1", 36 | "@vitest/browser": "^3.0.0", 37 | "autoprefixer": "^10.4.19", 38 | "bootstrap": "^5.3.3", 39 | "chromatic": "^11.7.0", 40 | "happy-dom": "^14.12.3", 41 | "jsdom": "^24.1.1", 42 | "playwright": "^1.45.3", 43 | "postcss": "^8.4.38", 44 | "serve": "^14.2.3", 45 | "storybook": "^9.0.0-0", 46 | "tailwindcss": "^3.4.4", 47 | "typescript": "^5", 48 | "vite-plugin-inspect": "^0.8.5", 49 | "vitest": "^3.0.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /example/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /example/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/src/app/components/BootstrapButton/BootstrapButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import { BootstrapButton } from "./BootstrapButton"; 2 | 3 | export default { 4 | component: BootstrapButton, 5 | }; 6 | 7 | export const Default = {}; 8 | -------------------------------------------------------------------------------- /example/src/app/components/BootstrapButton/BootstrapButton.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This component checks Next.js' global css and external stylesheet support 3 | * by importing Bootstrap's css globally in ./storybook/preview.js 4 | * https://nextjs.org/docs/app/building-your-application/styling/css-modules#external-stylesheets 5 | */ 6 | export function BootstrapButton() { 7 | return ( 8 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /example/src/app/components/CSS/CSSModules.module.css: -------------------------------------------------------------------------------- 1 | // some random scss for a random component 2 | .random { 3 | color: red; 4 | font-size: 20px; 5 | font-weight: bold; 6 | } 7 | -------------------------------------------------------------------------------- /example/src/app/components/CSS/CSSModules.module.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/app/components/CSS/CSSModules.module.scss -------------------------------------------------------------------------------- /example/src/app/components/CSS/CSSModules.tsx: -------------------------------------------------------------------------------- 1 | // This component should show the usage of CSS Modules in React 2 | import styles from "./styles.module.css"; 3 | 4 | export default function CSSModules() { 5 | return
Hello, CSS Modules!
; 6 | } 7 | -------------------------------------------------------------------------------- /example/src/app/components/CSS/Sass.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | p { 3 | color: red; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /example/src/app/components/CSS/Sass.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Sass.module.scss"; 2 | 3 | export function SASS() { 4 | return ( 5 |
6 |

This paragraph is red

7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /example/src/app/components/CSS/Tailwind.stories.tsx: -------------------------------------------------------------------------------- 1 | import { TailwindCSS } from "./Tailwind"; 2 | 3 | export default { 4 | component: TailwindCSS, 5 | }; 6 | 7 | export const Default = {}; 8 | -------------------------------------------------------------------------------- /example/src/app/components/CSS/Tailwind.tsx: -------------------------------------------------------------------------------- 1 | export function TailwindCSS() { 2 | return ( 3 |
4 |

Tailwind CSS

5 |

6 | Tailwind CSS is a utility-first CSS framework that helps you design 7 | websites quickly without having to write custom CSS. 8 |

9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /example/src/app/components/DynamicImport/DynamicImport.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/nextjs-vite"; 2 | import dynamic from "next/dynamic"; 3 | import { expect, waitFor } from "storybook/test"; 4 | 5 | const DynamicComponent = dynamic(() => import("./DynamicImport")); 6 | const DynamicComponentNoSSR = dynamic(() => import("./DynamicImport"), { 7 | ssr: false, 8 | }); 9 | 10 | function Component() { 11 | return ; 12 | } 13 | 14 | const meta = { 15 | component: Component, 16 | } satisfies Meta; 17 | 18 | export default meta; 19 | 20 | type Story = StoryObj; 21 | 22 | export const Default: Story = { 23 | play: async ({ canvas }) => { 24 | await waitFor(() => 25 | expect( 26 | canvas.getByText("I am a dynamically loaded component"), 27 | ).toBeDefined(), 28 | ); 29 | }, 30 | }; 31 | 32 | export const NoSSR: Story = { 33 | render: () => , 34 | }; 35 | -------------------------------------------------------------------------------- /example/src/app/components/DynamicImport/DynamicImport.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function DynamicComponent() { 4 | return
I am a dynamically loaded component
; 5 | } 6 | -------------------------------------------------------------------------------- /example/src/app/components/EnvironmentVariables/EnvironmentVariables.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/nextjs-vite"; 2 | import { expect } from "storybook/test"; 3 | import EnvironmentVariables from "./EnvironmentVariables"; 4 | 5 | const meta = { 6 | component: EnvironmentVariables, 7 | } satisfies Meta; 8 | export default meta; 9 | 10 | type Story = StoryObj; 11 | 12 | export const Default: Story = { 13 | play: async ({ canvas }) => { 14 | await expect(canvas.getByTestId("nextConfigEnv")).toHaveTextContent( 15 | "next-config-env", 16 | ); 17 | await expect(canvas.getByTestId("nextPrefixEnv")).toHaveTextContent( 18 | "example1", 19 | ); 20 | // await expect(canvas.getByTestId('nonNextPrefixEnv')).toHaveTextContent('RESTRICTED_VALUE') 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /example/src/app/components/EnvironmentVariables/EnvironmentVariables.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default () => { 4 | return ( 5 |
6 |

7 | Environment variable from next.config:{" "} 8 | {process.env.nextConfigEnv} 9 |

10 |

11 | Environment variable from .env:{" "} 12 | 13 | {process.env.NEXT_PUBLIC_EXAMPLE1} 14 | 15 |

16 |

17 | 18 | Environment variable from .env and not prefixed with NEXT_PUBLIC: 19 | {" "} 20 | 21 | {process.env.EXAMPLE2 ?? "RESTRICTED_VALUE"} 22 | 23 |

24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /example/src/app/components/Font/Font.stories.tsx: -------------------------------------------------------------------------------- 1 | import Font from "./Font"; 2 | 3 | export default { 4 | component: Font, 5 | }; 6 | 7 | export const WithClassName = { 8 | args: { 9 | variant: "className", 10 | }, 11 | }; 12 | 13 | export const WithStyle = { 14 | args: { 15 | variant: "style", 16 | }, 17 | }; 18 | 19 | export const WithVariable = { 20 | args: { 21 | variant: "variable", 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /example/src/app/components/Font/Font.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | import Font from "./Font"; 5 | 6 | describe("Font", () => { 7 | it("should render correctly with className", () => { 8 | const { getByText } = render(); 9 | 10 | const heading = getByText("Google Kalina"); 11 | expect(heading.className).toBe("kalnia-normal"); 12 | expect(heading.style.fontFamily).toBeFalsy(); 13 | }); 14 | 15 | it("should render correctly with style", () => { 16 | const { getByText } = render(); 17 | 18 | const heading = getByText("Google Kalina"); 19 | expect(heading.style.fontFamily).toBe("Kalnia"); 20 | }); 21 | 22 | it("should render correctly with variable", () => { 23 | const { getByText } = render(); 24 | 25 | const heading = getByText("Google Kalina"); 26 | expect(heading.style.fontFamily).toBe("var(--font-kalina)"); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /example/src/app/components/Font/Font.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import { Comic_Neue } from "next/font/google"; 3 | import { Kalnia, Roboto_Mono } from "next/font/google"; 4 | import localFont from "next/font/local"; 5 | 6 | const kalina = Kalnia({ 7 | subsets: ["latin"], 8 | variable: "--font-kalina", 9 | display: "swap", 10 | }); 11 | 12 | const roboto_mono = Roboto_Mono({ 13 | subsets: ["latin"], 14 | variable: "--font-roboto-mono", 15 | display: "swap", 16 | }); 17 | 18 | const comicNeue = Comic_Neue({ 19 | subsets: ["latin"], 20 | variable: "--font-comic-neue", 21 | weight: ["700"], 22 | }); 23 | 24 | const localRubikStorm = localFont({ 25 | src: "/fonts/RubikStorm/RubikStorm-Regular.ttf", 26 | variable: "--font-rubik-storm", 27 | }); 28 | 29 | const localPlaywrite = localFont({ 30 | src: [ 31 | { path: "/fonts/Playwrite/PlaywriteBEVLG-Regular.ttf", weight: "400" }, 32 | { path: "/fonts/Playwrite/PlaywriteBEVLG-ExtraLight.ttf", weight: "200" }, 33 | ], 34 | variable: "--font-playwrite", 35 | }); 36 | 37 | type FontProps = { 38 | variant: "className" | "style" | "variable"; 39 | }; 40 | 41 | export default function Font({ variant }: FontProps) { 42 | switch (variant) { 43 | case "className": 44 | return ( 45 |
46 |

Google Kalina

47 |

Google Roboto Mono

48 |

Google Comic Neue

49 |

Local Rubik Storm

50 |

Local Playwrite 400

51 |

52 | Local Playwrite 200 53 |

54 |
55 | ); 56 | case "style": 57 | return ( 58 |
59 |

Google Kalina

60 |

Google Roboto Mono

61 |

Google Comic Neue

62 |

Local Rubik Storm

63 |

Local Playwrite 400

64 |

65 | Local Playwrite 200 66 |

67 |
68 | ); 69 | case "variable": 70 | return ( 71 |
72 |
73 |

80 | Google Kalina 81 |

82 |
83 |
84 |

91 | Google Roboto Mono 92 |

93 |
94 |
95 |

102 | Google Comic Neue 103 |

104 |
105 |
106 |

113 | Local Rubik Storm 114 |

115 |
116 |
117 |

124 | Local Playwrite 400 125 |

126 |
127 |
128 |

135 | Local Playwrite 200 136 |

137 |
138 |
139 | ); 140 | default: 141 | return null; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /example/src/app/components/Font/fonts/Playwrite/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2023 The Playwrite Project Authors (https://github.com/TypeTogether/Playwrite) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | https://openfontlicense.org 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /example/src/app/components/Font/fonts/Playwrite/PlaywriteBEVLG-ExtraLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/app/components/Font/fonts/Playwrite/PlaywriteBEVLG-ExtraLight.ttf -------------------------------------------------------------------------------- /example/src/app/components/Font/fonts/Playwrite/PlaywriteBEVLG-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/app/components/Font/fonts/Playwrite/PlaywriteBEVLG-Regular.ttf -------------------------------------------------------------------------------- /example/src/app/components/Font/fonts/RubikStorm/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2020 The Rubik Filtered Project Authors (https://https://github.com/NaN-xyz/Rubik-Filtered) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /example/src/app/components/Font/fonts/RubikStorm/RubikStorm-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/app/components/Font/fonts/RubikStorm/RubikStorm-Regular.ttf -------------------------------------------------------------------------------- /example/src/app/components/Header/Header.stories.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | 5 | import type { Meta } from "@storybook/nextjs-vite"; 6 | import type { StoryObj } from "@storybook/nextjs-vite"; 7 | import { cookies, headers } from "@storybook/nextjs-vite/headers.mock"; 8 | import { expect, userEvent, within } from "storybook/test"; 9 | import NextHeader from "./Header"; 10 | 11 | export default { 12 | component: NextHeader, 13 | } as Meta; 14 | 15 | type Story = StoryObj; 16 | 17 | export const Default: Story = { 18 | loaders: async () => { 19 | cookies().set("firstName", "Jane"); 20 | cookies().set({ 21 | name: "lastName", 22 | value: "Doe", 23 | }); 24 | headers().set("timezone", "Central European Summer Time"); 25 | }, 26 | play: async ({ canvasElement, step }) => { 27 | const canvas = within(canvasElement); 28 | const headersMock = headers(); 29 | const cookiesMock = cookies(); 30 | await step( 31 | "Cookie and header store apis are called upon rendering", 32 | async () => { 33 | await expect(cookiesMock.getAll).toHaveBeenCalled(); 34 | await expect(headersMock.entries).toHaveBeenCalled(); 35 | }, 36 | ); 37 | 38 | await step( 39 | "Upon clicking on submit, the user-id cookie is set", 40 | async () => { 41 | const submitButton = await canvas.findByRole("button"); 42 | await userEvent.click(submitButton); 43 | 44 | await expect(cookiesMock.set).toHaveBeenCalledWith( 45 | "user-id", 46 | "encrypted-id", 47 | ); 48 | }, 49 | ); 50 | 51 | await step( 52 | "The user-id cookie is available in cookie and header stores", 53 | async () => { 54 | await expect(headersMock.get("cookie")).toContain( 55 | "user-id=encrypted-id", 56 | ); 57 | await expect(cookiesMock.get("user-id")).toEqual({ 58 | name: "user-id", 59 | value: "encrypted-id", 60 | }); 61 | }, 62 | ); 63 | }, 64 | }; 65 | -------------------------------------------------------------------------------- /example/src/app/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { cookies, headers } from "next/headers"; 2 | 3 | export default async function Component() { 4 | async function handleClick() { 5 | "use server"; 6 | cookies().set("user-id", "encrypted-id"); 7 | } 8 | 9 | return ( 10 | <> 11 |

Cookies:

12 | {cookies() 13 | .getAll() 14 | .map(({ name, value }) => { 15 | return ( 16 |

20 | Name: {name} 21 | Value: {value} 22 |

23 | ); 24 | })} 25 | 26 |

Headers:

27 | {Array.from(headers().entries()).map( 28 | ([name, value]: [string, string]) => { 29 | return ( 30 |

34 | Name: {name} 35 | Value: {value} 36 |

37 | ); 38 | }, 39 | )} 40 | 41 |
42 | 43 |
44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /example/src/app/components/Image/GetImageProps.stories.tsx: -------------------------------------------------------------------------------- 1 | import { type ImageProps, getImageProps } from "next/image"; 2 | import React from "react"; 3 | 4 | import Accessibility from "./assets/accessibility.svg"; 5 | import Testing from "./assets/testing.png"; 6 | 7 | const Component = (props: ImageProps) => { 8 | const { 9 | props: { srcSet: dark }, 10 | } = getImageProps({ ...props, src: Accessibility }); 11 | const { 12 | props: { srcSet: light, ...rest }, 13 | } = getImageProps({ ...props, src: Testing }); 14 | 15 | return ( 16 | 17 | 18 | 19 | {/* biome-ignore lint/a11y/useAltText: */} 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default { 26 | component: Component, 27 | args: { 28 | alt: "getImageProps Example", 29 | }, 30 | }; 31 | 32 | export const Default = {}; 33 | -------------------------------------------------------------------------------- /example/src/app/components/Image/Image.stories.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import React, { useRef, useState } from "react"; 3 | 4 | import type { Meta, StoryObj } from "@storybook/nextjs-vite"; 5 | import Accessibility from "./assets/accessibility.svg"; 6 | import AvifImage from "./assets/avif-test-image.avif"; 7 | 8 | const meta = { 9 | component: Image, 10 | args: { 11 | src: Accessibility, 12 | alt: "Accessibility", 13 | }, 14 | } satisfies Meta; 15 | 16 | export default meta; 17 | 18 | type Story = StoryObj; 19 | 20 | export const Default = {} satisfies Story; 21 | 22 | export const Avif = { 23 | args: { 24 | src: AvifImage, 25 | alt: "Avif Test Image", 26 | }, 27 | } satisfies Story; 28 | 29 | export const BlurredPlaceholder = { 30 | args: { 31 | placeholder: "blur", 32 | }, 33 | } satisfies Story; 34 | 35 | export const BlurredAbsolutePlaceholder = { 36 | args: { 37 | src: "https://storybook.js.org/images/placeholders/50x50.png", 38 | width: 50, 39 | height: 50, 40 | blurDataURL: 41 | "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAABP5JREFUWEeNlwtz2zgMhEGKsv/k9XFp82xe11763yOJVGcXC4m2czM3GYa0E2s/LACSTi+vv1czM/7CvPpqxY/ejPeS3khmFiPHOiVLHKaZi4ux4j18GpMlS6cALupEQBCKQM4BdnGzjIcJgs//QBxAPQAem55f3yL4PWJIdyCyhlMfPdYZot0cwj3Ayg/5JwHA13paen7pADphxr/n5VI8JQsHCCGQ3gVGLLsxQ3h/LYSn5383B05rwNOws3Z576LOTOduvwfrOd5FtVat4akx0uPTrw8BNuUz23vLsY7hmg7i4ipqM2saiAdruNuirh4ff0bNdb3Qy3vkvfAQwrkHoDxTTZtDCOKrC1bMEkdnsQh/PLyetOGHkXeRAgQAQ84efQcBdUhvFofoulpdm9WGGTA+AJEe7l+i37a2c371tCFKs5zzJjxQNBMi1im7OCudP2aNghJuzZaGdSMEZjpwf/t0UgNdg9DOyLGLnY0BUHlzwVtNDkgEQhBeKkb1tUDgQrq7frwAiIJi5BKAeIFgHk5mOpPzvgltOfcoK0Rrs7lWHwsgqtXarK3N0u23h5Ne8+3Cqxn5RYSMfHCAMgDAx4CBWlA9RAGw0GA/ol0gvFB4WjAvBAFUa83SzdUdAbYMqp28uHpxCRefxwAYhksAFBlthxCiXig+zT4TYqkC+Hq7OdAfJv8lPpZiZShWBBIuRP+jspDb2lwcDkzz7OLzbO/zvAHAoXTz5eYMQL0t2yHAiCFcfPY1QDwNFylA5bPoFpsV9fsEiMl8dhcc4PP1CYD3drYcBYdIKQrx0cbRxd2JHSDcQ297/vvoZ5smRC+AyV2AQ+nm03evge08Tyy4jGqXzWWEoIvTgXHU38pWiNgH4ixB/ukAcy/xycXfp4kwdAAAt399W+OCgMjxILQacxvRQ3gEwHgKUIr/rz53CuDFNyP/Eob4+/vEWkBq6AAA/HIi62n/Lk67Q7wDYQ0UpQB7hc54T4E6gACLTYxeAwB0YKZL6U4ATEGIBwCs7qPfQJCCHkCnoK50noJKcXcAojsEAJZZKXhgCoziGKxqWV8IMNp4kP2aC+oB0TMFvhGxDQHQfIPhDrilwKOm/YCZASAHfgBABQjr3f7CyAkA0cPB03AQULRhKd4xAIjzHymo2Gp7gN0FAMAVOoA2fPz03a9ssh/RM7Iz8QKIzYF9HyB0XEZ1xJ4DzNoDOAfAslhDDTyjDfv8A2AcBeCiu/jBHQEgxnYW6Kp6BlCVAkQM8VnieF2Xyr0ivXy+XvsCzKOihwNHCCryw8HrQXVB8dgFeRfAVQiXjMbIIgXINQYB2H7Kf5wF/2Ar7h0AgKKGuAP4zOjhzlkLbpcRXKRZhNUjxG6HIQDOjN47gCn4+fWW3xVS9urPESEEwwHMo9IhAGxS2ISiA1iEnQOoA4hXRAwItp7WzL9Ow18ESJaw/ar4NgeOR49cAHCAnaH8swBhv+6CBGjeBSxEOUAI7HyKHkD4O9xKb3/feQouAI4uLBciHRRHmgbfA7h/xFc9AngNBADthvii1sMOiPwDAFeyt6s7FSFS4PmnA1v0vQvqDqQKAAPE/weAUuEgsj8c+H11Twdw/AKANXA82EDr5cJBEEzB3oI4Mb0AdR3nNw8vQnegWuvqAABwJFJEBwDgNdA7IOs3gL0LhuJdwBY8c4BfNnDdVgooHiOqn/b7JoSW/QODjTHXhU7hMQAAAABJRU5ErkJggg==", 42 | placeholder: "blur", 43 | }, 44 | parameters: { 45 | // ignoring in Chromatic to avoid inconsistent snapshots 46 | // given that the switch from blur to image is quite fast 47 | chromatic: { disableSnapshot: true }, 48 | }, 49 | } satisfies Story; 50 | 51 | export const FilledParent = { 52 | args: { 53 | fill: true, 54 | }, 55 | decorators: [ 56 | (Story) => ( 57 |
58 | {Story()} 59 |
60 | ), 61 | ], 62 | } satisfies Story; 63 | 64 | export const Sized = { 65 | args: { 66 | fill: true, 67 | sizes: "(max-width: 600px) 100vw, 600px", 68 | }, 69 | decorators: [ 70 | (Story) => ( 71 |
72 | {Story()} 73 |
74 | ), 75 | ], 76 | } satisfies Story; 77 | 78 | export const Lazy = { 79 | args: { 80 | src: "https://storybook.js.org/images/placeholders/50x50.png", 81 | width: 50, 82 | height: 50, 83 | }, 84 | decorators: [ 85 | (Story) => ( 86 | <> 87 |
88 | {Story()} 89 | 90 | ), 91 | ], 92 | } satisfies Story; 93 | 94 | export const Eager = { 95 | ...Lazy, 96 | parameters: { 97 | nextjs: { 98 | image: { 99 | loading: "eager", 100 | }, 101 | }, 102 | }, 103 | } satisfies Story; 104 | 105 | export const WithRef = { 106 | render() { 107 | const [ref, setRef] = useState(null); 108 | 109 | return ( 110 |
111 | Accessibility 112 |

Alt attribute of image: {ref?.alt}

113 |
114 | ); 115 | }, 116 | } satisfies Story; 117 | -------------------------------------------------------------------------------- /example/src/app/components/Image/ImageLegacy.stories.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/legacy/image"; 2 | import React, { useRef, useState } from "react"; 3 | 4 | import type { Meta, StoryObj } from "@storybook/nextjs-vite"; 5 | import Accessibility from "./assets/accessibility.svg"; 6 | import AvifImage from "./assets/avif-test-image.avif"; 7 | 8 | const meta = { 9 | component: Image, 10 | args: { 11 | src: Accessibility, 12 | alt: "Accessibility", 13 | }, 14 | } satisfies Meta; 15 | 16 | export default meta; 17 | 18 | type Story = StoryObj; 19 | 20 | export const Default = {} satisfies Story; 21 | 22 | export const Avif = { 23 | args: { 24 | src: AvifImage, 25 | alt: "Avif Test Image", 26 | }, 27 | } satisfies Story; 28 | 29 | export const BlurredPlaceholder = { 30 | args: { 31 | placeholder: "blur", 32 | }, 33 | } satisfies Story; 34 | 35 | export const BlurredAbsolutePlaceholder = { 36 | args: { 37 | src: "https://storybook.js.org/images/placeholders/50x50.png", 38 | width: 50, 39 | height: 50, 40 | blurDataURL: 41 | "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAABP5JREFUWEeNlwtz2zgMhEGKsv/k9XFp82xe11763yOJVGcXC4m2czM3GYa0E2s/LACSTi+vv1czM/7CvPpqxY/ejPeS3khmFiPHOiVLHKaZi4ux4j18GpMlS6cALupEQBCKQM4BdnGzjIcJgs//QBxAPQAem55f3yL4PWJIdyCyhlMfPdYZot0cwj3Ayg/5JwHA13paen7pADphxr/n5VI8JQsHCCGQ3gVGLLsxQ3h/LYSn5383B05rwNOws3Z576LOTOduvwfrOd5FtVat4akx0uPTrw8BNuUz23vLsY7hmg7i4ipqM2saiAdruNuirh4ff0bNdb3Qy3vkvfAQwrkHoDxTTZtDCOKrC1bMEkdnsQh/PLyetOGHkXeRAgQAQ84efQcBdUhvFofoulpdm9WGGTA+AJEe7l+i37a2c371tCFKs5zzJjxQNBMi1im7OCudP2aNghJuzZaGdSMEZjpwf/t0UgNdg9DOyLGLnY0BUHlzwVtNDkgEQhBeKkb1tUDgQrq7frwAiIJi5BKAeIFgHk5mOpPzvgltOfcoK0Rrs7lWHwsgqtXarK3N0u23h5Ne8+3Cqxn5RYSMfHCAMgDAx4CBWlA9RAGw0GA/ol0gvFB4WjAvBAFUa83SzdUdAbYMqp28uHpxCRefxwAYhksAFBlthxCiXig+zT4TYqkC+Hq7OdAfJv8lPpZiZShWBBIuRP+jspDb2lwcDkzz7OLzbO/zvAHAoXTz5eYMQL0t2yHAiCFcfPY1QDwNFylA5bPoFpsV9fsEiMl8dhcc4PP1CYD3drYcBYdIKQrx0cbRxd2JHSDcQ297/vvoZ5smRC+AyV2AQ+nm03evge08Tyy4jGqXzWWEoIvTgXHU38pWiNgH4ixB/ukAcy/xycXfp4kwdAAAt399W+OCgMjxILQacxvRQ3gEwHgKUIr/rz53CuDFNyP/Eob4+/vEWkBq6AAA/HIi62n/Lk67Q7wDYQ0UpQB7hc54T4E6gACLTYxeAwB0YKZL6U4ATEGIBwCs7qPfQJCCHkCnoK50noJKcXcAojsEAJZZKXhgCoziGKxqWV8IMNp4kP2aC+oB0TMFvhGxDQHQfIPhDrilwKOm/YCZASAHfgBABQjr3f7CyAkA0cPB03AQULRhKd4xAIjzHymo2Gp7gN0FAMAVOoA2fPz03a9ssh/RM7Iz8QKIzYF9HyB0XEZ1xJ4DzNoDOAfAslhDDTyjDfv8A2AcBeCiu/jBHQEgxnYW6Kp6BlCVAkQM8VnieF2Xyr0ivXy+XvsCzKOihwNHCCryw8HrQXVB8dgFeRfAVQiXjMbIIgXINQYB2H7Kf5wF/2Ar7h0AgKKGuAP4zOjhzlkLbpcRXKRZhNUjxG6HIQDOjN47gCn4+fWW3xVS9urPESEEwwHMo9IhAGxS2ISiA1iEnQOoA4hXRAwItp7WzL9Ow18ESJaw/ar4NgeOR49cAHCAnaH8swBhv+6CBGjeBSxEOUAI7HyKHkD4O9xKb3/feQouAI4uLBciHRRHmgbfA7h/xFc9AngNBADthvii1sMOiPwDAFeyt6s7FSFS4PmnA1v0vQvqDqQKAAPE/weAUuEgsj8c+H11Twdw/AKANXA82EDr5cJBEEzB3oI4Mb0AdR3nNw8vQnegWuvqAABwJFJEBwDgNdA7IOs3gL0LhuJdwBY8c4BfNnDdVgooHiOqn/b7JoSW/QODjTHXhU7hMQAAAABJRU5ErkJggg==", 42 | placeholder: "blur", 43 | }, 44 | parameters: { 45 | // ignoring in Chromatic to avoid inconsistent snapshots 46 | // given that the switch from blur to image is quite fast 47 | chromatic: { disableSnapshot: true }, 48 | }, 49 | } satisfies Story; 50 | 51 | export const Lazy = { 52 | args: { 53 | src: "https://storybook.js.org/images/placeholders/50x50.png", 54 | width: 50, 55 | height: 50, 56 | }, 57 | decorators: [ 58 | (Story) => ( 59 | <> 60 |
61 | {Story()} 62 | 63 | ), 64 | ], 65 | } satisfies Story; 66 | 67 | export const Eager = { 68 | ...Lazy, 69 | parameters: { 70 | nextjs: { 71 | image: { 72 | loading: "eager", 73 | }, 74 | }, 75 | }, 76 | } satisfies Story; 77 | -------------------------------------------------------------------------------- /example/src/app/components/Image/assets/accessibility.svg: -------------------------------------------------------------------------------- 1 | Accessibility -------------------------------------------------------------------------------- /example/src/app/components/Image/assets/avif-test-image.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/app/components/Image/assets/avif-test-image.avif -------------------------------------------------------------------------------- /example/src/app/components/Image/assets/testing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/app/components/Image/assets/testing.png -------------------------------------------------------------------------------- /example/src/app/components/Link/Link.stories.module.css: -------------------------------------------------------------------------------- 1 | .link { 2 | color: green; 3 | } 4 | -------------------------------------------------------------------------------- /example/src/app/components/Link/Link.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/nextjs-vite"; 2 | import Link from "next/link"; 3 | import React from "react"; 4 | 5 | import style from "./Link.stories.module.css"; 6 | 7 | // `onClick`, `href`, and `ref` need to be passed to the DOM element 8 | // for proper handling 9 | const MyButton = React.forwardRef< 10 | HTMLAnchorElement, 11 | React.DetailedHTMLProps< 12 | React.AnchorHTMLAttributes, 13 | HTMLAnchorElement 14 | > 15 | >(function Button({ onClick, href, children }, ref) { 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | }); 22 | 23 | const Component = () => ( 24 |
    25 |
  • 26 | Normal Link 27 |
  • 28 |
  • 29 | 35 | With URL Object 36 | 37 |
  • 38 |
  • 39 | 40 | Replace the URL instead of push 41 | 42 |
  • 43 |
  • 44 | 45 | Disables scrolling to the top 46 | 47 |
  • 48 |
  • 49 | 50 | No Prefetching 51 | 52 |
  • 53 |
  • 54 | 55 | With style 56 | 57 |
  • 58 |
  • 59 | 60 | With className 61 | 62 |
  • 63 |
64 | ); 65 | 66 | export default { 67 | component: Component, 68 | } as Meta; 69 | 70 | export const Default: StoryObj = {}; 71 | 72 | export const InAppDir: StoryObj = { 73 | parameters: { 74 | nextjs: { 75 | appDirectory: true, 76 | }, 77 | }, 78 | }; 79 | -------------------------------------------------------------------------------- /example/src/app/components/Link/__snapshots__/Link.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Page > renders a heading 1`] = ` 4 |
5 |
6 |

7 | Home 8 |

9 | 12 | About 13 | 14 |
15 |
16 | `; 17 | -------------------------------------------------------------------------------- /example/src/app/components/Link/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Page > renders a heading 1`] = ` 4 |
5 |
6 |

7 | Home 8 |

9 | 12 | About 13 | 14 |
15 |
16 | `; 17 | -------------------------------------------------------------------------------- /example/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/app/favicon.ico -------------------------------------------------------------------------------- /example/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | 29 | @layer utilities { 30 | .text-balance { 31 | text-wrap: balance; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /example/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata: Metadata = { 8 | title: "Create Next App", 9 | description: "Generated by create next app", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /example/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export default function Home() { 4 | return ( 5 |
6 |
7 |

8 | Get started by editing  9 | src/app/page.tsx 10 |

11 | 29 |
30 | 31 |
32 | Next.js Logo 40 |
41 | 42 | 111 |
112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /example/src/env.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | describe("environment", () => { 4 | it("should provide user defined env variables via .env file", () => { 5 | expect(import.meta.env.NEXT_PUBLIC_EXAMPLE1).toBe("example1"); 6 | // expect(import.meta.env.EXAMPLE2).toBe("abcdefghijk"); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /example/src/stories/Button.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/nextjs-vite"; 2 | import { expect, fn, userEvent } from "storybook/test"; 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 | // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args 20 | args: { onClick: fn() }, 21 | } satisfies Meta; 22 | 23 | export default meta; 24 | type Story = StoryObj; 25 | 26 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 27 | export const Primary: Story = { 28 | args: { 29 | primary: true, 30 | label: "Button", 31 | }, 32 | play: async ({ canvas, args }) => { 33 | await userEvent.click(await canvas.getByRole("button")); 34 | await expect(args.onClick).toHaveBeenCalled(); 35 | }, 36 | }; 37 | 38 | export const Secondary: Story = { 39 | args: { 40 | label: "Button", 41 | }, 42 | }; 43 | 44 | export const Large: Story = { 45 | args: { 46 | size: "large", 47 | label: "Button", 48 | }, 49 | }; 50 | 51 | export const Small: Story = { 52 | args: { 53 | size: "small", 54 | label: "Button", 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /example/src/stories/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./button.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 38 | ? "storybook-button--primary" 39 | : "storybook-button--secondary"; 40 | return ( 41 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /example/src/stories/Configure.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from "@storybook/addon-docs/blocks"; 2 | import Image from "next/image"; 3 | 4 | import Github from "./assets/github.svg"; 5 | import Discord from "./assets/discord.svg"; 6 | import Youtube from "./assets/youtube.svg"; 7 | import Tutorials from "./assets/tutorials.svg"; 8 | import Styling from "./assets/styling.png"; 9 | import Context from "./assets/context.png"; 10 | import Assets from "./assets/assets.png"; 11 | import Docs from "./assets/docs.png"; 12 | import Share from "./assets/share.png"; 13 | import FigmaPlugin from "./assets/figma-plugin.png"; 14 | import Testing from "./assets/testing.png"; 15 | import Accessibility from "./assets/accessibility.png"; 16 | import Theming from "./assets/theming.png"; 17 | import AddonLibrary from "./assets/addon-library.png"; 18 | 19 | export const RightArrow = () => 32 | 33 | 34 | 35 | 36 | 37 |
38 |
39 | # Configure your project 40 | 41 | Because Storybook works separately from your app, you'll need to configure it for your specific stack and setup. Below, explore guides for configuring Storybook with popular frameworks and tools. If you get stuck, learn how you can ask for help from our community. 42 |
43 |
44 |
45 | A wall of logos representing different styling technologies 52 |

Add styling and CSS

53 |

Like with web applications, there are many ways to include CSS within Storybook. Learn more about setting up styling within Storybook.

54 | Learn more 58 |
59 |
60 | An abstraction representing the composition of data for a component 67 |

Provide context and mocking

68 |

Often when a story doesn't render, it's because your component is expecting a specific environment or context (like a theme provider) to be available.

69 | Learn more 73 |
74 |
75 | A representation of typography and image assets 82 |
83 |

Load assets and resources

84 |

To link static files (like fonts) to your projects and stories, use the 85 | `staticDirs` configuration option to specify folders to load when 86 | starting Storybook.

87 | Learn more 91 |
92 |
93 |
94 |
95 |
96 |
97 | # Do more with Storybook 98 | 99 | Now that you know the basics, let's explore other parts of Storybook that will improve your experience. This list is just to get you started. You can customise Storybook in many ways to fit your needs. 100 |
101 | 102 |
103 |
104 |
105 | A screenshot showing the autodocs tag being set, pointing a docs page being generated 112 |

Autodocs

113 |

Auto-generate living, 114 | interactive reference documentation from your components and stories.

115 | Learn more 119 |
120 |
121 | A browser window showing a Storybook being published to a chromatic.com URL 128 |

Publish to Chromatic

129 |

Publish your Storybook to review and collaborate with your entire team.

130 | Learn more 134 |
135 |
136 | Windows showing the Storybook plugin in Figma 143 |

Figma Plugin

144 |

Embed your stories into Figma to cross-reference the design and live 145 | implementation in one place.

146 | Learn more 150 |
151 |
152 | Screenshot of tests passing and failing 159 |

Testing

160 |

Use stories to test a component in all its variations, no matter how 161 | complex.

162 | Learn more 166 |
167 |
168 | Screenshot of accessibility tests passing and failing 175 |

Accessibility

176 |

Automatically test your components for a11y issues as you develop.

177 | Learn more 181 |
182 |
183 | Screenshot of Storybook in light and dark mode 190 |

Theming

191 |

Theme Storybook's UI to personalize it to your project.

192 | Learn more 196 |
197 |
198 |
199 |
200 |
201 |
202 |

Addons

203 |

Integrate your tools with Storybook to connect workflows.

204 | Discover all addons 208 |
209 |
210 | Integrate your tools with Storybook to connect workflows. 216 |
217 |
218 | 219 |
220 |
221 | Github logo 229 | Join our contributors building the future of UI development. 230 | 231 | Star on GitHub 235 |
236 |
237 | Discord logo 245 |
246 | Get support and chat with frontend developers. 247 | 248 | Join Discord server 252 |
253 |
254 |
255 | Youtube logo 263 |
264 | Watch tutorials, feature previews and interviews. 265 | 266 | Watch on YouTube 270 |
271 |
272 |
273 | A book 281 |

Follow guided walkthroughs on for key workflows.

282 | 283 | Discover tutorials 287 |
288 |
289 | 290 | 447 | -------------------------------------------------------------------------------- /example/src/stories/Header.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/nextjs-vite"; 2 | import { fn } from "storybook/test"; 3 | import { Header } from "./Header"; 4 | 5 | const meta = { 6 | title: "Example/Header", 7 | component: Header, 8 | // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs 9 | tags: ["autodocs"], 10 | parameters: { 11 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout 12 | layout: "fullscreen", 13 | }, 14 | args: { 15 | onLogin: fn(), 16 | onLogout: fn(), 17 | onCreateAccount: fn(), 18 | }, 19 | } satisfies Meta; 20 | 21 | export default meta; 22 | type Story = StoryObj; 23 | 24 | export const LoggedIn: Story = { 25 | args: { 26 | user: { 27 | name: "Jane Doe", 28 | }, 29 | }, 30 | }; 31 | 32 | export const LoggedOut: Story = {}; 33 | -------------------------------------------------------------------------------- /example/src/stories/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Button } from "./Button"; 4 | import "./header.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 = ({ 18 | user, 19 | onLogin, 20 | onLogout, 21 | onCreateAccount, 22 | }: HeaderProps) => ( 23 |
24 |
25 |
26 | {/* biome-ignore lint/a11y/noSvgWithoutTitle: */} 27 | 33 | 34 | 38 | 42 | 46 | 47 | 48 |

Acme

49 |
50 |
51 | {user ? ( 52 | <> 53 | 54 | Welcome, {user.name}! 55 | 56 |
70 |
71 |
72 | ); 73 | -------------------------------------------------------------------------------- /example/src/stories/Page.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/nextjs-vite"; 2 | import { expect, userEvent, within } 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 | -------------------------------------------------------------------------------- /example/src/stories/Page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Header } from "./Header"; 4 | import "./page.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 | 31 | component-driven 32 | {" "} 33 | process starting with atomic components and ending with pages. 34 |

35 |

36 | Render pages with mock data. This makes it easy to build and review 37 | page states without needing to navigate to them in your app. Here are 38 | some handy patterns for managing page data in Storybook: 39 |

40 |
    41 |
  • 42 | Use a higher-level connected component. Storybook helps you compose 43 | such data from the "args" of child component stories 44 |
  • 45 |
  • 46 | Assemble data in the page component from your services. You can mock 47 | these services out using Storybook. 48 |
  • 49 |
50 |

51 | Get a guided tutorial on component-driven development at{" "} 52 | 57 | Storybook tutorials 58 | 59 | . Read more in the{" "} 60 | 65 | docs 66 | 67 | . 68 |

69 |
70 | Tip Adjust the width of the canvas with 71 | the {/* biome-ignore lint/a11y/noSvgWithoutTitle: */} 72 | 78 | 79 | 84 | 85 | 86 | Viewports addon in the toolbar 87 |
88 |
89 |
90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /example/src/stories/assets/accessibility.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/stories/assets/accessibility.png -------------------------------------------------------------------------------- /example/src/stories/assets/accessibility.svg: -------------------------------------------------------------------------------- 1 | Accessibility -------------------------------------------------------------------------------- /example/src/stories/assets/addon-library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/stories/assets/addon-library.png -------------------------------------------------------------------------------- /example/src/stories/assets/assets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/stories/assets/assets.png -------------------------------------------------------------------------------- /example/src/stories/assets/avif-test-image.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/stories/assets/avif-test-image.avif -------------------------------------------------------------------------------- /example/src/stories/assets/context.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/stories/assets/context.png -------------------------------------------------------------------------------- /example/src/stories/assets/discord.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/src/stories/assets/docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/stories/assets/docs.png -------------------------------------------------------------------------------- /example/src/stories/assets/figma-plugin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/stories/assets/figma-plugin.png -------------------------------------------------------------------------------- /example/src/stories/assets/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/src/stories/assets/share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/stories/assets/share.png -------------------------------------------------------------------------------- /example/src/stories/assets/styling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/stories/assets/styling.png -------------------------------------------------------------------------------- /example/src/stories/assets/testing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/stories/assets/testing.png -------------------------------------------------------------------------------- /example/src/stories/assets/theming.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/vite-plugin-storybook-nextjs/36fabc649d03ea18929f4f845e1d1c919caf2cfb/example/src/stories/assets/theming.png -------------------------------------------------------------------------------- /example/src/stories/assets/tutorials.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/src/stories/assets/youtube.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/src/stories/button.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 | -------------------------------------------------------------------------------- /example/src/stories/header.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 | -------------------------------------------------------------------------------- /example/src/stories/page.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 | -------------------------------------------------------------------------------- /example/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": 14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 15 | }, 16 | }, 17 | fontWeight: { 18 | bold: "900", 19 | }, 20 | }, 21 | plugins: [], 22 | }; 23 | export default config; 24 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": [ 25 | "next-env.d.ts", 26 | "env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | ".next/types/**/*.ts", 30 | "vitest.config.mts" 31 | ], 32 | "exclude": ["node_modules"] 33 | } 34 | -------------------------------------------------------------------------------- /example/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; 2 | import { storybookNextJsPlugin } from "@storybook/nextjs-vite/vite-plugin"; 3 | import { defineConfig } from "vitest/config"; 4 | 5 | // More info at: https://storybook.js.org/docs/writing-tests/vitest-plugin 6 | export default defineConfig({ 7 | plugins: [ 8 | // See options at: https://storybook.js.org/docs/writing-tests/vitest-plugin#storybooktest 9 | storybookTest({ configDir: ".storybook" }), 10 | storybookNextJsPlugin(), 11 | ], 12 | test: { 13 | name: "storybook", 14 | browser: { 15 | enabled: true, 16 | headless: true, 17 | name: "chromium", 18 | provider: "playwright", 19 | }, 20 | setupFiles: [".storybook/vitest.setup.ts"], 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | commands: 3 | check: 4 | glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}" 5 | run: npx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true {staged_files} && git update-index --again 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-plugin-storybook-nextjs", 3 | "version": "2.0.1", 4 | "description": "", 5 | "keywords": [ 6 | "vite-plugin", 7 | "nextjs", 8 | "storybook", 9 | "vitest" 10 | ], 11 | "author": "Storybook Bot ", 12 | "license": "MIT", 13 | "sideEffects": [ 14 | "./src/polyfills/promise-with-resolvers.ts" 15 | ], 16 | "files": [ 17 | "dist" 18 | ], 19 | "type": "module", 20 | "types": "./dist/index.d.ts", 21 | "exports": { 22 | ".": { 23 | "import": { 24 | "types": "./dist/index.d.ts", 25 | "default": "./dist/index.js" 26 | }, 27 | "require": { 28 | "types": "./dist/index.d.cjs", 29 | "default": "./dist/index.cjs" 30 | } 31 | }, 32 | "./browser/mocks/cache": "./dist/plugins/next-mocks/alias/cache/index.js", 33 | "./browser/mocks/navigation": "./dist/plugins/next-mocks/alias/navigation/index.js", 34 | "./browser/mocks/headers": "./dist/plugins/next-mocks/alias/headers/index.js", 35 | "./browser/mocks/router": "./dist/plugins/next-mocks/alias/router/index.js", 36 | "./browser/mocks/server-only": "./dist/plugins/next-mocks/alias/rsc/server-only.js", 37 | "./browser/mocks/dynamic": "./dist/plugins/next-mocks/alias/dynamic/index.js", 38 | "./browser/mocks/image": "./dist/plugins/next-image/alias/next-image.js", 39 | "./browser/mocks/legacy-image": "./dist/plugins/next-image/alias/next-legacy-image.js", 40 | "./browser/mocks/image-default-loader": "./dist/plugins/next-image/alias/image-default-loader.js", 41 | "./browser/mocks/image-context": "./dist/plugins/next-image/alias/image-context.js", 42 | "./browser/mocks/draft-mode.compat": "./dist/plugins/next-mocks/compatibility/draft-mode.compat.js", 43 | "./node/mocks/cache": "./dist/plugins/next-mocks/alias/cache/index.cjs", 44 | "./node/mocks/navigation": "./dist/plugins/next-mocks/alias/navigation/index.cjs", 45 | "./node/mocks/headers": "./dist/plugins/next-mocks/alias/headers/index.cjs", 46 | "./node/mocks/router": "./dist/plugins/next-mocks/alias/router/index.cjs", 47 | "./node/mocks/server-only": "./dist/plugins/next-mocks/alias/rsc/server-only.cjs", 48 | "./node/mocks/dynamic": "./dist/plugins/next-mocks/alias/dynamic/index.cjs", 49 | "./node/mocks/image": "./dist/plugins/next-image/alias/next-image.cjs", 50 | "./node/mocks/legacy-image": "./dist/plugins/next-image/alias/next-legacy-image.cjs", 51 | "./node/mocks/image-default-loader": "./dist/plugins/next-image/alias/image-default-loader.cjs", 52 | "./node/mocks/image-context": "./dist/plugins/next-image/alias/image-context.cjs", 53 | "./node/mocks/draft-mode.compat": "./dist/plugins/next-mocks/compatibility/draft-mode.compat.cjs" 54 | }, 55 | "scripts": { 56 | "prepublishOnly": "pnpm build", 57 | "build": "tsup", 58 | "dev": "pnpm build --watch", 59 | "check": "biome check", 60 | "check:write": "biome check --write", 61 | "release": "auto shipit" 62 | }, 63 | "peerDependencies": { 64 | "next": "^14.1.0 || ^15.0.0", 65 | "storybook": "^0.0.0-0 || ^9.0.0 || ^9.1.0-0", 66 | "vite": "^5.0.0 || ^6.0.0" 67 | }, 68 | "devDependencies": { 69 | "@biomejs/biome": "1.8.1", 70 | "@types/node": "^18", 71 | "@types/react": "^18", 72 | "@types/semver": "^7.5.8", 73 | "auto": "^11.2.0", 74 | "lefthook": "^1.6.16", 75 | "next": "^15.3.0", 76 | "react": "19.1.0", 77 | "rollup": "^4.18.0", 78 | "semver": "^7.6.3", 79 | "storybook": "^9.0.0", 80 | "tsup": "^8.1.0", 81 | "typescript": "^5.0.0", 82 | "vite": "^5.0.0", 83 | "vitest": "^3.0.0" 84 | }, 85 | "packageManager": "pnpm@9.4.0", 86 | "dependencies": { 87 | "@next/env": "^15.0.3", 88 | "image-size": "^2.0.0", 89 | "magic-string": "^0.30.11", 90 | "module-alias": "^2.2.3", 91 | "ts-dedent": "^2.2.0" 92 | }, 93 | "publishConfig": { 94 | "access": "public" 95 | }, 96 | "pnpm": { 97 | "overrides": { 98 | "vite-plugin-storybook-nextjs": "workspace:*" 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - example 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | 3 | import { createRequire } from "node:module"; 4 | import type { NextConfigComplete } from "next/dist/server/config-shared.js"; 5 | import type { Plugin } from "vite"; 6 | import { vitePluginNextEnv } from "./plugins/next-env/plugin"; 7 | import { vitePluginNextFont } from "./plugins/next-font/plugin"; 8 | import { vitePluginNextSwc } from "./plugins/next-swc/plugin"; 9 | 10 | import "./polyfills/promise-with-resolvers"; 11 | import nextServerConfig from "next/dist/server/config.js"; 12 | import { 13 | PHASE_DEVELOPMENT_SERVER, 14 | PHASE_PRODUCTION_BUILD, 15 | PHASE_TEST, 16 | } from "next/dist/shared/lib/constants.js"; 17 | import { vitePluginNextDynamic } from "./plugins/next-dynamic/plugin"; 18 | import { vitePluginNextImage } from "./plugins/next-image/plugin"; 19 | import { vitePluginNextMocks } from "./plugins/next-mocks/plugin"; 20 | import { getExecutionEnvironment, isVitestEnv } from "./utils"; 21 | 22 | const require = createRequire(import.meta.url); 23 | const loadConfig: typeof nextServerConfig = 24 | // biome-ignore lint/suspicious/noExplicitAny: CJS support 25 | (nextServerConfig as any).default || nextServerConfig; 26 | 27 | type VitePluginOptions = { 28 | /** 29 | * Provide the path to your Next.js project directory 30 | * @default process.cwd() 31 | */ 32 | dir?: string; 33 | }; 34 | 35 | function VitePlugin({ dir = process.cwd() }: VitePluginOptions = {}): Plugin[] { 36 | const resolvedDir = resolve(dir); 37 | const nextConfigResolver = Promise.withResolvers(); 38 | 39 | return [ 40 | { 41 | name: "vite-plugin-storybook-nextjs", 42 | enforce: "pre" as const, 43 | async config(config, env) { 44 | const phase = 45 | env.mode === "development" 46 | ? PHASE_DEVELOPMENT_SERVER 47 | : env.mode === "test" 48 | ? PHASE_TEST 49 | : PHASE_PRODUCTION_BUILD; 50 | 51 | nextConfigResolver.resolve(await loadConfig(phase, resolvedDir)); 52 | 53 | const executionEnvironment = getExecutionEnvironment(config); 54 | 55 | return { 56 | ...(!isVitestEnv && { 57 | resolve: { 58 | alias: [ 59 | { 60 | find: /^react$/, 61 | replacement: require.resolve("next/dist/compiled/react"), 62 | }, 63 | { 64 | find: /^react\/jsx-runtime$/, 65 | replacement: require.resolve( 66 | "next/dist/compiled/react/jsx-runtime", 67 | ), 68 | }, 69 | { 70 | find: /^react\/jsx-dev-runtime$/, 71 | replacement: require.resolve( 72 | "next/dist/compiled/react/jsx-dev-runtime", 73 | ), 74 | }, 75 | { 76 | find: /^react-dom$/, 77 | replacement: require.resolve("next/dist/compiled/react-dom"), 78 | }, 79 | { 80 | find: /^react-dom\/server$/, 81 | replacement: require.resolve( 82 | "next/dist/compiled/react-dom/server.browser.js", 83 | ), 84 | }, 85 | { 86 | find: /^react-dom\/test-utils$/, 87 | replacement: require.resolve( 88 | "next/dist/compiled/react-dom/cjs/react-dom-test-utils.production.js", 89 | ), 90 | }, 91 | { 92 | find: /^react-dom\/client$/, 93 | replacement: require.resolve( 94 | "next/dist/compiled/react-dom/client.js", 95 | ), 96 | }, 97 | { 98 | find: /^react-dom\/cjs\/react-dom\.development\.js$/, 99 | replacement: require.resolve( 100 | "next/dist/compiled/react-dom/cjs/react-dom.development.js", 101 | ), 102 | }, 103 | ], 104 | }, 105 | }), 106 | optimizeDeps: { 107 | include: [ 108 | "next/dist/shared/lib/app-router-context.shared-runtime", 109 | "next/dist/shared/lib/head-manager-context.shared-runtime", 110 | "next/dist/shared/lib/hooks-client-context.shared-runtime", 111 | "next/dist/shared/lib/router-context.shared-runtime", 112 | "next/dist/client/components/redirect-boundary", 113 | "next/dist/client/head-manager", 114 | "next/dist/client/components/is-next-router-error", 115 | "next/config", 116 | "next/dist/shared/lib/segment", 117 | "styled-jsx", 118 | "sb-original/image-context", 119 | "sb-original/default-loader", 120 | "@mdx-js/react", 121 | "next/dist/compiled/react", 122 | "next/image", 123 | "next/legacy/image", 124 | "react/jsx-dev-runtime", 125 | // Required for pnpm setups, since styled-jsx is a transitive dependency of Next.js and not directly listed. 126 | // Refer to this pnpm issue for more details: 127 | // https://github.com/vitejs/vite/issues/16293 128 | "next > styled-jsx/style", 129 | ], 130 | }, 131 | test: { 132 | alias: { 133 | "react/jsx-dev-runtime": require.resolve( 134 | "next/dist/compiled/react/jsx-dev-runtime.js", 135 | ), 136 | "react/jsx-runtime": require.resolve( 137 | "next/dist/compiled/react/jsx-runtime.js", 138 | ), 139 | 140 | react: require.resolve("next/dist/compiled/react"), 141 | 142 | "react-dom/server": require.resolve( 143 | executionEnvironment === "node" 144 | ? "next/dist/compiled/react-dom/server.js" 145 | : "next/dist/compiled/react-dom/server.browser.js", 146 | ), 147 | 148 | "react-dom/test-utils": require.resolve( 149 | "next/dist/compiled/react-dom/cjs/react-dom-test-utils.production.js", 150 | ), 151 | 152 | "react-dom/cjs/react-dom.development.js": require.resolve( 153 | "next/dist/compiled/react-dom/cjs/react-dom.development.js", 154 | ), 155 | 156 | "react-dom/client": require.resolve( 157 | "next/dist/compiled/react-dom/client.js", 158 | ), 159 | 160 | "react-dom": require.resolve("next/dist/compiled/react-dom"), 161 | }, 162 | }, 163 | }; 164 | }, 165 | configResolved(config) { 166 | if (isVitestEnv && !config.test?.browser?.enabled) { 167 | // biome-ignore lint/style/noNonNullAssertion: test is available in the config 168 | config.test!.setupFiles = [ 169 | require.resolve("./mocks/storybook.global.js"), 170 | ...(config.test?.setupFiles ?? []), 171 | ]; 172 | } 173 | }, 174 | }, 175 | vitePluginNextFont(), 176 | vitePluginNextSwc(dir, nextConfigResolver), 177 | vitePluginNextEnv(dir, nextConfigResolver), 178 | vitePluginNextImage(nextConfigResolver), 179 | vitePluginNextMocks(), 180 | vitePluginNextDynamic(), 181 | ]; 182 | } 183 | 184 | export default VitePlugin; 185 | -------------------------------------------------------------------------------- /src/mocks/storybook.global.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from "node:module"; 2 | // @ts-ignore no types 3 | import moduleAlias from "module-alias"; 4 | import { getAlias as getNextImageAlias } from "../plugins/next-image/alias"; 5 | import { getAlias as getNextMocksAlias } from "../plugins/next-mocks/plugin"; 6 | 7 | const require = createRequire(import.meta.url); 8 | 9 | moduleAlias.addAliases({ 10 | react: "next/dist/compiled/react", 11 | "react-dom/server": "next/dist/compiled/react-dom/server.js", 12 | "react-dom/test-utils": require.resolve( 13 | "next/dist/compiled/react-dom/cjs/react-dom-test-utils.production.js", 14 | ), 15 | "react-dom": "next/dist/compiled/react-dom", 16 | ...getNextMocksAlias("node"), 17 | ...getNextImageAlias("node"), 18 | }); 19 | -------------------------------------------------------------------------------- /src/plugins/next-dynamic/plugin.ts: -------------------------------------------------------------------------------- 1 | import MagicString from "magic-string"; 2 | import type { Plugin } from "vite"; 3 | 4 | /** 5 | * A Vite plugin to transform dynamic imports using `require.resolveWeak` into standard dynamic imports. 6 | * `require.resolveWeak` is Webpack-specific and doesn't have any effect in Vite environments. 7 | * 8 | * This plugin searches for patterns like: 9 | * 10 | * ```typescript 11 | * const DynamicComponent = dynamic(async () => { 12 | * typeof require.resolveWeak !== "undefined" && require.resolveWeak(); 13 | * }); 14 | * ``` 15 | * 16 | * And transforms them into: 17 | * 18 | * ```typescript 19 | * const DynamicComponent = dynamic(() => import(), {}); 20 | * ``` 21 | * 22 | * @returns A Vite plugin object. 23 | */ 24 | export const vitePluginNextDynamic = () => 25 | ({ 26 | name: "vite-plugin-storybook-nextjs-dynamic", 27 | transform(code, id) { 28 | // Regex to match the dynamic import pattern 29 | const dynamicImportRegex = 30 | /dynamic\(\s*async\s*\(\s*\)\s*=>\s*\{\s*typeof\s*require\.resolveWeak\s*!==\s*"undefined"\s*&&\s*require\.resolveWeak\(([^)]+)\);\s*\}/g; 31 | 32 | // Check if the code matches the pattern 33 | if (dynamicImportRegex.test(code)) { 34 | const s = new MagicString(code); 35 | dynamicImportRegex.lastIndex = 0; 36 | 37 | let match = dynamicImportRegex.exec(code); 38 | 39 | while (match !== null) { 40 | const [fullMatch, importPath] = match; 41 | 42 | // Construct the new import statement 43 | const newImport = `dynamic(() => import(${importPath})`; 44 | 45 | // Replace the old code with the new import statement 46 | s.overwrite(match.index, match.index + fullMatch.length, newImport); 47 | match = dynamicImportRegex.exec(code); 48 | } 49 | 50 | return { 51 | code: s.toString(), 52 | map: s.generateMap({ hires: true }), 53 | }; 54 | } 55 | 56 | return null; 57 | }, 58 | }) satisfies Plugin; 59 | -------------------------------------------------------------------------------- /src/plugins/next-env/plugin.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import type { Env } from "@next/env"; 3 | import { getDefineEnv } from "next/dist/build/webpack/plugins/define-env-plugin.js"; 4 | import type { NextConfigComplete } from "next/dist/server/config-shared.js"; 5 | import type { Plugin } from "vite"; 6 | 7 | import * as NextUtils from "../../utils/nextjs"; 8 | 9 | export function vitePluginNextEnv( 10 | rootDir: string, 11 | nextConfigResolver: PromiseWithResolvers, 12 | ) { 13 | let envConfig: Env; 14 | let isDev: boolean; 15 | 16 | const resolvedDir = resolve(rootDir); 17 | 18 | return { 19 | name: "vite-plugin-storybook-nextjs-env", 20 | enforce: "pre" as const, 21 | async config(config, env) { 22 | isDev = env.mode !== "production"; 23 | envConfig = (await NextUtils.loadEnvironmentConfig(resolvedDir, isDev)) 24 | .combinedEnv; 25 | 26 | const nextConfig = await nextConfigResolver.promise; 27 | 28 | const publicNextEnvMap = Object.fromEntries( 29 | Object.entries(envConfig) 30 | .filter(([key]) => key.startsWith("NEXT_PUBLIC_")) 31 | .map(([key, value]) => { 32 | return [`process.env.${key}`, JSON.stringify(value)]; 33 | }), 34 | ); 35 | 36 | const finalConfig = { 37 | ...config.define, 38 | ...publicNextEnvMap, 39 | ...getDefineEnv({ 40 | isTurbopack: false, 41 | config: nextConfig, 42 | isClient: true, 43 | isEdgeServer: false, 44 | isNodeOrEdgeCompilation: false, 45 | isNodeServer: false, 46 | clientRouterFilters: undefined, 47 | dev: isDev, 48 | middlewareMatchers: undefined, 49 | hasRewrites: false, 50 | distDir: nextConfig.distDir, 51 | fetchCacheKeyPrefix: nextConfig?.experimental?.fetchCacheKeyPrefix, 52 | }), 53 | }; 54 | 55 | // NEXT_IMAGE_OPTS is used by next/image to pass options to the loader 56 | // it doesn't get properly serialized by Vitest (Vite seems to be fine) so we need to remove it 57 | // for now 58 | // biome-ignore lint/performance/noDelete: 59 | delete process.env.__NEXT_IMAGE_OPTS; 60 | // biome-ignore lint/performance/noDelete: 61 | delete finalConfig["process.env.__NEXT_IMAGE_OPTS"]; 62 | 63 | return { 64 | define: finalConfig, 65 | test: { 66 | deps: { 67 | optimizer: { 68 | ssr: { 69 | include: ["next"], 70 | }, 71 | }, 72 | }, 73 | }, 74 | }; 75 | }, 76 | } satisfies Plugin; 77 | } 78 | -------------------------------------------------------------------------------- /src/plugins/next-font/google/get-font-face-declarations.ts: -------------------------------------------------------------------------------- 1 | import { fetchCSSFromGoogleFonts } from "next/dist/compiled/@next/font/dist/google/fetch-css-from-google-fonts.js"; 2 | import { getFontAxes } from "next/dist/compiled/@next/font/dist/google/get-font-axes.js"; 3 | import { getGoogleFontsUrl } from "next/dist/compiled/@next/font/dist/google/get-google-fonts-url.js"; 4 | import { validateGoogleFontFunctionCall } from "next/dist/compiled/@next/font/dist/google/validate-google-font-function-call.js"; 5 | // @ts-expect-error no types 6 | import loaderUtils from "next/dist/compiled/loader-utils3/index.js"; 7 | 8 | const cssCache = new Map(); 9 | 10 | type FontOrigin = string; 11 | 12 | export type LocalFontSrc = 13 | | FontOrigin 14 | | Array<{ path: FontOrigin; weight?: string; style?: string }>; 15 | 16 | export type LoaderOptions = { 17 | /** 18 | * Initial import name. Can be `next/font/google` or `next/font/local` 19 | */ 20 | source: string; 21 | /** 22 | * Props passed to the `next/font` function call 23 | */ 24 | props: { 25 | src?: LocalFontSrc; 26 | }; 27 | /** 28 | * Font Family name 29 | */ 30 | fontFamily: string; 31 | /** 32 | * Filename of the issuer file, which imports `next/font/google` or `next/font/local 33 | */ 34 | filename: string; 35 | }; 36 | 37 | export async function getFontFaceDeclarations(options: LoaderOptions) { 38 | const { 39 | fontFamily, 40 | weights, 41 | styles, 42 | selectedVariableAxes, 43 | display, 44 | variable, 45 | } = validateGoogleFontFunctionCall(options.fontFamily, options.props); 46 | 47 | const fontAxes = getFontAxes( 48 | fontFamily, 49 | weights, 50 | styles, 51 | selectedVariableAxes, 52 | ); 53 | const url = getGoogleFontsUrl(fontFamily, fontAxes, display); 54 | 55 | try { 56 | const hasCachedCSS = cssCache.has(url); 57 | const fontFaceCSS = hasCachedCSS 58 | ? cssCache.get(url) 59 | : await fetchCSSFromGoogleFonts(url, fontFamily, true).catch(() => null); 60 | if (!hasCachedCSS) { 61 | cssCache.set(url, fontFaceCSS as string); 62 | } else { 63 | cssCache.delete(url); 64 | } 65 | if (fontFaceCSS === null) { 66 | throw new Error( 67 | `Failed to fetch \`${fontFamily}\` from Google Fonts with URL: \`${url}\``, 68 | ); 69 | } 70 | 71 | return { 72 | id: loaderUtils.getHashDigest(url, "md5", "hex", 6), 73 | fontFamily, 74 | fontFaceCSS, 75 | weights, 76 | styles, 77 | variable, 78 | }; 79 | } catch (error) { 80 | throw new Error( 81 | `Failed to fetch \`${fontFamily}\` from Google Fonts with URL: \`${url}\``, 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/plugins/next-font/local/get-font-face-declarations.ts: -------------------------------------------------------------------------------- 1 | import { validateLocalFontFunctionCall } from "next/dist/compiled/@next/font/dist/local/validate-local-font-function-call.js"; 2 | // @ts-expect-error no types 3 | import loaderUtils from "next/dist/compiled/loader-utils3/index.js"; 4 | import { dedent } from "ts-dedent"; 5 | 6 | type FontOrigin = { fontReferenceId?: string; fontPath: string }; 7 | 8 | export type LocalFontSrc = 9 | | FontOrigin 10 | | Array<{ path: FontOrigin; weight?: string; style?: string }>; 11 | 12 | export type LoaderOptions = { 13 | /** 14 | * Initial import name. Can be `next/font/google` or `next/font/local` 15 | */ 16 | source: string; 17 | /** 18 | * Props passed to the `next/font` function call 19 | */ 20 | props: { 21 | src?: string | Array<{ path: string; weight?: string; style?: string }>; 22 | metaSrc?: LocalFontSrc; 23 | }; 24 | /** 25 | * Font Family name 26 | */ 27 | fontFamily: string; 28 | /** 29 | * Filename of the issuer file, which imports `next/font/google` or `next/font/local 30 | */ 31 | filename: string; 32 | }; 33 | 34 | /** 35 | * Returns a placeholder URL for a font reference 36 | * @param refId - The reference ID of the font 37 | * @returns The placeholder URL 38 | */ 39 | export const getPlaceholderFontUrl = (refId: string) => 40 | `__%%import.meta.ROLLUP_FILE_URL_${refId}%%__`; 41 | /** 42 | * Regular expression to match the placeholder URL 43 | */ 44 | getPlaceholderFontUrl.regexp = /__%%import\.meta\.ROLLUP_FILE_URL_(.*?)%%__/g; 45 | 46 | export async function getFontFaceDeclarations(options: LoaderOptions) { 47 | const localFontSrc = options.props.metaSrc; 48 | 49 | const { 50 | weight, 51 | style, 52 | variable, 53 | declarations = [], 54 | } = validateLocalFontFunctionCall("", options.props); 55 | 56 | const id = `font-${loaderUtils.getHashDigest( 57 | Buffer.from(JSON.stringify(localFontSrc)), 58 | "md5", 59 | "hex", 60 | 6, 61 | )}`; 62 | 63 | const fontDeclarations = declarations 64 | .map( 65 | ({ prop, value }: { prop: string; value: string }) => 66 | `${prop}: ${value};`, 67 | ) 68 | .join("\n"); 69 | 70 | const getFontFaceCSS = () => { 71 | if (localFontSrc) { 72 | if ("fontReferenceId" in localFontSrc) { 73 | return dedent`@font-face { 74 | font-family: ${id}; 75 | src: url(${localFontSrc.fontReferenceId ? getPlaceholderFontUrl(localFontSrc.fontReferenceId) : `/@fs${localFontSrc.fontPath}`}) 76 | ${fontDeclarations} 77 | }`; 78 | } 79 | return ( 80 | localFontSrc as Array<{ 81 | path: FontOrigin; 82 | weight?: string; 83 | style?: string; 84 | }> 85 | ) 86 | .map((font) => { 87 | return dedent`@font-face { 88 | font-family: ${id}; 89 | src: url(${font.path.fontReferenceId ? getPlaceholderFontUrl(font.path.fontReferenceId) : `/@fs${font.path.fontPath}`}); 90 | ${font.weight ? `font-weight: ${font.weight};` : ""} 91 | ${font.style ? `font-style: ${font.style};` : ""} 92 | ${fontDeclarations} 93 | }`; 94 | }) 95 | .join(""); 96 | } 97 | 98 | return ""; 99 | }; 100 | 101 | return { 102 | id, 103 | fontFamily: id, 104 | fontFaceCSS: getFontFaceCSS(), 105 | weights: weight ? [weight] : [], 106 | styles: style ? [style] : [], 107 | variable, 108 | }; 109 | } 110 | -------------------------------------------------------------------------------- /src/plugins/next-font/plugin.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import path from "node:path"; 3 | import type { Plugin } from "vite"; 4 | 5 | import { getFontFaceDeclarations as getGoogleFontFaceDeclarations } from "./google/get-font-face-declarations"; 6 | import { 7 | type LoaderOptions, 8 | getFontFaceDeclarations as getLocalFontFaceDeclarations, 9 | } from "./local/get-font-face-declarations"; 10 | import { getCSSMeta } from "./utils/get-css-meta"; 11 | import { setFontDeclarationsInHead } from "./utils/set-font-declarations-in-head"; 12 | 13 | type FontFaceDeclaration = { 14 | id: string; 15 | fontFamily: string; 16 | fontFaceCSS: unknown; 17 | weights: string[]; 18 | styles: string[]; 19 | variable?: string; 20 | }; 21 | 22 | type FontOptions = { 23 | filename: string; 24 | fontFamily: string; 25 | props: { 26 | src?: string | Array<{ path: string; weight?: string; style?: string }>; 27 | }; 28 | source: string; 29 | }; 30 | 31 | const includePattern = /next(\\|\/|\\\\).*(\\|\/|\\\\)target\.css.*$/; 32 | 33 | const virtualModuleId = "virtual:next-font"; 34 | 35 | export function vitePluginNextFont() { 36 | let devMode = true; 37 | 38 | return { 39 | name: "vite-plugin-storybook-nextjs-font", 40 | enforce: "pre" as const, 41 | async config(config, env) { 42 | devMode = env.mode !== "production"; 43 | 44 | return config; 45 | }, 46 | async resolveId(source, importer) { 47 | const cwd = process.cwd(); 48 | if (!includePattern.test(source) || !importer) { 49 | return null; 50 | } 51 | 52 | const [sourceWithoutQuery, rawQuery] = source.split("?"); 53 | const queryParams = JSON.parse(rawQuery); 54 | 55 | const fontOptions = { 56 | filename: (queryParams.path as string) ?? "", 57 | fontFamily: (queryParams.import as string) ?? "", 58 | props: queryParams.arguments?.[0] ?? {}, 59 | source: importer, 60 | } as FontOptions; 61 | 62 | if (fontOptions.source.endsWith("html")) { 63 | // Workaround for HTML files because Vite extracts the css and places it in a separate file 64 | // to inject it in the head of the HTML file 65 | return null; 66 | } 67 | 68 | let fontFaceDeclaration: FontFaceDeclaration | undefined; 69 | 70 | const pathSep = path.sep; 71 | 72 | if ( 73 | sourceWithoutQuery.endsWith( 74 | ["next", "font", "google", "target.css"].join(pathSep), 75 | ) 76 | ) { 77 | fontFaceDeclaration = await getGoogleFontFaceDeclarations(fontOptions); 78 | } 79 | 80 | if ( 81 | sourceWithoutQuery.endsWith( 82 | ["next", "font", "local", "target.css"].join(pathSep), 83 | ) 84 | ) { 85 | const importerDirPath = path.dirname(fontOptions.filename); 86 | 87 | const emitFont = async (importerRelativeFontPath: string) => { 88 | const fontExtension = path.extname(importerRelativeFontPath); 89 | const fontBaseName = path.basename( 90 | importerRelativeFontPath, 91 | fontExtension, 92 | ); 93 | 94 | const fontPath = path.join(importerDirPath, importerRelativeFontPath); 95 | 96 | if (devMode) { 97 | return { 98 | fontPath: path.join(cwd, fontPath), 99 | fontReferenceId: undefined, 100 | }; 101 | } 102 | 103 | try { 104 | const fontData = await fs.readFile(fontPath); 105 | 106 | const fontReferenceId = this.emitFile({ 107 | name: `${fontBaseName}${fontExtension}`, 108 | type: "asset", 109 | source: fontData, 110 | }); 111 | 112 | return { fontReferenceId, fontPath }; 113 | } catch (err) { 114 | console.error(`Could not read font file ${fontPath}:`, err); 115 | return undefined; 116 | } 117 | }; 118 | 119 | const loaderOptions: LoaderOptions = { 120 | ...fontOptions, 121 | }; 122 | 123 | if (loaderOptions) { 124 | if (typeof fontOptions.props.src === "string") { 125 | const font = await emitFont(fontOptions.props.src); 126 | loaderOptions.props.metaSrc = font; 127 | } else { 128 | loaderOptions.props.metaSrc = ( 129 | await Promise.all( 130 | (fontOptions.props.src ?? []).map(async (fontSrc) => { 131 | const font = await emitFont(fontSrc.path); 132 | if (!font) { 133 | return undefined; 134 | } 135 | return { 136 | ...fontSrc, 137 | path: font, 138 | }; 139 | }), 140 | ) 141 | ).filter((font) => font !== undefined); 142 | } 143 | } 144 | 145 | fontFaceDeclaration = await getLocalFontFaceDeclarations(loaderOptions); 146 | } 147 | 148 | return { 149 | id: `${virtualModuleId}?${rawQuery}`, 150 | meta: { 151 | fontFaceDeclaration, 152 | }, 153 | }; 154 | }, 155 | load(id) { 156 | // Check if the file matches the specific pattern 157 | const [source] = id.split("?"); 158 | if (source !== virtualModuleId) { 159 | return undefined; 160 | } 161 | 162 | const moduleInfo = this.getModuleInfo(id); 163 | 164 | const fontFaceDeclaration = moduleInfo?.meta?.fontFaceDeclaration; 165 | 166 | if (typeof fontFaceDeclaration !== "undefined") { 167 | const cssMeta = getCSSMeta(fontFaceDeclaration); 168 | 169 | return ` 170 | ${setFontDeclarationsInHead({ 171 | fontFaceCSS: cssMeta.fontFaceCSS, 172 | id: fontFaceDeclaration.id, 173 | classNamesCSS: cssMeta.classNamesCSS, 174 | })} 175 | 176 | export default { 177 | className: "${cssMeta.className}", 178 | style: ${JSON.stringify(cssMeta.style)} 179 | ${cssMeta.variableClassName ? `, variable: "${cssMeta.variableClassName}"` : ""} 180 | } 181 | `; 182 | } 183 | 184 | return "export default {}"; 185 | }, 186 | } satisfies Plugin; 187 | } 188 | -------------------------------------------------------------------------------- /src/plugins/next-font/utils/get-css-meta.ts: -------------------------------------------------------------------------------- 1 | type Options = { 2 | fontFamily: string; 3 | styles: string[]; 4 | weights: string[]; 5 | fontFaceCSS: string; 6 | variable?: string; 7 | }; 8 | 9 | export function getCSSMeta(options: Options) { 10 | const className = getClassName(options); 11 | const style = getStylesObj(options); 12 | const variableClassName = `__variable_${className}`; 13 | 14 | const classNamesCSS = ` 15 | .${className} { 16 | font-family: ${options.fontFamily}; 17 | ${isNextCSSPropertyValid(options.styles) ? `font-style: ${options.styles[0]};` : ""} 18 | ${isNextCSSPropertyValid(options.weights) ? `font-weight: ${options.weights[0]};` : ""} 19 | } 20 | 21 | ${ 22 | options.variable 23 | ? `.${variableClassName} { ${options.variable}: '${options.fontFamily}'; }` 24 | : "" 25 | } 26 | `; 27 | 28 | const fontFaceCSS = `${changeFontDisplayToSwap(options.fontFaceCSS)}`; 29 | 30 | return { 31 | className, 32 | fontFaceCSS, 33 | classNamesCSS, 34 | style, 35 | ...(options.variable ? { variableClassName } : {}), 36 | }; 37 | } 38 | 39 | function getClassName({ styles, weights, fontFamily }: Options) { 40 | const font = fontFamily.replaceAll(" ", "-").toLowerCase(); 41 | const style = isNextCSSPropertyValid(styles) ? styles[0] : null; 42 | const weight = isNextCSSPropertyValid(weights) ? weights[0] : null; 43 | 44 | return `${font}${style ? `-${style}` : ""}${weight ? `-${weight}` : ""}`; 45 | } 46 | 47 | function getStylesObj({ styles, weights, fontFamily }: Options) { 48 | return { 49 | fontFamily, 50 | ...(isNextCSSPropertyValid(styles) ? { fontStyle: styles[0] } : {}), 51 | ...(isNextCSSPropertyValid(weights) ? { fontWeight: weights[0] } : {}), 52 | }; 53 | } 54 | 55 | function isNextCSSPropertyValid(prop: string[]) { 56 | return prop.length === 1 && prop[0] !== "variable"; 57 | } 58 | 59 | /** 60 | * This step is necessary, because otherwise the font-display: optional; property 61 | * blocks Storybook from rendering the font, because the @font-face declaration 62 | * is not loaded in time. 63 | */ 64 | function changeFontDisplayToSwap(css: string) { 65 | return css.replaceAll("font-display: optional;", "font-display: block;"); 66 | } 67 | -------------------------------------------------------------------------------- /src/plugins/next-font/utils/set-font-declarations-in-head.ts: -------------------------------------------------------------------------------- 1 | import { dedent } from "ts-dedent"; 2 | import { getPlaceholderFontUrl } from "../local/get-font-face-declarations"; 3 | 4 | type Props = { 5 | id: string; 6 | fontFaceCSS: string; 7 | classNamesCSS: string; 8 | }; 9 | 10 | export function setFontDeclarationsInHead({ 11 | id, 12 | fontFaceCSS, 13 | classNamesCSS, 14 | }: Props) { 15 | // fontFaceCSS has placeholders for font path and fontReferenceId 16 | // I want to extract them 17 | const regex = new RegExp(getPlaceholderFontUrl.regexp); 18 | const fontPaths = fontFaceCSS.matchAll(regex); 19 | 20 | const fontPathsImportUrls = []; 21 | 22 | if (fontPaths) { 23 | for (const match of fontFaceCSS.matchAll(regex)) { 24 | fontPathsImportUrls.push({ 25 | id: match[1], 26 | path: match[0].replaceAll(/__%%|%%__/g, ""), 27 | }); 28 | } 29 | } 30 | 31 | return dedent` 32 | const fontPaths = [${fontPathsImportUrls.map((fontPath) => `{id: '${fontPath.id}', path: ${fontPath.path}}`).join(", ")}]; 33 | if (!document.getElementById('id-${id}')) { 34 | let fontDeclarations = \`${fontFaceCSS}\`; 35 | fontPaths.forEach((fontPath, i) => { 36 | fontDeclarations = fontDeclarations.replace('__%%import.meta.ROLLUP_FILE_URL_' + fontPath.id + '%%__', fontPath.path.toString()); 37 | }); 38 | const style = document.createElement('style'); 39 | style.setAttribute('id', 'font-face-${id}'); 40 | style.innerHTML = fontDeclarations; 41 | document.head.appendChild(style); 42 | 43 | const classNamesCSS = \`${classNamesCSS}\`; 44 | const classNamesStyle = document.createElement('style'); 45 | classNamesStyle.setAttribute('id', 'classnames-${id}'); 46 | classNamesStyle.innerHTML = classNamesCSS; 47 | document.head.appendChild(classNamesStyle); 48 | } 49 | `; 50 | } 51 | -------------------------------------------------------------------------------- /src/plugins/next-image/alias/image-context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "next/dist/compiled/react"; 2 | import type { ImageProps, StaticImageData } from "next/image"; 3 | import type { ImageProps as LegacyImageProps } from "next/legacy/image"; 4 | 5 | // StaticRequire needs to be in scope for the TypeScript compiler to work. 6 | // See: https://github.com/microsoft/TypeScript/issues/5711 7 | // Since next/image doesn't export StaticRequire we need to re-define it here and set src's type to it. 8 | interface StaticRequire { 9 | default: StaticImageData; 10 | } 11 | 12 | declare type StaticImport = StaticRequire | StaticImageData; 13 | 14 | export const ImageContext = createContext< 15 | Partial & { src: string | StaticImport }> & 16 | Omit 17 | >({}); 18 | -------------------------------------------------------------------------------- /src/plugins/next-image/alias/image-default-loader.tsx: -------------------------------------------------------------------------------- 1 | import type * as _NextImage from "next/image"; 2 | 3 | export const defaultLoader = ({ 4 | src, 5 | width, 6 | quality = 75, 7 | }: _NextImage.ImageLoaderProps) => { 8 | const missingValues = []; 9 | if (!src) { 10 | missingValues.push("src"); 11 | } 12 | 13 | if (!width) { 14 | missingValues.push("width"); 15 | } 16 | 17 | if (missingValues.length > 0) { 18 | throw new Error( 19 | `Next Image Optimization requires ${missingValues.join( 20 | ", ", 21 | )} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify( 22 | { 23 | src, 24 | width, 25 | quality, 26 | }, 27 | )}`, 28 | ); 29 | } 30 | 31 | const url = new URL(src, window.location.href); 32 | 33 | if (!url.searchParams.has("w") && !url.searchParams.has("q")) { 34 | url.searchParams.set("w", width.toString()); 35 | url.searchParams.set("q", quality.toString()); 36 | } 37 | 38 | if (!src.startsWith("http://") && !src.startsWith("https://")) { 39 | return url.toString().slice(url.origin.length); 40 | } 41 | 42 | return url.toString(); 43 | }; 44 | -------------------------------------------------------------------------------- /src/plugins/next-image/alias/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRequire } from "node:module"; 2 | import { VITEST_PLUGIN_NAME } from "../../../utils"; 3 | 4 | const require = createRequire(import.meta.url); 5 | 6 | type Env = "browser" | "node"; 7 | 8 | const getEntryPoint = (subPath: string, env: Env) => 9 | require.resolve(`${VITEST_PLUGIN_NAME}/${env}/mocks/${subPath}`); 10 | 11 | export const getAlias = (env: Env) => ({ 12 | "sb-original/default-loader": getEntryPoint("image-default-loader", env), 13 | "sb-original/image-context": getEntryPoint("image-context", env), 14 | }); 15 | -------------------------------------------------------------------------------- /src/plugins/next-image/alias/next-image.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore import is aliased in webpack config 2 | import * as NextImageNamespace from "next/image"; 3 | import type * as _NextImage from "next/image"; 4 | 5 | import { defaultLoader } from "sb-original/default-loader"; 6 | import { ImageContext } from "sb-original/image-context"; 7 | 8 | import React from "next/dist/compiled/react"; 9 | 10 | const OriginalNextImage = NextImageNamespace.default; 11 | const { getImageProps: originalGetImageProps } = NextImageNamespace; 12 | 13 | const MockedNextImage = React.forwardRef< 14 | HTMLImageElement, 15 | _NextImage.ImageProps 16 | >(({ loader, ...props }, ref) => { 17 | const imageParameters = React.useContext(ImageContext); 18 | 19 | return ( 20 | 26 | ); 27 | }); 28 | 29 | MockedNextImage.displayName = "NextImage"; 30 | 31 | export const getImageProps = (props: _NextImage.ImageProps) => 32 | originalGetImageProps?.({ loader: defaultLoader, ...props }); 33 | 34 | export default MockedNextImage; 35 | -------------------------------------------------------------------------------- /src/plugins/next-image/alias/next-legacy-image.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore import is aliased in webpack config 2 | import OriginalNextLegacyImage from "next/legacy/image"; 3 | import { defaultLoader } from "sb-original/default-loader"; 4 | import { ImageContext } from "sb-original/image-context"; 5 | 6 | import React from "next/dist/compiled/react"; 7 | import type * as _NextLegacyImage from "next/legacy/image"; 8 | 9 | function NextLegacyImage({ loader, ...props }: _NextLegacyImage.ImageProps) { 10 | const imageParameters = React.useContext(ImageContext); 11 | 12 | return ( 13 | 18 | ); 19 | } 20 | 21 | export default NextLegacyImage; 22 | -------------------------------------------------------------------------------- /src/plugins/next-image/plugin.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import { createRequire } from "node:module"; 3 | import path from "node:path"; 4 | import { decode } from "node:querystring"; 5 | import { imageSize } from "image-size"; 6 | import type { NextConfigComplete } from "next/dist/server/config-shared.js"; 7 | import { dedent } from "ts-dedent"; 8 | import type { Plugin } from "vite"; 9 | import { VITEST_PLUGIN_NAME, isVitestEnv } from "../../utils"; 10 | import { getAlias } from "./alias"; 11 | 12 | const includePattern = /\.(png|jpg|jpeg|gif|webp|avif|ico|bmp|svg)$/; 13 | const excludeImporterPattern = /\.(css|scss|sass)$/; 14 | 15 | const virtualImage = "virtual:next-image"; 16 | const virtualNextImage = "virtual:next/image"; 17 | const virtualNextLegacyImage = "virtual:next/legacy/image"; 18 | 19 | const require = createRequire(import.meta.url); 20 | 21 | export function vitePluginNextImage( 22 | nextConfigResolver: PromiseWithResolvers, 23 | ) { 24 | let isBrowser = !isVitestEnv; 25 | return { 26 | name: "vite-plugin-storybook-nextjs-image", 27 | enforce: "pre" as const, 28 | async config(config, env) { 29 | if (config.test?.browser?.enabled === true) { 30 | isBrowser = true; 31 | } 32 | return { 33 | resolve: { 34 | alias: getAlias(isBrowser ? "browser" : "node"), 35 | }, 36 | }; 37 | }, 38 | async resolveId(id, importer) { 39 | const [source, queryA] = id.split("?"); 40 | 41 | if (queryA === "ignore") { 42 | return null; 43 | } 44 | 45 | if ( 46 | includePattern.test(source) && 47 | !excludeImporterPattern.test(importer ?? "") && 48 | !importer?.startsWith(virtualImage) 49 | ) { 50 | const isAbsolute = path.isAbsolute(id); 51 | const imagePath = importer 52 | ? isAbsolute 53 | ? source 54 | : path.join(path.dirname(importer), source) 55 | : source; 56 | 57 | return `${virtualImage}?imagePath=${imagePath}`; 58 | } 59 | 60 | if (id === "next/image" && importer !== virtualNextImage) { 61 | return virtualNextImage; 62 | } 63 | 64 | if (id === "next/legacy/image" && importer !== virtualNextLegacyImage) { 65 | return virtualNextLegacyImage; 66 | } 67 | 68 | return null; 69 | }, 70 | 71 | async load(id) { 72 | const aliasEnv = isBrowser ? "browser" : "node"; 73 | if (virtualNextImage === id) { 74 | return ( 75 | await fs.promises.readFile( 76 | require.resolve(`${VITEST_PLUGIN_NAME}/${aliasEnv}/mocks/image`), 77 | ) 78 | ).toString("utf-8"); 79 | } 80 | 81 | if (virtualNextLegacyImage === id) { 82 | return ( 83 | await fs.promises.readFile( 84 | require.resolve( 85 | `${VITEST_PLUGIN_NAME}/${aliasEnv}/mocks/legacy-image`, 86 | ), 87 | ) 88 | ).toString("utf-8"); 89 | } 90 | 91 | const [source, query] = id.split("?"); 92 | 93 | if (virtualImage === source) { 94 | const imagePath = decode(query).imagePath as string; 95 | 96 | const nextConfig = await nextConfigResolver.promise; 97 | 98 | try { 99 | if (nextConfig.images?.disableStaticImages) { 100 | return dedent` 101 | import image from "${imagePath}?ignore"; 102 | export default image; 103 | `; 104 | } 105 | 106 | const imageData = await fs.promises.readFile(imagePath); 107 | 108 | const { width, height } = imageSize(imageData); 109 | 110 | return dedent` 111 | import src from "${imagePath}?ignore"; 112 | export default { 113 | src, 114 | height: ${height}, 115 | width: ${width}, 116 | blurDataURL: src 117 | }; 118 | `; 119 | } catch (err) { 120 | console.error(`Could not read font file ${imagePath}:`, err); 121 | return undefined; 122 | } 123 | } 124 | 125 | return null; 126 | }, 127 | } satisfies Plugin; 128 | } 129 | -------------------------------------------------------------------------------- /src/plugins/next-mocks/alias/cache/index.ts: -------------------------------------------------------------------------------- 1 | import { fn } from "storybook/test"; 2 | import type { Mock } from "vitest"; 3 | 4 | // biome-ignore lint/suspicious/noExplicitAny: 5 | type Callback = (...args: any[]) => Promise; 6 | 7 | // biome-ignore lint/suspicious/noExplicitAny: 8 | type Procedure = (...args: any[]) => any; 9 | 10 | // mock utilities/overrides (as of Next v14.2.0) 11 | const revalidatePath: Mock = fn().mockName( 12 | "next/cache::revalidatePath", 13 | ); 14 | const revalidateTag: Mock = fn().mockName( 15 | "next/cache::revalidateTag", 16 | ); 17 | const unstable_cache: Mock = fn() 18 | .mockName("next/cache::unstable_cache") 19 | .mockImplementation((cb: Callback) => cb); 20 | const unstable_noStore: Mock = fn().mockName( 21 | "next/cache::unstable_noStore", 22 | ); 23 | 24 | const cacheExports = { 25 | unstable_cache, 26 | revalidateTag, 27 | revalidatePath, 28 | unstable_noStore, 29 | }; 30 | 31 | export default cacheExports; 32 | export { unstable_cache, revalidateTag, revalidatePath, unstable_noStore }; 33 | -------------------------------------------------------------------------------- /src/plugins/next-mocks/alias/dynamic/index.tsx: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | 3 | // Copyright (c) 2024 Vercel, Inc. 4 | // https://github.com/vercel/next.js/blob/main/packages/next/src/shared/lib/dynamic.tsx 5 | 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | // The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | import Loadable from "next/dist/shared/lib/loadable.shared-runtime.js"; 13 | // biome-ignore lint/style/useImportType: 14 | import React from "react"; 15 | import type { JSX } from "react"; 16 | 17 | type ComponentModule

> = { 18 | default: React.ComponentType

; 19 | }; 20 | 21 | export declare type LoaderComponent

> = Promise< 22 | React.ComponentType

| ComponentModule

23 | >; 24 | 25 | declare type Loader

> = 26 | | (() => LoaderComponent

) 27 | | LoaderComponent

; 28 | 29 | type LoaderMap = { [module: string]: () => Loader }; 30 | 31 | type LoadableGeneratedOptions = { 32 | webpack?(): unknown; 33 | modules?(): LoaderMap; 34 | }; 35 | 36 | type DynamicOptionsLoadingProps = { 37 | error?: Error | null; 38 | isLoading?: boolean; 39 | pastDelay?: boolean; 40 | retry?: () => void; 41 | timedOut?: boolean; 42 | }; 43 | 44 | // Normalize loader to return the module as form { default: Component } for `React.lazy`. 45 | // Also for backward compatible since next/dynamic allows to resolve a component directly with loader 46 | // Client component reference proxy need to be converted to a module. 47 | function convertModule

(mod: React.ComponentType

| ComponentModule

) { 48 | return { default: (mod as ComponentModule

)?.default || mod }; 49 | } 50 | 51 | type DynamicOptions

> = LoadableGeneratedOptions & { 52 | loading?: (loadingProps: DynamicOptionsLoadingProps) => JSX.Element | null; 53 | loader?: Loader

| LoaderMap; 54 | loadableGenerated?: LoadableGeneratedOptions; 55 | ssr?: boolean; 56 | }; 57 | 58 | type LoadableOptions

> = DynamicOptions

; 59 | 60 | type LoadableFn

> = ( 61 | opts: LoadableOptions

, 62 | ) => React.ComponentType

; 63 | 64 | export function noSSR

>(): React.ComponentType

{ 65 | throw new Error("noSSR is not implemented in Storybook"); 66 | } 67 | 68 | /** 69 | * This function lets you dynamically import a component. 70 | * It uses [React.lazy()](https://react.dev/reference/react/lazy) with [Suspense](https://react.dev/reference/react/Suspense) under the hood. 71 | * 72 | * Read more: [Next.js Docs: `next/dynamic`](https://nextjs.org/docs/app/building-your-application/optimizing/lazy-loading#nextdynamic) 73 | */ 74 | export default function dynamic

>( 75 | dynamicOptions: DynamicOptions

| Loader

, 76 | options?: DynamicOptions

, 77 | ): React.ComponentType

{ 78 | const loadableFn = Loadable as LoadableFn

; 79 | if (options?.ssr === false) { 80 | // biome-ignore lint/performance/noDelete: 81 | delete options.ssr; 82 | } 83 | 84 | let loadableOptions: LoadableOptions

= { 85 | // A loading component is not required, so we default it 86 | loading: ({ error, isLoading, pastDelay }) => { 87 | if (!pastDelay) return null; 88 | if (process.env.NODE_ENV !== "production") { 89 | if (isLoading) { 90 | return null; 91 | } 92 | if (error) { 93 | return ( 94 |

95 | {error.message} 96 |
97 | {error.stack} 98 |

99 | ); 100 | } 101 | } 102 | return null; 103 | }, 104 | }; 105 | 106 | // Support for direct import(), eg: dynamic(import('../hello-world')) 107 | // Note that this is only kept for the edge case where someone is passing in a promise as first argument 108 | // The react-loadable babel plugin will turn dynamic(import('../hello-world')) into dynamic(() => import('../hello-world')) 109 | // To make sure we don't execute the import without rendering first 110 | if (dynamicOptions instanceof Promise) { 111 | loadableOptions.loader = () => dynamicOptions; 112 | // Support for having import as a function, eg: dynamic(() => import('../hello-world')) 113 | } else if (typeof dynamicOptions === "function") { 114 | loadableOptions.loader = dynamicOptions; 115 | // Support for having first argument being options, eg: dynamic({loader: import('../hello-world')}) 116 | } else if (typeof dynamicOptions === "object") { 117 | loadableOptions = { ...loadableOptions, ...dynamicOptions }; 118 | } 119 | 120 | // Support for passing options, eg: dynamic(import('../hello-world'), {loading: () =>

Loading something

}) 121 | loadableOptions = { ...loadableOptions, ...options }; 122 | 123 | const loaderFn = loadableOptions.loader as () => LoaderComponent

; 124 | const loader = () => 125 | loaderFn != null 126 | ? loaderFn().then(convertModule) 127 | : Promise.resolve(convertModule(() => null)); 128 | 129 | // coming from build/babel/plugins/react-loadable-plugin.js 130 | if (loadableOptions.loadableGenerated) { 131 | loadableOptions = { 132 | ...loadableOptions, 133 | ...loadableOptions.loadableGenerated, 134 | }; 135 | // biome-ignore lint/performance/noDelete: 136 | delete loadableOptions.loadableGenerated; 137 | } 138 | 139 | return loadableFn({ ...loadableOptions, loader: loader as Loader

}); 140 | } 141 | -------------------------------------------------------------------------------- /src/plugins/next-mocks/alias/headers/cookies.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type RequestCookie, 3 | RequestCookies, 4 | } from "next/dist/compiled/@edge-runtime/cookies/index.js"; 5 | import { fn } from "storybook/test"; 6 | import type { Mock } from "vitest"; 7 | import { headers } from "./index.js"; 8 | 9 | class RequestCookiesMock extends RequestCookies { 10 | get: Mock< 11 | (...args: [name: string] | [RequestCookie]) => RequestCookie | undefined 12 | > = fn(super.get.bind(this)).mockName("next/headers::cookies().get"); 13 | 14 | getAll: Mock< 15 | (...args: [name: string] | [RequestCookie] | []) => RequestCookie[] 16 | > = fn(super.getAll.bind(this)).mockName("next/headers::cookies().getAll"); 17 | 18 | has: Mock<(name: string) => boolean> = fn(super.has.bind(this)).mockName( 19 | "next/headers::cookies().has", 20 | ); 21 | 22 | set: Mock< 23 | (...args: [key: string, value: string] | [options: RequestCookie]) => this 24 | > = fn(super.set.bind(this)).mockName("next/headers::cookies().set"); 25 | 26 | delete: Mock<(names: string | string[]) => boolean | boolean[]> = fn( 27 | super.delete.bind(this), 28 | ).mockName("next/headers::cookies().delete"); 29 | } 30 | 31 | let requestCookiesMock: RequestCookiesMock; 32 | 33 | export const cookies: Mock<() => RequestCookiesMock> = fn(() => { 34 | if (!requestCookiesMock) { 35 | requestCookiesMock = new RequestCookiesMock(headers()); 36 | } 37 | return requestCookiesMock; 38 | }).mockName("next/headers::cookies()"); 39 | 40 | const originalRestore = cookies.mockRestore.bind(null); 41 | 42 | // will be called automatically by the test loader 43 | cookies.mockRestore = () => { 44 | originalRestore(); 45 | headers.mockRestore(); 46 | requestCookiesMock = new RequestCookiesMock(headers()); 47 | }; 48 | -------------------------------------------------------------------------------- /src/plugins/next-mocks/alias/headers/headers.ts: -------------------------------------------------------------------------------- 1 | import { fn } from "storybook/test"; 2 | 3 | import { HeadersAdapter } from "next/dist/server/web/spec-extension/adapters/headers.js"; 4 | import type { Mock } from "vitest"; 5 | 6 | class HeadersAdapterMock extends HeadersAdapter { 7 | constructor() { 8 | super({}); 9 | } 10 | 11 | append: Mock<(name: string, value: string) => void> = fn( 12 | super.append.bind(this), 13 | ).mockName("next/headers::headers().append"); 14 | 15 | delete: Mock<(name: string) => void> = fn(super.delete.bind(this)).mockName( 16 | "next/headers::headers().delete", 17 | ); 18 | 19 | get: Mock<(name: string) => string | null> = fn( 20 | super.get.bind(this), 21 | ).mockName("next/headers::headers().get"); 22 | 23 | has: Mock<(name: string) => boolean> = fn(super.has.bind(this)).mockName( 24 | "next/headers::headers().has", 25 | ); 26 | 27 | set: Mock<(name: string, value: string) => void> = fn( 28 | super.set.bind(this), 29 | ).mockName("next/headers::headers().set"); 30 | 31 | forEach: Mock< 32 | ( 33 | callbackfn: (value: string, name: string, parent: Headers) => void, 34 | // biome-ignore lint/suspicious/noExplicitAny: 35 | thisArg?: any, 36 | ) => void 37 | > = fn(super.forEach.bind(this)).mockName("next/headers::headers().forEach"); 38 | 39 | entries: Mock<() => IterableIterator<[string, string]>> = fn( 40 | super.entries.bind(this), 41 | ).mockName("next/headers::headers().entries"); 42 | 43 | keys: Mock<() => IterableIterator> = fn( 44 | super.keys.bind(this), 45 | ).mockName("next/headers::headers().keys"); 46 | 47 | values: Mock<() => IterableIterator> = fn( 48 | super.values.bind(this), 49 | ).mockName("next/headers::headers().values"); 50 | } 51 | 52 | let headersAdapterMock: HeadersAdapterMock; 53 | 54 | export const headers = () => { 55 | if (!headersAdapterMock) headersAdapterMock = new HeadersAdapterMock(); 56 | return headersAdapterMock; 57 | }; 58 | 59 | // This fn is called by ./cookies to restore the headers in the right order 60 | headers.mockRestore = () => { 61 | headersAdapterMock = new HeadersAdapterMock(); 62 | }; 63 | -------------------------------------------------------------------------------- /src/plugins/next-mocks/alias/headers/index.ts: -------------------------------------------------------------------------------- 1 | import { draftMode as originalDraftMode } from "next/dist/server/request/draft-mode"; 2 | import * as headers from "next/dist/server/request/headers"; 3 | import { fn } from "storybook/test"; 4 | import type { Mock } from "vitest"; 5 | 6 | // mock utilities/overrides (as of Next v14.2.0) 7 | export { headers } from "./headers"; 8 | export { cookies } from "./cookies"; 9 | 10 | // re-exports of the actual module 11 | export { UnsafeUnwrappedHeaders } from "next/dist/server/request/headers"; 12 | 13 | // passthrough mocks - keep original implementation but allow for spying 14 | const draftMode: Mock<() => ReturnType> = fn( 15 | // biome-ignore lint/suspicious/noExplicitAny: 16 | originalDraftMode ?? (headers as any).draftMode, 17 | ).mockName("draftMode"); 18 | export { draftMode }; 19 | -------------------------------------------------------------------------------- /src/plugins/next-mocks/alias/navigation/index.ts: -------------------------------------------------------------------------------- 1 | import * as actual from "next/dist/client/components/navigation.js"; 2 | import { RedirectStatusCode } from "next/dist/client/components/redirect-status-code.js"; 3 | import { getRedirectError } from "next/dist/client/components/redirect.js"; 4 | import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; 5 | import { NextjsRouterMocksNotAvailable } from "storybook/internal/preview-errors"; 6 | import type { Mock } from "storybook/test"; 7 | import { fn } from "storybook/test"; 8 | 9 | let navigationAPI: { 10 | push: Mock; 11 | replace: Mock; 12 | forward: Mock; 13 | back: Mock; 14 | prefetch: Mock; 15 | refresh: Mock; 16 | }; 17 | 18 | /** 19 | * Creates a next/navigation router API mock. Used internally. 20 | * @ignore 21 | * @internal 22 | * */ 23 | 24 | type NavigationActions = typeof navigationAPI & Record; 25 | 26 | export const createNavigation = ( 27 | overrides?: Record unknown>, 28 | ) => { 29 | const navigationActions: NavigationActions = { 30 | push: fn().mockName("next/navigation::useRouter().push"), 31 | replace: fn().mockName("next/navigation::useRouter().replace"), 32 | forward: fn().mockName("next/navigation::useRouter().forward"), 33 | back: fn().mockName("next/navigation::useRouter().back"), 34 | prefetch: fn().mockName("next/navigation::useRouter().prefetch"), 35 | refresh: fn().mockName("next/navigation::useRouter().refresh"), 36 | }; 37 | 38 | if (overrides) { 39 | for (const key of Object.keys(navigationActions)) { 40 | if (key in overrides) { 41 | navigationActions[key] = fn((...args: unknown[]) => { 42 | return overrides[key](...args); 43 | }).mockName(`useRouter().${key}`); 44 | } 45 | } 46 | } 47 | 48 | navigationAPI = navigationActions; 49 | 50 | return navigationAPI; 51 | }; 52 | 53 | export const getRouter = () => { 54 | if (!navigationAPI) { 55 | throw new NextjsRouterMocksNotAvailable({ 56 | importType: "next/navigation", 57 | }); 58 | } 59 | 60 | return navigationAPI; 61 | }; 62 | 63 | // re-exports of the actual module 64 | export * from "next/dist/client/components/navigation.js"; 65 | 66 | // mock utilities/overrides (as of Next v14.2.0) 67 | export const redirect: Mock< 68 | (url: string, type?: actual.RedirectType) => never 69 | > = fn( 70 | ( 71 | url: string, 72 | type: actual.RedirectType = actual.RedirectType.push, 73 | ): never => { 74 | throw getRedirectError(url, type, RedirectStatusCode.SeeOther); 75 | }, 76 | ).mockName("next/navigation::redirect"); 77 | 78 | export const permanentRedirect: Mock< 79 | (url: string, type?: actual.RedirectType) => never 80 | > = fn( 81 | ( 82 | url: string, 83 | type: actual.RedirectType = actual.RedirectType.push, 84 | ): never => { 85 | throw getRedirectError(url, type, RedirectStatusCode.SeeOther); 86 | }, 87 | ).mockName("next/navigation::permanentRedirect"); 88 | 89 | // passthrough mocks - keep original implementation but allow for spying 90 | export const useSearchParams: Mock<() => actual.ReadonlyURLSearchParams> = fn( 91 | actual.useSearchParams, 92 | ).mockName("next/navigation::useSearchParams"); 93 | export const usePathname: Mock<() => string> = fn(actual.usePathname).mockName( 94 | "next/navigation::usePathname", 95 | ); 96 | export const useSelectedLayoutSegment: Mock< 97 | (parallelRouteKey?: string) => string | null 98 | > = fn(actual.useSelectedLayoutSegment).mockName( 99 | "next/navigation::useSelectedLayoutSegment", 100 | ); 101 | export const useSelectedLayoutSegments: Mock< 102 | (parallelRouteKey?: string) => string[] 103 | > = fn(actual.useSelectedLayoutSegments).mockName( 104 | "next/navigation::useSelectedLayoutSegments", 105 | ); 106 | export const useRouter: Mock<() => AppRouterInstance> = fn( 107 | actual.useRouter, 108 | ).mockName("next/navigation::useRouter"); 109 | export const useServerInsertedHTML: Mock< 110 | (callback: () => React.ReactNode) => void 111 | > = fn(actual.useServerInsertedHTML).mockName( 112 | "next/navigation::useServerInsertedHTML", 113 | ); 114 | export const notFound: Mock<() => never> = fn(actual.notFound).mockName( 115 | "next/navigation::notFound", 116 | ); 117 | 118 | // Params, not exported by Next.js, is manually declared to avoid inference issues. 119 | interface Params { 120 | [key: string]: string | string[]; 121 | } 122 | export const useParams: Mock<() => Params> = fn<[], Params>( 123 | actual.useParams, 124 | ).mockName("next/navigation::useParams"); 125 | -------------------------------------------------------------------------------- /src/plugins/next-mocks/alias/router/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextComponentType, NextPageContext } from "next"; 2 | import singletonRouter, * as originalRouter from "next/dist/client/router.js"; 3 | import type { 4 | ExcludeRouterProps, 5 | WithRouterProps, 6 | } from "next/dist/client/with-router"; 7 | import type { NextRouter, SingletonRouter } from "next/router.js"; 8 | import type { ComponentType } from "react"; 9 | import { NextjsRouterMocksNotAvailable } from "storybook/internal/preview-errors"; 10 | import type { Mock } from "storybook/test"; 11 | import { fn } from "storybook/test"; 12 | 13 | const defaultRouterState = { 14 | route: "/", 15 | asPath: "/", 16 | basePath: "/", 17 | pathname: "/", 18 | query: {}, 19 | isFallback: false, 20 | isLocaleDomain: false, 21 | isReady: true, 22 | isPreview: false, 23 | }; 24 | 25 | let routerAPI: { 26 | push: Mock; 27 | replace: Mock; 28 | reload: Mock; 29 | back: Mock; 30 | forward: Mock; 31 | prefetch: Mock; 32 | beforePopState: Mock; 33 | events: { 34 | on: Mock; 35 | off: Mock; 36 | emit: Mock; 37 | }; 38 | } & typeof defaultRouterState; 39 | 40 | /** 41 | * Creates a next/router router API mock. Used internally. 42 | * @ignore 43 | * @internal 44 | * */ 45 | export const createRouter = (overrides: Partial) => { 46 | const routerActions: Partial = { 47 | push: fn((..._args: unknown[]) => { 48 | return Promise.resolve(true); 49 | }).mockName("next/router::useRouter().push"), 50 | replace: fn((..._args: unknown[]) => { 51 | return Promise.resolve(true); 52 | }).mockName("next/router::useRouter().replace"), 53 | reload: fn((..._args: unknown[]) => {}).mockName( 54 | "next/router::useRouter().reload", 55 | ), 56 | back: fn((..._args: unknown[]) => {}).mockName( 57 | "next/router::useRouter().back", 58 | ), 59 | forward: fn(() => {}).mockName("next/router::useRouter().forward"), 60 | prefetch: fn((..._args: unknown[]) => { 61 | return Promise.resolve(); 62 | }).mockName("next/router::useRouter().prefetch"), 63 | beforePopState: fn((..._args: unknown[]) => {}).mockName( 64 | "next/router::useRouter().beforePopState", 65 | ), 66 | }; 67 | 68 | const routerEvents: NextRouter["events"] = { 69 | on: fn((..._args: unknown[]) => {}).mockName( 70 | "next/router::useRouter().events.on", 71 | ), 72 | off: fn((..._args: unknown[]) => {}).mockName( 73 | "next/router::useRouter().events.off", 74 | ), 75 | emit: fn((..._args: unknown[]) => {}).mockName( 76 | "next/router::useRouter().events.emit", 77 | ), 78 | }; 79 | 80 | if (overrides) { 81 | for (const key of Object.keys(routerActions)) { 82 | if (key in overrides) { 83 | // biome-ignore lint/suspicious/noExplicitAny: simply casting to any for convenience 84 | (routerActions as any)[key] = fn((...args: unknown[]) => { 85 | // biome-ignore lint/suspicious/noExplicitAny: simply casting to any for convenience 86 | return (overrides as any)[key](...args); 87 | }).mockName(`useRouter().${key}`); 88 | } 89 | } 90 | } 91 | 92 | if (overrides?.events) { 93 | for (const key of Object.keys(routerEvents)) { 94 | if (key in routerEvents) { 95 | // biome-ignore lint/suspicious/noExplicitAny: simply casting to any for convenience 96 | (routerEvents as any)[key] = fn((...args: unknown[]) => { 97 | // biome-ignore lint/suspicious/noExplicitAny: simply casting to any for convenience 98 | return (overrides.events as any)[key](...args); 99 | }).mockName(`useRouter().events.${key}`); 100 | } 101 | } 102 | } 103 | 104 | routerAPI = { 105 | ...defaultRouterState, 106 | ...overrides, 107 | ...routerActions, 108 | // @ts-expect-error TODO improve typings 109 | events: routerEvents, 110 | }; 111 | 112 | // overwrite the singleton router from next/router 113 | // biome-ignore lint/suspicious/noExplicitAny: simply casting to any for convenience 114 | (singletonRouter as unknown as SingletonRouter).router = routerAPI as any; 115 | 116 | for (const cb of (singletonRouter as unknown as SingletonRouter) 117 | .readyCallbacks) { 118 | cb(); 119 | } 120 | 121 | (singletonRouter as unknown as SingletonRouter).readyCallbacks = []; 122 | 123 | return routerAPI as unknown as NextRouter; 124 | }; 125 | 126 | export const getRouter = () => { 127 | if (!routerAPI) { 128 | throw new NextjsRouterMocksNotAvailable({ 129 | importType: "next/router", 130 | }); 131 | } 132 | 133 | return routerAPI; 134 | }; 135 | 136 | // re-exports of the actual module 137 | export * from "next/dist/client/router"; 138 | export default singletonRouter; 139 | 140 | // mock utilities/overrides (as of Next v14.2.0) 141 | // passthrough mocks - keep original implementation but allow for spying 142 | export const useRouter: Mock<() => NextRouter> = fn( 143 | originalRouter.useRouter, 144 | ).mockName("next/router::useRouter"); 145 | export const withRouter: Mock< 146 | ( 147 | // biome-ignore lint/suspicious/noExplicitAny: 148 | ComposedComponent: NextComponentType, 149 | ) => ComponentType> 150 | > = fn(originalRouter.withRouter).mockName("next/router::withRouter"); 151 | -------------------------------------------------------------------------------- /src/plugins/next-mocks/alias/rsc/server-only.ts: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /src/plugins/next-mocks/compatibility/compatibility-map.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from "node:module"; 2 | import semver from "semver"; 3 | 4 | import { VITEST_PLUGIN_NAME } from "../../../utils"; 5 | import { getNextjsVersion } from "./utils"; 6 | 7 | const require = createRequire(import.meta.url); 8 | 9 | type Env = "browser" | "node"; 10 | 11 | const getEntryPoint = (subPath: string, env: Env) => 12 | require.resolve(`${VITEST_PLUGIN_NAME}/${env}/mocks/${subPath}`); 13 | 14 | const mapping = ( 15 | env: Env, 16 | ): Record> => ({ 17 | "<15.0.0": { 18 | "next/dist/server/request/headers": "next/dist/client/components/headers", 19 | // this path only exists from Next 15 onwards 20 | "next/dist/server/request/draft-mode": getEntryPoint( 21 | "draft-mode.compat", 22 | env, 23 | ), 24 | }, 25 | }); 26 | 27 | export const getCompatibilityAliases = (env: Env) => { 28 | const version = getNextjsVersion(); 29 | const result: Record = {}; 30 | 31 | const compatMap = mapping(env); 32 | 33 | // biome-ignore lint/complexity/noForEach: 34 | Object.keys(compatMap).forEach((key) => { 35 | if (semver.intersects(version, key)) { 36 | Object.assign(result, compatMap[key]); 37 | } 38 | }); 39 | 40 | return result; 41 | }; 42 | -------------------------------------------------------------------------------- /src/plugins/next-mocks/compatibility/draft-mode.compat.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error Compatibility for Next 14 2 | export { draftMode } from "next/dist/client/components/headers"; 3 | -------------------------------------------------------------------------------- /src/plugins/next-mocks/compatibility/utils.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from "node:module"; 2 | import { resolve, sep } from "node:path"; 3 | 4 | const require = createRequire(import.meta.url); 5 | 6 | export const getNextjsVersion = (): string => 7 | require(scopedResolve("next/package.json")).version; 8 | 9 | /** 10 | * This is copied from Storybook's monorepo 11 | * https://github.com/storybookjs/storybook/blob/0e1c9a50941bd318c2154c1568fded057c38e07b/code/frameworks/nextjs/src/utils.ts#L85 12 | */ 13 | export const scopedResolve = (id: string): string => { 14 | // biome-ignore lint/suspicious/noImplicitAnyLet: 15 | let scopedModulePath; 16 | 17 | try { 18 | scopedModulePath = require.resolve(id, { paths: [resolve()] }); 19 | } catch (e) { 20 | scopedModulePath = require.resolve(id); 21 | } 22 | 23 | const idWithNativePathSep = id.replace(/\//g /* all '/' occurrences */, sep); 24 | 25 | // If the id referenced the file specifically, return the full module path & filename 26 | if (scopedModulePath.endsWith(idWithNativePathSep)) { 27 | return scopedModulePath; 28 | } 29 | 30 | // Otherwise, return just the path to the module folder or named export 31 | const moduleFolderStrPosition = 32 | scopedModulePath.lastIndexOf(idWithNativePathSep); 33 | const beginningOfMainScriptPath = moduleFolderStrPosition + id.length; 34 | return scopedModulePath.substring(0, beginningOfMainScriptPath); 35 | }; 36 | -------------------------------------------------------------------------------- /src/plugins/next-mocks/plugin.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from "node:module"; 2 | import type { Plugin } from "vite"; 3 | import { VITEST_PLUGIN_NAME, getExecutionEnvironment } from "../../utils"; 4 | import { getCompatibilityAliases } from "./compatibility/compatibility-map"; 5 | 6 | const require = createRequire(import.meta.url); 7 | 8 | type Env = "browser" | "node"; 9 | 10 | const getEntryPoint = (subPath: string, env: Env) => 11 | require.resolve(`${VITEST_PLUGIN_NAME}/${env}/mocks/${subPath}`); 12 | 13 | export const getAlias = (env: Env) => ({ 14 | "next/headers": getEntryPoint("headers", env), 15 | "@storybook/nextjs/headers.mock": getEntryPoint("headers", env), 16 | "@storybook/nextjs-vite/headers.mock": getEntryPoint("headers", env), 17 | "@storybook/experimental-nextjs-vite/headers.mock": getEntryPoint( 18 | "headers", 19 | env, 20 | ), 21 | "next/navigation": getEntryPoint("navigation", env), 22 | "@storybook/nextjs/navigation.mock": getEntryPoint("navigation", env), 23 | "@storybook/nextjs-vite/navigation.mock": getEntryPoint("navigation", env), 24 | "@storybook/experimental-nextjs-vite/navigation.mock": getEntryPoint( 25 | "navigation", 26 | env, 27 | ), 28 | "next/router": getEntryPoint("router", env), 29 | "@storybook/nextjs/router.mock": getEntryPoint("router", env), 30 | "@storybook/nextjs-vite/router.mock": getEntryPoint("router", env), 31 | "@storybook/experimental-nextjs-vite/router.mock": getEntryPoint( 32 | "router", 33 | env, 34 | ), 35 | "next/cache": getEntryPoint("cache", env), 36 | "@storybook/nextjs/cache.mock": getEntryPoint("cache", env), 37 | "@storybook/nextjs-vite/cache.mock": getEntryPoint("cache", env), 38 | "@storybook/experimental-nextjs-vite/cache.mock": getEntryPoint("cache", env), 39 | "server-only": getEntryPoint("server-only", env), 40 | "@opentelemetry/api": require.resolve( 41 | "next/dist/compiled/@opentelemetry/api", 42 | ), 43 | "next/dynamic": getEntryPoint("dynamic", env), 44 | ...getCompatibilityAliases(env), 45 | }); 46 | 47 | export const vitePluginNextMocks = () => 48 | ({ 49 | name: "vite-plugin-next-mocks", 50 | config: (config) => { 51 | const aliasEnv = getExecutionEnvironment(config); 52 | return { 53 | resolve: { 54 | alias: getAlias(aliasEnv), 55 | }, 56 | }; 57 | }, 58 | }) satisfies Plugin; 59 | -------------------------------------------------------------------------------- /src/plugins/next-swc/plugin.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import nextLoadJsConfig from "next/dist/build/load-jsconfig.js"; 3 | import { transform } from "next/dist/build/swc/index.js"; 4 | import type { NextConfigComplete } from "next/dist/server/config-shared.js"; 5 | import { type Plugin, createFilter } from "vite"; 6 | 7 | import * as NextUtils from "../../utils/nextjs"; 8 | import { getVitestSWCTransformConfig } from "../../utils/swc/transform"; 9 | import { isDefined } from "../../utils/typescript"; 10 | 11 | /** Regular expression to exclude certain files from transformation */ 12 | const excluded = /[\\/]((cache[\\/][^\\/]+\.zip[\\/])|virtual:)[\\/]/; 13 | 14 | const included = /\.((c|m)?(j|t)sx?)$/; 15 | 16 | const loadJsConfig: typeof nextLoadJsConfig = 17 | // biome-ignore lint/suspicious/noExplicitAny: CJS support 18 | (nextLoadJsConfig as any).default || nextLoadJsConfig; 19 | 20 | export function vitePluginNextSwc( 21 | rootDir: string, 22 | nextConfigResolver: PromiseWithResolvers, 23 | ) { 24 | let loadedJSConfig: Awaited>; 25 | let nextDirectories: ReturnType; 26 | let isServerEnvironment: boolean; 27 | let isDev: boolean; 28 | let isEsmProject: boolean; 29 | let packageJson: { type: string }; 30 | const filter = createFilter(included, excluded); 31 | 32 | const resolvedDir = resolve(rootDir); 33 | 34 | return { 35 | name: "vite-plugin-storybook-nextjs-swc", 36 | enforce: "pre" as const, 37 | async config(config, env) { 38 | const nextConfig = await nextConfigResolver.promise; 39 | nextDirectories = NextUtils.findNextDirectories(resolvedDir); 40 | loadedJSConfig = await loadJsConfig(resolvedDir, nextConfig); 41 | isDev = env.mode !== "production"; 42 | packageJson = await NextUtils.loadClosestPackageJson(resolvedDir); 43 | isEsmProject = true; 44 | // TODO: Setting isEsmProject to false errors. Need to investigate further. 45 | // isEsmProject = packageJson.type === "module"; 46 | 47 | await NextUtils.loadSWCBindingsEagerly(nextConfig); 48 | 49 | const serverWatchIgnored = config.server?.watch?.ignored; 50 | const isServerWatchIgnoredArray = Array.isArray(serverWatchIgnored); 51 | 52 | if ( 53 | config.test?.environment === "node" || 54 | config.test?.environment === "edge-runtime" || 55 | config.test?.browser?.enabled !== false 56 | ) { 57 | isServerEnvironment = true; 58 | } 59 | 60 | return { 61 | // esbuild: { 62 | // // We will use Next.js custom SWC transpiler instead of Vite's build-in esbuild 63 | // exclude: [/node_modules/, /.m?(t|j)sx?/], 64 | // }, 65 | server: { 66 | watch: { 67 | ignored: [ 68 | ...(isServerWatchIgnoredArray 69 | ? serverWatchIgnored 70 | : [serverWatchIgnored]), 71 | "/.next/", 72 | ].filter(isDefined), 73 | }, 74 | }, 75 | }; 76 | }, 77 | async transform(code, id) { 78 | if (id.includes("/node_modules/") || !filter(id)) { 79 | return; 80 | } 81 | 82 | const inputSourceMap = this.getCombinedSourcemap(); 83 | 84 | const output = await transform( 85 | code, 86 | getVitestSWCTransformConfig({ 87 | filename: id, 88 | inputSourceMap, 89 | isServerEnvironment, 90 | loadedJSConfig, 91 | nextConfig: await nextConfigResolver.promise, 92 | nextDirectories, 93 | rootDir, 94 | isDev, 95 | isEsmProject, 96 | }), 97 | ); 98 | 99 | return output; 100 | }, 101 | } satisfies Plugin; 102 | } 103 | -------------------------------------------------------------------------------- /src/polyfills/promise-with-resolvers.ts: -------------------------------------------------------------------------------- 1 | if (typeof Promise.withResolvers === "undefined") { 2 | Promise.withResolvers = (): { 3 | promise: Promise; 4 | resolve: (value: T | PromiseLike) => void; 5 | // biome-ignore lint/suspicious/noExplicitAny: any is needed for the reject function 6 | reject: (reason?: any) => void; 7 | } => { 8 | let resolve: (value: T | PromiseLike) => void; 9 | let reject: (reason?: unknown) => void; 10 | 11 | const promise = new Promise((res, rej) => { 12 | resolve = res; 13 | reject = rej; 14 | }); 15 | 16 | // @ts-ignore 17 | return { promise, resolve, reject }; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "sb-original/image-context" { 2 | import type { StaticImport } from "next/dist/shared/lib/get-img-props"; 3 | import type { Context } from "next/dist/compiled/react"; 4 | import type { ImageProps } from "next/image"; 5 | import type { ImageProps as LegacyImageProps } from "next/legacy/image"; 6 | 7 | export const ImageContext: Context< 8 | Partial< 9 | Omit & { 10 | src: string | StaticImport; 11 | } 12 | > & 13 | Omit 14 | >; 15 | } 16 | 17 | declare module "sb-original/default-loader" { 18 | import type { ImageLoaderProps } from "next/image"; 19 | 20 | export const defaultLoader: (props: ImageLoaderProps) => string; 21 | } 22 | 23 | declare module "next/dist/compiled/react" { 24 | import * as React from "react"; 25 | export default React; 26 | export type Context = React.Context; 27 | export function createContext( 28 | // If you thought this should be optional, see 29 | // https://github.com/DefinitelyTyped/DefinitelyTyped/pull/24509#issuecomment-382213106 30 | defaultValue: T, 31 | ): Context; 32 | } 33 | 34 | // TODO: Should be removed once storybook/test is available 35 | declare module "storybook/test" { 36 | import type { Mock } from "vitest"; 37 | export type { Mock }; 38 | // biome-ignore lint/suspicious/noExplicitAny: will be removed 39 | export function fn(...args: any[]): Mock; 40 | // biome-ignore lint/suspicious/noExplicitAny: will be removed 41 | export function fn( 42 | implementation: (...args: A[]) => B, 43 | ): Mock; 44 | } 45 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from "vite"; 2 | 3 | export const VITEST_PLUGIN_NAME = "vite-plugin-storybook-nextjs"; 4 | 5 | export const isVitestEnv = process.env.VITEST === "true"; 6 | 7 | export function getExecutionEnvironment(config: UserConfig) { 8 | return isVitestEnv && config.test?.browser?.enabled !== true 9 | ? "node" 10 | : "browser"; 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/nextjs.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import { findPagesDir } from "next/dist/lib/find-pages-dir.js"; 4 | import { describe, expect, it, vi } from "vitest"; 5 | import { 6 | findNextDirectories, 7 | loadClosestPackageJson, 8 | loadSWCBindingsEagerly, 9 | shouldOutputCommonJs, 10 | } from "./nextjs"; 11 | 12 | // Mocking the necessary modules and functions 13 | vi.mock("node:fs"); 14 | vi.mock("node:path"); 15 | vi.mock("next/dist/build/output/log.js"); 16 | vi.mock("@next/env"); 17 | vi.mock("next/dist/build/swc/index.js", () => ({ 18 | loadBindings: vi.fn(), 19 | lockfilePatchPromise: { cur: Promise.resolve() }, 20 | })); 21 | vi.mock("next/dist/lib/find-pages-dir.js"); 22 | 23 | describe("nextjs.ts", () => { 24 | describe("loadSWCBindingsEagerly", () => { 25 | it("should call loadBindings and lockfilePatchPromise.cur", async () => { 26 | const { loadBindings, lockfilePatchPromise } = await import( 27 | "next/dist/build/swc/index.js" 28 | ); 29 | 30 | await loadSWCBindingsEagerly(); 31 | 32 | expect(loadBindings).toHaveBeenCalled(); 33 | expect(lockfilePatchPromise.cur).resolves.toBeUndefined(); 34 | }); 35 | }); 36 | 37 | describe("shouldOutputCommonJs", () => { 38 | it("should return true for .cjs files", () => { 39 | expect(shouldOutputCommonJs("file.cjs")).toBe(true); 40 | }); 41 | 42 | it("should return true for next/dist paths", () => { 43 | expect(shouldOutputCommonJs("next/dist/shared/lib/somefile.js")).toBe( 44 | true, 45 | ); 46 | }); 47 | 48 | it("should return false for other files", () => { 49 | expect(shouldOutputCommonJs("file.js")).toBe(false); 50 | }); 51 | }); 52 | 53 | describe("loadClosestPackageJson", () => { 54 | it("should load the closest package.json file", async () => { 55 | const readFileMock = vi.fn().mockResolvedValue('{"name": "test"}'); 56 | fs.promises.readFile = readFileMock; 57 | 58 | const result = await loadClosestPackageJson("/path/to/dir"); 59 | 60 | expect(readFileMock).toHaveBeenCalledWith( 61 | path.join("/path/to/dir", "package.json"), 62 | "utf8", 63 | ); 64 | expect(result).toEqual({ name: "test" }); 65 | }); 66 | 67 | it("should throw an error after 5 attempts", async () => { 68 | const readFileMock = vi 69 | .fn() 70 | .mockRejectedValue(new Error("File not found")); 71 | fs.promises.readFile = readFileMock; 72 | 73 | await expect(loadClosestPackageJson("/path/to/dir")).rejects.toThrow( 74 | "Can't resolve main package.json file", 75 | ); 76 | }); 77 | }); 78 | 79 | describe("findNextDirectories", () => { 80 | it("should return directories from findPagesDir", () => { 81 | vi.mocked(findPagesDir).mockReturnValue({ 82 | appDir: "/path/to/app", 83 | pagesDir: "/path/to/pages", 84 | }); 85 | 86 | const result = findNextDirectories("/path/to/dir"); 87 | 88 | expect(result).toEqual({ 89 | appDir: "/path/to/app", 90 | pagesDir: "/path/to/pages", 91 | }); 92 | }); 93 | 94 | it("should return default directories if findPagesDir throws an error", () => { 95 | vi.mocked(findPagesDir).mockImplementation(() => { 96 | throw new Error("Not found"); 97 | }); 98 | 99 | const result = findNextDirectories("/path/to/dir"); 100 | 101 | expect(result).toEqual({ 102 | appDir: path.join("/path/to/dir", "app"), 103 | pagesDir: path.join("/path/to/dir", "pages"), 104 | }); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/utils/nextjs.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path, { join } from "node:path"; 3 | import * as nextEnv from "@next/env"; 4 | import Log from "next/dist/build/output/log.js"; 5 | import { 6 | loadBindings, 7 | lockfilePatchPromise, 8 | } from "next/dist/build/swc/index.js"; 9 | import { findPagesDir } from "next/dist/lib/find-pages-dir.js"; 10 | import type { NextConfigComplete } from "next/dist/server/config-shared.js"; 11 | 12 | const nextDistPath = 13 | /(next[\\/]dist[\\/]shared[\\/]lib)|(next[\\/]dist[\\/]client)|(next[\\/]dist[\\/]pages)/; 14 | 15 | // biome-ignore lint/suspicious/noExplicitAny: Support for CJS 16 | const { loadEnvConfig } = ((nextEnv as any).default || 17 | nextEnv) as typeof nextEnv; 18 | 19 | /** 20 | * Set up the environment variables for the Next.js project 21 | */ 22 | export async function loadEnvironmentConfig(dir: string, dev: boolean) { 23 | return loadEnvConfig(dir, dev, Log); 24 | } 25 | 26 | /** 27 | * Load the SWC bindings eagerly instead of waiting for transform calls 28 | */ 29 | export async function loadSWCBindingsEagerly(nextConfig?: NextConfigComplete) { 30 | await loadBindings(nextConfig?.experimental?.useWasmBinary); 31 | 32 | if (lockfilePatchPromise.cur) { 33 | await lockfilePatchPromise.cur; 34 | } 35 | } 36 | 37 | /** 38 | * Check if the file should be output as CommonJS 39 | */ 40 | export function shouldOutputCommonJs(filename: string) { 41 | return filename.endsWith(".cjs") || nextDistPath.test(filename); 42 | } 43 | 44 | /** 45 | * Load the closest package.json file to the given directory 46 | */ 47 | export async function loadClosestPackageJson(dir: string, attempts = 1) { 48 | if (attempts > 5) { 49 | throw new Error("Can't resolve main package.json file"); 50 | } 51 | 52 | const mainPath = attempts === 1 ? ["."] : new Array(attempts).fill(".."); 53 | 54 | try { 55 | const file = await fs.promises.readFile( 56 | join(dir, ...mainPath, "package.json"), 57 | "utf8", 58 | ); 59 | return JSON.parse(file); 60 | } catch (e) { 61 | return loadClosestPackageJson(dir, attempts + 1); 62 | } 63 | } 64 | 65 | export function findNextDirectories( 66 | dir: string, 67 | ): ReturnType { 68 | try { 69 | return findPagesDir(dir); 70 | } catch (e) { 71 | return { 72 | appDir: path.join(dir, "app"), 73 | pagesDir: path.join(dir, "pages"), 74 | }; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/utils/swc/options.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | 3 | // Copyright (c) 2024 Vercel, Inc. 4 | // https://github.com/vercel/next.js/blob/canary/packages/next/src/build/swc/options.ts 5 | 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | // The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | import type { NextConfig } from "next"; 13 | import type { 14 | JsConfig, 15 | ResolvedBaseUrl, 16 | } from "next/dist/build/load-jsconfig.js"; 17 | import { getParserOptions } from "next/dist/build/swc/options.js"; 18 | import type { ExperimentalConfig } from "next/dist/server/config-shared.js"; 19 | 20 | import { createRequire } from "node:module"; 21 | import { getEmotionOptions, getStyledComponentsOptions } from "./styles"; 22 | 23 | const require = createRequire(import.meta.url); 24 | 25 | const regeneratorRuntimePath = require.resolve( 26 | "next/dist/compiled/regenerator-runtime", 27 | ); 28 | 29 | export function getBaseSWCOptions({ 30 | filename, 31 | development, 32 | hasReactRefresh, 33 | globalWindow, 34 | esm, 35 | modularizeImports, 36 | swcPlugins, 37 | compiler, 38 | resolvedBaseUrl, 39 | jsConfig, 40 | swcCacheDir, 41 | }: { 42 | filename: string; 43 | development: boolean; 44 | hasReactRefresh: boolean; 45 | globalWindow: boolean; 46 | esm: boolean; 47 | modularizeImports?: NextConfig["modularizeImports"]; 48 | compiler: NextConfig["compiler"]; 49 | swcPlugins: ExperimentalConfig["swcPlugins"]; 50 | resolvedBaseUrl?: ResolvedBaseUrl; 51 | jsConfig: JsConfig; 52 | swcCacheDir?: string; 53 | }) { 54 | const parserConfig = getParserOptions({ filename, jsConfig }); 55 | const paths = jsConfig?.compilerOptions?.paths; 56 | const enableDecorators = Boolean( 57 | jsConfig?.compilerOptions?.experimentalDecorators, 58 | ); 59 | const emitDecoratorMetadata = Boolean( 60 | jsConfig?.compilerOptions?.emitDecoratorMetadata, 61 | ); 62 | const useDefineForClassFields = Boolean( 63 | jsConfig?.compilerOptions?.useDefineForClassFields, 64 | ); 65 | const plugins = (swcPlugins ?? []) 66 | .filter(Array.isArray) 67 | .map(([name, options]) => [require.resolve(name), options]); 68 | 69 | return { 70 | jsc: { 71 | ...(resolvedBaseUrl && paths 72 | ? { 73 | baseUrl: resolvedBaseUrl.baseUrl, 74 | paths, 75 | } 76 | : {}), 77 | externalHelpers: false, 78 | parser: parserConfig, 79 | experimental: { 80 | keepImportAttributes: true, 81 | emitAssertForImportAttributes: true, 82 | plugins, 83 | cacheRoot: swcCacheDir, 84 | }, 85 | transform: { 86 | legacyDecorator: enableDecorators, 87 | decoratorMetadata: emitDecoratorMetadata, 88 | useDefineForClassFields: useDefineForClassFields, 89 | react: { 90 | importSource: 91 | jsConfig?.compilerOptions?.jsxImportSource ?? 92 | (compiler?.emotion ? "@emotion/react" : "react"), 93 | runtime: "automatic", 94 | pragmaFrag: "React.Fragment", 95 | throwIfNamespace: true, 96 | development: !!development, 97 | useBuiltins: true, 98 | refresh: !!hasReactRefresh, 99 | }, 100 | optimizer: { 101 | simplify: false, 102 | // TODO: Figuring out for what globals are exactly used for 103 | globals: { 104 | typeofs: { 105 | window: globalWindow ? "object" : "undefined", 106 | }, 107 | envs: { 108 | NODE_ENV: development ? '"development"' : '"production"', 109 | }, 110 | }, 111 | }, 112 | regenerator: { 113 | importPath: regeneratorRuntimePath, 114 | }, 115 | }, 116 | }, 117 | sourceMaps: true, 118 | removeConsole: compiler?.removeConsole, 119 | reactRemoveProperties: false, 120 | // Map the k-v map to an array of pairs. 121 | modularizeImports: modularizeImports 122 | ? Object.fromEntries( 123 | Object.entries(modularizeImports).map(([mod, config]) => [ 124 | mod, 125 | { 126 | ...config, 127 | transform: 128 | typeof config.transform === "string" 129 | ? config.transform 130 | : Object.entries(config.transform).map(([key, value]) => [ 131 | key, 132 | value, 133 | ]), 134 | }, 135 | ]), 136 | ) 137 | : undefined, 138 | relay: compiler?.relay, 139 | // Always transform styled-jsx and error when `client-only` condition is triggered 140 | styledJsx: {}, 141 | // Disable css-in-js libs (without client-only integration) transform on server layer for server components 142 | emotion: getEmotionOptions(compiler?.emotion, development), 143 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 144 | styledComponents: getStyledComponentsOptions( 145 | compiler?.styledComponents, 146 | development, 147 | ), 148 | serverComponents: undefined, 149 | serverActions: undefined, 150 | // For app router we prefer to bundle ESM, 151 | // On server side of pages router we prefer CJS. 152 | preferEsm: esm, 153 | }; 154 | } 155 | -------------------------------------------------------------------------------- /src/utils/swc/styles.ts: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | 3 | // Copyright (c) 2024 Vercel, Inc. 4 | // https://github.com/vercel/next.js/blob/canary/packages/next/src/build/swc/options.ts 5 | 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | // The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | import type { 13 | EmotionConfig, 14 | StyledComponentsConfig, 15 | } from "next/dist/server/config-shared.js"; 16 | 17 | export function getStyledComponentsOptions( 18 | styledComponentsConfig: undefined | boolean | StyledComponentsConfig, 19 | // biome-ignore lint/suspicious/noExplicitAny: 20 | development: any, 21 | ) { 22 | if (!styledComponentsConfig) { 23 | return null; 24 | } 25 | 26 | if (typeof styledComponentsConfig === "object") { 27 | return { 28 | ...styledComponentsConfig, 29 | displayName: styledComponentsConfig.displayName ?? Boolean(development), 30 | }; 31 | } 32 | 33 | return { 34 | displayName: Boolean(development), 35 | }; 36 | } 37 | 38 | export function getEmotionOptions( 39 | emotionConfig: undefined | boolean | EmotionConfig, 40 | development: boolean, 41 | ) { 42 | if (!emotionConfig) { 43 | return null; 44 | } 45 | let autoLabel = !!development; 46 | switch (typeof emotionConfig === "object" && emotionConfig.autoLabel) { 47 | case "never": 48 | autoLabel = false; 49 | break; 50 | case "always": 51 | autoLabel = true; 52 | break; 53 | default: 54 | break; 55 | } 56 | return { 57 | enabled: true, 58 | autoLabel, 59 | sourcemap: development, 60 | ...(typeof emotionConfig === "object" && { 61 | importMap: emotionConfig.importMap, 62 | labelFormat: emotionConfig.labelFormat, 63 | sourcemap: development && emotionConfig.sourceMap, 64 | }), 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/swc/transform.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import type loadJsConfig from "next/dist/build/load-jsconfig.js"; 3 | import { getSupportedBrowsers } from "next/dist/build/utils.js"; 4 | import type { findPagesDir } from "next/dist/lib/find-pages-dir.js"; 5 | import type { NextConfigComplete } from "next/dist/server/config-shared.js"; 6 | import type { SourceMap } from "rollup"; 7 | import { shouldOutputCommonJs } from "../nextjs"; 8 | import { getBaseSWCOptions } from "./options"; 9 | 10 | type VitestSWCTransformConfigParams = { 11 | filename: string; 12 | inputSourceMap: SourceMap; 13 | isServerEnvironment: boolean; 14 | loadedJSConfig: Awaited>; 15 | nextDirectories: ReturnType; 16 | nextConfig: NextConfigComplete; 17 | rootDir: string; 18 | isDev: boolean; 19 | isEsmProject: boolean; 20 | }; 21 | 22 | /** 23 | * Get the SWC transform options for a file which is passed to Next.js' custom SWC transpiler 24 | */ 25 | export const getVitestSWCTransformConfig = ({ 26 | filename, 27 | inputSourceMap, 28 | isServerEnvironment, 29 | loadedJSConfig, 30 | nextDirectories, 31 | nextConfig, 32 | rootDir, 33 | isDev, 34 | isEsmProject, 35 | }: VitestSWCTransformConfigParams) => { 36 | const baseOptions = getBaseSWCOptions({ 37 | filename, 38 | development: isDev, 39 | hasReactRefresh: false, 40 | globalWindow: !isServerEnvironment, 41 | modularizeImports: nextConfig.modularizeImports, 42 | jsConfig: loadedJSConfig.jsConfig, 43 | resolvedBaseUrl: loadedJSConfig.resolvedBaseUrl, 44 | swcPlugins: nextConfig.experimental.swcPlugins, 45 | compiler: nextConfig?.compiler, 46 | esm: isEsmProject, 47 | swcCacheDir: path.join( 48 | rootDir, 49 | nextConfig.distDir ?? ".next", 50 | "cache", 51 | "swc", 52 | ), 53 | }); 54 | const useCjsModules = shouldOutputCommonJs(filename); 55 | const isPageFile = nextDirectories.pagesDir 56 | ? filename.startsWith(nextDirectories.pagesDir) 57 | : false; 58 | 59 | return { 60 | ...baseOptions, 61 | fontLoaders: { 62 | fontLoaders: ["next/font/local", "next/font/google"], 63 | relativeFilePathFromRoot: path.relative(rootDir, filename), 64 | }, 65 | cjsRequireOptimizer: { 66 | packages: { 67 | "next/server": { 68 | transforms: { 69 | NextRequest: "next/dist/server/web/spec-extension/request", 70 | NextResponse: "next/dist/server/web/spec-extension/response", 71 | ImageResponse: "next/dist/server/web/spec-extension/image-response", 72 | userAgentFromString: 73 | "next/dist/server/web/spec-extension/user-agent", 74 | userAgent: "next/dist/server/web/spec-extension/user-agent", 75 | }, 76 | }, 77 | }, 78 | }, 79 | ...(isServerEnvironment 80 | ? { 81 | env: { 82 | targets: { 83 | // Targets the current version of Node.js 84 | node: process.versions.node, 85 | }, 86 | }, 87 | } 88 | : { 89 | env: { 90 | targets: getSupportedBrowsers(rootDir, isDev), 91 | }, 92 | }), 93 | module: { 94 | type: isEsmProject && !useCjsModules ? "es6" : "commonjs", 95 | }, 96 | disableNextSsg: !isPageFile, 97 | disablePageConfig: true, 98 | isPageFile, 99 | pagesDir: nextDirectories.pagesDir, 100 | appDir: nextDirectories.appDir, 101 | isDevelopment: isDev, 102 | isServerCompiler: isServerEnvironment, 103 | inputSourceMap: 104 | inputSourceMap && typeof inputSourceMap === "object" 105 | ? JSON.stringify(inputSourceMap) 106 | : undefined, 107 | sourceFileName: filename, 108 | filename, 109 | }; 110 | }; 111 | -------------------------------------------------------------------------------- /src/utils/typescript.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type guard to check if a value is defined. 3 | */ 4 | export const isDefined = (value: T | undefined): value is T => 5 | value !== undefined; 6 | -------------------------------------------------------------------------------- /src/vitest.d.ts: -------------------------------------------------------------------------------- 1 | import type { InlineConfig } from "vitest"; 2 | 3 | declare module "vite" { 4 | interface UserConfig { 5 | /** 6 | * Options for Vitest 7 | */ 8 | test?: InlineConfig; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "scripts"], 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "target": "ES2020", 6 | "lib": ["ESNext", "DOM"], 7 | "module": "Preserve", 8 | "jsx": "react", 9 | "moduleResolution": "bundler", 10 | "strict": true, 11 | "declaration": true, 12 | "sourceMap": true, 13 | "noEmit": true, 14 | "esModuleInterop": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig([ 4 | { 5 | entry: [ 6 | "src/index.ts", 7 | "src/plugins/next-image/alias/next-image.tsx", 8 | "src/plugins/next-image/alias/next-legacy-image.tsx", 9 | "src/plugins/next-image/alias/image-default-loader.tsx", 10 | "src/plugins/next-image/alias/image-context.tsx", 11 | "src/plugins/next-mocks/alias/headers/cookies.ts", 12 | "src/plugins/next-mocks/alias/headers/headers.ts", 13 | "src/plugins/next-mocks/alias/headers/index.ts", 14 | "src/plugins/next-mocks/alias/cache/index.ts", 15 | "src/plugins/next-mocks/alias/navigation/index.ts", 16 | "src/plugins/next-mocks/alias/router/index.ts", 17 | "src/plugins/next-mocks/alias/rsc/server-only.ts", 18 | "src/plugins/next-mocks/alias/dynamic/index.tsx", 19 | "src/mocks/storybook.global.ts", 20 | "src/plugins/next-mocks/compatibility/draft-mode.compat.ts", 21 | ], 22 | splitting: false, 23 | clean: true, 24 | format: ["esm", "cjs"], 25 | treeshake: true, 26 | dts: true, 27 | target: "node18", 28 | external: [ 29 | "sb-original/image-context", 30 | "sb-original/default-loader", 31 | "storybook", 32 | "@storybook/test-runner", 33 | "@storybook/nextjs", 34 | "react", 35 | "next", 36 | ], 37 | }, 38 | ]); 39 | --------------------------------------------------------------------------------