storybook/code/lib/cli/src/automigrate/fixes/new-frameworks.ts
Kasper Peulen 13c46e6c0b Prettier
2024-01-09 17:20:55 +01:00

528 lines
18 KiB
TypeScript

import chalk from 'chalk';
import dedent from 'ts-dedent';
import semver from 'semver';
import { frameworkPackages, rendererPackages } from '@storybook/core-common';
import type { Preset } from '@storybook/types';
import invariant from 'tiny-invariant';
import type { Fix } from '../types';
import { getStorybookVersionSpecifier } from '../../helpers';
import {
getNextjsAddonOptions,
detectBuilderInfo,
packagesMap,
} from '../helpers/new-frameworks-utils';
import {
getFrameworkPackageName,
getRendererPackageNameFromFramework,
updateMainConfig,
} from '../helpers/mainConfigFile';
import { detectRenderer } from '../helpers/detectRenderer';
const logger = console;
interface NewFrameworkRunOptions {
mainConfigPath: string;
dependenciesToAdd: string[];
dependenciesToRemove: string[];
hasFrameworkInMainConfig: boolean;
frameworkPackage: string;
metaFramework: string | undefined;
renderer: string;
addonsToRemove: string[];
frameworkOptions: Record<string, any>;
rendererOptions: Record<string, any>;
addonOptions: Record<string, any>;
builderConfig: string | Record<string, any> | undefined;
builderInfo: {
name: string;
options: Record<string, any>;
};
}
/**
* Does the user have separate framework and builders (e.g. @storybook/react + core.builder -> webpack5)?
*
* If so:
* - Remove the dependencies (@storybook/react + @storybook/builder-webpack5 + @storybook/manager-webpack5)
* - Install the correct new package e.g. (@storybook/react-webpack5)
* - Update the main config to use the new framework
* -- moving core.builder into framework.options.builder
* -- moving renderer options (e.g. reactOptions) into framework.options
* -- removing the now unnecessary fields in main.js
*
* Extra step:
* -- after figuring out a candidate framework, e.g. @storybook/react-webpack5, check if the user has a supported metaframework (e.g. Next.js or SvelteKit)
* -- if so, override the framework with the supporting metaframework package e.g. @storybook/nextjs
*/
export const newFrameworks: Fix<NewFrameworkRunOptions> = {
id: 'new-frameworks',
async check({
configDir,
packageManager,
storybookVersion,
mainConfig,
mainConfigPath,
rendererPackage,
}) {
if (!semver.gte(storybookVersion, '7.0.0')) {
return null;
}
if (typeof configDir === 'undefined') {
return null;
}
const packageJson = await packageManager.retrievePackageJson();
const frameworkPackageName = getFrameworkPackageName(mainConfig);
const rendererPackageName =
rendererPackage ??
(await getRendererPackageNameFromFramework(frameworkPackageName as string)) ??
(await detectRenderer(packageJson));
let hasFrameworkInMainConfig = !!frameworkPackageName;
if (frameworkPackageName && !!Object.values(rendererPackages).includes(frameworkPackageName)) {
hasFrameworkInMainConfig = false;
}
const builderConfig = mainConfig.core?.builder;
// bail if we can't detect an official renderer
const supportedPackages = Object.keys(packagesMap);
if (!supportedPackages.includes(rendererPackageName)) {
return null;
}
const allDependencies = await packageManager.getAllDependencies();
const builderInfo = await detectBuilderInfo({
mainConfig,
configDir,
packageManager,
});
// if the user has a new framework already, use it
let newFrameworkPackage = Object.keys(frameworkPackages).find(
(pkg) => pkg === frameworkPackageName
);
if (!newFrameworkPackage) {
newFrameworkPackage = packagesMap[rendererPackageName]?.[builderInfo.name];
}
// bail if there is no framework that matches the renderer + builder
if (!newFrameworkPackage) {
return null;
}
const renderer = rendererPackages[rendererPackageName];
// @ts-expect-error account for renderer options for packages that supported it: reactOptions, angularOptions. (svelteOptions got removed)
let rendererOptions = mainConfig[`${renderer}Options`] || {};
const frameworkOptions =
typeof mainConfig.framework === 'string' ? {} : mainConfig.framework?.options;
let dependenciesToRemove = [
'@storybook/builder-webpack5',
'@storybook/manager-webpack5',
'@storybook/builder-webpack4',
'@storybook/manager-webpack4',
'@storybook/builder-vite',
'storybook-builder-vite',
];
let addonsToRemove: string[] = [];
let addonOptions: Record<string, any> = {};
let metaFramework: string | undefined;
const nextVersion = await packageManager.getPackageVersion('next');
const svelteKitVersion = await packageManager.getPackageVersion('@sveltejs/kit');
const viteVersion = await packageManager.getPackageVersion('vite');
if (rendererPackageName === '@storybook/react' && nextVersion) {
const nextAddonOptions = getNextjsAddonOptions(mainConfig.addons);
const isNextJsCandidate =
(nextVersion && semver.gte(nextVersion, '12.0.0')) ||
Object.keys(nextAddonOptions).length > 0;
if (isNextJsCandidate) {
metaFramework = 'nextjs';
if (
newFrameworkPackage === '@storybook/react-webpack5' ||
// when framework is already set up but maybe legacy addons are still installed
newFrameworkPackage === '@storybook/nextjs'
) {
newFrameworkPackage = '@storybook/nextjs';
addonsToRemove = ['storybook-addon-next', 'storybook-addon-next-router'].filter(
(dep) =>
allDependencies[dep] ||
mainConfig.addons?.find((addon: Preset) =>
typeof addon === 'string' ? dep === addon : dep === addon.name
)
);
addonOptions = nextAddonOptions;
dependenciesToRemove.push(
// in case users are coming from a properly set up @storybook/webpack5 project
'@storybook/react-webpack5',
'storybook-addon-next',
'storybook-addon-next-router'
);
}
}
} else if (
rendererPackageName === '@storybook/svelte' &&
svelteKitVersion &&
semver.gte(svelteKitVersion, '1.0.0')
) {
metaFramework = 'sveltekit';
if (newFrameworkPackage === '@storybook/svelte-vite') {
newFrameworkPackage = '@storybook/sveltekit';
// in case svelteOptions are set, we remove them as they are not needed in sveltekit
rendererOptions = {};
dependenciesToRemove.push(
// in case users are coming from a properly set up @storybook/vite project
'@storybook/svelte-vite'
);
}
}
// only install what's not already installed
const dependenciesToAdd = [newFrameworkPackage]
.filter((dep) => !allDependencies[dep])
.filter(Boolean);
// only uninstall what's installed
dependenciesToRemove = dependenciesToRemove
.filter((dep) => allDependencies[dep])
.filter(Boolean);
const isProjectAlreadyCorrect =
hasFrameworkInMainConfig &&
!builderConfig &&
!Object.keys(rendererOptions).length &&
!Object.keys(addonOptions).length &&
!dependenciesToRemove.length &&
!dependenciesToAdd.length;
if (isProjectAlreadyCorrect) {
return null;
}
if (viteVersion && semver.lt(viteVersion, '3.0.0')) {
throw new Error(dedent`
❌ Your project should be upgraded to use the framework package ${chalk.bold(
newFrameworkPackage
)}, but we detected that you are using Vite ${chalk.bold(
viteVersion
)}, which is unsupported in ${chalk.bold(
'Storybook 7.0'
)}. Please upgrade Vite to ${chalk.bold('3.0.0 or higher')} and rerun this migration.
`);
}
invariant(mainConfigPath, 'Missing main config path.');
const result: Awaited<ReturnType<Fix<NewFrameworkRunOptions>['check']>> = {
mainConfigPath,
dependenciesToAdd,
dependenciesToRemove,
frameworkPackage: newFrameworkPackage,
hasFrameworkInMainConfig,
frameworkOptions: {
...frameworkOptions,
...rendererOptions,
...addonOptions,
},
rendererOptions,
addonOptions,
addonsToRemove,
builderInfo,
renderer,
builderConfig,
metaFramework,
};
return result;
},
prompt({
dependenciesToRemove,
dependenciesToAdd,
hasFrameworkInMainConfig,
mainConfigPath,
frameworkPackage,
addonOptions,
renderer,
rendererOptions,
builderConfig,
addonsToRemove,
metaFramework,
}) {
let disclaimer = '';
let migrationSteps = '';
if (dependenciesToRemove.length > 0) {
migrationSteps += `- Remove the following dependencies:
${dependenciesToRemove.map((dep) => `- * ${chalk.cyan(dep)}`).join('\n')}\n`;
}
if (dependenciesToAdd.length > 0) {
migrationSteps += `- Add the following dependencies:
${dependenciesToAdd.map((dep) => `- * ${chalk.cyan(dep)}`).join('\n')}\n`;
}
if (!hasFrameworkInMainConfig) {
migrationSteps += `- Update or specify the ${chalk.yellow('framework')} field in ${chalk.blue(
mainConfigPath
)} with the value of "${chalk.cyan(frameworkPackage)}".\n`;
}
if (Object.keys(rendererOptions).length > 0) {
migrationSteps += `- Move the ${chalk.yellow(`${renderer}Options`)} field in ${chalk.blue(
mainConfigPath
)} to ${chalk.yellow('framework.options')}, and remove that field entirely.
More info: ${chalk.yellow(
'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#frameworkoptions-renamed'
)}\n`;
}
if (addonsToRemove.length > 0) {
migrationSteps += `- Remove the following addons from your ${chalk.blue(
mainConfigPath
)}, as the new framework also supports features provided by them:
${addonsToRemove.map((dep) => `- * ${chalk.cyan(dep)}`).join('\n')}
`;
}
if (Object.keys(addonOptions).length > 0) {
migrationSteps += `- Move the addon options "${chalk.yellow(
Object.keys(addonOptions).join(', ')
)}" in ${chalk.blue(mainConfigPath)} to the ${chalk.yellow('framework.options')} field.\n`;
}
if (builderConfig) {
if (
typeof builderConfig === 'object' &&
Object.keys(builderConfig.options || {}).length > 0
) {
migrationSteps += `- Move the ${chalk.yellow('core.builder.options')} field in ${chalk.blue(
mainConfigPath
)} to ${chalk.yellow('framework.options.builder')}\n`;
}
migrationSteps += `- Remove the ${chalk.yellow('core.builder')} field in ${chalk.blue(
mainConfigPath
)}.\n`;
}
if (
dependenciesToRemove.includes('@storybook/builder-webpack4') ||
dependenciesToRemove.includes('@storybook/manager-webpack4')
) {
disclaimer = dedent`\n\n
${chalk.underline(chalk.bold(chalk.cyan('Webpack 4 users')))}
Unless you're using Storybook's Vite builder, this automigration will install a Webpack 5 based framework.
Given you were using Storybook's Webpack 4 builder (default in 6.x, discontinued in 7.0), this could be a breaking change -- especially if your project has a custom webpack configuration.
To learn more about migrating from Webpack4, see: ${chalk.yellow(
'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#webpack4-support-discontinued'
)}`;
}
if (metaFramework === 'nextjs') {
if (dependenciesToRemove.includes('storybook-addon-next-router')) {
migrationSteps += `- Migrate the usage of the ${chalk.cyan(
'storybook-addon-next-router'
)} addon to use the APIs from the ${chalk.magenta(
'@storybook/nextjs'
)} framework package instead. Follow the instructions below.`;
}
if (frameworkPackage === '@storybook/react-vite') {
disclaimer = dedent`\n\n
${chalk.bold('Important')}: We've detected you are using Storybook in a Next.js project.
This migration is set to update your project to use the ${chalk.magenta(
'@storybook/react-vite'
)} framework, but Storybook provides a framework package specifically for Next.js projects: ${chalk.magenta(
'@storybook/nextjs'
)}.
This package provides a better, out of the box experience for Next.js users, however it is only compatible with the Webpack 5 builder, so we can't automigrate for you, as you are using the Vite builder. If you switch this project to use Webpack 5 and rerun this migration, we can update your project.
If you are interested in using this package, see: ${chalk.yellow(
'https://github.com/storybookjs/storybook/blob/next/code/frameworks/nextjs/README.md'
)}
`;
} else if (frameworkPackage === '@storybook/nextjs') {
disclaimer = dedent`\n\n
The ${chalk.magenta(
'@storybook/nextjs'
)} package provides great user experience for Next.js users, and we highly recommend you to read more about it at ${chalk.yellow(
'https://github.com/storybookjs/storybook/blob/next/code/frameworks/nextjs/README.md'
)}
`;
}
}
if (metaFramework === 'sveltekit') {
if (frameworkPackage === '@storybook/svelte-webpack5') {
disclaimer = dedent`\n\n
${chalk.bold('Important')}: We've detected you are using Storybook in a SvelteKit project.
This migration is set to update your project to use the ${chalk.magenta(
'@storybook/svelte-webpack5'
)} framework, but Storybook provides a framework package specifically for SvelteKit projects: ${chalk.magenta(
'@storybook/sveltekit'
)}.
This package provides a better experience for SvelteKit users, however it is only compatible with the Vite builder, so we can't automigrate for you, as you are using the Webpack builder.
If you are interested in using this package, see: ${chalk.yellow(
'https://github.com/storybookjs/storybook/blob/next/code/frameworks/sveltekit/README.md'
)}
`;
} else {
migrationSteps += `- Remove the ${chalk.yellow(
`${renderer}Options`
)} field from ${chalk.blue(mainConfigPath)}.
More info: ${chalk.yellow(
'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#vite-builder-uses-vite-config-automatically'
)}\n`;
disclaimer = dedent`\n\n
The ${chalk.magenta(
'@storybook/sveltekit'
)} package provides great user experience for SvelteKit users, and we highly recommend you to read more about it at ${chalk.yellow(
'https://github.com/storybookjs/storybook/blob/next/code/frameworks/sveltekit/README.md'
)}
`;
}
}
return dedent`
We've detected your project is not fully setup with Storybook's 7 new framework format.
Storybook 7 introduced the concept of frameworks, which abstracts configuration for renderers (e.g. React, Vue), builders (e.g. Webpack, Vite) and defaults to make integrations easier.
Your project should be updated to use Storybook's framework: ${chalk.magenta(
frameworkPackage
)}. We can attempt to do this for you automatically.
Here are the steps this migration will do to migrate your project:
${migrationSteps}
To learn more about the new framework format, see: ${chalk.yellow(
'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#new-framework-api'
)}${disclaimer}
`;
},
async run({
result: {
dependenciesToAdd,
dependenciesToRemove,
frameworkPackage,
frameworkOptions,
builderInfo,
renderer,
addonsToRemove,
},
packageManager,
dryRun,
mainConfigPath,
skipInstall,
}) {
const packageJson = await packageManager.retrievePackageJson();
if (dependenciesToRemove.length > 0) {
logger.info(`✅ Removing dependencies: ${dependenciesToRemove.join(', ')}`);
if (!dryRun) {
await packageManager.removeDependencies(
{ skipInstall: skipInstall || dependenciesToAdd.length > 0, packageJson },
dependenciesToRemove
);
}
}
if (dependenciesToAdd.length > 0) {
logger.info(`✅ Installing new dependencies: ${dependenciesToAdd.join(', ')}`);
if (!dryRun) {
const versionToInstall = getStorybookVersionSpecifier(packageJson);
const depsToAdd = dependenciesToAdd.map((dep) => `${dep}@${versionToInstall}`);
await packageManager.addDependencies(
{ installAsDevDependencies: true, skipInstall, packageJson },
depsToAdd
);
}
}
await updateMainConfig({ mainConfigPath, dryRun: !!dryRun }, async (main) => {
logger.info(`✅ Updating main.js`);
logger.info(`✅ Updating "framework" field`);
if (!dryRun) {
main.setFieldValue(['framework', 'name'], frameworkPackage);
}
if (!dryRun) {
main.setFieldValue(['framework', 'options'], frameworkOptions);
}
if (main.getFieldNode([`${renderer}Options`])) {
logger.info(`✅ Moving "${renderer}Options" to "framework.options"`);
if (!dryRun) {
main.removeField([`${renderer}Options`]);
}
}
const builder = main.getFieldNode(['core', 'builder']);
if (builder) {
logger.info(`✅ Removing "core.builder" field`);
if (!dryRun) {
main.removeField(['core', 'builder']);
}
}
if (Object.keys(builderInfo.options).length > 0) {
logger.info(`✅ Moving "core.builder.options" into "framework.options.builder"`);
if (!dryRun) {
main.setFieldValue(['framework', 'options', 'builder'], builderInfo.options);
}
}
const currentCore = main.getFieldValue(['core']);
if (currentCore) {
if (Object.keys(currentCore).length === 0) {
logger.info(`✅ Removing "core" field`);
if (!dryRun) {
main.removeField(['core']);
}
}
}
if (addonsToRemove.length > 0) {
const existingAddons = main.getFieldValue(['addons']) as Preset[];
const updatedAddons = existingAddons.filter((addon) => {
if (typeof addon === 'string') {
return !addonsToRemove.includes(addon);
}
if (addon.name) {
return !addonsToRemove.includes(addon.name);
}
return false;
});
logger.info(`✅ Removing unnecessary addons`);
if (!dryRun) {
main.setFieldValue(['addons'], updatedAddons);
}
}
});
},
};