diff --git a/code/lib/builder-vite/package.json b/code/lib/builder-vite/package.json index 5b9a484e8967..929bb975db66 100644 --- a/code/lib/builder-vite/package.json +++ b/code/lib/builder-vite/package.json @@ -61,8 +61,7 @@ "glob-promise": "^4.2.0", "magic-string": "^0.26.1", "rollup": "^2.25.0 || ^3.3.0", - "slash": "^3.0.0", - "vite-plugin-externals": "^0.5.1" + "slash": "^3.0.0" }, "devDependencies": { "@types/express": "^4.17.13", diff --git a/code/lib/builder-vite/src/plugins/external-globals-plugin.test.ts b/code/lib/builder-vite/src/plugins/external-globals-plugin.test.ts new file mode 100644 index 000000000000..2bd540ee9c29 --- /dev/null +++ b/code/lib/builder-vite/src/plugins/external-globals-plugin.test.ts @@ -0,0 +1,43 @@ +import { rewriteImport } from './external-globals-plugin'; + +const packageName = '@storybook/package'; +const globals = { [packageName]: '_STORYBOOK_PACKAGE_' }; + +const cases = [ + { + globals, + packageName, + input: `import { Rain, Jour as Day, Nuit as Night, Sun } from "${packageName}"`, + output: `const { Rain, Jour: Day, Nuit: Night, Sun } = ${globals[packageName]}`, + }, + { + globals, + packageName, + input: `import * as Foo from "${packageName}"`, + output: `const Foo = ${globals[packageName]}`, + }, + { + globals, + packageName, + input: `import Foo from "${packageName}"`, + output: `const {default: Foo} = ${globals[packageName]}`, + }, + { + globals, + packageName, + input: `import{Rain,Jour as Day,Nuit as Night,Sun}from'${packageName}'`, + output: `const {Rain,Jour: Day,Nuit: Night,Sun} =${globals[packageName]}`, + }, + { + globals, + packageName, + input: `const { Afternoon } = await import('${packageName}')`, + output: `const { Afternoon } = ${globals[packageName]}`, + }, +]; + +test('rewriteImport', () => { + cases.forEach(({ input, output, globals: caseGlobals, packageName: casePackage }) => { + expect(rewriteImport(input, caseGlobals, casePackage)).toStrictEqual(output); + }); +}); diff --git a/code/lib/builder-vite/src/plugins/external-globals-plugin.ts b/code/lib/builder-vite/src/plugins/external-globals-plugin.ts new file mode 100644 index 000000000000..60841e22bc97 --- /dev/null +++ b/code/lib/builder-vite/src/plugins/external-globals-plugin.ts @@ -0,0 +1,119 @@ +import { join } from 'node:path'; +import { init, parse } from 'es-module-lexer'; +import MagicString from 'magic-string'; +import { emptyDir, ensureDir, ensureFile, writeFile } from 'fs-extra'; +import { mergeAlias } from 'vite'; +import type { Alias, Plugin } from 'vite'; + +const escapeKeys = (key: string) => key.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); +const defaultImportRegExp = 'import ([^*{}]+) from'; +const replacementMap = new Map([ + ['import ', 'const '], + ['import{', 'const {'], + ['* as ', ''], + [' as ', ': '], + [' from ', ' = '], + ['}from', '} ='], +]); + +/** + * This plugin swaps out imports of pre-bundled storybook preview modules for destructures from global + * variables that are added in runtime.mjs. + * + * For instance: + * + * ```js + * import { useMemo as useMemo2, useEffect as useEffect2 } from "@storybook/preview-api"; + * ``` + * + * becomes + * + * ```js + * const { useMemo: useMemo2, useEffect: useEffect2 } = __STORYBOOK_MODULE_PREVIEW_API__; + * ``` + * + * It is based on existing plugins like https://github.com/crcong/vite-plugin-externals + * and https://github.com/eight04/rollup-plugin-external-globals, but simplified to meet our simple needs. + */ +export async function externalGlobalsPlugin(externals: Record) { + await init; + return { + name: 'storybook:external-globals-plugin', + enforce: 'post', + // In dev (serve), we set up aliases to files that we write into node_modules/.cache. + async config(config, { command }) { + if (command !== 'serve') { + return undefined; + } + const newAlias = mergeAlias([], config.resolve?.alias) as Alias[]; + + const cachePath = join(process.cwd(), 'node_modules', '.cache', 'vite-plugin-externals'); + await ensureDir(cachePath); + await emptyDir(cachePath); + await Promise.all( + (Object.keys(externals) as Array).map(async (externalKey) => { + const externalCachePath = join(cachePath, `${externalKey}.js`); + newAlias.push({ find: new RegExp(`^${externalKey}$`), replacement: externalCachePath }); + await ensureFile(externalCachePath); + await writeFile(externalCachePath, `module.exports = ${externals[externalKey]};`); + }) + ); + + return { + resolve: { + alias: newAlias, + }, + }; + }, + // 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); + } + }); + + return { + code: src.toString(), + map: src.generateMap({ + source: id, + includeContent: true, + hires: true, + }), + }; + }, + } satisfies Plugin; +} + +function getDefaultImportReplacement(match: string) { + const matched = match.match(defaultImportRegExp); + return matched && `const {default: ${matched[1]}} =`; +} + +function getSearchRegExp(packageName: string) { + const staticKeys = [...replacementMap.keys()].map(escapeKeys); + const packageNameLiteral = `.${packageName}.`; + const dynamicImportExpression = `await import\\(.${packageName}.\\)`; + const lookup = [defaultImportRegExp, ...staticKeys, packageNameLiteral, dynamicImportExpression]; + return new RegExp(`(${lookup.join('|')})`, 'g'); +} + +export function rewriteImport( + importStatement: string, + globs: Record, + packageName: string +): string { + const search = getSearchRegExp(packageName); + return importStatement.replace( + search, + (match) => replacementMap.get(match) ?? getDefaultImportReplacement(match) ?? globs[packageName] + ); +} diff --git a/code/lib/builder-vite/src/plugins/index.ts b/code/lib/builder-vite/src/plugins/index.ts index 114a09191365..bccebbdb4833 100644 --- a/code/lib/builder-vite/src/plugins/index.ts +++ b/code/lib/builder-vite/src/plugins/index.ts @@ -3,3 +3,4 @@ export * from './mdx-plugin'; export * from './strip-story-hmr-boundaries'; export * from './code-generator-plugin'; export * from './csf-plugin'; +export * from './external-globals-plugin'; diff --git a/code/lib/builder-vite/src/vite-config.ts b/code/lib/builder-vite/src/vite-config.ts index 0e123bf640ee..066abda9e4ea 100644 --- a/code/lib/builder-vite/src/vite-config.ts +++ b/code/lib/builder-vite/src/vite-config.ts @@ -7,7 +7,6 @@ import type { UserConfig as ViteConfig, InlineConfig, } from 'vite'; -import { viteExternalsPlugin } from 'vite-plugin-externals'; import { isPreservingSymlinks, getFrameworkName, getBuilderOptions } from '@storybook/core-common'; import { globals } from '@storybook/preview/globals'; import type { Options } from '@storybook/types'; @@ -17,7 +16,9 @@ import { injectExportOrderPlugin, mdxPlugin, stripStoryHMRBoundary, + externalGlobalsPlugin, } from './plugins'; + import type { BuilderOptions } from './types'; export type PluginConfigType = 'build' | 'development'; @@ -93,7 +94,7 @@ export async function pluginConfig(options: Options) { } }, }, - viteExternalsPlugin(globals, { useWindow: false, disableInServe: true }), + await externalGlobalsPlugin(globals), ] as PluginOption[]; // TODO: framework doesn't exist, should move into framework when/if built diff --git a/code/yarn.lock b/code/yarn.lock index 29fe9e351cb9..34b40d91ace4 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -5766,7 +5766,6 @@ __metadata: slash: ^3.0.0 typescript: ~4.9.3 vite: ^4.0.4 - vite-plugin-externals: ^0.5.1 peerDependencies: "@preact/preset-vite": "*" typescript: ">= 4.3.x" @@ -9658,7 +9657,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.1.0, acorn@npm:^8.4.0, acorn@npm:^8.4.1, acorn@npm:^8.5.0, acorn@npm:^8.6.0, acorn@npm:^8.7.1, acorn@npm:^8.8.0, acorn@npm:^8.8.1": +"acorn@npm:^8.1.0, acorn@npm:^8.4.1, acorn@npm:^8.5.0, acorn@npm:^8.6.0, acorn@npm:^8.7.1, acorn@npm:^8.8.0, acorn@npm:^8.8.1": version: 8.8.1 resolution: "acorn@npm:8.8.1" bin: @@ -13997,13 +13996,6 @@ __metadata: languageName: node linkType: hard -"es-module-lexer@npm:^0.4.1": - version: 0.4.1 - resolution: "es-module-lexer@npm:0.4.1" - checksum: 6463778f04367979d7770cefb1969b6bfc277319e8437a39718b3516df16b1b496b725ceec96a2d24975837a15cf4d56838f16d9c8c7640ad13ad9c8f93ad6fc - languageName: node - linkType: hard - "es-module-lexer@npm:^0.9.0, es-module-lexer@npm:^0.9.3": version: 0.9.3 resolution: "es-module-lexer@npm:0.9.3" @@ -28063,20 +28055,6 @@ __metadata: languageName: node linkType: hard -"vite-plugin-externals@npm:^0.5.1": - version: 0.5.1 - resolution: "vite-plugin-externals@npm:0.5.1" - dependencies: - acorn: ^8.4.0 - es-module-lexer: ^0.4.1 - fs-extra: ^10.0.0 - magic-string: ^0.25.7 - peerDependencies: - vite: ">=2.0.0" - checksum: a8b07fc911efb0a0ed47e12c6dc8f71280c40d222ae9b9ffa5c238aa5427bfda1b13444b378bdb649734057a53574f362f4e9af3ef96180be8901af18cab2f78 - languageName: node - linkType: hard - "vite-plugin-turbosnap@npm:^1.0.1": version: 1.0.1 resolution: "vite-plugin-turbosnap@npm:1.0.1"