diff --git a/code/lib/cli/src/automigrate/fixes/eslint-plugin.test.ts b/code/lib/cli/src/automigrate/fixes/eslint-plugin.test.ts index 78fdedec90b8..cb242c9b0626 100644 --- a/code/lib/cli/src/automigrate/fixes/eslint-plugin.test.ts +++ b/code/lib/cli/src/automigrate/fixes/eslint-plugin.test.ts @@ -52,6 +52,7 @@ describe('eslint-plugin fix', () => { await expect( checkEslint({ packageJson, + hasEslint: false, }) ).resolves.toBeFalsy(); }); diff --git a/code/lib/cli/src/automigrate/fixes/eslint-plugin.ts b/code/lib/cli/src/automigrate/fixes/eslint-plugin.ts index 9b461e97d1a7..a34f30e78340 100644 --- a/code/lib/cli/src/automigrate/fixes/eslint-plugin.ts +++ b/code/lib/cli/src/automigrate/fixes/eslint-plugin.ts @@ -1,10 +1,12 @@ import chalk from 'chalk'; import { dedent } from 'ts-dedent'; -import { readConfig, writeConfig } from '@storybook/csf-tools'; -import { readFile, readJson, writeJson } from 'fs-extra'; -import detectIndent from 'detect-indent'; -import { findEslintFile, SUPPORTED_ESLINT_EXTENSIONS } from '../helpers/getEslintInfo'; +import { + configureEslintPlugin, + extractEslintInfo, + findEslintFile, + SUPPORTED_ESLINT_EXTENSIONS, +} from '../helpers/eslintPlugin'; import type { Fix } from '../types'; @@ -25,12 +27,9 @@ export const eslintPlugin: Fix = { id: 'eslintPlugin', async check({ packageManager }) { - const allDependencies = await packageManager.getAllDependencies(); + const { hasEslint, isStorybookPluginInstalled } = await extractEslintInfo(packageManager); - const eslintPluginStorybook = allDependencies['eslint-plugin-storybook']; - const eslintDependency = allDependencies.eslint; - - if (eslintPluginStorybook || !eslintDependency) { + if (isStorybookPluginInstalled || !hasEslint) { return null; } @@ -82,26 +81,8 @@ export const eslintPlugin: Fix = { return; } - logger.info(`✅ Adding Storybook plugin to ${eslintFile}`); if (!dryRun) { - if (eslintFile.endsWith('json')) { - const eslintConfig = (await readJson(eslintFile)) as { extends?: string[] }; - const existingConfigValue = Array.isArray(eslintConfig.extends) - ? eslintConfig.extends - : [eslintConfig.extends]; - eslintConfig.extends = [...(existingConfigValue || []), 'plugin:storybook/recommended']; - - const eslintFileContents = await readFile(eslintFile, 'utf8'); - const spaces = detectIndent(eslintFileContents).amount || 2; - await writeJson(eslintFile, eslintConfig, { spaces }); - } else { - const eslint = await readConfig(eslintFile); - const extendsConfig = eslint.getFieldValue(['extends']) || []; - const existingConfigValue = Array.isArray(extendsConfig) ? extendsConfig : [extendsConfig]; - eslint.setFieldValue(['extends'], [...existingConfigValue, 'plugin:storybook/recommended']); - - await writeConfig(eslint); - } + await configureEslintPlugin(eslintFile, packageManager); } }, }; diff --git a/code/lib/cli/src/automigrate/fixes/missing-babelrc.ts b/code/lib/cli/src/automigrate/fixes/missing-babelrc.ts index 2e13685fd8b4..1b403a6b0d4b 100644 --- a/code/lib/cli/src/automigrate/fixes/missing-babelrc.ts +++ b/code/lib/cli/src/automigrate/fixes/missing-babelrc.ts @@ -65,20 +65,19 @@ export const missingBabelRc: Fix = { If your project does not have a babel configuration file, we can generate one that's equivalent to the 6.x defaults for you. Keep in mind that this can affect your project if it uses babel, and you may need to make additional changes based on your projects needs. + We can create a ${chalk.blue( + '.babelrc.json' + )} file with some basic configuration and add any necessary package devDependencies. + ${chalk.bold( 'Note:' - )} This automatic setup doesn't work in a monorepo, see the babel documentation for how to setup babel manually: + )} After installing the necessary presets, if it does not work in a monorepo, see the babel documentation for reference: ${chalk.yellow('https://babeljs.io/docs')} - We can create a ${chalk.blue( - '.babelrc.json' - )} file with some basic configuration and add any necessary package devDependencies. - Please see the migration guide for more information: ${chalk.yellow( 'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#babel-mode-v7-exclusively' )} - `; }, async run() { diff --git a/code/lib/cli/src/automigrate/helpers/eslintPlugin.ts b/code/lib/cli/src/automigrate/helpers/eslintPlugin.ts new file mode 100644 index 000000000000..3d31d1111498 --- /dev/null +++ b/code/lib/cli/src/automigrate/helpers/eslintPlugin.ts @@ -0,0 +1,99 @@ +import fse, { readFile, readJson, writeJson } from 'fs-extra'; + +import { dedent } from 'ts-dedent'; +import detectIndent from 'detect-indent'; +import { readConfig, writeConfig } from '@storybook/csf-tools'; +import prompts from 'prompts'; +import chalk from 'chalk'; +import type { JsPackageManager } from '../../js-package-manager'; +import { paddedLog } from '../../helpers'; + +export const SUPPORTED_ESLINT_EXTENSIONS = ['js', 'cjs', 'json']; +const UNSUPPORTED_ESLINT_EXTENSIONS = ['yaml', 'yml']; + +export const findEslintFile = () => { + const filePrefix = '.eslintrc'; + const unsupportedExtension = UNSUPPORTED_ESLINT_EXTENSIONS.find((ext: string) => + fse.existsSync(`${filePrefix}.${ext}`) + ); + + if (unsupportedExtension) { + throw new Error(unsupportedExtension); + } + + const extension = SUPPORTED_ESLINT_EXTENSIONS.find((ext: string) => + fse.existsSync(`${filePrefix}.${ext}`) + ); + return extension ? `${filePrefix}.${extension}` : null; +}; + +export async function extractEslintInfo(packageManager: JsPackageManager): Promise<{ + hasEslint: boolean; + isStorybookPluginInstalled: boolean; + eslintConfigFile: string | null; +}> { + const allDependencies = await packageManager.getAllDependencies(); + const packageJson = await packageManager.retrievePackageJson(); + let eslintConfigFile: string | null = null; + + try { + eslintConfigFile = findEslintFile(); + } catch (err) { + // + } + + const isStorybookPluginInstalled = !!allDependencies['eslint-plugin-storybook']; + const hasEslint = allDependencies.eslint || eslintConfigFile || packageJson.eslintConfig; + return { hasEslint, isStorybookPluginInstalled, eslintConfigFile }; +} + +export async function configureEslintPlugin(eslintFile: string, packageManager: JsPackageManager) { + if (eslintFile) { + paddedLog(`Configuring Storybook ESLint plugin at ${eslintFile}`); + if (eslintFile.endsWith('json')) { + const eslintConfig = (await readJson(eslintFile)) as { extends?: string[] }; + const existingConfigValue = Array.isArray(eslintConfig.extends) + ? eslintConfig.extends + : [eslintConfig.extends]; + eslintConfig.extends = [...(existingConfigValue || []), 'plugin:storybook/recommended']; + + const eslintFileContents = await readFile(eslintFile, 'utf8'); + const spaces = detectIndent(eslintFileContents).amount || 2; + await writeJson(eslintFile, eslintConfig, { spaces }); + } else { + const eslint = await readConfig(eslintFile); + const extendsConfig = eslint.getFieldValue(['extends']) || []; + const existingConfigValue = Array.isArray(extendsConfig) ? extendsConfig : [extendsConfig]; + eslint.setFieldValue(['extends'], [...existingConfigValue, 'plugin:storybook/recommended']); + + await writeConfig(eslint); + } + } else { + paddedLog(`Configuring eslint-plugin-storybook in your package.json`); + const packageJson = await packageManager.retrievePackageJson(); + await packageManager.writePackageJson({ + ...packageJson, + eslintConfig: { + ...packageJson.eslintConfig, + extends: [...(packageJson.eslintConfig?.extends || []), 'plugin:storybook/recommended'], + }, + }); + } +} + +export const suggestESLintPlugin = async (): Promise => { + const { shouldInstall } = await prompts({ + type: 'confirm', + name: 'shouldInstall', + message: dedent` + We have detected that you're using ESLint. Storybook provides a plugin that gives the best experience with Storybook and helps follow best practices: ${chalk.yellow( + 'https://github.com/storybookjs/eslint-plugin-storybook#readme' + )} + + Would you like to install it? + `, + initial: true, + }); + + return shouldInstall; +}; diff --git a/code/lib/cli/src/automigrate/helpers/getEslintInfo.ts b/code/lib/cli/src/automigrate/helpers/getEslintInfo.ts deleted file mode 100644 index 698e2c4bde7e..000000000000 --- a/code/lib/cli/src/automigrate/helpers/getEslintInfo.ts +++ /dev/null @@ -1,20 +0,0 @@ -import fse from 'fs-extra'; - -export const SUPPORTED_ESLINT_EXTENSIONS = ['js', 'cjs', 'json']; -const UNSUPPORTED_ESLINT_EXTENSIONS = ['yaml', 'yml']; - -export const findEslintFile = () => { - const filePrefix = '.eslintrc'; - const unsupportedExtension = UNSUPPORTED_ESLINT_EXTENSIONS.find((ext: string) => - fse.existsSync(`${filePrefix}.${ext}`) - ); - - if (unsupportedExtension) { - throw new Error(unsupportedExtension); - } - - const extension = SUPPORTED_ESLINT_EXTENSIONS.find((ext: string) => - fse.existsSync(`${filePrefix}.${ext}`) - ); - return extension ? `${filePrefix}.${extension}` : null; -}; diff --git a/code/lib/cli/src/babel-config.ts b/code/lib/cli/src/babel-config.ts index e97b51f0328d..c918ba78cff7 100644 --- a/code/lib/cli/src/babel-config.ts +++ b/code/lib/cli/src/babel-config.ts @@ -2,15 +2,63 @@ import { writeFile, pathExists } from 'fs-extra'; import { logger } from '@storybook/node-logger'; import path from 'path'; import prompts from 'prompts'; -import chalk from 'chalk'; import { JsPackageManagerFactory } from './js-package-manager'; export const generateStorybookBabelConfigInCWD = async () => { const target = process.cwd(); return generateStorybookBabelConfig({ target }); }; + +export const getBabelPresets = ({ typescript, jsx }: { typescript: boolean; jsx: boolean }) => { + const dependencies = ['@babel/preset-env']; + + if (typescript) { + dependencies.push('@babel/preset-typescript'); + } + + if (jsx) { + dependencies.push('@babel/preset-react'); + } + + return dependencies; +}; + +export const writeBabelConfigFile = async ({ + location, + typescript, + jsx, +}: { + location?: string; + typescript: boolean; + jsx: boolean; +}) => { + const fileLocation = location || path.join(process.cwd(), '.babelrc.json'); + + const presets: (string | [string, any])[] = [['@babel/preset-env', { targets: { chrome: 100 } }]]; + + if (typescript) { + presets.push('@babel/preset-typescript'); + } + + if (jsx) { + presets.push('@babel/preset-react'); + } + + const contents = JSON.stringify( + { + sourceType: 'unambiguous', + presets, + plugins: [], + }, + null, + 2 + ); + + await writeFile(fileLocation, contents); +}; + export const generateStorybookBabelConfig = async ({ target }: { target: string }) => { - logger.info(`Generating the storybook default babel config at ${target}`); + logger.info(`Generating the Storybook default babel config at ${target}`); const fileName = '.babelrc.json'; const location = path.join(target, fileName); @@ -31,12 +79,6 @@ export const generateStorybookBabelConfig = async ({ target }: { target: string } } - logger.info( - `The config will contain ${chalk.yellow( - '@babel/preset-env' - )} and you will be prompted for additional presets, if you wish to add them depending on your project needs.` - ); - const { typescript, jsx } = await prompts([ { type: 'confirm', @@ -52,48 +94,13 @@ export const generateStorybookBabelConfig = async ({ target }: { target: string }, ]); - const added = ['@babel/preset-env']; - const presets: (string | [string, any])[] = [['@babel/preset-env', { targets: { chrome: 100 } }]]; - - if (typescript) { - added.push('@babel/preset-typescript'); - presets.push('@babel/preset-typescript'); - } - - if (jsx) { - added.push('@babel/preset-react'); - presets.push('@babel/preset-react'); - } - - const contents = JSON.stringify( - { - sourceType: 'unambiguous', - presets, - plugins: [], - }, - null, - 2 - ); + const dependencies = getBabelPresets({ typescript, jsx }); logger.info(`Writing file to ${location}`); - await writeFile(location, contents); + await writeBabelConfigFile({ location, typescript, jsx }); - const { runInstall } = await prompts({ - type: 'confirm', - initial: true, - name: 'runInstall', - message: `Shall we install the required dependencies now? (${added.join(', ')})`, - }); + const packageManager = JsPackageManagerFactory.getPackageManager(); - if (runInstall) { - logger.info(`Installing dependencies...`); - - const packageManager = JsPackageManagerFactory.getPackageManager(); - - await packageManager.addDependencies({ installAsDevDependencies: true }, added); - } else { - logger.info( - `⚠️ Please remember to install the required dependencies yourself: (${added.join(', ')})` - ); - } + logger.info(`Installing dependencies (${dependencies.join(', ')})`); + await packageManager.addDependencies({ installAsDevDependencies: true }, dependencies); }; diff --git a/code/lib/cli/src/generators/REACT_SCRIPTS/index.ts b/code/lib/cli/src/generators/REACT_SCRIPTS/index.ts index 1871a13faedb..8948aea95500 100644 --- a/code/lib/cli/src/generators/REACT_SCRIPTS/index.ts +++ b/code/lib/cli/src/generators/REACT_SCRIPTS/index.ts @@ -54,8 +54,7 @@ const generator: Generator = async (packageManager, npmOptions, options) => { extraAddons, extraPackages, staticDir: fs.existsSync(path.resolve('./public')) ? 'public' : undefined, - addBabel: false, - addESLint: true, + skipBabel: true, extraMain, }); }; diff --git a/code/lib/cli/src/generators/baseGenerator.ts b/code/lib/cli/src/generators/baseGenerator.ts index 9a44785b7146..5f94d6d969bc 100644 --- a/code/lib/cli/src/generators/baseGenerator.ts +++ b/code/lib/cli/src/generators/baseGenerator.ts @@ -3,14 +3,19 @@ import fse from 'fs-extra'; import { dedent } from 'ts-dedent'; import type { NpmOptions } from '../NpmOptions'; import type { SupportedRenderers, SupportedFrameworks, Builder } from '../project_types'; -import { externalFrameworks, CoreBuilder } from '../project_types'; -import { getBabelDependencies, copyTemplateFiles } from '../helpers'; +import { SupportedLanguage, externalFrameworks, CoreBuilder } from '../project_types'; +import { copyTemplateFiles, paddedLog } from '../helpers'; import { configureMain, configurePreview } from './configure'; import type { JsPackageManager } from '../js-package-manager'; import { getPackageDetails } from '../js-package-manager'; -import { generateStorybookBabelConfigInCWD } from '../babel-config'; +import { getBabelPresets, writeBabelConfigFile } from '../babel-config'; import packageVersions from '../versions'; import type { FrameworkOptions, GeneratorOptions } from './types'; +import { + configureEslintPlugin, + extractEslintInfo, + suggestESLintPlugin, +} from '../automigrate/helpers/eslintPlugin'; const defaultOptions: FrameworkOptions = { extraPackages: [], @@ -19,8 +24,7 @@ const defaultOptions: FrameworkOptions = { addScripts: true, addMainFile: true, addComponents: true, - addBabel: false, - addESLint: false, + skipBabel: false, extraMain: undefined, framework: undefined, extensions: undefined, @@ -154,8 +158,7 @@ export async function baseGenerator( addScripts, addMainFile, addComponents, - addBabel, - addESLint, + skipBabel, extraMain, extensions, storybookConfigFolder, @@ -260,20 +263,60 @@ export async function baseGenerator( await configurePreview({ frameworkPreviewParts, storybookConfigFolder, language, rendererId }); - const babelDependencies = - addBabel && builder !== CoreBuilder.Vite - ? await getBabelDependencies(packageManager, packageJson) - : []; - const isNewFolder = !files.some( - (fname) => fname.startsWith('.babel') || fname.startsWith('babel') || fname === 'package.json' - ); - if (isNewFolder) { - await generateStorybookBabelConfigInCWD(); + const depsToInstall = [...versionedPackages]; + + // Add basic babel config for a select few frameworks that need it, if they do not have a babel config file already + if (builder !== CoreBuilder.Vite && !skipBabel) { + const frameworksThatNeedBabelConfig = [ + '@storybook/react-webpack5', + '@storybook/vue-webpack5', + '@storybook/vue3-webpack5', + '@storybook/html-webpack5', + '@storybook/web-components-webpack5', + ]; + const needsBabelConfig = frameworkPackages.find((pkg) => + frameworksThatNeedBabelConfig.includes(pkg) + ); + const hasNoBabelFile = !files.some( + (fname) => fname.startsWith('.babel') || fname.startsWith('babel') + ); + + if (hasNoBabelFile && needsBabelConfig) { + const isTypescript = language !== SupportedLanguage.JAVASCRIPT; + const isReact = rendererId === 'react'; + depsToInstall.push( + ...getBabelPresets({ + typescript: isTypescript, + jsx: isReact, + }) + ); + await writeBabelConfigFile({ + typescript: isTypescript, + jsx: isReact, + }); + } } - const depsToInstall = [...versionedPackages, ...babelDependencies]; + try { + if (process.env.CI !== 'true') { + const { hasEslint, isStorybookPluginInstalled, eslintConfigFile } = await extractEslintInfo( + packageManager + ); + + if (hasEslint && !isStorybookPluginInstalled) { + const shouldInstallESLintPlugin = await suggestESLintPlugin(); + if (shouldInstallESLintPlugin) { + depsToInstall.push('eslint-plugin-storybook'); + await configureEslintPlugin(eslintConfigFile, packageManager); + } + } + } + } catch (err) { + // any failure regarding configuring the eslint plugin should not fail the whole generator + } if (depsToInstall.length > 0) { + paddedLog('Installing Storybook dependencies'); await packageManager.addDependencies({ ...npmOptions, packageJson }, depsToInstall); } @@ -283,10 +326,6 @@ export async function baseGenerator( }); } - if (addESLint) { - await packageManager.addESLintConfig(); - } - if (addComponents) { const templateLocation = hasFrameworkTemplates(framework) ? framework : rendererId; await copyTemplateFiles({ diff --git a/code/lib/cli/src/generators/types.ts b/code/lib/cli/src/generators/types.ts index bb62010bbd58..6ef446c6538c 100644 --- a/code/lib/cli/src/generators/types.ts +++ b/code/lib/cli/src/generators/types.ts @@ -18,8 +18,7 @@ export interface FrameworkOptions { addScripts?: boolean; addMainFile?: boolean; addComponents?: boolean; - addBabel?: boolean; - addESLint?: boolean; + skipBabel?: boolean; extraMain?: any; extensions?: string[]; framework?: Record; diff --git a/code/lib/cli/src/helpers.ts b/code/lib/cli/src/helpers.ts index b8cd29253ebd..a1e162a3d263 100644 --- a/code/lib/cli/src/helpers.ts +++ b/code/lib/cli/src/helpers.ts @@ -119,6 +119,7 @@ export function codeLog(codeLines: string[], leftPadAmount?: number) { /** * Detect if any babel dependencies need to be added to the project + * This is currently used by react-native generator * @param {Object} packageJson The current package.json so we can inspect its contents * @returns {Array} Contains the packages and versions that need to be installed * @example diff --git a/code/lib/cli/src/js-package-manager/JsPackageManager.ts b/code/lib/cli/src/js-package-manager/JsPackageManager.ts index 8e8ae0b2ce1c..f2d1c1b3ed3d 100644 --- a/code/lib/cli/src/js-package-manager/JsPackageManager.ts +++ b/code/lib/cli/src/js-package-manager/JsPackageManager.ts @@ -365,25 +365,6 @@ export abstract class JsPackageManager { }); } - public async addESLintConfig() { - const packageJson = await this.retrievePackageJson(); - await this.writePackageJson({ - ...packageJson, - eslintConfig: { - ...packageJson.eslintConfig, - overrides: [ - ...(packageJson.eslintConfig?.overrides || []), - { - files: ['**/*.stories.*'], - rules: { - 'import/no-anonymous-default-export': 'off', - }, - }, - ], - }, - }); - } - public async addScripts(scripts: Record) { const packageJson = await this.retrievePackageJson(); await this.writePackageJson({