diff --git a/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts b/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts index 3cea0d84fc7a..ad8fe95f4b39 100644 --- a/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts +++ b/code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts @@ -1,4 +1,4 @@ -import { dirname, isAbsolute, join, resolve } from 'path'; +import { dirname, join, resolve } from 'path'; import { DefinePlugin, HotModuleReplacementPlugin, ProgressPlugin, ProvidePlugin } from 'webpack'; import type { Configuration } from 'webpack'; import HtmlWebpackPlugin from 'html-webpack-plugin'; @@ -7,25 +7,20 @@ import CaseSensitivePathsPlugin from 'case-sensitive-paths-webpack-plugin'; import TerserWebpackPlugin from 'terser-webpack-plugin'; import VirtualModulePlugin from 'webpack-virtual-modules'; import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; -import slash from 'slash'; import type { TransformOptions as EsbuildOptions } from 'esbuild'; import type { JsMinifyOptions as SwcOptions } from '@swc/core'; -import type { Options, CoreConfig, DocsOptions, PreviewAnnotation } from '@storybook/types'; +import type { Options, CoreConfig, DocsOptions } from '@storybook/types'; import { globalsNameReferenceMap } from '@storybook/preview/globals'; import { getBuilderOptions, - getRendererName, stringifyProcessEnvs, - handlebars, - interpolate, normalizeStories, - readTemplate, - loadPreviewOrConfigFile, isPreservingSymlinks, } from '@storybook/core-common'; -import { toRequireContextString, toImportFn } from '@storybook/core-webpack'; +import type { BuilderOptions } from '@storybook/core-webpack'; +import { getVirtualModuleMapping } from '@storybook/core-webpack'; import { dedent } from 'ts-dedent'; -import type { BuilderOptions, TypescriptOptions } from '../types'; +import type { TypescriptOptions } from '../types'; import { createBabelLoader, createSWCLoader } from './loaders'; const getAbsolutePath = (input: I): I => @@ -114,92 +109,6 @@ export default async ( const builderOptions = await getBuilderOptions(options); - const previewAnnotations = [ - ...(await presets.apply('previewAnnotations', [], options)).map( - (entry) => { - // If entry is an object, use the absolute import specifier. - // This is to maintain back-compat with community addons that bundle other addons - // and package managers that "hide" sub dependencies (e.g. pnpm / yarn pnp) - // The vite builder uses the bare import specifier. - if (typeof entry === 'object') { - return entry.absolute; - } - - // TODO: Remove as soon as we drop support for disabled StoryStoreV7 - if (isAbsolute(entry)) { - return entry; - } - - return slash(entry); - } - ), - loadPreviewOrConfigFile(options), - ].filter(Boolean); - - const virtualModuleMapping: Record = {}; - if (features?.storyStoreV7) { - const storiesFilename = 'storybook-stories.js'; - const storiesPath = resolve(join(workingDir, storiesFilename)); - - const needPipelinedImport = !!builderOptions.lazyCompilation && !isProd; - virtualModuleMapping[storiesPath] = toImportFn(stories, { needPipelinedImport }); - const configEntryPath = resolve(join(workingDir, 'storybook-config-entry.js')); - virtualModuleMapping[configEntryPath] = handlebars( - await readTemplate( - require.resolve( - '@storybook/builder-webpack5/templates/virtualModuleModernEntry.js.handlebars' - ) - ), - { - storiesFilename, - previewAnnotations, - } - // We need to double escape `\` for webpack. We may have some in windows paths - ).replace(/\\/g, '\\\\'); - entries.push(configEntryPath); - } else { - const rendererName = await getRendererName(options); - - const rendererInitEntry = resolve(join(workingDir, 'storybook-init-renderer-entry.js')); - virtualModuleMapping[rendererInitEntry] = `import '${slash(rendererName)}';`; - entries.push(rendererInitEntry); - - const entryTemplate = await readTemplate( - join(__dirname, '..', '..', 'templates', 'virtualModuleEntry.template.js') - ); - - previewAnnotations.forEach((previewAnnotationFilename: string | undefined) => { - if (!previewAnnotationFilename) return; - - // Ensure that relative paths end up mapped to a filename in the cwd, so a later import - // of the `previewAnnotationFilename` in the template works. - const entryFilename = previewAnnotationFilename.startsWith('.') - ? `${previewAnnotationFilename.replace(/(\w)(\/|\\)/g, '$1-')}-generated-config-entry.js` - : `${previewAnnotationFilename}-generated-config-entry.js`; - // NOTE: although this file is also from the `dist/cjs` directory, it is actually a ESM - // file, see https://github.com/storybookjs/storybook/pull/16727#issuecomment-986485173 - virtualModuleMapping[entryFilename] = interpolate(entryTemplate, { - previewAnnotationFilename, - }); - entries.push(entryFilename); - }); - if (stories.length > 0) { - const storyTemplate = await readTemplate( - join(__dirname, '..', '..', 'templates', 'virtualModuleStory.template.js') - ); - // NOTE: this file has a `.cjs` extension as it is a CJS file (from `dist/cjs`) and runs - // in the user's webpack mode, which may be strict about the use of require/import. - // See https://github.com/storybookjs/storybook/issues/14877 - const storiesFilename = resolve(join(workingDir, `generated-stories-entry.cjs`)); - virtualModuleMapping[storiesFilename] = interpolate(storyTemplate, { - rendererName, - }) - // Make sure we also replace quotes for this one - .replace("'{{stories}}'", stories.map(toRequireContextString).join(',')); - entries.push(storiesFilename); - } - } - const shouldCheckTs = typescriptOptions.check && !typescriptOptions.skipBabel && !typescriptOptions.skipCompiler; const tsCheckOptions = typescriptOptions.checkOptions || {}; @@ -226,6 +135,12 @@ export default async ( externals['@storybook/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__'; } + const virtualModuleMapping = await getVirtualModuleMapping(options); + + Object.keys(virtualModuleMapping).forEach((key) => { + entries.push(key); + }); + return { name: 'preview', mode: isProd ? 'production' : 'development', diff --git a/code/frameworks/nextjs/package.json b/code/frameworks/nextjs/package.json index c1bee25f670f..8a0994b37a35 100644 --- a/code/frameworks/nextjs/package.json +++ b/code/frameworks/nextjs/package.json @@ -36,6 +36,11 @@ "types": "./dist/preset.d.ts", "require": "./dist/preset.js" }, + "./font/webpack/loader/storybook-nextjs-font-loader": { + "types": "./dist/font/webpack/loader/storybook-nextjs-font-loader.d.ts", + "require": "./dist/font/webpack/loader/storybook-nextjs-font-loader.js", + "import": "./dist/font/webpack/loader/storybook-nextjs-font-loader.mjs" + }, "./dist/preview.mjs": "./dist/preview.mjs", "./next-image-loader-stub.js": { "types": "./dist/next-image-loader-stub.d.ts", @@ -83,10 +88,12 @@ "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.2", "@babel/runtime": "^7.23.2", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", "@storybook/addon-actions": "workspace:*", "@storybook/builder-webpack5": "workspace:*", "@storybook/core-common": "workspace:*", "@storybook/core-events": "workspace:*", + "@storybook/core-webpack": "workspace:*", "@storybook/node-logger": "workspace:*", "@storybook/preset-react-webpack": "workspace:*", "@storybook/preview-api": "workspace:*", @@ -117,7 +124,7 @@ "@types/babel__plugin-transform-runtime": "^7", "@types/babel__preset-env": "^7", "@types/loader-utils": "^2.0.5", - "next": "^14.0.0", + "next": "^14.0.2", "typescript": "^4.9.3", "webpack": "^5.65.0" }, @@ -156,7 +163,8 @@ "./src/images/next-future-image.tsx", "./src/images/next-legacy-image.tsx", "./src/images/next-image.tsx", - "./src/font/webpack/loader/storybook-nextjs-font-loader.ts" + "./src/font/webpack/loader/storybook-nextjs-font-loader.ts", + "./src/swc/next-swc-loader-patch.ts" ], "externals": [ "sb-original/next/image", diff --git a/code/frameworks/nextjs/src/css/webpack.ts b/code/frameworks/nextjs/src/css/webpack.ts index 8f5ed1bfcae3..75718527e7dd 100644 --- a/code/frameworks/nextjs/src/css/webpack.ts +++ b/code/frameworks/nextjs/src/css/webpack.ts @@ -34,6 +34,9 @@ export const configureCss = (baseConfig: WebpackConfig, nextConfig: NextConfig): }, require.resolve('postcss-loader'), ], + // We transform the "target.css" files from next.js into Javascript + // for Next.js to support fonts, so it should be ignored by the css-loader. + exclude: /next\/.*\/target.css$/, }; } }); diff --git a/code/frameworks/nextjs/src/font/webpack/configureNextFont.ts b/code/frameworks/nextjs/src/font/webpack/configureNextFont.ts index 47723fed4034..d8dded3064d7 100644 --- a/code/frameworks/nextjs/src/font/webpack/configureNextFont.ts +++ b/code/frameworks/nextjs/src/font/webpack/configureNextFont.ts @@ -2,13 +2,11 @@ import type { Configuration } from 'webpack'; export function configureNextFont(baseConfig: Configuration) { baseConfig.plugins = [...(baseConfig.plugins || [])]; - baseConfig.resolveLoader = { - ...baseConfig.resolveLoader, - alias: { - ...baseConfig.resolveLoader?.alias, - 'storybook-nextjs-font-loader': require.resolve( - './font/webpack/loader/storybook-nextjs-font-loader' - ), - }, - }; + + const fontLoaderPath = require.resolve('./font/webpack/loader/storybook-nextjs-font-loader'); + + baseConfig.module?.rules?.push({ + test: /next\/.*\/target.css$/, + loader: fontLoaderPath, + }); } diff --git a/code/frameworks/nextjs/src/font/webpack/loader/local/get-font-face-declarations.ts b/code/frameworks/nextjs/src/font/webpack/loader/local/get-font-face-declarations.ts index 006c7f126f5b..d7d26ae55a37 100644 --- a/code/frameworks/nextjs/src/font/webpack/loader/local/get-font-face-declarations.ts +++ b/code/frameworks/nextjs/src/font/webpack/loader/local/get-font-face-declarations.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error import loaderUtils from 'next/dist/compiled/loader-utils3'; +import { getProjectRoot } from '@storybook/core-common'; import path from 'path'; import type { LoaderOptions } from '../types'; @@ -11,7 +12,9 @@ export async function getFontFaceDeclarations(options: LoaderOptions, rootContex const localFontSrc = options.props.src as LocalFontSrc; // Parent folder relative to the root context - const parentFolder = path.dirname(options.filename).replace(rootContext, ''); + const parentFolder = path + .dirname(path.join(getProjectRoot(), options.filename)) + .replace(rootContext, ''); const { validateData } = require('../utils/local-font-utils'); const { weight, style, variable } = validateData('', options.props); diff --git a/code/frameworks/nextjs/src/font/webpack/loader/storybook-nextjs-font-loader.ts b/code/frameworks/nextjs/src/font/webpack/loader/storybook-nextjs-font-loader.ts index 85076ecd7201..24029aa1f5cc 100644 --- a/code/frameworks/nextjs/src/font/webpack/loader/storybook-nextjs-font-loader.ts +++ b/code/frameworks/nextjs/src/font/webpack/loader/storybook-nextjs-font-loader.ts @@ -14,18 +14,31 @@ type FontFaceDeclaration = { }; export default async function storybookNextjsFontLoader(this: any) { - const options = this.getOptions() as LoaderOptions; + const importQuery = JSON.parse(this.resourceQuery.slice(1)); + const loaderOptions = this.getOptions() as LoaderOptions; + let options; + + if (Object.keys(loaderOptions).length > 0) { + options = loaderOptions; + } else { + options = { + filename: importQuery.path, + fontFamily: importQuery.import, + props: importQuery.arguments[0], + source: this.context.replace(this.rootContext, ''), + }; + } // get execution context const rootCtx = this.rootContext; let fontFaceDeclaration: FontFaceDeclaration | undefined; - if (options.source === 'next/font/google' || options.source === '@next/font/google') { + if (options.source.endsWith('next/font/google') || options.source.endsWith('@next/font/google')) { fontFaceDeclaration = await getGoogleFontFaceDeclarations(options); } - if (options.source === 'next/font/local' || options.source === '@next/font/local') { + if (options.source.endsWith('next/font/local') || options.source.endsWith('@next/font/local')) { fontFaceDeclaration = await getLocalFontFaceDeclarations(options, rootCtx); } diff --git a/code/frameworks/nextjs/src/preset.ts b/code/frameworks/nextjs/src/preset.ts index 7b0e150f735a..148a7113aa91 100644 --- a/code/frameworks/nextjs/src/preset.ts +++ b/code/frameworks/nextjs/src/preset.ts @@ -17,6 +17,7 @@ import { configureNextFont } from './font/webpack/configureNextFont'; import nextBabelPreset from './babel/preset'; import { configureNodePolyfills } from './nodePolyfills/webpack'; import { configureAliasing } from './dependency-map'; +import { configureSWCLoader } from './swc/loader'; export const addons: PresetProperty<'addons', StorybookConfig> = [ dirname(require.resolve(join('@storybook/preset-react-webpack', 'package.json'))), @@ -61,7 +62,9 @@ export const core: PresetProperty<'core', StorybookConfig> = async (config, opti name: dirname( require.resolve(join('@storybook/builder-webpack5', 'package.json')) ) as '@storybook/builder-webpack5', - options: typeof framework === 'string' ? {} : framework.options.builder || {}, + options: { + ...(typeof framework === 'string' ? {} : framework.options.builder || {}), + }, }, renderer: dirname(require.resolve(join('@storybook/react', 'package.json'))), }; @@ -135,7 +138,7 @@ export const webpackFinal: StorybookConfig['webpackFinal'] = async (baseConfig, const frameworkOptions = await options.presets.apply<{ options: FrameworkOptions }>( 'frameworkOptions' ); - const { options: { nextConfigPath } = {} } = frameworkOptions; + const { options: { nextConfigPath, builder } = {} } = frameworkOptions; const nextConfig = await configureConfig({ baseConfig, nextConfigPath, @@ -152,5 +155,10 @@ export const webpackFinal: StorybookConfig['webpackFinal'] = async (baseConfig, configureStyledJsx(baseConfig); configureNodePolyfills(baseConfig); + // TODO: In Storybook 8.0, we have to check whether the babel-compiler addon is used. Otherwise, swc should be used. + if (builder?.useSWC) { + await configureSWCLoader(baseConfig, options, nextConfig); + } + return baseConfig; }; diff --git a/code/frameworks/nextjs/src/swc/loader.ts b/code/frameworks/nextjs/src/swc/loader.ts new file mode 100644 index 000000000000..352aa4367dc5 --- /dev/null +++ b/code/frameworks/nextjs/src/swc/loader.ts @@ -0,0 +1,60 @@ +import { getProjectRoot } from '@storybook/core-common'; +import { getVirtualModuleMapping } from '@storybook/core-webpack'; +import type { Options } from '@storybook/types'; +import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; +import type { NextConfig } from 'next'; +import { getSupportedBrowsers } from 'next/dist/build/utils'; +import path from 'path'; +import type { RuleSetRule } from 'webpack'; + +export const configureSWCLoader = async ( + baseConfig: any, + options: Options, + nextConfig: NextConfig +) => { + const isDevelopment = options.configType !== 'PRODUCTION'; + + const dir = getProjectRoot(); + + baseConfig.plugins = [ + ...baseConfig.plugins, + new ReactRefreshWebpackPlugin({ + overlay: { + sockIntegration: 'whm', + }, + }), + ]; + + const virtualModules = await getVirtualModuleMapping(options); + + baseConfig.module.rules = [ + // TODO: Remove filtering in Storybook 8.0 + ...baseConfig.module.rules.filter( + (r: RuleSetRule) => + !(typeof r.use === 'object' && 'loader' in r.use && r.use.loader?.includes('swc-loader')) + ), + { + test: /\.(m?(j|t)sx?)$/, + include: [getProjectRoot()], + exclude: [/(node_modules)/, ...Object.keys(virtualModules)], + use: { + // we use our own patch because we need to remove tracing from the original code + // which is not possible otherwise + loader: require.resolve('./swc/next-swc-loader-patch.js'), + options: { + isServer: false, + rootDir: dir, + pagesDir: `${dir}/pages`, + appDir: `${dir}/apps`, + hasReactRefresh: isDevelopment, + hasServerComponents: true, + nextConfig, + supportedBrowsers: getSupportedBrowsers(dir, isDevelopment), + swcCacheDir: path.join(dir, nextConfig?.distDir ?? '.next', 'cache', 'swc'), + isServerLayer: false, + bundleTarget: 'default', + }, + }, + }, + ]; +}; diff --git a/code/frameworks/nextjs/src/swc/next-swc-loader-patch.ts b/code/frameworks/nextjs/src/swc/next-swc-loader-patch.ts new file mode 100644 index 000000000000..9fd155951fcd --- /dev/null +++ b/code/frameworks/nextjs/src/swc/next-swc-loader-patch.ts @@ -0,0 +1,184 @@ +// THIS IS A PATCH over the original code from Next 14.0.0 +// we use our own patch because we need to remove tracing from the original code +// which is not possible otherwise + +/* eslint-disable no-restricted-syntax */ +/* +Copyright (c) 2017 The swc Project Developers +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: +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. +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. +*/ + +import type { NextConfig } from 'next'; +import { isWasm, transform } from 'next/dist/build/swc'; +import { getLoaderSWCOptions } from 'next/dist/build/swc/options'; +import path, { isAbsolute } from 'path'; + +export interface SWCLoaderOptions { + rootDir: string; + isServer: boolean; + pagesDir?: string; + appDir?: string; + hasReactRefresh: boolean; + optimizeServerReact?: boolean; + nextConfig: NextConfig; + jsConfig: any; + supportedBrowsers: string[] | undefined; + swcCacheDir: string; + serverComponents?: boolean; + isReactServerLayer?: boolean; +} + +const mockCurrentTraceSpan = { + traceChild: (name: string) => mockCurrentTraceSpan, + traceAsyncFn: async (fn: any) => fn(), +}; + +async function loaderTransform(this: any, parentTrace: any, source?: string, inputSourceMap?: any) { + // Make the loader async + const filename = this.resourcePath; + + const loaderOptions: SWCLoaderOptions = this.getOptions() || {}; + + const { + isServer, + rootDir, + pagesDir, + appDir, + hasReactRefresh, + nextConfig, + jsConfig, + supportedBrowsers, + swcCacheDir, + serverComponents, + isReactServerLayer, + } = loaderOptions; + const isPageFile = filename.startsWith(pagesDir); + const relativeFilePathFromRoot = path.relative(rootDir, filename); + + const swcOptions = getLoaderSWCOptions({ + pagesDir, + appDir, + filename, + isServer, + isPageFile, + development: this.mode === 'development', + hasReactRefresh, + modularizeImports: nextConfig?.modularizeImports, + optimizePackageImports: nextConfig?.experimental?.optimizePackageImports, + swcPlugins: nextConfig?.experimental?.swcPlugins, + compilerOptions: nextConfig?.compiler, + optimizeServerReact: nextConfig?.experimental?.optimizeServerReact, + jsConfig, + supportedBrowsers, + swcCacheDir, + relativeFilePathFromRoot, + serverComponents, + isReactServerLayer, + }); + + const programmaticOptions = { + ...swcOptions, + filename, + inputSourceMap: inputSourceMap ? JSON.stringify(inputSourceMap) : undefined, + + // Set the default sourcemap behavior based on Webpack's mapping flag, + sourceMaps: this.sourceMap, + inlineSourcesContent: this.sourceMap, + + // Ensure that Webpack will get a full absolute path in the sourcemap + // so that it can properly map the module back to its internal cached + // modules. + sourceFileName: filename, + }; + + if (!programmaticOptions.inputSourceMap) { + delete programmaticOptions.inputSourceMap; + } + + // auto detect development mode + if ( + this.mode && + programmaticOptions.jsc && + programmaticOptions.jsc.transform && + programmaticOptions.jsc.transform.react && + !Object.prototype.hasOwnProperty.call(programmaticOptions.jsc.transform.react, 'development') + ) { + programmaticOptions.jsc.transform.react.development = this.mode === 'development'; + } + + const swcSpan = parentTrace.traceChild('next-swc-transform'); + return swcSpan.traceAsyncFn(() => + transform(source as any, programmaticOptions).then((output) => { + if (output.eliminatedPackages && this.eliminatedPackages) { + for (const pkg of JSON.parse(output.eliminatedPackages)) { + this.eliminatedPackages.add(pkg); + } + } + return [output.code, output.map ? JSON.parse(output.map) : undefined]; + }) + ); +} + +const EXCLUDED_PATHS = /[\\/](cache[\\/][^\\/]+\.zip[\\/]node_modules|__virtual__)[\\/]/g; + +export function pitch(this: any) { + const callback = this.async(); + (async () => { + if ( + // TODO: investigate swc file reading in PnP mode? + !process.versions.pnp && + !EXCLUDED_PATHS.test(this.resourcePath) && + this.loaders.length - 1 === this.loaderIndex && + isAbsolute(this.resourcePath) && + !(await isWasm()) + ) { + const loaderSpan = mockCurrentTraceSpan.traceChild('next-swc-loader'); + this.addDependency(this.resourcePath); + return loaderSpan.traceAsyncFn(() => loaderTransform.call(this, loaderSpan)); + } + + return null; + })().then((r) => { + if (r) return callback(null, ...r); + callback(); + return null; + }, callback); +} + +export default function swcLoader(this: any, inputSource: string, inputSourceMap: any) { + const loaderSpan = mockCurrentTraceSpan.traceChild('next-swc-loader'); + const callback = this.async(); + loaderSpan + .traceAsyncFn(() => loaderTransform.call(this, loaderSpan, inputSource, inputSourceMap)) + .then( + ([transformedSource, outputSourceMap]: any) => { + callback(null, transformedSource, outputSourceMap || inputSourceMap); + }, + (err: Error) => { + callback(err); + } + ); +} + +// accept Buffers instead of strings +export const raw = true; diff --git a/code/lib/core-webpack/package.json b/code/lib/core-webpack/package.json index 0b9a988df5d4..fd69a427abbd 100644 --- a/code/lib/core-webpack/package.json +++ b/code/lib/core-webpack/package.json @@ -51,6 +51,7 @@ "ts-dedent": "^2.0.0" }, "devDependencies": { + "slash": "^5.1.0", "typescript": "~4.9.3", "webpack": "5" }, diff --git a/code/lib/core-webpack/src/index.ts b/code/lib/core-webpack/src/index.ts index 370187367538..562860cbe1a6 100644 --- a/code/lib/core-webpack/src/index.ts +++ b/code/lib/core-webpack/src/index.ts @@ -4,3 +4,4 @@ export * from './check-webpack-version'; export * from './merge-webpack-config'; export * from './to-importFn'; export * from './to-require-context'; +export * from './virtual-module-mapping'; diff --git a/code/lib/core-webpack/src/types.ts b/code/lib/core-webpack/src/types.ts index 012b95f41cf6..1028c08a0a4d 100644 --- a/code/lib/core-webpack/src/types.ts +++ b/code/lib/core-webpack/src/types.ts @@ -22,6 +22,12 @@ export interface WebpackConfiguration { devtool?: false | string; } +export type BuilderOptions = { + fsCache?: boolean; + useSWC?: boolean; + lazyCompilation?: boolean; +}; + export type StorybookConfig = StorybookConfigBase & { /** * Modify or return a custom Webpack config after the Storybook's default configuration diff --git a/code/lib/core-webpack/src/virtual-module-mapping.ts b/code/lib/core-webpack/src/virtual-module-mapping.ts new file mode 100644 index 000000000000..ca3b30cd0e6a --- /dev/null +++ b/code/lib/core-webpack/src/virtual-module-mapping.ts @@ -0,0 +1,111 @@ +import type { Options, PreviewAnnotation } from '@storybook/types'; +import { isAbsolute, join, resolve } from 'path'; +import { + getBuilderOptions, + getRendererName, + handlebars, + interpolate, + loadPreviewOrConfigFile, + normalizeStories, + readTemplate, +} from '@storybook/core-common'; +import slash from 'slash'; +import type { BuilderOptions } from './types'; +import { toImportFn } from './to-importFn'; +import { toRequireContextString } from './to-require-context'; + +export const getVirtualModuleMapping = async (options: Options) => { + const virtualModuleMapping: Record = {}; + const builderOptions = await getBuilderOptions(options); + const workingDir = process.cwd(); + const isProd = options.configType === 'PRODUCTION'; + const nonNormalizedStories = await options.presets.apply('stories', []); + + const stories = normalizeStories(nonNormalizedStories, { + configDir: options.configDir, + workingDir, + }); + + const previewAnnotations = [ + ...(await options.presets.apply('previewAnnotations', [], options)).map( + (entry) => { + // If entry is an object, use the absolute import specifier. + // This is to maintain back-compat with community addons that bundle other addons + // and package managers that "hide" sub dependencies (e.g. pnpm / yarn pnp) + // The vite builder uses the bare import specifier. + if (typeof entry === 'object') { + return entry.absolute; + } + + // TODO: Remove as soon as we drop support for disabled StoryStoreV7 + if (isAbsolute(entry)) { + return entry; + } + + return slash(entry); + } + ), + loadPreviewOrConfigFile(options), + ].filter(Boolean); + + if (options.features?.storyStoreV7) { + const storiesFilename = 'storybook-stories.js'; + const storiesPath = resolve(join(workingDir, storiesFilename)); + + const needPipelinedImport = !!builderOptions.lazyCompilation && !isProd; + virtualModuleMapping[storiesPath] = toImportFn(stories, { needPipelinedImport }); + const configEntryPath = resolve(join(workingDir, 'storybook-config-entry.js')); + virtualModuleMapping[configEntryPath] = handlebars( + await readTemplate( + require.resolve( + '@storybook/builder-webpack5/templates/virtualModuleModernEntry.js.handlebars' + ) + ), + { + storiesFilename, + previewAnnotations, + } + // We need to double escape `\` for webpack. We may have some in windows paths + ).replace(/\\/g, '\\\\'); + } else { + const rendererName = await getRendererName(options); + + const rendererInitEntry = resolve(join(workingDir, 'storybook-init-renderer-entry.js')); + virtualModuleMapping[rendererInitEntry] = `import '${slash(rendererName)}';`; + + const entryTemplate = await readTemplate( + join(__dirname, '..', '..', 'templates', 'virtualModuleEntry.template.js') + ); + + previewAnnotations.forEach((previewAnnotationFilename: string | undefined) => { + if (!previewAnnotationFilename) return; + + // Ensure that relative paths end up mapped to a filename in the cwd, so a later import + // of the `previewAnnotationFilename` in the template works. + const entryFilename = previewAnnotationFilename.startsWith('.') + ? `${previewAnnotationFilename.replace(/(\w)(\/|\\)/g, '$1-')}-generated-config-entry.js` + : `${previewAnnotationFilename}-generated-config-entry.js`; + // NOTE: although this file is also from the `dist/cjs` directory, it is actually a ESM + // file, see https://github.com/storybookjs/storybook/pull/16727#issuecomment-986485173 + virtualModuleMapping[entryFilename] = interpolate(entryTemplate, { + previewAnnotationFilename, + }); + }); + if (stories.length > 0) { + const storyTemplate = await readTemplate( + join(__dirname, '..', '..', 'templates', 'virtualModuleStory.template.js') + ); + // NOTE: this file has a `.cjs` extension as it is a CJS file (from `dist/cjs`) and runs + // in the user's webpack mode, which may be strict about the use of require/import. + // See https://github.com/storybookjs/storybook/issues/14877 + const storiesFilename = resolve(join(workingDir, `generated-stories-entry.cjs`)); + virtualModuleMapping[storiesFilename] = interpolate(storyTemplate, { + rendererName, + }) + // Make sure we also replace quotes for this one + .replace("'{{stories}}'", stories.map(toRequireContextString).join(',')); + } + } + + return virtualModuleMapping; +}; diff --git a/code/yarn.lock b/code/yarn.lock index ba6d74b8127a..d1f886f58981 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -3952,72 +3952,72 @@ __metadata: languageName: node linkType: hard -"@next/env@npm:14.0.0": - version: 14.0.0 - resolution: "@next/env@npm:14.0.0" - checksum: c43e81dbd162a29a4b380342e416209d69d731e8ced7688d09668ec8196f543e358ed65adad81a26e943c63a293d7a018552f8389b6b1ac95cd0f63f4ef257c0 +"@next/env@npm:14.0.2": + version: 14.0.2 + resolution: "@next/env@npm:14.0.2" + checksum: 9fad703ce13b7b7fecf898d3c239f8976f2ec7f3c7c461c06da70898a0221775c48e1a2e2c76740216c4093c2db9bd7adaacd196586cd4283e09eb89de4c1db6 languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:14.0.0": - version: 14.0.0 - resolution: "@next/swc-darwin-arm64@npm:14.0.0" +"@next/swc-darwin-arm64@npm:14.0.2": + version: 14.0.2 + resolution: "@next/swc-darwin-arm64@npm:14.0.2" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@next/swc-darwin-x64@npm:14.0.0": - version: 14.0.0 - resolution: "@next/swc-darwin-x64@npm:14.0.0" +"@next/swc-darwin-x64@npm:14.0.2": + version: 14.0.2 + resolution: "@next/swc-darwin-x64@npm:14.0.2" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:14.0.0": - version: 14.0.0 - resolution: "@next/swc-linux-arm64-gnu@npm:14.0.0" +"@next/swc-linux-arm64-gnu@npm:14.0.2": + version: 14.0.2 + resolution: "@next/swc-linux-arm64-gnu@npm:14.0.2" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:14.0.0": - version: 14.0.0 - resolution: "@next/swc-linux-arm64-musl@npm:14.0.0" +"@next/swc-linux-arm64-musl@npm:14.0.2": + version: 14.0.2 + resolution: "@next/swc-linux-arm64-musl@npm:14.0.2" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:14.0.0": - version: 14.0.0 - resolution: "@next/swc-linux-x64-gnu@npm:14.0.0" +"@next/swc-linux-x64-gnu@npm:14.0.2": + version: 14.0.2 + resolution: "@next/swc-linux-x64-gnu@npm:14.0.2" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:14.0.0": - version: 14.0.0 - resolution: "@next/swc-linux-x64-musl@npm:14.0.0" +"@next/swc-linux-x64-musl@npm:14.0.2": + version: 14.0.2 + resolution: "@next/swc-linux-x64-musl@npm:14.0.2" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:14.0.0": - version: 14.0.0 - resolution: "@next/swc-win32-arm64-msvc@npm:14.0.0" +"@next/swc-win32-arm64-msvc@npm:14.0.2": + version: 14.0.2 + resolution: "@next/swc-win32-arm64-msvc@npm:14.0.2" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@next/swc-win32-ia32-msvc@npm:14.0.0": - version: 14.0.0 - resolution: "@next/swc-win32-ia32-msvc@npm:14.0.0" +"@next/swc-win32-ia32-msvc@npm:14.0.2": + version: 14.0.2 + resolution: "@next/swc-win32-ia32-msvc@npm:14.0.2" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@next/swc-win32-x64-msvc@npm:14.0.0": - version: 14.0.0 - resolution: "@next/swc-win32-x64-msvc@npm:14.0.0" +"@next/swc-win32-x64-msvc@npm:14.0.2": + version: 14.0.2 + resolution: "@next/swc-win32-x64-msvc@npm:14.0.2" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -6411,6 +6411,7 @@ __metadata: "@storybook/node-logger": "workspace:*" "@storybook/types": "workspace:*" "@types/node": "npm:^18.0.0" + slash: "npm:^5.1.0" ts-dedent: "npm:^2.0.0" typescript: "npm:~4.9.3" webpack: "npm:5" @@ -6774,10 +6775,12 @@ __metadata: "@babel/preset-typescript": "npm:^7.23.2" "@babel/runtime": "npm:^7.23.2" "@babel/types": "npm:^7.23.0" + "@pmmmwh/react-refresh-webpack-plugin": "npm:^0.5.11" "@storybook/addon-actions": "workspace:*" "@storybook/builder-webpack5": "workspace:*" "@storybook/core-common": "workspace:*" "@storybook/core-events": "workspace:*" + "@storybook/core-webpack": "workspace:*" "@storybook/node-logger": "workspace:*" "@storybook/preset-react-webpack": "workspace:*" "@storybook/preview-api": "workspace:*" @@ -6792,7 +6795,7 @@ __metadata: fs-extra: "npm:^11.1.0" image-size: "npm:^1.0.0" loader-utils: "npm:^3.2.1" - next: "npm:^14.0.0" + next: "npm:^14.0.2" node-polyfill-webpack-plugin: "npm:^2.0.1" pnp-webpack-plugin: "npm:^1.7.0" postcss: "npm:^8.4.21" @@ -22663,20 +22666,20 @@ __metadata: languageName: node linkType: hard -"next@npm:^14.0.0": - version: 14.0.0 - resolution: "next@npm:14.0.0" +"next@npm:^14.0.2": + version: 14.0.2 + resolution: "next@npm:14.0.2" dependencies: - "@next/env": "npm:14.0.0" - "@next/swc-darwin-arm64": "npm:14.0.0" - "@next/swc-darwin-x64": "npm:14.0.0" - "@next/swc-linux-arm64-gnu": "npm:14.0.0" - "@next/swc-linux-arm64-musl": "npm:14.0.0" - "@next/swc-linux-x64-gnu": "npm:14.0.0" - "@next/swc-linux-x64-musl": "npm:14.0.0" - "@next/swc-win32-arm64-msvc": "npm:14.0.0" - "@next/swc-win32-ia32-msvc": "npm:14.0.0" - "@next/swc-win32-x64-msvc": "npm:14.0.0" + "@next/env": "npm:14.0.2" + "@next/swc-darwin-arm64": "npm:14.0.2" + "@next/swc-darwin-x64": "npm:14.0.2" + "@next/swc-linux-arm64-gnu": "npm:14.0.2" + "@next/swc-linux-arm64-musl": "npm:14.0.2" + "@next/swc-linux-x64-gnu": "npm:14.0.2" + "@next/swc-linux-x64-musl": "npm:14.0.2" + "@next/swc-win32-arm64-msvc": "npm:14.0.2" + "@next/swc-win32-ia32-msvc": "npm:14.0.2" + "@next/swc-win32-x64-msvc": "npm:14.0.2" "@swc/helpers": "npm:0.5.2" busboy: "npm:1.6.0" caniuse-lite: "npm:^1.0.30001406" @@ -22714,7 +22717,7 @@ __metadata: optional: true bin: next: dist/bin/next - checksum: cfb18a72d6e1d875efb1bb3806f9a06551f482c5cb87231e77e179a71d26f3d43700290988ad27e739302bfa7ff8ac8081aafd5456c39a2819fdd315617e5acf + checksum: 65ae7a09f1643bc3deafdbdae9ce0c02326346c4a60a7c739f8f6b154b2226b8fcc5efb984cdcb4ef100116910d4c1013089135800d30c7a50cf98c9d22e5a26 languageName: node linkType: hard @@ -27405,7 +27408,7 @@ __metadata: languageName: node linkType: hard -"slash@npm:^5.0.0": +"slash@npm:^5.0.0, slash@npm:^5.1.0": version: 5.1.0 resolution: "slash@npm:5.1.0" checksum: eb48b815caf0bdc390d0519d41b9e0556a14380f6799c72ba35caf03544d501d18befdeeef074bc9c052acf69654bc9e0d79d7f1de0866284137a40805299eb3