diff --git a/code/builders/builder-manager/src/utils/template.ts b/code/builders/builder-manager/src/utils/template.ts index 64fb626869b3..0d7b67a1dff3 100644 --- a/code/builders/builder-manager/src/utils/template.ts +++ b/code/builders/builder-manager/src/utils/template.ts @@ -34,7 +34,7 @@ export const renderHTML = async ( refs: Promise>, logLevel: Promise, docsOptions: Promise, - { versionCheck, previewUrl, configType }: Options + { versionCheck, previewUrl, configType, ignorePreview }: Options ) => { const titleRef = await title; const templateRef = await template; @@ -54,5 +54,6 @@ export const renderHTML = async ( PREVIEW_URL: JSON.stringify(previewUrl, null, 2), // global preview URL }, head: (await customHead) || '', + ignorePreview, }); }; diff --git a/code/builders/builder-manager/templates/template.ejs b/code/builders/builder-manager/templates/template.ejs index 7961f4ae4f7c..af42859a0791 100644 --- a/code/builders/builder-manager/templates/template.ejs +++ b/code/builders/builder-manager/templates/template.ejs @@ -66,6 +66,8 @@ import './sb-manager/runtime.js'; + <% if (!ignorePreview) { %> + <% } %> diff --git a/code/frameworks/nextjs-server/README.md b/code/frameworks/nextjs-server/README.md new file mode 100644 index 000000000000..4e8d1783a969 --- /dev/null +++ b/code/frameworks/nextjs-server/README.md @@ -0,0 +1,3 @@ +# Storybook NextJS Server + +https://chromatic-ui.notion.site/Storybook-NextJS-Server-df380c489bae4a5e9d6f326d703e4c4c?pvs=4 diff --git a/code/frameworks/nextjs-server/jest.config.js b/code/frameworks/nextjs-server/jest.config.js new file mode 100644 index 000000000000..343e4c7a7f32 --- /dev/null +++ b/code/frameworks/nextjs-server/jest.config.js @@ -0,0 +1,7 @@ +const path = require('path'); +const baseConfig = require('../../jest.config.node'); + +module.exports = { + ...baseConfig, + displayName: __dirname.split(path.sep).slice(-2).join(path.posix.sep), +}; diff --git a/code/frameworks/nextjs-server/package.json b/code/frameworks/nextjs-server/package.json new file mode 100644 index 000000000000..0654dec74e17 --- /dev/null +++ b/code/frameworks/nextjs-server/package.json @@ -0,0 +1,135 @@ +{ + "name": "@storybook/nextjs-server", + "version": "8.0.0-alpha.0", + "description": "Storybook for NextJS Server: Server-side rendering.", + "keywords": [ + "storybook" + ], + "homepage": "https://github.com/storybookjs/storybook/tree/next/code/frameworks/nextjs-server", + "bugs": { + "url": "https://github.com/storybookjs/storybook/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/storybookjs/storybook.git", + "directory": "code/frameworks/nextjs-server" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "license": "MIT", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "node": "./dist/index.js", + "require": "./dist/index.js", + "import": "./dist/index.mjs" + }, + "./preset": { + "types": "./dist/preset.d.ts", + "require": "./dist/preset.js" + }, + "./plugin": { + "types": "./dist/plugin.d.ts", + "require": "./dist/plugin.js" + }, + "./mock": { + "types": "./dist/mock.d.ts", + "node": "./dist/mock.js", + "require": "./dist/mock.js", + "import": "./dist/mock.mjs" + }, + "./next-config": { + "node": "./dist/next-config.js" + }, + "./pages": { + "types": "./dist/pages/index.d.ts", + "import": "./dist/pages/index.mjs" + }, + "./channels": { + "types": "./dist/reexports/channels.d.ts", + "node": "./dist/reexports/channels.js", + "require": "./dist/reexports/channels.js", + "import": "./dist/reexports/channels.mjs" + }, + "./core-events": { + "types": "./dist/reexports/core-events.d.ts", + "node": "./dist/reexports/core-events.js", + "require": "./dist/reexports/core-events.js", + "import": "./dist/reexports/core-events.mjs" + }, + "./preview-api": { + "types": "./dist/reexports/preview-api.d.ts", + "node": "./dist/reexports/preview-api.js", + "require": "./dist/reexports/preview-api.js", + "import": "./dist/reexports/preview-api.mjs" + }, + "./types": { + "types": "./dist/reexports/types.d.ts" + }, + "./package.json": "./package.json" + }, + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*", + "template/**/*", + "README.md", + "*.js", + "*.d.ts" + ], + "scripts": { + "check": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/check.ts", + "prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/bundle.ts" + }, + "dependencies": { + "@babel/core": "^7.22.9", + "@babel/types": "^7.22.5", + "@storybook/channels": "workspace:*", + "@storybook/core-common": "workspace:*", + "@storybook/core-events": "workspace:*", + "@storybook/core-server": "workspace:*", + "@storybook/csf-tools": "workspace:*", + "@storybook/node-logger": "workspace:*", + "@storybook/preview-api": "workspace:*", + "@storybook/react": "workspace:*", + "@storybook/types": "workspace:*", + "@types/node": "^18.0.0", + "fs-extra": "^11.1.0", + "ts-dedent": "^2.0.0", + "unplugin": "^1.3.1" + }, + "devDependencies": { + "typescript": "~4.9.3" + }, + "peerDependencies": { + "next": "^13", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "publishConfig": { + "access": "public" + }, + "bundler": { + "entries": [ + "./src/index.ts", + "./src/null-builder.ts", + "./src/null-renderer.ts", + "./src/preset.ts", + "./src/mock.ts", + "./src/next-config.cts", + "./src/pages/index.ts", + "./src/reexports/channels.ts", + "./src/reexports/core-events.ts", + "./src/reexports/preview-api.ts", + "./src/reexports/types.ts" + ], + "platform": "node" + }, + "gitHead": "72ae6b0d965d1ae596159cdb15109c5e13376d78" +} diff --git a/code/frameworks/nextjs-server/preset.js b/code/frameworks/nextjs-server/preset.js new file mode 100644 index 000000000000..a83f95279e7f --- /dev/null +++ b/code/frameworks/nextjs-server/preset.js @@ -0,0 +1 @@ +module.exports = require('./dist/preset'); diff --git a/code/frameworks/nextjs-server/project.json b/code/frameworks/nextjs-server/project.json new file mode 100644 index 000000000000..7b016fad4704 --- /dev/null +++ b/code/frameworks/nextjs-server/project.json @@ -0,0 +1,6 @@ +{ + "name": "@storybook/nextjs-server", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "implicitDependencies": [], + "type": "library" +} diff --git a/code/frameworks/nextjs-server/server.d.ts b/code/frameworks/nextjs-server/server.d.ts new file mode 100644 index 000000000000..3c52d98e573d --- /dev/null +++ b/code/frameworks/nextjs-server/server.d.ts @@ -0,0 +1 @@ +export * from './dist/server/index.d'; diff --git a/code/frameworks/nextjs-server/server.js b/code/frameworks/nextjs-server/server.js new file mode 100644 index 000000000000..699fd532abd7 --- /dev/null +++ b/code/frameworks/nextjs-server/server.js @@ -0,0 +1 @@ +export * from './dist/server'; diff --git a/code/frameworks/nextjs-server/src/index.ts b/code/frameworks/nextjs-server/src/index.ts new file mode 100644 index 000000000000..fcb073fefcd6 --- /dev/null +++ b/code/frameworks/nextjs-server/src/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/code/frameworks/nextjs-server/src/indexers.ts b/code/frameworks/nextjs-server/src/indexers.ts new file mode 100644 index 000000000000..39980e32803d --- /dev/null +++ b/code/frameworks/nextjs-server/src/indexers.ts @@ -0,0 +1,181 @@ +import { cp, readFile, writeFile } from 'fs/promises'; +import { ensureDir, exists } from 'fs-extra'; +import { join, relative, resolve } from 'path'; +import { dedent } from 'ts-dedent'; +import type { Indexer, PreviewAnnotation } from '@storybook/types'; +import { loadCsf } from '@storybook/csf-tools'; +import type { StorybookNextJSOptions } from './types'; + +const LAYOUT_FILES = ['layout.tsx', 'layout.jsx']; + +export const appIndexer = ( + allPreviewAnnotations: PreviewAnnotation[], + { previewPath }: StorybookNextJSOptions +): Indexer => { + return { + test: /(stories|story)\.[tj]sx?$/, + createIndex: async (fileName, opts) => { + console.log('indexing', fileName); + const code = (await readFile(fileName, 'utf-8')).toString(); + const csf = await loadCsf(code, { ...opts, fileName }).parse(); + + const inputAppDir = resolve(__dirname, '../template/app'); + const inputGroupDir = join(inputAppDir, 'groupLayouts'); + const inputStorybookDir = join(inputAppDir, 'storybook-preview'); + const appDir = join(process.cwd(), 'app'); + const sbGroupDir = join(appDir, '(sb)'); + const storybookDir = join(sbGroupDir, previewPath); + await ensureDir(storybookDir); + + try { + await cp(inputStorybookDir, storybookDir, { recursive: true }); + const hasRootLayout = await Promise.any( + LAYOUT_FILES.map((file) => exists(join(appDir, file))) + ); + const inputLayout = hasRootLayout ? 'layout-nested.tsx' : 'layout-root.tsx'; + await cp(`${inputGroupDir}/${inputLayout}`, join(sbGroupDir, 'layout.tsx')); + + const routeLayoutTsx = dedent`import type { PropsWithChildren } from 'react'; + import React from 'react'; + import { Storybook } from './components/Storybook'; + + export default function NestedLayout({ children }: PropsWithChildren<{}>) { + return {children}; + }`; + const routeLayoutFile = join(storybookDir, 'layout.tsx'); + await writeFile(routeLayoutFile, routeLayoutTsx); + } catch (err) { + // FIXME: assume we've copied already + // console.log({ err }); + } + + await Promise.all( + csf.stories.map(async (story) => { + const storyDir = join(storybookDir, story.id); + await ensureDir(storyDir); + const relativeStoryPath = relative(storyDir, fileName).replace(/\.tsx?$/, ''); + const { exportName } = story; + + const pageTsx = dedent` + import React from 'react'; + import { composeStory } from '@storybook/react/testing-api'; + import { getArgs } from '../components/args'; + import { Prepare, StoryAnnotations } from '../components/Prepare'; + import { Args } from '@storybook/react'; + + const page = async () => { + const stories = await import('${relativeStoryPath}'); + const projectAnnotations = {}; + const Composed = composeStory(stories.${exportName}, stories.default, projectAnnotations?.default || {}, '${exportName}'); + const extraArgs = await getArgs(Composed.id); + + const { id, parameters, argTypes, initialArgs } = Composed; + const args = { ...initialArgs, ...extraArgs }; + + const storyAnnotations: StoryAnnotations = { + id, + parameters, + argTypes, + initialArgs, + args, + }; + return ( + <> + + {/* @ts-ignore TODO -- why? */} + + + ); + }; + export default page; + `; + + const pageFile = join(storyDir, 'page.tsx'); + await writeFile(pageFile, pageTsx); + }) + ); + + return csf.indexInputs; + }, + }; +}; + +export const pagesIndexer = ( + allPreviewAnnotations: PreviewAnnotation[], + { previewPath }: StorybookNextJSOptions +): Indexer => { + const workingDir = process.cwd(); // TODO we should probably put this on the preset options + + return { + test: /(stories|story)\.[tj]sx?$/, + createIndex: async (fileName, opts) => { + console.log('indexing', fileName); + const code = (await readFile(fileName, 'utf-8')).toString(); + const csf = await loadCsf(code, { ...opts, fileName }).parse(); + + const routeDir = 'pages'; + const storybookDir = join(process.cwd(), routeDir, previewPath); + await ensureDir(storybookDir); + + const indexTsx = dedent` + import React from 'react'; + import { Storybook } from './components/Storybook'; + + const page = () => ; + export default page; + `; + const indexFile = join(storybookDir, 'index.tsx'); + await writeFile(indexFile, indexTsx); + + const projectAnnotationImports = allPreviewAnnotations + .map((path, index) => `const projectAnnotations${index} = await import('${path}');`) + .join('\n'); + + const projectAnnotationArray = allPreviewAnnotations + .map((_, index) => `projectAnnotations${index}`) + .join(','); + + const storybookTsx = dedent` + import React, { useEffect } from 'react'; + import { composeConfigs } from '@storybook/preview-api'; + import { Preview } from '@storybook/nextjs-server/pages'; + + const getProjectAnnotations = async () => { + ${projectAnnotationImports} + return composeConfigs([${projectAnnotationArray}]); + } + + export const Storybook = () => ( + + ); + `; + + const componentsDir = join(storybookDir, 'components'); + await ensureDir(componentsDir); + const storybookFile = join(componentsDir, 'Storybook.tsx'); + await writeFile(storybookFile, storybookTsx); + + const componentId = csf.stories[0].id.split('--')[0]; + const relativeStoryPath = relative(storybookDir, fileName).replace(/\.tsx?$/, ''); + const importPath = relative(workingDir, fileName).replace(/^([^./])/, './$1'); + + const csfImportTsx = dedent` + import React from 'react'; + import { Storybook } from './components/Storybook'; + import * as stories from '${relativeStoryPath}'; + + if (typeof window !== 'undefined') { + window._storybook_onImport('${importPath}', stories); + } + + const page = () => ; + export default page; + `; + + const csfImportFile = join(storybookDir, `${componentId}.tsx`); + await writeFile(csfImportFile, csfImportTsx); + + return csf.indexInputs; + }, + }; +}; diff --git a/code/frameworks/nextjs-server/src/mock.ts b/code/frameworks/nextjs-server/src/mock.ts new file mode 100644 index 000000000000..8a35896e560e --- /dev/null +++ b/code/frameworks/nextjs-server/src/mock.ts @@ -0,0 +1,36 @@ +// @ts-expect-error wrong react version +import React, { cache } from 'react'; + +const requestId = cache(() => Math.random()); +type Exports = Record; + +export const Mock = { + cache: {} as Record, + storybookRequests: {} as Record, + set(data: any) { + const id = requestId(); + console.log('Mock.set', { id, data }); + this.cache[id] = data; + this.storybookRequests[id] = true; + }, + get() { + const id = requestId(); + const data = this.cache[id]; + console.log('Mock.get', { id, data }); + return data; + }, + fn(original: any, mock: any) { + return (...args: any) => { + const id = requestId(); + const isStorybook = !!this.storybookRequests[id]; + return isStorybook ? mock(...args) : original(...args); + }; + }, + module(original: Exports, mocks: Exports) { + const result = { ...original }; + Object.keys(mocks).forEach((key) => { + result[key] = Mock.fn(original[key], mocks[key]); + }); + return result; + }, +}; diff --git a/code/frameworks/nextjs-server/src/next-config.cts b/code/frameworks/nextjs-server/src/next-config.cts new file mode 100644 index 000000000000..e718837d7f7f --- /dev/null +++ b/code/frameworks/nextjs-server/src/next-config.cts @@ -0,0 +1,174 @@ +import type { NextConfig } from 'next'; +import type { ChildProcess } from 'child_process'; +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import type { StorybookNextJSOptions } from './types'; +import { verifyPort } from './verifyPort'; + +const logger = console; +let childProcess: ChildProcess | undefined; + +[ + 'SIGHUP', + 'SIGINT', + 'SIGQUIT', + 'SIGILL', + 'SIGTRAP', + 'SIGABRT', + 'SIGBUS', + 'SIGFPE', + 'SIGUSR1', + 'SIGSEGV', + 'SIGUSR2', + 'SIGTERM', +].forEach((sig) => { + process.on(sig, () => { + if (childProcess) { + logger.log('Stopping storybook'); + childProcess.kill(); + } + }); +}); + +function addRewrites( + existing: NextConfig['rewrites'] | undefined, + ourRewrites: { source: string; destination: string }[] +): NextConfig['rewrites'] { + if (!existing) return async () => ourRewrites; + + return async () => { + const existingRewrites = await existing(); + + if (Array.isArray(existingRewrites)) return [...existingRewrites, ...ourRewrites]; + + return { + ...existingRewrites, + fallback: [...existingRewrites.fallback, ...ourRewrites], + }; + }; +} + +interface WithStorybookOptions { + /** + * Port that the Next.js app will run on. + * @default 3000 + */ + port: string | number; + + /** + * Internal port that Storybook will run on. + * @default 34567 + */ + sbPort: string | number; + + /** + * URL path to Storybook's "manager" UI. + * @default 'storybook' + */ + managerPath: string; + + /** + * URL path to Storybook's story preview iframe. + * @default 'storybook-preview' + */ + previewPath: string; + + /** + * Directory where Storybook's config files are located. + * @default '.storybook' + */ + configDir: string; + + /** + * Whether to use the NextJS app directory. + * @default undefined + */ + appDir: boolean; +} + +const withStorybook = ({ + port = process.env.PORT ?? 3000, + sbPort = 34567, + managerPath = 'storybook', + previewPath = 'storybook-preview', + configDir = '.storybook', + appDir = undefined, +}: Partial = {}) => { + const isAppDir = appDir ?? existsSync('app'); + const storybookNextJSOptions: StorybookNextJSOptions = { + appDir: isAppDir, + managerPath, + previewPath, + }; + + childProcess = spawn( + 'npm', + [ + 'exec', + 'storybook', + '--', + 'dev', + '--preview-url', + `http://localhost:${port}/${previewPath}`, + '-p', + sbPort.toString(), + '--ci', + // NOTE that this is still a race condition. However, if two instances of SB use the exact port, + // the second will fail and the first will still be running, which is what we want. There must be + // a more graceful way to handle this. + '--exact-port', + '--config-dir', + configDir, + ], + { + stdio: 'inherit', + env: { ...process.env, STORYBOOK_NEXTJS_OPTIONS: JSON.stringify(storybookNextJSOptions) }, + } + ); + + verifyPort(port, { appDir: isAppDir, previewPath }); + + return (config: NextConfig) => ({ + ...config, + rewrites: addRewrites(config.rewrites, [ + { + source: '/logo.svg', + destination: `http://localhost:${sbPort}/logo.svg`, + }, + { + source: `/${managerPath}/:path*`, + destination: `http://localhost:${sbPort}/:path*`, + }, + { + source: '/sb-manager/:path*', + destination: `http://localhost:${sbPort}/sb-manager/:path*`, + }, + { + source: '/sb-common-assets/:path*', + destination: `http://localhost:${sbPort}/sb-common-assets/:path*`, + }, + { + source: '/sb-preview/:path*', + destination: `http://localhost:${sbPort}/sb-preview/:path*`, + }, + { + source: '/sb-addons/:path*', + destination: `http://localhost:${sbPort}/sb-addons/:path*`, + }, + { + source: '/storybook-server-channel', + destination: `http://localhost:${sbPort}/storybook-server-channel`, + }, + { + source: '/index.json', + destination: `http://localhost:${sbPort}/index.json`, + }, + { + source: `/${previewPath}/index.json`, + destination: `http://localhost:${sbPort}/index.json`, + }, + ]), + }); +}; + +module.exports = withStorybook; diff --git a/code/frameworks/nextjs-server/src/null-builder.ts b/code/frameworks/nextjs-server/src/null-builder.ts new file mode 100644 index 000000000000..ec3a7c2863e5 --- /dev/null +++ b/code/frameworks/nextjs-server/src/null-builder.ts @@ -0,0 +1,12 @@ +import type { Builder } from '@storybook/types'; + +interface NullStats { + toJson: () => any; +} +export type NullBuilder = Builder<{}, NullStats>; + +export const start: NullBuilder['start'] = async () => {}; + +export const build: NullBuilder['build'] = async () => {}; + +export const bail: NullBuilder['bail'] = async () => {}; diff --git a/code/frameworks/nextjs-server/src/null-renderer.ts b/code/frameworks/nextjs-server/src/null-renderer.ts new file mode 100644 index 000000000000..b550addcdc46 --- /dev/null +++ b/code/frameworks/nextjs-server/src/null-renderer.ts @@ -0,0 +1,3 @@ +export const renderToCanvas = () => { + throw new Error('This should never be called'); +}; diff --git a/code/frameworks/nextjs-server/src/pages/Preview.tsx b/code/frameworks/nextjs-server/src/pages/Preview.tsx new file mode 100644 index 000000000000..b92e99911024 --- /dev/null +++ b/code/frameworks/nextjs-server/src/pages/Preview.tsx @@ -0,0 +1,60 @@ +/* eslint-disable no-underscore-dangle */ +import React from 'react'; +import { useRouter } from 'next/navigation.js'; +import type { Renderer } from '@storybook/csf'; +import { createBrowserChannel } from '@storybook/channels'; +import { PreviewWithSelection, addons, UrlStore, WebView } from '@storybook/preview-api'; + +import { previewHtml } from './previewHtml'; +import { importFn } from './importFn'; + +// A version of the URL store that doesn't change route when the selection changes +// (as we change the URL as part of rendering the story) +class StaticUrlStore extends UrlStore { + setSelection(selection: Parameters[0]) { + this.selection = selection; + } +} + +type GetProjectAnnotations = Parameters< + PreviewWithSelection['initialize'] +>[0]['getProjectAnnotations']; + +export const Preview = ({ + getProjectAnnotations, + previewPath, +}: { + getProjectAnnotations: GetProjectAnnotations; + previewPath: string; +}) => { + const router = useRouter(); + + // We can't use React's useEffect in the monorepo because of dependency issues, + // but we only need to ensure code runs *once* on the client only, so let's just make + // our own version of that + if (typeof window !== 'undefined') { + if (!window.__STORYBOOK_PREVIEW__) { + console.log('creating preview'); + const channel = createBrowserChannel({ page: 'preview' }); + addons.setChannel(channel); + window.__STORYBOOK_ADDONS_CHANNEL__ = channel; + + const preview = new PreviewWithSelection(new StaticUrlStore(), new WebView()); + + preview.initialize({ + importFn: (path) => + importFn(preview.storyStore.storyIndex!.entries, router, previewPath, path), + getProjectAnnotations, + }); + window.__STORYBOOK_PREVIEW__ = preview; + } + + // Render the the SB UI (ie iframe.html / preview.ejs) in a non-react way to ensure + // it doesn't get ripped down when a new route renders + if (!document.querySelector('#storybook-root')) { + document.body.innerHTML += previewHtml; + } + } + + return <>; +}; diff --git a/code/frameworks/nextjs-server/src/pages/importFn.ts b/code/frameworks/nextjs-server/src/pages/importFn.ts new file mode 100644 index 000000000000..de7a68ddacd0 --- /dev/null +++ b/code/frameworks/nextjs-server/src/pages/importFn.ts @@ -0,0 +1,59 @@ +/* eslint-disable no-underscore-dangle */ +import type { ModuleExports, StoryIndex } from '@storybook/types'; +import type { useRouter } from 'next/navigation'; + +type Path = string; + +const csfFiles: Record = {}; +const csfResolvers: Record void> = {}; +const csfPromises: Record> = {}; +let useEffect = (_1: any, _2: any) => {}; +if (typeof window !== 'undefined') { + window.FEATURES = { storyStoreV7: true }; + + window._storybook_onImport = (path: Path, moduleExports: ModuleExports) => { + console.log('_storybook_onImport', path, Object.keys(csfFiles), Object.keys(csfResolvers)); + csfFiles[path] = moduleExports; + csfResolvers[path]?.(moduleExports); + }; + + useEffect = (cb, _) => cb(); +} + +export const importFn = async ( + allEntries: StoryIndex['entries'], + router: ReturnType, + previewPath: Path, + path: Path +) => { + console.log('importing', path); + + if (csfFiles[path]) { + console.log('got it already, short circuiting'); + return csfFiles[path]; + } + + // @ts-expect-error TS is confused, this is not a bug + if (csfPromises[path]) { + console.log('got promise, short circuiting'); + return csfPromises[path]; + } + + // Find all index entries for this import path, to find a story id + const entries = Object.values(allEntries || []).filter( + ({ importPath }: any) => importPath === path + ) as { id: string; name: string; title: string }[]; + + if (entries.length === 0) throw new Error(`Couldn't find import path ${path}, this is odd`); + + const firstStoryId = entries[0].id; + const componentId = firstStoryId.split('--')[0]; + + csfPromises[path] = new Promise((resolve) => { + csfResolvers[path] = resolve; + }); + + router.push(`/${previewPath}/${componentId}`); + + return csfPromises[path]; +}; diff --git a/code/frameworks/nextjs-server/src/pages/index.ts b/code/frameworks/nextjs-server/src/pages/index.ts new file mode 100644 index 000000000000..7d1d8598f2ed --- /dev/null +++ b/code/frameworks/nextjs-server/src/pages/index.ts @@ -0,0 +1 @@ +export * from './Preview'; diff --git a/code/frameworks/nextjs-server/src/pages/previewHtml.ts b/code/frameworks/nextjs-server/src/pages/previewHtml.ts new file mode 100644 index 000000000000..40863a36f699 --- /dev/null +++ b/code/frameworks/nextjs-server/src/pages/previewHtml.ts @@ -0,0 +1,419 @@ +export const previewHtml = ` + + + + + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+

No Preview

+

Sorry, but you either have no stories or none are selected somehow.

+
    +
  • Please check the Storybook config.
  • +
  • Try reloading the page.
  • +
+

+ If the problem persists, check the browser console, or the terminal you've run Storybook from. +

+
+
+ +
+

+  
+
`; diff --git a/code/frameworks/nextjs-server/src/plugin.test.ts b/code/frameworks/nextjs-server/src/plugin.test.ts new file mode 100644 index 000000000000..27f546869985 --- /dev/null +++ b/code/frameworks/nextjs-server/src/plugin.test.ts @@ -0,0 +1,81 @@ +import dedent from 'ts-dedent'; +import { transformCode } from './plugin'; + +it('named import', () => { + const namedImport = dedent` + import { Meta } from '@storybook/react'; + import { Button } from './Button'; + export default { + title: 'Button', + component: Button, + }; + export const Basic = {}; + `; + expect(transformCode(namedImport, {}).code).toMatchInlineSnapshot(` + "import { Meta } from '@storybook/react'; + export default { + title: 'Button' + }; + export const Basic = {};" + `); +}); +it('default import', () => { + const defaultImport = dedent` + import { Meta } from '@storybook/react'; + import Button from './Button'; + export default { + title: 'Button', + component: Button, + }; + export const Basic = {}; + `; + expect(transformCode(defaultImport, {}).code).toMatchInlineSnapshot(` + "import { Meta } from '@storybook/react'; + export default { + title: 'Button' + }; + export const Basic = {};" + `); +}); +it('multi import', () => { + const multiImport = dedent` + import { Meta } from '@storybook/react'; + import Button, { helper } from './Button'; + export default { + title: 'Button', + component: Button, + }; + export const Basic = {}; + `; + expect(transformCode(multiImport, {}).code).toMatchInlineSnapshot(` + "import { Meta } from '@storybook/react'; + import { helper } from './Button'; + export default { + title: 'Button' + }; + export const Basic = {};" + `); +}); +it('satisfies type', () => { + const satisfiesType = dedent` + import { Meta } from '@storybook/react'; + import Button from './Button'; + const meta = { + title: 'Button', + component: Button, + } satisfies Meta; + + export default meta; + type Story = Story; + export const Basic = {}; + `; + expect(transformCode(satisfiesType, {}).code).toMatchInlineSnapshot(` + "import { Meta } from '@storybook/react'; + const meta = ({ + title: 'Button' + } satisfies Meta); + export default meta; + type Story = Story; + export const Basic = {};" + `); +}); diff --git a/code/frameworks/nextjs-server/src/plugin.ts b/code/frameworks/nextjs-server/src/plugin.ts new file mode 100644 index 000000000000..513a5c583bc9 --- /dev/null +++ b/code/frameworks/nextjs-server/src/plugin.ts @@ -0,0 +1,86 @@ +/* eslint-disable no-underscore-dangle */ +import * as t from '@babel/types'; +import { traverse } from '@babel/core'; +import { createUnplugin } from 'unplugin'; +import type { CsfFile } from '@storybook/csf-tools'; +import { loadCsf, formatCsf } from '@storybook/csf-tools'; + +const logger = console; + +const STORIES_REGEX = /\.(story|stories)\.[tj]sx?$/; + +type TransformOptions = {}; + +const transformCsf = (csf: CsfFile, options: TransformOptions) => { + const meta = csf._metaNode; + if (t.isObjectExpression(meta)) { + const idx = meta.properties.findIndex( + (p) => t.isObjectProperty(p) && t.isIdentifier(p.key) && p.key.name === 'component' + ); + if (idx >= 0) { + const prop = meta.properties[idx] as t.ObjectProperty; + const componentId = t.isIdentifier(prop.value) ? prop.value.name : undefined; + + if (componentId) { + traverse(csf._ast, { + TSTypeQuery(path) { + if (t.isIdentifier(path.node.exprName) && path.node.exprName.name === componentId) { + path.replaceWith(t.tsAnyKeyword()); + } + }, + }); + const { body } = csf._ast.program; + const importIdx = body.findIndex( + (n) => t.isImportDeclaration(n) && n.specifiers.find((s) => s.local.name === componentId) + ); + if (importIdx >= 0) { + const importDecl = body[importIdx] as t.ImportDeclaration; + const specifierIdx = importDecl.specifiers.findIndex((s) => s.local.name === componentId); + importDecl.specifiers.splice(specifierIdx, 1); + if (!importDecl.specifiers.length) { + body.splice(importIdx, 1); + } + } + } + + meta.properties.splice(idx, 1); + } + } + return csf; +}; + +export const transformCode = ( + code: string, + options: TransformOptions +): { code: string; map?: any } => { + try { + const csf = loadCsf(code, { makeTitle: (userTitle) => userTitle || 'default' }).parse(); + transformCsf(csf, options); + const transformed = formatCsf(csf, { sourceMaps: true }); + return transformed as { code: string; map: any }; + } catch (err: any) { + // This can be called on legacy storiesOf files, so just ignore + // those errors. But warn about other errors. + if (!err.message?.startsWith('CSF:')) { + logger.warn(err.message); + } + } + return { code }; +}; + +export const unplugin = createUnplugin((options) => { + return { + name: 'unplugin-nextjs', + transformInclude(id) { + return STORIES_REGEX.test(id); + }, + async transform(code, id) { + return transformCode(code, options); + }, + }; +}); + +export const { esbuild } = unplugin; +export const { webpack } = unplugin; +export const { rollup } = unplugin; +export const { vite } = unplugin; diff --git a/code/frameworks/nextjs-server/src/preset.ts b/code/frameworks/nextjs-server/src/preset.ts new file mode 100644 index 000000000000..11747f1b85c6 --- /dev/null +++ b/code/frameworks/nextjs-server/src/preset.ts @@ -0,0 +1,48 @@ +import { dirname, join } from 'path'; + +import type { PresetProperty, PreviewAnnotation } from '@storybook/types'; +import type { StorybookConfig, StorybookNextJSOptions } from './types'; +import { appIndexer, pagesIndexer } from './indexers'; + +const wrapForPnP = (input: string) => dirname(require.resolve(join(input, 'package.json'))); + +const nextJsOptions: StorybookNextJSOptions = process.env.STORYBOOK_NEXTJS_OPTIONS + ? JSON.parse(process.env.STORYBOOK_NEXTJS_OPTIONS) + : {}; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const experimental_indexers: StorybookConfig['experimental_indexers'] = async ( + existingIndexers, + { presets, configDir } +) => { + console.log('experimental_indexers'); + + const allPreviewAnnotations = [ + ...(await presets.apply('previewAnnotations', [])).map((entry) => { + if (typeof entry === 'object') { + return entry.absolute; + } + return entry; + }), + join(configDir, 'preview'), // FIXME is :point_down: better? + // loadPreviewOrConfigFile(options), + ].filter(Boolean); + + const rewritingIndexer = nextJsOptions.appDir + ? appIndexer(allPreviewAnnotations, nextJsOptions) + : pagesIndexer(allPreviewAnnotations, nextJsOptions); + return [rewritingIndexer, ...(existingIndexers || [])]; +}; + +export const core: PresetProperty<'core'> = async (config) => { + return { + ...config, + builder: { + name: require.resolve('./null-builder') as '@storybook/builder-vite', + options: {}, + }, + renderer: nextJsOptions.appDir + ? require.resolve('./null-renderer') + : wrapForPnP('@storybook/react'), + }; +}; diff --git a/code/frameworks/nextjs-server/src/reexports/channels.ts b/code/frameworks/nextjs-server/src/reexports/channels.ts new file mode 100644 index 000000000000..78a07e69cd70 --- /dev/null +++ b/code/frameworks/nextjs-server/src/reexports/channels.ts @@ -0,0 +1 @@ +export * from '@storybook/channels'; diff --git a/code/frameworks/nextjs-server/src/reexports/core-events.ts b/code/frameworks/nextjs-server/src/reexports/core-events.ts new file mode 100644 index 000000000000..260d0dbdea32 --- /dev/null +++ b/code/frameworks/nextjs-server/src/reexports/core-events.ts @@ -0,0 +1 @@ +export * from '@storybook/core-events'; diff --git a/code/frameworks/nextjs-server/src/reexports/preview-api.ts b/code/frameworks/nextjs-server/src/reexports/preview-api.ts new file mode 100644 index 000000000000..6d4e5b8465d3 --- /dev/null +++ b/code/frameworks/nextjs-server/src/reexports/preview-api.ts @@ -0,0 +1 @@ +export * from '@storybook/preview-api'; diff --git a/code/frameworks/nextjs-server/src/reexports/types.ts b/code/frameworks/nextjs-server/src/reexports/types.ts new file mode 100644 index 000000000000..c9dcbcdd7757 --- /dev/null +++ b/code/frameworks/nextjs-server/src/reexports/types.ts @@ -0,0 +1 @@ +export * from '@storybook/types'; diff --git a/code/frameworks/nextjs-server/src/server/server.tsx b/code/frameworks/nextjs-server/src/server/server.tsx new file mode 100644 index 000000000000..e8c8d3f93dd8 --- /dev/null +++ b/code/frameworks/nextjs-server/src/server/server.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { join } from 'path'; +import { StoryIndexGenerator } from '@storybook/core-server'; +import { composeStories } from '@storybook/react'; +import type { DocsOptions } from '@storybook/types'; +import { loadAllPresets, loadMainConfig, normalizeStories } from '@storybook/core-common'; +import { logger } from '@storybook/node-logger'; + +const getIndex = async () => { + // FIXME + const options = { + configDir: join(process.cwd(), '.storybook'), + packageJson: {}, + }; + const config = await loadMainConfig(options); + const { framework } = config; + const corePresets = []; + + const frameworkName = typeof framework === 'string' ? framework : framework?.name; + if (frameworkName) { + corePresets.push(join(frameworkName, 'preset')); + } else { + logger.warn(`you have not specified a framework in your ${options.configDir}/main.js`); + } + + const presets = await loadAllPresets({ + corePresets: [ + require.resolve('@storybook/core-server/dist/presets/common-preset'), + ...corePresets, + ], + overridePresets: [], + ...options, + }); + + const workingDir = process.cwd(); + const directories = { + configDir: options.configDir, + workingDir, + }; + + const [indexers, storyIndexers, stories, docsOptions] = await Promise.all([ + presets.apply('indexers', []), + presets.apply('storyIndexers', []), + presets.apply('stories'), + presets.apply('docs', {}), + ]); + + const normalizedStories = normalizeStories(stories, directories); + const generator = new StoryIndexGenerator(normalizedStories, { + ...directories, + indexers, + storyIndexers, + docs: docsOptions, + storiesV2Compatibility: false, + storyStoreV7: true, + }); + + await generator.initialize(); + const index = await generator.getIndex(); + return index; +}; + +export const getStory = async (searchParams: any) => { + const { _id: storyId } = searchParams; + const index = await getIndex(); + const storyEntry = index.entries[storyId]; + console.log({ storyEntry }); + const imported = await import(storyEntry.importPath); + console.log({ imported }); + const composed = composeStories(imported); + console.log({ composed }); + const Component = composed[storyEntry.name]; + // @ts-expect-error FIXME + return ; +}; diff --git a/code/frameworks/nextjs-server/src/types.ts b/code/frameworks/nextjs-server/src/types.ts new file mode 100644 index 000000000000..1ff89b918af4 --- /dev/null +++ b/code/frameworks/nextjs-server/src/types.ts @@ -0,0 +1,27 @@ +import type { StorybookConfig as StorybookConfigBase } from '@storybook/types'; + +type FrameworkName = '@storybook/nextjs-server'; + +export type FrameworkOptions = { + builder?: {}; +}; + +type StorybookConfigFramework = { + framework: + | FrameworkName + | { + name: FrameworkName; + options: FrameworkOptions; + }; +}; + +/** + * The interface for Storybook configuration in `main.ts` files. + */ +export type StorybookConfig = StorybookConfigBase & StorybookConfigFramework; + +export interface StorybookNextJSOptions { + appDir: boolean; + managerPath: string; + previewPath: string; +} diff --git a/code/frameworks/nextjs-server/src/typings.d.ts b/code/frameworks/nextjs-server/src/typings.d.ts new file mode 100644 index 000000000000..ace5bc44e673 --- /dev/null +++ b/code/frameworks/nextjs-server/src/typings.d.ts @@ -0,0 +1,8 @@ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable @typescript-eslint/naming-convention */ + +declare var FEATURES: import('@storybook/types').StorybookConfig['features']; + +declare var __STORYBOOK_PREVIEW__: any; +declare var __STORYBOOK_ADDONS_CHANNEL__: any; +declare var _storybook_onImport: any; diff --git a/code/frameworks/nextjs-server/src/verifyPort.ts b/code/frameworks/nextjs-server/src/verifyPort.ts new file mode 100644 index 000000000000..1c3ef993fb85 --- /dev/null +++ b/code/frameworks/nextjs-server/src/verifyPort.ts @@ -0,0 +1,92 @@ +import { join, dirname } from 'path'; +import { ensureDir, exists, writeFile } from 'fs-extra'; + +interface VerifyOptions { + pid: number; + ppid: number; + port: string | number; + appDir: boolean; + previewPath: string; +} + +const writePidFilePages = async ({ previewPath }: VerifyOptions) => { + const pidFile = join(process.cwd(), 'pages', previewPath, 'pid.tsx'); + + if (await exists(pidFile)) return; + + await ensureDir(dirname(pidFile)); + const pidTsx = ` + import type { InferGetServerSidePropsType, GetServerSideProps } from 'next' + + export const getServerSideProps = (async () => { + return { props: { ppid: process.ppid } } + }) satisfies GetServerSideProps<{ ppid: number }>; + + export default function Page( + { ppid }: InferGetServerSidePropsType + ) { + const ppidTag = '__ppid_' + ppid + '__'; + return <>{ppidTag}; + }; + `; + await writeFile(pidFile, pidTsx); +}; + +const writePidFileApp = async ({ previewPath }: VerifyOptions) => { + const pidFile = join(process.cwd(), 'app', '(sb)', previewPath, 'pid', 'page.tsx'); + + if (await exists(pidFile)) return; + + await ensureDir(dirname(pidFile)); + const pidTsx = ` + const page = () => { + const ppidTag = '__ppid_' + process.ppid + '__'; + return <>{ppidTag}; + }; + export default page;`; + await writeFile(pidFile, pidTsx); +}; + +const PPID_RE = /__ppid_(\d+)__/; +const checkPidRoute = async ({ pid, ppid, port, previewPath }: VerifyOptions) => { + const res = await fetch(`http://localhost:${port}/${previewPath}/pid`); + const pidHtml = await res.text(); + const match = PPID_RE.exec(pidHtml); + const pidMatch = match?.[1].toString(); + + if (pidMatch === pid.toString() || pidMatch === ppid.toString()) { + console.log(`Verified NextJS pid ${pidMatch} is running on port ${port}`); + } else { + console.error(`NextJS server failed to start on port ${port}`); + console.error(`Wanted pid ${pid} or parent ${ppid}, got ${pidMatch}`); + console.error(`${pid.toString() === pidMatch} || ${ppid.toString() === pidMatch}`); + process.exit(1); + } +}; + +/** + * Helper function to verify that the NextJS + * server is actually running on the port we + * requested. Since NextJS can run multiple + * processes, defer to the parent process if + * it has already written to the pid file. + */ +export const verifyPort = ( + port: string | number, + { appDir, previewPath }: { appDir: boolean; previewPath: string } +) => { + const { pid, ppid } = process; + + setTimeout(async () => { + try { + const writePidFile = appDir ? writePidFileApp : writePidFilePages; + await writePidFile({ pid, ppid, port, appDir, previewPath }); + setTimeout( + () => checkPidRoute({ pid, ppid, port, appDir, previewPath }), + parseInt(process.env.STORYBOOK_VERIFY_PORT_DELAY ?? '100', 10) + ); + } catch (e) { + console.error(e); + } + }, 200); +}; diff --git a/code/frameworks/nextjs-server/template/app/groupLayouts/layout-nested.tsx b/code/frameworks/nextjs-server/template/app/groupLayouts/layout-nested.tsx new file mode 100644 index 000000000000..32a4123c4a31 --- /dev/null +++ b/code/frameworks/nextjs-server/template/app/groupLayouts/layout-nested.tsx @@ -0,0 +1,6 @@ +import type { PropsWithChildren } from 'react'; +import React from 'react'; + +export default function NestedLayout({ children }: PropsWithChildren<{}>) { + return <>{children}; +} diff --git a/code/frameworks/nextjs-server/template/app/groupLayouts/layout-root.tsx b/code/frameworks/nextjs-server/template/app/groupLayouts/layout-root.tsx new file mode 100644 index 000000000000..40145a32cb7f --- /dev/null +++ b/code/frameworks/nextjs-server/template/app/groupLayouts/layout-root.tsx @@ -0,0 +1,16 @@ +import type { PropsWithChildren } from 'react'; +import React from 'react'; + +export default function RootLayout({ children }: PropsWithChildren<{}>) { + return ( + + +
+
+ <>{children} +
+
+ + + ); +} diff --git a/code/frameworks/nextjs-server/template/app/storybook-preview/components/Prepare.tsx b/code/frameworks/nextjs-server/template/app/storybook-preview/components/Prepare.tsx new file mode 100644 index 000000000000..b25ff68d8a7b --- /dev/null +++ b/code/frameworks/nextjs-server/template/app/storybook-preview/components/Prepare.tsx @@ -0,0 +1,26 @@ +'use client'; + +import React, { useEffect } from 'react'; +import { STORY_PREPARED } from '@storybook/nextjs-server/core-events'; +import type { Args, Parameters, StoryId, StrictArgTypes } from '@storybook/nextjs-server/types'; +import { addons } from '@storybook/nextjs-server/preview-api'; + +export type StoryAnnotations = { + id: StoryId; + parameters: Parameters; + argTypes: StrictArgTypes; + initialArgs: TArgs; + args: TArgs; +}; + +// A component to emit the prepared event +export function Prepare({ story }: { story: StoryAnnotations }) { + const channel = addons.getChannel(); + useEffect(() => { + if (story) { + channel.emit(STORY_PREPARED, { ...story }); + } + }, [channel, story]); + + return <>; +} diff --git a/code/frameworks/nextjs-server/template/app/storybook-preview/components/Preview.tsx b/code/frameworks/nextjs-server/template/app/storybook-preview/components/Preview.tsx new file mode 100644 index 000000000000..f040572f998e --- /dev/null +++ b/code/frameworks/nextjs-server/template/app/storybook-preview/components/Preview.tsx @@ -0,0 +1,78 @@ +/* eslint-disable no-underscore-dangle */ + +'use client'; + +import React, { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { + PreviewWithSelection, + addons, + UrlStore, + WebView, +} from '@storybook/nextjs-server/preview-api'; +import { createBrowserChannel } from '@storybook/nextjs-server/channels'; +import type { StoryIndex } from '@storybook/nextjs-server/types'; +import { setArgs } from './args'; +import { previewHtml } from './previewHtml'; + +global.FEATURES = { storyStoreV7: true }; + +// A version of the URL store that doesn't change route when the selection changes +// (as we change the URL as part of rendering the story) +class StaticUrlStore extends UrlStore { + setSelection(selection: Parameters[0]) { + this.selection = selection; + } +} + +// Construct a CSF file from all the index entries on a path +function pathToCSFile(allEntries: StoryIndex['entries'], path: string) { + const entries = Object.values(allEntries || []).filter( + ({ importPath }: any) => importPath === path + ) as { id: string; name: string; title: string }[]; + + if (entries.length === 0) throw new Error(`Couldn't find import path ${path}, this is odd`); + + const mappedEntries: [string, { name: string }][] = entries.map(({ id, name }) => [ + id.split('--')[1], + { name }, + ]); + + return Object.fromEntries([['default', { title: entries[0].title }] as const, ...mappedEntries]); +} + +export const Preview = ({ previewPath }: { previewPath: string }) => { + const router = useRouter(); + useEffect(() => { + if (!window.__STORYBOOK_ADDONS_CHANNEL__) { + const channel = createBrowserChannel({ page: 'preview' }); + addons.setChannel(channel); + window.__STORYBOOK_ADDONS_CHANNEL__ = channel; + } + + if (!window.__STORYBOOK_PREVIEW__) { + const preview = new PreviewWithSelection(new StaticUrlStore(), new WebView()); + preview.initialize({ + importFn: async (path) => pathToCSFile(preview.storyStore.storyIndex!.entries, path), + getProjectAnnotations: () => ({ + render: () => {}, + renderToCanvas: async ({ id, showMain, storyContext: { args } }) => { + setArgs(previewPath, id, args); + await router.push(`/${previewPath}/${id}`); + showMain(); + }, + }), + }); + window.__STORYBOOK_PREVIEW__ = preview; + } + + // Render the the SB UI (ie iframe.html / preview.ejs) in a non-react way to ensure + // it doesn't get ripped down when a new route renders + if (!document.querySelector('#storybook-docs')) { + document.body.insertAdjacentHTML('beforeend', previewHtml); + } + + return () => {}; + }, []); + return <>; +}; diff --git a/code/frameworks/nextjs-server/template/app/storybook-preview/components/Storybook.tsx b/code/frameworks/nextjs-server/template/app/storybook-preview/components/Storybook.tsx new file mode 100644 index 000000000000..04f9f45277b9 --- /dev/null +++ b/code/frameworks/nextjs-server/template/app/storybook-preview/components/Storybook.tsx @@ -0,0 +1,19 @@ +'use client'; + +import type { PropsWithChildren } from 'react'; +import React, { useRef } from 'react'; +import { Preview } from './Preview'; + +export const Storybook = ({ + previewPath, + children, +}: PropsWithChildren<{ previewPath: string }>) => { + const ref = useRef(null); // To be used by the play fn? + return ( +
+ + + {children} +
+ ); +}; diff --git a/code/frameworks/nextjs-server/template/app/storybook-preview/components/args.ts b/code/frameworks/nextjs-server/template/app/storybook-preview/components/args.ts new file mode 100644 index 000000000000..86a735fb4f5a --- /dev/null +++ b/code/frameworks/nextjs-server/template/app/storybook-preview/components/args.ts @@ -0,0 +1,34 @@ +'use server'; + +import type { Args } from '@storybook/react'; +import { revalidatePath } from 'next/cache'; +import { cookies } from 'next/headers'; + +const cookieName = 'sbSessionId'; + +type SessionId = string; +const args: Record = {}; + +function getSessionId() { + return cookies().get(cookieName)?.value; +} + +export async function getArgs(storyId: string): Promise { + const sessionId = await getSessionId(); + if (!sessionId) return {}; + return args[sessionId]?.[storyId] || {}; +} + +export async function setArgs(previewPath: string, storyId: string, newArgs: any) { + revalidatePath(`/${previewPath}/${storyId}`); + + let sessionId = getSessionId(); + if (!sessionId) { + sessionId = Math.random().toString(); + cookies().set(cookieName, sessionId); + } + + console.log(`[${sessionId}]: setting '${storyId}' args:`, { newArgs }); + args[sessionId] ||= {}; + args[sessionId][storyId] = newArgs; +} diff --git a/code/frameworks/nextjs-server/template/app/storybook-preview/components/previewHtml.ts b/code/frameworks/nextjs-server/template/app/storybook-preview/components/previewHtml.ts new file mode 100644 index 000000000000..cab2f48b424a --- /dev/null +++ b/code/frameworks/nextjs-server/template/app/storybook-preview/components/previewHtml.ts @@ -0,0 +1,418 @@ +export const previewHtml = ` + + + + + +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+

No Preview

+

Sorry, but you either have no stories or none are selected somehow.

+
    +
  • Please check the Storybook config.
  • +
  • Try reloading the page.
  • +
+

+ If the problem persists, check the browser console, or the terminal you've run Storybook from. +

+
+
+ +
+

+  
+
`; diff --git a/code/frameworks/nextjs-server/template/app/storybook-preview/page.tsx b/code/frameworks/nextjs-server/template/app/storybook-preview/page.tsx new file mode 100644 index 000000000000..f6d55acda4c8 --- /dev/null +++ b/code/frameworks/nextjs-server/template/app/storybook-preview/page.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +const Page = () => <>; + +export default Page; diff --git a/code/frameworks/nextjs-server/template/cli/.eslintrc.json b/code/frameworks/nextjs-server/template/cli/.eslintrc.json new file mode 100644 index 000000000000..2ce44cb74ab3 --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "rules": { + "import/no-extraneous-dependencies": "off", + "import/extensions": "off", + "react/no-unknown-property": "off" + } +} diff --git a/code/frameworks/nextjs-server/template/cli/js/Button.jsx b/code/frameworks/nextjs-server/template/cli/js/Button.jsx new file mode 100644 index 000000000000..6e61cd1c7bd4 --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/js/Button.jsx @@ -0,0 +1,56 @@ +'use client'; + +import React from 'react'; +import PropTypes from 'prop-types'; +import './button.css'; + +/** + * Primary UI component for user interaction + */ +export const Button = ({ primary, backgroundColor, size, label, ...props }) => { + const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + return ( + + ); +}; + +Button.propTypes = { + /** + * Is this the principal call to action on the page? + */ + primary: PropTypes.bool, + /** + * What background color to use + */ + backgroundColor: PropTypes.string, + /** + * How large should the button be? + */ + size: PropTypes.oneOf(['small', 'medium', 'large']), + /** + * Button contents + */ + label: PropTypes.string.isRequired, + /** + * Optional click handler + */ + onClick: PropTypes.func, +}; + +Button.defaultProps = { + backgroundColor: null, + primary: false, + size: 'medium', + onClick: undefined, +}; diff --git a/code/frameworks/nextjs-server/template/cli/js/Button.stories.js b/code/frameworks/nextjs-server/template/cli/js/Button.stories.js new file mode 100644 index 000000000000..e085f9ed312f --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/js/Button.stories.js @@ -0,0 +1,45 @@ +import { Button } from './Button'; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +export default { + title: 'Example/Button', + component: Button, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/react/configure/story-layout + layout: 'centered', + }, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs + tags: ['autodocs'], + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes + argTypes: { + backgroundColor: { control: 'color' }, + }, +}; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const Primary = { + args: { + primary: true, + label: 'Button', + }, +}; + +export const Secondary = { + args: { + label: 'Button', + }, +}; + +export const Large = { + args: { + size: 'large', + label: 'Button', + }, +}; + +export const Small = { + args: { + size: 'small', + label: 'Button', + }, +}; diff --git a/code/frameworks/nextjs-server/template/cli/js/Header.jsx b/code/frameworks/nextjs-server/template/cli/js/Header.jsx new file mode 100644 index 000000000000..162ff4e5619b --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/js/Header.jsx @@ -0,0 +1,61 @@ +'use client'; + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Button } from './Button'; +import './header.css'; + +export const Header = ({ user, onLogin, onLogout, onCreateAccount }) => ( +
+
+
+ + + + + + + +

Acme

+
+
+ {user ? ( + <> + + Welcome, {user.name}! + +
+
+
+); + +Header.propTypes = { + user: PropTypes.shape({ + name: PropTypes.string.isRequired, + }), + onLogin: PropTypes.func.isRequired, + onLogout: PropTypes.func.isRequired, + onCreateAccount: PropTypes.func.isRequired, +}; + +Header.defaultProps = { + user: null, +}; diff --git a/code/frameworks/nextjs-server/template/cli/js/Header.stories.js b/code/frameworks/nextjs-server/template/cli/js/Header.stories.js new file mode 100644 index 000000000000..704a8c699534 --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/js/Header.stories.js @@ -0,0 +1,23 @@ +import { Header } from './Header'; + +export default { + title: 'Example/Header', + component: Header, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs + tags: ['autodocs'], + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: 'fullscreen', + }, +}; +export const LoggedIn = { + args: { + user: { + name: 'Jane Doe', + }, + }, +}; + +export const LoggedOut = { + args: {}, +}; diff --git a/code/frameworks/nextjs-server/template/cli/js/Page.jsx b/code/frameworks/nextjs-server/template/cli/js/Page.jsx new file mode 100644 index 000000000000..9dd658a0da9d --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/js/Page.jsx @@ -0,0 +1,70 @@ +'use client'; + +import React from 'react'; + +import { Header } from './Header'; +import './page.css'; + +export const Page = () => { + const [user, setUser] = React.useState(); + + return ( +
+
setUser({ name: 'Jane Doe' })} + onLogout={() => setUser(undefined)} + onCreateAccount={() => setUser({ name: 'Jane Doe' })} + /> +
+

Pages in Storybook

+

+ We recommend building UIs with a{' '} + + component-driven + {' '} + process starting with atomic components and ending with pages. +

+

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

+
    +
  • + Use a higher-level connected component. Storybook helps you compose such data from the + "args" of child component stories +
  • +
  • + Assemble data in the page component from your services. You can mock these services out + using Storybook. +
  • +
+

+ Get a guided tutorial on component-driven development at{' '} + + Storybook tutorials + + . Read more in the{' '} + + docs + + . +

+
+ Tip Adjust the width of the canvas with the{' '} + + + + + + Viewports addon in the toolbar +
+
+
+ ); +}; diff --git a/code/frameworks/nextjs-server/template/cli/js/Page.stories.js b/code/frameworks/nextjs-server/template/cli/js/Page.stories.js new file mode 100644 index 000000000000..ca59ab1f2952 --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/js/Page.stories.js @@ -0,0 +1,24 @@ +// import { within, userEvent } from '@storybook/testing-library'; +import { Page } from './Page'; + +export default { + title: 'Example/Page', + component: Page, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: 'fullscreen', + }, +}; + +export const LoggedOut = {}; + +// More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing +// export const LoggedIn = { +// play: async ({ canvasElement }) => { +// const canvas = within(canvasElement); +// const loginButton = await canvas.getByRole('button', { +// name: /Log in/i, +// }); +// await userEvent.click(loginButton); +// }, +// }; diff --git a/code/frameworks/nextjs-server/template/cli/ts-3-8/Button.stories.ts b/code/frameworks/nextjs-server/template/cli/ts-3-8/Button.stories.ts new file mode 100644 index 000000000000..7d049116d13f --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/ts-3-8/Button.stories.ts @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Button } from './Button'; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +const meta: Meta = { + title: 'Example/Button', + component: Button, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/react/configure/story-layout + layout: 'centered', + }, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs + tags: ['autodocs'], + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes + argTypes: { + backgroundColor: { control: 'color' }, + }, +}; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const Primary: Story = { + args: { + primary: true, + label: 'Button', + }, +}; + +export const Secondary: Story = { + args: { + label: 'Button', + }, +}; + +export const Large: Story = { + args: { + size: 'large', + label: 'Button', + }, +}; + +export const Small: Story = { + args: { + size: 'small', + label: 'Button', + }, +}; diff --git a/code/frameworks/nextjs-server/template/cli/ts-3-8/Button.tsx b/code/frameworks/nextjs-server/template/cli/ts-3-8/Button.tsx new file mode 100644 index 000000000000..bcd7f39979b0 --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/ts-3-8/Button.tsx @@ -0,0 +1,54 @@ +'use client'; + +import React from 'react'; +import './button.css'; + +interface ButtonProps { + /** + * Is this the principal call to action on the page? + */ + primary?: boolean; + /** + * What background color to use + */ + backgroundColor?: string; + /** + * How large should the button be? + */ + size?: 'small' | 'medium' | 'large'; + /** + * Button contents + */ + label: string; + /** + * Optional click handler + */ + onClick?: () => void; +} + +/** + * Primary UI component for user interaction + */ +export const Button = ({ + primary = false, + size = 'medium', + backgroundColor, + label, + ...props +}: ButtonProps) => { + const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + return ( + + ); +}; diff --git a/code/frameworks/nextjs-server/template/cli/ts-3-8/Header.stories.ts b/code/frameworks/nextjs-server/template/cli/ts-3-8/Header.stories.ts new file mode 100644 index 000000000000..448685eab0eb --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/ts-3-8/Header.stories.ts @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Header } from './Header'; + +const meta: Meta = { + title: 'Example/Header', + component: Header, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs + tags: ['autodocs'], + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +export const LoggedIn: Story = { + args: { + user: { + name: 'Jane Doe', + }, + }, +}; + +export const LoggedOut: Story = {}; diff --git a/code/frameworks/nextjs-server/template/cli/ts-3-8/Header.tsx b/code/frameworks/nextjs-server/template/cli/ts-3-8/Header.tsx new file mode 100644 index 000000000000..6dfb6ffbccee --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/ts-3-8/Header.tsx @@ -0,0 +1,58 @@ +'use client'; + +import React from 'react'; + +import { Button } from './Button'; +import './header.css'; + +type User = { + name: string; +}; + +interface HeaderProps { + user?: User; + onLogin: () => void; + onLogout: () => void; + onCreateAccount: () => void; +} + +export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => ( +
+
+
+ + + + + + + +

Acme

+
+
+ {user ? ( + <> + + Welcome, {user.name}! + +
+
+
+); diff --git a/code/frameworks/nextjs-server/template/cli/ts-3-8/Page.stories.ts b/code/frameworks/nextjs-server/template/cli/ts-3-8/Page.stories.ts new file mode 100644 index 000000000000..60a618e3349f --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/ts-3-8/Page.stories.ts @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/react'; +// import { within, userEvent } from '@storybook/testing-library'; + +import { Page } from './Page'; + +const meta: Meta = { + title: 'Example/Page', + component: Page, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +export const LoggedOut: Story = {}; + +// More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing +// export const LoggedIn: Story = { +// play: async ({ canvasElement }) => { +// const canvas = within(canvasElement); +// const loginButton = await canvas.getByRole('button', { +// name: /Log in/i, +// }); +// await userEvent.click(loginButton); +// }, +// }; diff --git a/code/frameworks/nextjs-server/template/cli/ts-3-8/Page.tsx b/code/frameworks/nextjs-server/template/cli/ts-3-8/Page.tsx new file mode 100644 index 000000000000..0d547f0ebead --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/ts-3-8/Page.tsx @@ -0,0 +1,75 @@ +'use client'; + +import React from 'react'; + +import { Header } from './Header'; +import './page.css'; + +type User = { + name: string; +}; + +export const Page: React.FC = () => { + const [user, setUser] = React.useState(); + + return ( +
+
setUser({ name: 'Jane Doe' })} + onLogout={() => setUser(undefined)} + onCreateAccount={() => setUser({ name: 'Jane Doe' })} + /> + +
+

Pages in Storybook

+

+ We recommend building UIs with a{' '} + + component-driven + {' '} + process starting with atomic components and ending with pages. +

+

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

+
    +
  • + Use a higher-level connected component. Storybook helps you compose such data from the + "args" of child component stories +
  • +
  • + Assemble data in the page component from your services. You can mock these services out + using Storybook. +
  • +
+

+ Get a guided tutorial on component-driven development at{' '} + + Storybook tutorials + + . Read more in the{' '} + + docs + + . +

+
+ Tip Adjust the width of the canvas with the{' '} + + + + + + Viewports addon in the toolbar +
+
+
+ ); +}; diff --git a/code/frameworks/nextjs-server/template/cli/ts-4-9/Button.stories.ts b/code/frameworks/nextjs-server/template/cli/ts-4-9/Button.stories.ts new file mode 100644 index 000000000000..7a68cbfec557 --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/ts-4-9/Button.stories.ts @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Button } from './Button'; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +const meta = { + title: 'Example/Button', + component: Button, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/react/configure/story-layout + layout: 'centered', + }, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs + tags: ['autodocs'], + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes + argTypes: { + backgroundColor: { control: 'color' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const Primary: Story = { + args: { + primary: true, + label: 'Button', + }, +}; + +export const Secondary: Story = { + args: { + label: 'Button', + }, +}; + +export const Large: Story = { + args: { + size: 'large', + label: 'Button', + }, +}; + +export const Small: Story = { + args: { + size: 'small', + label: 'Button', + }, +}; diff --git a/code/frameworks/nextjs-server/template/cli/ts-4-9/Button.tsx b/code/frameworks/nextjs-server/template/cli/ts-4-9/Button.tsx new file mode 100644 index 000000000000..bcd7f39979b0 --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/ts-4-9/Button.tsx @@ -0,0 +1,54 @@ +'use client'; + +import React from 'react'; +import './button.css'; + +interface ButtonProps { + /** + * Is this the principal call to action on the page? + */ + primary?: boolean; + /** + * What background color to use + */ + backgroundColor?: string; + /** + * How large should the button be? + */ + size?: 'small' | 'medium' | 'large'; + /** + * Button contents + */ + label: string; + /** + * Optional click handler + */ + onClick?: () => void; +} + +/** + * Primary UI component for user interaction + */ +export const Button = ({ + primary = false, + size = 'medium', + backgroundColor, + label, + ...props +}: ButtonProps) => { + const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + return ( + + ); +}; diff --git a/code/frameworks/nextjs-server/template/cli/ts-4-9/Header.stories.ts b/code/frameworks/nextjs-server/template/cli/ts-4-9/Header.stories.ts new file mode 100644 index 000000000000..b0766a5a4839 --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/ts-4-9/Header.stories.ts @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Header } from './Header'; + +const meta = { + title: 'Example/Header', + component: Header, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs + tags: ['autodocs'], + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: 'fullscreen', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const LoggedIn: Story = { + args: { + user: { + name: 'Jane Doe', + }, + }, +}; + +export const LoggedOut: Story = {}; diff --git a/code/frameworks/nextjs-server/template/cli/ts-4-9/Header.tsx b/code/frameworks/nextjs-server/template/cli/ts-4-9/Header.tsx new file mode 100644 index 000000000000..6dfb6ffbccee --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/ts-4-9/Header.tsx @@ -0,0 +1,58 @@ +'use client'; + +import React from 'react'; + +import { Button } from './Button'; +import './header.css'; + +type User = { + name: string; +}; + +interface HeaderProps { + user?: User; + onLogin: () => void; + onLogout: () => void; + onCreateAccount: () => void; +} + +export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => ( +
+
+
+ + + + + + + +

Acme

+
+
+ {user ? ( + <> + + Welcome, {user.name}! + +
+
+
+); diff --git a/code/frameworks/nextjs-server/template/cli/ts-4-9/Page.stories.ts b/code/frameworks/nextjs-server/template/cli/ts-4-9/Page.stories.ts new file mode 100644 index 000000000000..14280da1b719 --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/ts-4-9/Page.stories.ts @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/react'; +// import { within, userEvent } from '@storybook/testing-library'; + +import { Page } from './Page'; + +const meta = { + title: 'Example/Page', + component: Page, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: 'fullscreen', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const LoggedOut: Story = {}; + +// // More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing +// export const LoggedIn: Story = { +// play: async ({ canvasElement }) => { +// const canvas = within(canvasElement); +// const loginButton = await canvas.getByRole('button', { +// name: /Log in/i, +// }); +// await userEvent.click(loginButton); +// }, +// }; diff --git a/code/frameworks/nextjs-server/template/cli/ts-4-9/Page.tsx b/code/frameworks/nextjs-server/template/cli/ts-4-9/Page.tsx new file mode 100644 index 000000000000..0d547f0ebead --- /dev/null +++ b/code/frameworks/nextjs-server/template/cli/ts-4-9/Page.tsx @@ -0,0 +1,75 @@ +'use client'; + +import React from 'react'; + +import { Header } from './Header'; +import './page.css'; + +type User = { + name: string; +}; + +export const Page: React.FC = () => { + const [user, setUser] = React.useState(); + + return ( +
+
setUser({ name: 'Jane Doe' })} + onLogout={() => setUser(undefined)} + onCreateAccount={() => setUser({ name: 'Jane Doe' })} + /> + +
+

Pages in Storybook

+

+ We recommend building UIs with a{' '} + + component-driven + {' '} + process starting with atomic components and ending with pages. +

+

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

+
    +
  • + Use a higher-level connected component. Storybook helps you compose such data from the + "args" of child component stories +
  • +
  • + Assemble data in the page component from your services. You can mock these services out + using Storybook. +
  • +
+

+ Get a guided tutorial on component-driven development at{' '} + + Storybook tutorials + + . Read more in the{' '} + + docs + + . +

+
+ Tip Adjust the width of the canvas with the{' '} + + + + + + Viewports addon in the toolbar +
+
+
+ ); +}; diff --git a/code/frameworks/nextjs-server/tsconfig.json b/code/frameworks/nextjs-server/tsconfig.json new file mode 100644 index 000000000000..a4429176e35f --- /dev/null +++ b/code/frameworks/nextjs-server/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "strict": true + }, + "include": ["src/**/*"] +} diff --git a/code/lib/cli/src/generate.ts b/code/lib/cli/src/generate.ts index bc396ec428b7..2594863f0e90 100644 --- a/code/lib/cli/src/generate.ts +++ b/code/lib/cli/src/generate.ts @@ -223,6 +223,7 @@ command('dev') ) .option('--force-build-preview', 'Build the preview iframe even if you are using --preview-url') .option('--docs', 'Build a documentation-only site using addon-docs') + .option('--exact-port', 'Exit early if the desired port is not available') .option( '--initial-path [path]', 'URL path to be appended when visiting Storybook for the first time' diff --git a/code/lib/cli/src/generators/NEXTJS/index.ts b/code/lib/cli/src/generators/NEXTJS/index.ts index 2588b387312a..2c4ccbe47f32 100644 --- a/code/lib/cli/src/generators/NEXTJS/index.ts +++ b/code/lib/cli/src/generators/NEXTJS/index.ts @@ -6,7 +6,7 @@ const generator: Generator = async (packageManager, npmOptions, options) => { await baseGenerator( packageManager, npmOptions, - { ...options, builder: CoreBuilder.Webpack5 }, + { ...options, builder: CoreBuilder.Vite }, 'react', { extraAddons: ['@storybook/addon-onboarding'], diff --git a/code/lib/cli/src/generators/NEXTJS_SERVER/index.ts b/code/lib/cli/src/generators/NEXTJS_SERVER/index.ts new file mode 100644 index 000000000000..64ff8db624ea --- /dev/null +++ b/code/lib/cli/src/generators/NEXTJS_SERVER/index.ts @@ -0,0 +1,46 @@ +import { open, appendFile } from 'fs/promises'; +import { CoreBuilder } from '../../project_types'; +import { baseGenerator } from '../baseGenerator'; +import type { Generator } from '../types'; + +const previewTS = ` +import type { Preview } from '@storybook/react'; +import { Mock } from '@storybook/nextjs-server/mock'; + +const preview: Preview = { + decorators: [ + (storyFn, { args }) => { + Mock.set(args?.$mock); + return storyFn(); + }, + ], + argTypes: { + $mock: { control: { type: 'object' }, target: 'mock' }, + }, +}; + +export default preview; +`; + +const generator: Generator = async (packageManager, npmOptions, options) => { + await baseGenerator( + packageManager, + npmOptions, + { ...options, builder: CoreBuilder.Vite }, + 'react', + { + extraMain: { docs: { autodocs: false } }, + }, + 'nextjs-server' + ); + // add /app/storybookPreview to .gitignore + // overwrite .storybook/preview.js + const preview = await open('./.storybook/preview.ts', 'w'); + await preview.truncate(); + await preview.write(previewTS); + await preview.close(); + + await appendFile('./.gitignore', '\n/app/(sb)/storybookPreview\n'); +}; + +export default generator; diff --git a/code/lib/cli/src/generators/baseGenerator.ts b/code/lib/cli/src/generators/baseGenerator.ts index 813ba2d1dd59..2c93051e496a 100644 --- a/code/lib/cli/src/generators/baseGenerator.ts +++ b/code/lib/cli/src/generators/baseGenerator.ts @@ -172,7 +172,7 @@ const hasInteractiveStories = (rendererId: SupportedRenderers) => ); const hasFrameworkTemplates = (framework?: SupportedFrameworks) => - framework ? ['angular', 'nextjs'].includes(framework) : false; + framework ? ['angular', 'nextjs', 'nextjs-server'].includes(framework) : false; export async function baseGenerator( packageManager: JsPackageManager, diff --git a/code/lib/cli/src/helpers.ts b/code/lib/cli/src/helpers.ts index 605fab2d1849..3654746b974f 100644 --- a/code/lib/cli/src/helpers.ts +++ b/code/lib/cli/src/helpers.ts @@ -198,6 +198,7 @@ const frameworkToRenderer: Record( commandLog('Adding Storybook support to your "Next" app') ); + case ProjectType.NEXTJS_SERVER: + return nextjsServerGenerator(packageManager, npmOptions, generatorOptions).then( + commandLog('Adding Storybook support to your "Next" app') + ); + case ProjectType.SFC_VUE: return sfcVueGenerator(packageManager, npmOptions, generatorOptions).then( commandLog('Adding Storybook support to your "Single File Components Vue" app') @@ -394,6 +400,23 @@ async function doInitiate( return { shouldRunDev: false }; } + if (projectType === ProjectType.NEXTJS_SERVER) { + logger.log(); + logger.log(chalk.yellow('NOTE: installation is not 100% automated.\n')); + logger.log(`To set up Storybook, replace contents of ${chalk.cyan('next-config.js')} with:\n`); + codeLog([ + "const withStorybook = require('@storybook/nextjs-server/next-config')(/* storybook config */);", + 'module.exports = withStorybook({ /* nextjs config */ });', + ]); + logger.log('\n Then to run your NextJS app:\n'); + codeLog([packageManager.getRunCommand('dev')]); + logger.log('\n And open the URL:\n'); + logger.log(chalk.cyan('https://localhost:3000/storybook')); + logger.log(); + + return { shouldRunDev: false }; + } + const storybookCommand = projectType === ProjectType.ANGULAR ? `ng run ${installResult.projectName}:storybook` diff --git a/code/lib/cli/src/project_types.ts b/code/lib/cli/src/project_types.ts index 8d2809f97379..15da167de493 100644 --- a/code/lib/cli/src/project_types.ts +++ b/code/lib/cli/src/project_types.ts @@ -30,7 +30,13 @@ export const externalFrameworks: ExternalFramework[] = [ ]; // Should match @storybook/ -export type SupportedFrameworks = 'nextjs' | 'angular' | 'sveltekit' | 'qwik' | 'solid'; +export type SupportedFrameworks = + | 'nextjs' + | 'angular' + | 'sveltekit' + | 'qwik' + | 'solid' + | 'nextjs-server'; // Should match @storybook/ export type SupportedRenderers = @@ -70,6 +76,7 @@ export enum ProjectType { REACT_PROJECT = 'REACT_PROJECT', WEBPACK_REACT = 'WEBPACK_REACT', NEXTJS = 'NEXTJS', + NEXTJS_SERVER = 'NEXTJS_SERVER', VUE = 'VUE', VUE3 = 'VUE3', SFC_VUE = 'SFC_VUE', @@ -161,6 +168,16 @@ export const supportedTemplates: TemplateConfiguration[] = [ return dependencies?.every(Boolean) ?? true; }, }, + { + preset: ProjectType.NEXTJS_SERVER, + dependencies: { + next: (versionRange) => eqMajor(versionRange, 13) || gtMajor(versionRange, 13), + }, + matcherFunction: ({ dependencies }) => { + return dependencies.every(Boolean); + }, + files: ['app'], + }, { preset: ProjectType.NEXTJS, dependencies: ['next'], diff --git a/code/lib/cli/src/sandbox-templates.ts b/code/lib/cli/src/sandbox-templates.ts index 86b28646fb10..5fbb5153d74b 100644 --- a/code/lib/cli/src/sandbox-templates.ts +++ b/code/lib/cli/src/sandbox-templates.ts @@ -496,6 +496,19 @@ const internalTemplates = { isInternal: true, skipTasks: ['bench'], }, + 'internal/nextjs-server': { + name: 'NextJS Server', + script: + 'yarn create next-app {{beforeDir}} --typescript --eslint --no-tailwind --no-src-dir --app --import-alias="@/*"', + expected: { + framework: '@storybook/nextjs-server', + renderer: '@storybook/server', + builder: '@storybook/builder-webpack5', + }, + isInternal: true, + inDevelopment: true, + skipTasks: ['bench'], + }, // 'internal/pnp': { // ...baseTemplates['cra/default-ts'], // name: 'PNP (cra/default-ts)', diff --git a/code/lib/cli/src/versions.ts b/code/lib/cli/src/versions.ts index b477f0d53255..c8ff6c910228 100644 --- a/code/lib/cli/src/versions.ts +++ b/code/lib/cli/src/versions.ts @@ -42,6 +42,7 @@ export default { '@storybook/manager': '8.0.0-alpha.0', '@storybook/manager-api': '8.0.0-alpha.0', '@storybook/nextjs': '8.0.0-alpha.0', + '@storybook/nextjs-server': '8.0.0-alpha.0', '@storybook/node-logger': '8.0.0-alpha.0', '@storybook/postinstall': '8.0.0-alpha.0', '@storybook/preact': '8.0.0-alpha.0', diff --git a/code/lib/core-common/src/utils/get-storybook-info.ts b/code/lib/core-common/src/utils/get-storybook-info.ts index 8d97fed4d3ed..e65feb481bc1 100644 --- a/code/lib/core-common/src/utils/get-storybook-info.ts +++ b/code/lib/core-common/src/utils/get-storybook-info.ts @@ -26,6 +26,7 @@ export const frameworkPackages: Record = { '@storybook/html-vite': 'html-vite', '@storybook/html-webpack5': 'html-webpack5', '@storybook/nextjs': 'nextjs', + '@storybook/nextjs-server': 'nextjs', '@storybook/preact-vite': 'preact-vite', '@storybook/preact-webpack5': 'preact-webpack5', '@storybook/react-vite': 'react-vite', diff --git a/code/lib/core-common/templates/base-preview-head.html b/code/lib/core-common/templates/base-preview-head.html index c19b0b5bdbcf..2023885d9c6e 100644 --- a/code/lib/core-common/templates/base-preview-head.html +++ b/code/lib/core-common/templates/base-preview-head.html @@ -1,5 +1,3 @@ - -