diff --git a/code/core/src/common/utils/load-main-config.ts b/code/core/src/common/utils/load-main-config.ts index 4bc08250e602..579680e8da8f 100644 --- a/code/core/src/common/utils/load-main-config.ts +++ b/code/core/src/common/utils/load-main-config.ts @@ -1,8 +1,12 @@ -import { relative, resolve } from 'node:path'; +import { readFile, rm, writeFile } from 'node:fs/promises'; +import { join, parse, relative, resolve } from 'node:path'; +import { logger } from 'storybook/internal/node-logger'; import { MainFileEvaluationError } from 'storybook/internal/server-errors'; import type { StorybookConfig } from 'storybook/internal/types'; +import { dedent } from 'ts-dedent'; + import { importModule } from '../../shared/utils/module'; import { getInterpretedFile } from './interpret-files'; import { validateConfigurationFiles } from './validate-configuration-files'; @@ -25,6 +29,37 @@ export async function loadMainConfig({ if (!(e instanceof Error)) { throw e; } + if (e.message.includes('require is not defined')) { + logger.info( + 'Loading main config failed, trying a temporary fix, Please ensure the main config is valid ESM' + ); + const comment = + '// end of Storybook 10 migration assistant header, you can delete the above code'; + const content = await readFile(mainPath, 'utf-8'); + + if (!content.includes(comment)) { + const header = dedent` + import { createRequire } from "node:module"; + import { dirname } from "node:path"; + import { fileURLToPath } from "node:url"; + + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const require = createRequire(import.meta.url); + `; + + const { ext, name, dir } = parse(mainPath); + const modifiedMainPath = join(dir, `${name}.tmp.${ext}`); + await writeFile(modifiedMainPath, [header, comment, content].join('\n\n')); + let out; + try { + out = await importModule(modifiedMainPath); + } finally { + await rm(modifiedMainPath); + } + return out; + } + } throw new MainFileEvaluationError({ location: relative(process.cwd(), mainPath), diff --git a/code/lib/cli-storybook/src/automigrate/fixes/addon-a11y-addon-test.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/addon-a11y-addon-test.test.ts index d02b89184685..9795c2cfe32f 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/addon-a11y-addon-test.test.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/addon-a11y-addon-test.test.ts @@ -4,7 +4,6 @@ import { getAddonNames } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; import { existsSync, readFileSync, writeFileSync } from 'fs'; -import * as jscodeshift from 'jscodeshift'; import path from 'path'; import { dedent } from 'ts-dedent'; diff --git a/code/lib/cli-storybook/src/automigrate/fixes/fix-faux-esm-require.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/fix-faux-esm-require.test.ts new file mode 100644 index 000000000000..05aadc126244 --- /dev/null +++ b/code/lib/cli-storybook/src/automigrate/fixes/fix-faux-esm-require.test.ts @@ -0,0 +1,148 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { bannerComment } from '../helpers/mainConfigFile'; +import { fixFauxEsmRequire } from './fix-faux-esm-require'; + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readFile: vi.fn(), + writeFile: vi.fn(), +})); + +describe('fix-faux-esm-require', () => { + const mockReadFile = vi.mocked(readFile); + const mockWriteFile = vi.mocked(writeFile); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('check', () => { + it('should return null if no mainConfigPath', async () => { + const result = await fixFauxEsmRequire.check({ + mainConfigPath: undefined, + } as any); + + expect(result).toBeNull(); + }); + + it('should return null if file is not ESM', async () => { + const contentWithoutESM = ` + const config = require('./config'); + module.exports = config; + `; + + mockReadFile.mockResolvedValue(contentWithoutESM); + + const result = await fixFauxEsmRequire.check({ + mainConfigPath: 'main.js', + } as any); + + expect(result).toBeNull(); + }); + + it('should return null if file already has require banner', async () => { + const contentWithBanner = ` + import { createRequire } from "node:module"; + ${bannerComment} + const config = require('./some-config'); + `; + + mockReadFile.mockResolvedValue(contentWithBanner); + + const result = await fixFauxEsmRequire.check({ + mainConfigPath: 'main.js', + } as any); + + expect(result).toBeNull(); + }); + + it('should return null if file does not contain require usage', async () => { + const contentWithoutRequire = ` + import { addons } from '@storybook/addon-essentials'; + export default { + addons: ['@storybook/addon-essentials'], + }; + `; + + mockReadFile.mockResolvedValue(contentWithoutRequire); + + const result = await fixFauxEsmRequire.check({ + mainConfigPath: 'main.js', + } as any); + + expect(result).toBeNull(); + }); + + it('should return true if file is ESM with require usage', async () => { + const contentWithRequire = ` + import { addons } from '@storybook/addon-essentials'; + const config = require('./some-config'); + export default { + addons: ['@storybook/addon-essentials'], + }; + `; + + mockReadFile.mockResolvedValue(contentWithRequire); + + const result = await fixFauxEsmRequire.check({ + mainConfigPath: 'main.js', + } as any); + + expect(result).toBe(true); + }); + + it('should detect TypeScript config files', async () => { + const contentWithRequire = ` + import { addons } from '@storybook/addon-essentials'; + const config = require('./some-config'); + export default { + addons: ['@storybook/addon-essentials'], + }; + `; + + mockReadFile.mockResolvedValue(contentWithRequire); + + const result = await fixFauxEsmRequire.check({ + mainConfigPath: 'main.ts', + } as any); + + expect(result).toBe(true); + }); + }); + + describe('run', () => { + it('should add require banner to file', async () => { + const originalContent = ` + import { addons } from '@storybook/addon-essentials'; + const config = require('./some-config'); + export default { + addons: ['@storybook/addon-essentials'], + }; + `; + + mockReadFile.mockResolvedValue(originalContent); + + await fixFauxEsmRequire.run({ + dryRun: false, + mainConfigPath: 'main.js', + } as any); + + expect(mockWriteFile).toHaveBeenCalledWith( + 'main.js', + expect.stringContaining('import { createRequire } from "node:module"') + ); + }); + + it('should not write file in dry run mode', async () => { + await fixFauxEsmRequire.run({ + dryRun: true, + mainConfigPath: 'main.js', + } as any); + + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/code/lib/cli-storybook/src/automigrate/fixes/fix-faux-esm-require.ts b/code/lib/cli-storybook/src/automigrate/fixes/fix-faux-esm-require.ts new file mode 100644 index 000000000000..c73d719b3aa5 --- /dev/null +++ b/code/lib/cli-storybook/src/automigrate/fixes/fix-faux-esm-require.ts @@ -0,0 +1,65 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +import { dedent } from 'ts-dedent'; + +import { + bannerComment, + containsESMUsage, + containsRequireUsage, + getRequireBanner, + hasRequireBanner, +} from '../helpers/mainConfigFile'; +import type { Fix } from '../types'; + +export const fixFauxEsmRequire = { + id: 'fix-faux-esm-require', + link: 'https://storybook.js.org/docs/faq#how-do-i-fix-module-resolution-in-special-environments', + + async check({ mainConfigPath }) { + if (!mainConfigPath) { + return null; + } + + // Read the raw file content to check for ESM syntax and require usage + const content = await readFile(mainConfigPath, 'utf-8'); + + const isESM = containsESMUsage(content); + const isWithRequire = containsRequireUsage(content); + const isWithBanner = hasRequireBanner(content); + + // Check if the file is ESM format based on content + if (!isESM) { + return null; + } + + // Check if the file already has the require banner + if (isWithBanner) { + return null; + } + + // Check if the file contains require usage + if (!isWithRequire) { + return null; + } + + return true; + }, + + prompt() { + return dedent`Main config is ESM but uses 'require'. This will break in Storybook 10; Adding compatibility banner`; + }, + + async run({ dryRun, mainConfigPath }) { + if (dryRun) { + return; + } + + const content = await readFile(mainConfigPath, 'utf-8'); + const banner = getRequireBanner(); + const comment = bannerComment; + + const newContent = [banner, comment, content].join('\n'); + + await writeFile(mainConfigPath, newContent); + }, +} satisfies Fix; diff --git a/code/lib/cli-storybook/src/automigrate/fixes/index.ts b/code/lib/cli-storybook/src/automigrate/fixes/index.ts index 143a0702a574..a6ad35e962a4 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/index.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/index.ts @@ -7,6 +7,7 @@ import { addonMdxGfmRemove } from './addon-mdx-gfm-remove'; import { addonStorysourceCodePanel } from './addon-storysource-code-panel'; import { consolidatedImports } from './consolidated-imports'; import { eslintPlugin } from './eslint-plugin'; +import { fixFauxEsmRequire } from './fix-faux-esm-require'; import { initialGlobals } from './initial-globals'; import { migrateAddonConsole } from './migrate-addon-console'; import { removeAddonInteractions } from './remove-addon-interactions'; @@ -36,6 +37,7 @@ export const allFixes: Fix[] = [ addonA11yParameters, removeDocsAutodocs, wrapGetAbsolutePath, + fixFauxEsmRequire, ]; export const initFixes: Fix[] = [eslintPlugin]; diff --git a/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts b/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts index 33830078ec7a..ee3bd3e329c5 100644 --- a/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts +++ b/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts @@ -228,3 +228,49 @@ export const updateMainConfig = async ( ); } }; + +/** Check if a file is in ESM format based on its content */ +export function containsESMUsage(content: string): boolean { + // For .js/.ts files, check the content for ESM syntax + // Check for ESM syntax indicators (multiline aware) + const hasImportStatement = + /^\s*import\s+/m.test(content) || + /^\s*import\s*{/m.test(content) || + /^\s*import\s*\(/m.test(content); + const hasExportStatement = + /^\s*export\s+/m.test(content) || + /^\s*export\s*{/m.test(content) || + /^\s*export\s*default/m.test(content); + const hasImportMeta = /import\.meta/.test(content); + + // If any ESM syntax is found, it's likely an ESM file + return hasImportStatement || hasExportStatement || hasImportMeta; +} + +/** Check if the file content contains require usage */ +export function containsRequireUsage(content: string): boolean { + // Check for require() calls + const requireCallRegex = /\brequire\(/; + const requireDotRegex = /\brequire\./; + return requireCallRegex.test(content) || requireDotRegex.test(content); +} + +/** Check if the file already has the require banner */ +export const bannerComment = + '// end of Storybook 10 migration assistant header, You can delete the above code'; +export function hasRequireBanner(content: string): boolean { + return content.includes(bannerComment); +} + +/** Generate the require compatibility banner */ +export function getRequireBanner(): string { + return dedent` + import { createRequire } from "node:module"; + import { dirname } from "node:path"; + import { fileURLToPath } from "node:url"; + + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const require = createRequire(import.meta.url); + `; +}