diff --git a/code/addons/docs/src/mdx-plugin.ts b/code/addons/docs/src/mdx-plugin.ts index 81f204848049..0b8e1dd156a5 100644 --- a/code/addons/docs/src/mdx-plugin.ts +++ b/code/addons/docs/src/mdx-plugin.ts @@ -26,31 +26,36 @@ export async function mdxPlugin(options: Options): Promise { return { name: 'storybook:mdx-plugin', enforce: 'pre', - async transform(src, id) { - if (!filter(id)) { - return undefined; - } - - const mdxLoaderOptions: CompileOptions = await presets.apply('mdxLoaderOptions', { - ...mdxPluginOptions, - mdxCompileOptions: { - providerImportSource: import.meta.resolve('@storybook/addon-docs/mdx-react-shim'), - ...mdxPluginOptions?.mdxCompileOptions, - rehypePlugins: [ - ...(mdxPluginOptions?.mdxCompileOptions?.rehypePlugins ?? []), - rehypeSlug, - rehypeExternalLinks, - ], - }, - }); - - const code = String(await compile(src, mdxLoaderOptions)); - - return { - code, - // TODO: support source maps - map: null, - }; + transform: { + filter: { + id: include, + }, + async handler(src, id) { + if (!filter(id)) { + return undefined; + } + + const mdxLoaderOptions: CompileOptions = await presets.apply('mdxLoaderOptions', { + ...mdxPluginOptions, + mdxCompileOptions: { + providerImportSource: import.meta.resolve('@storybook/addon-docs/mdx-react-shim'), + ...mdxPluginOptions?.mdxCompileOptions, + rehypePlugins: [ + ...(mdxPluginOptions?.mdxCompileOptions?.rehypePlugins ?? []), + rehypeSlug, + rehypeExternalLinks, + ], + }, + }); + + const code = String(await compile(src, mdxLoaderOptions)); + + return { + code, + // TODO: support source maps + map: null, + }; + }, }, }; } diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index 2af433d0c90b..adda8c011e14 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -17,6 +17,8 @@ import { getResolvedVirtualModuleId, } from '../virtual-file-names'; +const escapeRegExp = (str: string) => str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); + export function codeGeneratorPlugin(options: Options): Plugin { const iframePath = fileURLToPath(importMetaResolve('@storybook/builder-vite/input/iframe.html')); let iframeId: string; @@ -24,6 +26,11 @@ export function codeGeneratorPlugin(options: Options): Plugin { const storyIndexGeneratorPromise: Promise = options.presets.apply('storyIndexGenerator'); + const resolveIdFilter = [ + ...SB_VIRTUAL_FILE_IDS.map((id) => new RegExp(escapeRegExp(id))), + new RegExp(escapeRegExp(iframePath)), + ]; + return { name: 'storybook:code-generator-plugin', enforce: 'pre', @@ -56,15 +63,18 @@ export function codeGeneratorPlugin(options: Options): Plugin { projectRoot = config.root; iframeId = `${config.root}/iframe.html`; }, - resolveId(source) { - if (SB_VIRTUAL_FILE_IDS.includes(source)) { - return getResolvedVirtualModuleId(source); - } - if (source === iframePath) { - return iframeId; - } + resolveId: { + filter: { id: resolveIdFilter }, + handler(source) { + if (SB_VIRTUAL_FILE_IDS.includes(source)) { + return getResolvedVirtualModuleId(source); + } + if (source === iframePath) { + return iframeId; + } - return undefined; + return undefined; + }, }, async load(id) { switch (id) { diff --git a/code/builders/builder-vite/src/plugins/external-globals-plugin.ts b/code/builders/builder-vite/src/plugins/external-globals-plugin.ts index 242fb98ff3dc..60378479ad50 100644 --- a/code/builders/builder-vite/src/plugins/external-globals-plugin.ts +++ b/code/builders/builder-vite/src/plugins/external-globals-plugin.ts @@ -42,6 +42,9 @@ export async function externalGlobalsPlugin(externals: Record): await init; const { mergeAlias } = await import('vite'); + const globalsList = Object.keys(externals); + const globalsCodeFilter = new RegExp(globalsList.map(escapeKeys).join('|')); + return { name: 'storybook:external-globals-plugin', enforce: 'post', @@ -75,28 +78,29 @@ export async function externalGlobalsPlugin(externals: Record): }; }, // Replace imports with variables destructured from global scope - async transform(code: string, id: string) { - const globalsList = Object.keys(externals); - - if (globalsList.every((glob) => !code.includes(glob))) { - return undefined; - } - - const [imports] = parse(code); - const src = new MagicString(code); - imports.forEach(({ n: path, ss: startPosition, se: endPosition }) => { - const packageName = path; - if (packageName && globalsList.includes(packageName)) { - const importStatement = src.slice(startPosition, endPosition); - const transformedImport = rewriteImport(importStatement, externals, packageName); - src.update(startPosition, endPosition, transformedImport); + transform: { + filter: { code: globalsCodeFilter }, + async handler(code: string, id: string) { + if (globalsList.every((glob) => !code.includes(glob))) { + return undefined; } - }); - return { - code: src.toString(), - map: null, - }; + const [imports] = parse(code); + const src = new MagicString(code); + imports.forEach(({ n: path, ss: startPosition, se: endPosition }) => { + const packageName = path; + if (packageName && globalsList.includes(packageName)) { + const importStatement = src.slice(startPosition, endPosition); + const transformedImport = rewriteImport(importStatement, externals, packageName); + src.update(startPosition, endPosition, transformedImport); + } + }); + + return { + code: src.toString(), + map: null, + }; + }, }, } satisfies Plugin; } diff --git a/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts b/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts index 01b835ff6187..679bb846071b 100644 --- a/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts +++ b/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts @@ -11,29 +11,32 @@ export async function injectExportOrderPlugin() { name: 'storybook:inject-export-order-plugin', // This should only run after the typescript has been transpiled enforce: 'post', - async transform(code: string, id: string) { - if (!filter(id)) { - return undefined; - } + transform: { + filter: { id: include }, + async handler(code: string, id: string) { + if (!filter(id)) { + return undefined; + } - // TODO: Maybe convert `injectExportOrderPlugin` to function that returns object, - // and run `await init;` once and then call `parse()` without `await`, - // instead of calling `await parse()` every time. - const [, exports] = await parse(code); + // TODO: Maybe convert `injectExportOrderPlugin` to function that returns object, + // and run `await init;` once and then call `parse()` without `await`, + // instead of calling `await parse()` every time. + const [, exports] = await parse(code); - const exportNames = exports.map((e) => code.substring(e.s, e.e)); + const exportNames = exports.map((e) => code.substring(e.s, e.e)); - if (exportNames.includes('__namedExportsOrder')) { - // user has defined named exports already - return undefined; - } - const s = new MagicString(code); - const orderedExports = exportNames.filter((e) => e !== 'default'); - s.append(`;export const __namedExportsOrder = ${JSON.stringify(orderedExports)};`); - return { - code: s.toString(), - map: s.generateMap({ hires: true, source: id }), - }; + if (exportNames.includes('__namedExportsOrder')) { + // user has defined named exports already + return undefined; + } + const s = new MagicString(code); + const orderedExports = exportNames.filter((e) => e !== 'default'); + s.append(`;export const __namedExportsOrder = ${JSON.stringify(orderedExports)};`); + return { + code: s.toString(), + map: s.generateMap({ hires: true, source: id }), + }; + }, }, }; } diff --git a/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts b/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts index 509a7d06adbd..86cf1e571295 100644 --- a/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts +++ b/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts @@ -9,22 +9,26 @@ import type { Plugin } from 'vite'; export async function stripStoryHMRBoundary(): Promise { const { createFilter } = await import('vite'); - const filter = createFilter(/\.stories\.(tsx?|jsx?|svelte|vue)$/); + const include = /\.stories\.(tsx?|jsx?|svelte|vue)$/; + const filter = createFilter(include); return { name: 'storybook:strip-hmr-boundary-plugin', enforce: 'post', - async transform(src, id) { - if (!filter(id)) { - return undefined; - } + transform: { + filter: { id: include }, + async handler(src, id) { + if (!filter(id)) { + return undefined; + } - const s = new MagicString(src); - s.replace(/import\.meta\.hot\.accept\w*/, '(function hmrBoundaryNoop(){})'); + const s = new MagicString(src); + s.replace(/import\.meta\.hot\.accept\w*/, '(function hmrBoundaryNoop(){})'); - return { - code: s.toString(), - map: s.generateMap({ hires: true, source: id }), - }; + return { + code: s.toString(), + map: s.generateMap({ hires: true, source: id }), + }; + }, }, }; } diff --git a/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.ts b/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.ts index 79dfaa64410f..7caef926c577 100644 --- a/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.ts +++ b/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.ts @@ -41,11 +41,14 @@ export const viteInjectMockerRuntime = (options: { }); } }, - resolveId(source) { - if (source === ENTRY_PATH) { - return mockerRuntimePath; - } - return undefined; + resolveId: { + filter: { id: /vite-inject-mocker-entry\.js/ }, + handler(source) { + if (source === ENTRY_PATH) { + return mockerRuntimePath; + } + return undefined; + }, }, transformIndexHtml(html: string) { const headTag = html.match(/]*>/); diff --git a/code/builders/builder-vite/src/plugins/vite-mock/plugin.ts b/code/builders/builder-vite/src/plugins/vite-mock/plugin.ts index 5f60995c05b0..06aa20be7701 100644 --- a/code/builders/builder-vite/src/plugins/vite-mock/plugin.ts +++ b/code/builders/builder-vite/src/plugins/vite-mock/plugin.ts @@ -162,16 +162,19 @@ export function viteMockPlugin(options: MockPluginOptions): Plugin[] { }, { name: 'storybook:mock-loader-preview', - transform(code, id) { - if (id === normalizedPreviewConfigPath) { - try { - return rewriteSbMockImportCalls(code); - } catch (e) { - logger.debug(`Could not transform sb.mock(import(...)) calls in ${id}: ${e}`); - return null; + transform: { + filter: { id: normalizedPreviewConfigPath }, + handler(code, id) { + if (id === normalizedPreviewConfigPath) { + try { + return rewriteSbMockImportCalls(code); + } catch (e) { + logger.debug(`Could not transform sb.mock(import(...)) calls in ${id}: ${e}`); + return null; + } } - } - return null; + return null; + }, }, }, ]; diff --git a/code/frameworks/svelte-vite/src/plugins/svelte-docgen.ts b/code/frameworks/svelte-vite/src/plugins/svelte-docgen.ts index 5a553f8ff79c..fe54a034f8bf 100644 --- a/code/frameworks/svelte-vite/src/plugins/svelte-docgen.ts +++ b/code/frameworks/svelte-vite/src/plugins/svelte-docgen.ts @@ -116,31 +116,34 @@ export async function svelteDocgen(): Promise { return { name: 'storybook:svelte-docgen-plugin', - async transform(src: string, id: string) { - if (id.startsWith('\0') || !filter(id)) { - return undefined; - } - - const resource = relative(cwd, id); - - // Get props information - const docgen = generateDocgen(resource, sourceFileCache); - const data = transformToSvelteDocParserDataItems(docgen); - - const componentDoc: SvelteComponentDoc & { keywords?: string[] } = { - data: data, - name: basename(resource), - }; - - const s = new MagicString(src); - const outputAst = this.parse(src); - const componentName = getComponentName(outputAst as unknown as AST.Program); - s.append(`\n;${componentName}.__docgen = ${JSON.stringify(componentDoc)}`); - - return { - code: s.toString(), - map: s.generateMap({ hires: true, source: id }), - }; + transform: { + filter: { id: { include, exclude } }, + async handler(src: string, id: string) { + if (id.startsWith('\0') || !filter(id)) { + return undefined; + } + + const resource = relative(cwd, id); + + // Get props information + const docgen = generateDocgen(resource, sourceFileCache); + const data = transformToSvelteDocParserDataItems(docgen); + + const componentDoc: SvelteComponentDoc & { keywords?: string[] } = { + data: data, + name: basename(resource), + }; + + const s = new MagicString(src); + const outputAst = this.parse(src); + const componentName = getComponentName(outputAst as unknown as AST.Program); + s.append(`\n;${componentName}.__docgen = ${JSON.stringify(componentDoc)}`); + + return { + code: s.toString(), + map: s.generateMap({ hires: true, source: id }), + }; + }, }, }; } diff --git a/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts b/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts index 35a8881117d4..d6587bd9dc2b 100644 --- a/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts +++ b/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts @@ -34,118 +34,124 @@ export async function vueComponentMeta(tsconfigPath = 'tsconfig.json'): Promise< return { name: 'storybook:vue-component-meta-plugin', - async transform(src, id) { - if (!filter(id)) { - return undefined; - } - - try { - const exportNames = checker.getExportNames(id); - let componentsMeta = exportNames.map((name) => checker.getComponentMeta(id, name)); - componentsMeta = await applyTempFixForEventDescriptions(id, componentsMeta); - - const metaSources: MetaSource[] = []; - - componentsMeta.forEach((meta, index) => { - // filter out empty meta - const isEmpty = - !meta.props.length && !meta.events.length && !meta.slots.length && !meta.exposed.length; + transform: { + filter: { id: { include, exclude } }, + async handler(src, id) { + if (!filter(id)) { + return undefined; + } - if (isEmpty || meta.type === TypeMeta.Unknown) { - return; - } + try { + const exportNames = checker.getExportNames(id); + let componentsMeta = exportNames.map((name) => checker.getComponentMeta(id, name)); + componentsMeta = await applyTempFixForEventDescriptions(id, componentsMeta); + + const metaSources: MetaSource[] = []; + + componentsMeta.forEach((meta, index) => { + // filter out empty meta + const isEmpty = + !meta.props.length && + !meta.events.length && + !meta.slots.length && + !meta.exposed.length; + + if (isEmpty || meta.type === TypeMeta.Unknown) { + return; + } + + const exportName = exportNames[index]; + + // we remove nested object schemas here since they are not used inside Storybook (we don't generate controls for object properties) + // and they can cause "out of memory" issues for large/complex schemas (e.g. HTMLElement) + // it also reduced the bundle size when running "storybook build" when such schemas are used + (['props', 'events', 'slots', 'exposed'] as const).forEach((key) => { + meta[key].forEach((value) => { + if (Array.isArray(value.schema)) { + value.schema.forEach((eventSchema) => removeNestedSchemas(eventSchema)); + } else { + removeNestedSchemas(value.schema); + } + }); + }); - const exportName = exportNames[index]; - - // we remove nested object schemas here since they are not used inside Storybook (we don't generate controls for object properties) - // and they can cause "out of memory" issues for large/complex schemas (e.g. HTMLElement) - // it also reduced the bundle size when running "storybook build" when such schemas are used - (['props', 'events', 'slots', 'exposed'] as const).forEach((key) => { - meta[key].forEach((value) => { - if (Array.isArray(value.schema)) { - value.schema.forEach((eventSchema) => removeNestedSchemas(eventSchema)); - } else { - removeNestedSchemas(value.schema); - } + const exposed = + // the meta also includes duplicated entries in the "exposed" array with "on" + // prefix (e.g. onClick instead of click), so we need to filter them out here + meta.exposed + .filter((expose) => { + let nameWithoutOnPrefix = expose.name; + + if (nameWithoutOnPrefix.startsWith('on')) { + nameWithoutOnPrefix = lowercaseFirstLetter(expose.name.replace('on', '')); + } + + const hasEvent = meta.events.find((event) => event.name === nameWithoutOnPrefix); + return !hasEvent; + }) + // remove unwanted duplicated "$slots" expose + .filter((expose) => { + if (expose.name === '$slots') { + const slotNames = meta.slots.map((slot) => slot.name); + return !slotNames.every((slotName) => expose.type.includes(slotName)); + } + return true; + }); + + metaSources.push({ + exportName, + displayName: exportName === 'default' ? getFilenameWithoutExtension(id) : exportName, + ...meta, + exposed, + sourceFiles: id, }); }); - const exposed = - // the meta also includes duplicated entries in the "exposed" array with "on" - // prefix (e.g. onClick instead of click), so we need to filter them out here - meta.exposed - .filter((expose) => { - let nameWithoutOnPrefix = expose.name; + // if there is no component meta, return undefined - if (nameWithoutOnPrefix.startsWith('on')) { - nameWithoutOnPrefix = lowercaseFirstLetter(expose.name.replace('on', '')); - } - - const hasEvent = meta.events.find((event) => event.name === nameWithoutOnPrefix); - return !hasEvent; - }) - // remove unwanted duplicated "$slots" expose - .filter((expose) => { - if (expose.name === '$slots') { - const slotNames = meta.slots.map((slot) => slot.name); - return !slotNames.every((slotName) => expose.type.includes(slotName)); - } - return true; - }); + // if there is no component meta, return undefined + if (metaSources.length === 0) { + return undefined; + } - metaSources.push({ - exportName, - displayName: exportName === 'default' ? getFilenameWithoutExtension(id) : exportName, - ...meta, - exposed, - sourceFiles: id, + const s = new MagicString(src); + + metaSources.forEach((meta) => { + const isDefaultExport = meta.exportName === 'default'; + const name = isDefaultExport ? '_sfc_main' : meta.exportName; + + // we can only add the "__docgenInfo" to variables that are actually defined in the current file + // so e.g. re-exports like "export { default as MyComponent } from './MyComponent.vue'" must be ignored + // to prevent runtime errors + if ( + new RegExp(`export {.*${name}.*}`).test(src) || + new RegExp(`export \\* from ['"]\\S*${name}['"]`).test(src) || + // when using re-exports, some exports might be resolved via checker.getExportNames + // but are not directly exported inside the current file so we need to ignore them too + !src.includes(name) + ) { + return; + } + + if (!id.endsWith('.vue') && isDefaultExport) { + // we can not add the __docgenInfo if the component is default exported directly + // so we need to safe it to a variable instead and export default it instead + s.replace('export default ', 'const _sfc_main = '); + s.append('\nexport default _sfc_main;'); + } + s.append(`\n;${name}.__docgenInfo = Object.assign({ + displayName: ${name}.name ?? ${name}.__name + }, ${JSON.stringify(meta)})`); }); - }); - - // if there is no component meta, return undefined - // if there is no component meta, return undefined - if (metaSources.length === 0) { + return { + code: s.toString(), + map: s.generateMap({ hires: true, source: id }), + }; + } catch (e) { return undefined; } - - const s = new MagicString(src); - - metaSources.forEach((meta) => { - const isDefaultExport = meta.exportName === 'default'; - const name = isDefaultExport ? '_sfc_main' : meta.exportName; - - // we can only add the "__docgenInfo" to variables that are actually defined in the current file - // so e.g. re-exports like "export { default as MyComponent } from './MyComponent.vue'" must be ignored - // to prevent runtime errors - if ( - new RegExp(`export {.*${name}.*}`).test(src) || - new RegExp(`export \\* from ['"]\\S*${name}['"]`).test(src) || - // when using re-exports, some exports might be resolved via checker.getExportNames - // but are not directly exported inside the current file so we need to ignore them too - !src.includes(name) - ) { - return; - } - - if (!id.endsWith('.vue') && isDefaultExport) { - // we can not add the __docgenInfo if the component is default exported directly - // so we need to safe it to a variable instead and export default it instead - s.replace('export default ', 'const _sfc_main = '); - s.append('\nexport default _sfc_main;'); - } - s.append(`\n;${name}.__docgenInfo = Object.assign({ - displayName: ${name}.name ?? ${name}.__name - }, ${JSON.stringify(meta)})`); - }); - - return { - code: s.toString(), - map: s.generateMap({ hires: true, source: id }), - }; - } catch (e) { - return undefined; - } + }, }, // handle hot updates to update the component meta on file changes async handleHotUpdate({ file, read, server, modules, timestamp }) { diff --git a/code/frameworks/vue3-vite/src/plugins/vue-docgen.ts b/code/frameworks/vue3-vite/src/plugins/vue-docgen.ts index 108cf3591bd4..686b4a09799c 100644 --- a/code/frameworks/vue3-vite/src/plugins/vue-docgen.ts +++ b/code/frameworks/vue3-vite/src/plugins/vue-docgen.ts @@ -10,22 +10,25 @@ export async function vueDocgen(): Promise { return { name: 'storybook:vue-docgen-plugin', - async transform(src, id) { - if (!filter(id)) { - return undefined; - } + transform: { + filter: { id: include }, + async handler(src, id) { + if (!filter(id)) { + return undefined; + } - const metaData = await parse(id); + const metaData = await parse(id); - const s = new MagicString(src); - s.append(`;_sfc_main.__docgenInfo = Object.assign({ - displayName: _sfc_main.name ?? _sfc_main.__name - }, ${JSON.stringify(metaData)});`); + const s = new MagicString(src); + s.append(`;_sfc_main.__docgenInfo = Object.assign({ + displayName: _sfc_main.name ?? _sfc_main.__name + }, ${JSON.stringify(metaData)});`); - return { - code: s.toString(), - map: s.generateMap({ hires: true, source: id }), - }; + return { + code: s.toString(), + map: s.generateMap({ hires: true, source: id }), + }; + }, }, }; }