diff --git a/code/builders/builder-vite/src/build.ts b/code/builders/builder-vite/src/build.ts index ccf9f9476a5f..31a2e6f62558 100644 --- a/code/builders/builder-vite/src/build.ts +++ b/code/builders/builder-vite/src/build.ts @@ -2,6 +2,15 @@ import type { Options } from '@storybook/types'; import { commonConfig } from './vite-config'; import { sanitizeEnvVars } from './envs'; +import type { WebpackStatsPlugin } from './plugins'; +import type { InlineConfig } from 'vite'; +import { logger } from '@storybook/node-logger'; +import { hasVitePlugins } from './utils/has-vite-plugins'; +import { withoutVitePlugins } from './utils/without-vite-plugins'; + +function findPlugin(config: InlineConfig, name: string) { + return config.plugins?.find((p) => p && 'name' in p && p.name === name); +} export async function build(options: Options) { const { build: viteBuild, mergeConfig } = await import('vite'); @@ -28,5 +37,20 @@ export async function build(options: Options) { }).build; const finalConfig = await presets.apply('viteFinal', config, options); + + const turbosnapPluginName = 'rollup-plugin-turbosnap'; + const hasTurbosnapPlugin = + finalConfig.plugins && hasVitePlugins(finalConfig.plugins, [turbosnapPluginName]); + if (hasTurbosnapPlugin) { + logger.warn(`Found '${turbosnapPluginName}' which is now included by default in Storybook 8.`); + logger.warn( + `Removing from your plugins list. Ensure you pass \`--webpack-stats-json\` to generate stats.` + ); + finalConfig.plugins = await withoutVitePlugins(finalConfig.plugins, [turbosnapPluginName]); + } + await viteBuild(await sanitizeEnvVars(options, finalConfig)); + + const statsPlugin = findPlugin(finalConfig, 'rollup-plugin-webpack-stats') as WebpackStatsPlugin; + return statsPlugin?.storybookGetStats(); } diff --git a/code/builders/builder-vite/src/plugins/index.ts b/code/builders/builder-vite/src/plugins/index.ts index 68e540908dc6..bc72dc8755d5 100644 --- a/code/builders/builder-vite/src/plugins/index.ts +++ b/code/builders/builder-vite/src/plugins/index.ts @@ -3,3 +3,4 @@ export * from './strip-story-hmr-boundaries'; export * from './code-generator-plugin'; export * from './csf-plugin'; export * from './external-globals-plugin'; +export * from './webpack-stats-plugin'; diff --git a/code/builders/builder-vite/src/plugins/webpack-stats-plugin.ts b/code/builders/builder-vite/src/plugins/webpack-stats-plugin.ts new file mode 100644 index 000000000000..affb130c07e1 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/webpack-stats-plugin.ts @@ -0,0 +1,116 @@ +// This plugin is a direct port of https://github.com/IanVS/vite-plugin-turbosnap + +import type { BuilderStats } from '@storybook/types'; +import path from 'path'; +import type { Plugin } from 'vite'; + +/* + * Reason, Module are copied from chromatic types + * https://github.com/chromaui/chromatic-cli/blob/145a5e295dde21042e96396c7e004f250d842182/bin-src/types.ts#L265-L276 + */ +interface Reason { + moduleName: string; +} +interface Module { + id: string | number; + name: string; + modules?: Array>; + reasons?: Reason[]; +} + +type WebpackStatsPluginOptions = { + workingDir: string; +}; + +/** + * Strips off query params added by rollup/vite to ids, to make paths compatible for comparison with git. + */ +function stripQueryParams(filePath: string): string { + return filePath.split('?')[0]; +} + +/** + * We only care about user code, not node_modules, vite files, or (most) virtual files. + */ +function isUserCode(moduleName: string) { + return Boolean( + moduleName && + !moduleName.startsWith('vite/') && + !moduleName.startsWith('\x00') && + !moduleName.startsWith('\u0000') && + moduleName !== 'react/jsx-runtime' && + !moduleName.match(/node_modules\//) + ); +} + +export type WebpackStatsPlugin = Plugin & { storybookGetStats: () => BuilderStats }; + +export function pluginWebpackStats({ workingDir }: WebpackStatsPluginOptions): WebpackStatsPlugin { + /** + * Convert an absolute path name to a path relative to the vite root, with a starting `./` + */ + function normalize(filename: string) { + // Do not try to resolve virtual files + if (filename.startsWith('/virtual:')) { + return filename; + } + // Otherwise, we need them in the format `./path/to/file.js`. + else { + const relativePath = path.relative(workingDir, stripQueryParams(filename)); + // This seems hacky, got to be a better way to add a `./` to the start of a path. + return `./${relativePath}`; + } + } + + /** + * Helper to create Reason objects out of a list of string paths + */ + function createReasons(importers?: readonly string[]): Reason[] { + return (importers || []).map((i) => ({ moduleName: normalize(i) })); + } + + /** + * Helper function to build a `Module` given a filename and list of files that import it + */ + function createStatsMapModule(filename: string, importers?: readonly string[]): Module { + return { + id: filename, + name: filename, + reasons: createReasons(importers), + }; + } + + const statsMap = new Map(); + + return { + name: 'storybook:rollup-plugin-webpack-stats', + // We want this to run after the vite build plugins (https://vitejs.dev/guide/api-plugin.html#plugin-ordering) + enforce: 'post', + moduleParsed: function (mod) { + if (isUserCode(mod.id)) { + mod.importedIds + .concat(mod.dynamicallyImportedIds) + .filter((name) => isUserCode(name)) + .forEach((depIdUnsafe) => { + const depId = normalize(depIdUnsafe); + if (statsMap.has(depId)) { + const m = statsMap.get(depId); + if (m) { + m.reasons = (m.reasons ?? []) + .concat(createReasons([mod.id])) + .filter((r) => r.moduleName !== depId); + statsMap.set(depId, m); + } + } else { + statsMap.set(depId, createStatsMapModule(depId, [mod.id])); + } + }); + } + }, + + storybookGetStats() { + const stats = { modules: Array.from(statsMap.values()) }; + return { ...stats, toJson: () => stats }; + }, + }; +} diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 2383ebd6e5e5..4a1c2fb3ee44 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -20,6 +20,7 @@ import { injectExportOrderPlugin, stripStoryHMRBoundary, externalGlobalsPlugin, + pluginWebpackStats, } from './plugins'; import type { BuilderOptions } from './types'; @@ -112,6 +113,7 @@ export async function pluginConfig(options: Options) { }, }, await externalGlobalsPlugin(externals), + pluginWebpackStats({ workingDir: process.cwd() }), ] as PluginOption[]; // TODO: framework doesn't exist, should move into framework when/if built