feat(storybook): migrator for webpack 5

This commit is contained in:
Katerina Skroumpelou 2021-10-19 13:54:30 +03:00 committed by Juri Strumpflohner
parent 0f85b49065
commit fd3868c94e
4 changed files with 735 additions and 0 deletions

View File

@ -98,6 +98,12 @@
"version": "13.0.0-beta.0", "version": "13.0.0-beta.0",
"description": "Update tsconfig.json to use `jsxImportSource` to support css prop", "description": "Update tsconfig.json to use `jsxImportSource` to support css prop",
"factory": "./src/migrations/update-13-0-0/update-emotion-setup" "factory": "./src/migrations/update-13-0-0/update-emotion-setup"
},
"migrate-storybook-to-webpack-5-13.0.0": {
"cli": "nx",
"version": "13.0.0-beta.0",
"description": "Migrate Storybook to use webpack 5",
"factory": "./src/migrations/update-13-0-0/migrate-storybook-to-webpack-5"
} }
}, },
"packageJsonUpdates": { "packageJsonUpdates": {

View File

@ -0,0 +1,361 @@
import { readJson, Tree, updateJson } from '@nrwl/devkit';
import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing';
import { migrateStorybookToWebPack5 } from './migrate-storybook-to-webpack-5';
describe('migrateStorybookToWebPack5', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
it('should add packages needed by Storybook if workspace has the @storybook/react package', async () => {
updateJson(tree, 'package.json', (json) => {
json.devDependencies = {
'@storybook/react': '~6.3.0',
};
return json;
});
await migrateStorybookToWebPack5(tree);
const json = readJson(tree, '/package.json');
expect(json.devDependencies['@storybook/builder-webpack5']).toBe('~6.3.0');
expect(json.devDependencies['@storybook/manager-webpack5']).toBe('~6.3.0');
});
it('should not add the webpack Storybook packages again if they already exist', async () => {
let newTree = createTreeWithEmptyWorkspace();
updateJson(newTree, 'package.json', (json) => {
json.dependencies = {
'@storybook/react': '~6.3.0',
'@storybook/builder-webpack5': '~6.3.0',
'@storybook/manager-webpack5': '~6.3.0',
};
return json;
});
await migrateStorybookToWebPack5(newTree);
const json = readJson(newTree, '/package.json');
expect(json.devDependencies['@storybook/builder-webpack5']).toBeUndefined();
expect(json.devDependencies['@storybook/manager-webpack5']).toBeUndefined();
});
it('should not add Storybook packages if @storybook/react does not exist', async () => {
updateJson(tree, 'package.json', (json) => {
json.devDependencies = {
'@storybook/angular': '~6.3.0',
};
return json;
});
await migrateStorybookToWebPack5(tree);
const json = readJson(tree, '/package.json');
expect(json.devDependencies['@storybook/builder-webpack5']).toBeUndefined();
expect(json.devDependencies['@storybook/manager-webpack5']).toBeUndefined();
});
describe('updating project-level .storybook/main.js configurations for webpack 5', () => {
beforeEach(async () => {
updateJson(tree, 'package.json', (json) => {
json.devDependencies = {
'@storybook/react': '~6.3.0',
};
return json;
});
updateJson(tree, 'workspace.json', (json) => {
json = {
...json,
projects: {
...json.projects,
'test-one': {
targets: {
storybook: {
options: {
uiFramework: '@storybook/react',
config: {
configFolder: 'libs/test-one/.storybook',
},
},
},
},
},
'test-two': {
targets: {
storybook: {
options: {
uiFramework: '@storybook/react',
config: {
configFolder: 'libs/test-two/.storybook',
},
},
},
},
},
},
};
return json;
});
tree.write(
`.storybook/main.js`,
`module.exports = {
stories: [],
addons: ['@storybook/addon-essentials'],
// uncomment the property below if you want to apply some webpack config globally
// webpackFinal: async (config, { configType }) => {
// // Make whatever fine-grained changes you need that should apply to all storybook configs
// // Return the altered config
// return config;
// },
};
`
);
});
describe('when main.js uses new syntax', () => {
it('should update the project-level .storybook/main.js if there is a core object', async () => {
tree.write(
`libs/test-one/.storybook/main.js`,
`const rootMain = require('../../../.storybook/main');
module.exports = {
...rootMain,
core: { ...rootMain.core },
stories: [
...rootMain.stories,
'../src/lib/**/*.stories.mdx',
'../src/lib/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [...rootMain.addons, '@nrwl/react/plugins/storybook'],
webpackFinal: async (config, { configType }) => {
// apply any global webpack configs that might have been specified in .storybook/main.js
if (rootMain.webpackFinal) {
config = await rootMain.webpackFinal(config, { configType });
}
// add your own webpack tweaks if needed
return config;
},
};`
);
tree.write(
`libs/test-two/.storybook/main.js`,
`const rootMain = require('../../../.storybook/main');
module.exports = {
...rootMain,
core: { ...rootMain.core },
stories: [
...rootMain.stories,
'../src/lib/**/*.stories.mdx',
'../src/lib/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [...rootMain.addons, '@nrwl/react/plugins/storybook'],
webpackFinal: async (config, { configType }) => {
// apply any global webpack configs that might have been specified in .storybook/main.js
if (rootMain.webpackFinal) {
config = await rootMain.webpackFinal(config, { configType });
}
// add your own webpack tweaks if needed
return config;
},
};`
);
await migrateStorybookToWebPack5(tree);
const projectOne = tree.read(
`libs/test-one/.storybook/main.js`,
'utf-8'
);
expect(projectOne).toContain(`builder: 'webpack5'`);
const projectTwo = tree.read(
`libs/test-two/.storybook/main.js`,
'utf-8'
);
expect(projectTwo).toContain(`builder: 'webpack5'`);
});
it('should update the project-level .storybook/main.js if there is not a core object', async () => {
tree.write(
`libs/test-one/.storybook/main.js`,
`const rootMain = require('../../../.storybook/main');
module.exports = {
...rootMain,
stories: [
...rootMain.stories,
'../src/lib/**/*.stories.mdx',
'../src/lib/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [...rootMain.addons, '@nrwl/react/plugins/storybook'],
webpackFinal: async (config, { configType }) => {
// apply any global webpack configs that might have been specified in .storybook/main.js
if (rootMain.webpackFinal) {
config = await rootMain.webpackFinal(config, { configType });
}
// add your own webpack tweaks if needed
return config;
},
};`
);
tree.write(
`libs/test-two/.storybook/main.js`,
`const rootMain = require('../../../.storybook/main');
module.exports = {
...rootMain,
stories: [
...rootMain.stories,
'../src/lib/**/*.stories.mdx',
'../src/lib/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [...rootMain.addons, '@nrwl/react/plugins/storybook'],
webpackFinal: async (config, { configType }) => {
// apply any global webpack configs that might have been specified in .storybook/main.js
if (rootMain.webpackFinal) {
config = await rootMain.webpackFinal(config, { configType });
}
// add your own webpack tweaks if needed
return config;
},
};`
);
await migrateStorybookToWebPack5(tree);
const projectOne = tree.read(
`libs/test-one/.storybook/main.js`,
'utf-8'
);
expect(projectOne).toContain(`builder: 'webpack5'`);
const projectTwo = tree.read(
`libs/test-two/.storybook/main.js`,
'utf-8'
);
expect(projectTwo).toContain(`builder: 'webpack5'`);
});
it('should not do anything if project-level .storybook/main.js is invalid', async () => {
tree.write(
`libs/test-one/.storybook/main.js`,
`const rootMain = require('../../../.storybook/main');
module.exports = {
};`
);
tree.write(
`libs/test-two/.storybook/main.js`,
`const rootMain = require('../../../.storybook/main');
module.exports = {
...rootMain,
},
};`
);
await migrateStorybookToWebPack5(tree);
const projectOne = tree.read(
`libs/test-one/.storybook/main.js`,
'utf-8'
);
expect(projectOne).not.toContain(`builder: 'webpack5'`);
const projectTwo = tree.read(
`libs/test-two/.storybook/main.js`,
'utf-8'
);
expect(projectTwo).not.toContain(`builder: 'webpack5'`);
});
});
describe('when main.js uses old syntax', () => {
it('should update the project-level .storybook/main.js if there is a core object', async () => {
tree.write(
`libs/test-one/.storybook/main.js`,
`const rootMain = require('../../../.storybook/main');
rootMain.core = {
...rootMain.core
};
// Use the following syntax to add addons!
// rootMain.addons.push('');
rootMain.stories.push(
...['../src/lib/**/*.stories.mdx', '../src/lib/**/*.stories.@(js|jsx|ts|tsx)']
);
module.exports = rootMain;`
);
tree.write(
`libs/test-two/.storybook/main.js`,
`const rootMain = require('../../../.storybook/main');
rootMain.core = { ...rootMain.core, builder: 'webpack5' };
// Use the following syntax to add addons!
// rootMain.addons.push('');
rootMain.stories.push(
...['../src/lib/**/*.stories.mdx', '../src/lib/**/*.stories.@(js|jsx|ts|tsx)']
);
module.exports = rootMain;`
);
await migrateStorybookToWebPack5(tree);
const projectOne = tree.read(
`libs/test-one/.storybook/main.js`,
'utf-8'
);
expect(projectOne).toContain(`builder: 'webpack5'`);
const projectTwo = tree.read(
`libs/test-two/.storybook/main.js`,
'utf-8'
);
expect(projectTwo).toContain(`builder: 'webpack5'`);
});
it('should update the project-level .storybook/main.js if there is not a core object', async () => {
tree.write(
`libs/test-one/.storybook/main.js`,
`const rootMain = require('../../../.storybook/main');
// Use the following syntax to add addons!
// rootMain.addons.push('');
rootMain.stories.push(
...['../src/lib/**/*.stories.mdx', '../src/lib/**/*.stories.@(js|jsx|ts|tsx)']
);
module.exports = rootMain;`
);
tree.write(
`libs/test-two/.storybook/main.js`,
`const rootMain = require('../../../.storybook/main');
// Use the following syntax to add addons!
// rootMain.addons.push('');
rootMain.stories.push(
...['../src/lib/**/*.stories.mdx', '../src/lib/**/*.stories.@(js|jsx|ts|tsx)']
);
module.exports = rootMain;`
);
await migrateStorybookToWebPack5(tree);
const projectOne = tree.read(
`libs/test-one/.storybook/main.js`,
'utf-8'
);
expect(projectOne).toContain(`builder: 'webpack5'`);
const projectTwo = tree.read(
`libs/test-two/.storybook/main.js`,
'utf-8'
);
expect(projectTwo).toContain(`builder: 'webpack5'`);
});
});
});
});

View File

@ -0,0 +1,46 @@
import { Tree, logger, updateJson, readJson } from '@nrwl/devkit';
import {
migrateToWebPack5,
workspaceHasStorybookForReact,
} from './webpack5-changes-utils';
let needsInstall = false;
export async function migrateStorybookToWebPack5(tree: Tree) {
const packageJson = readJson(tree, 'package.json');
if (workspaceHasStorybookForReact(packageJson)) {
updateJson(tree, 'package.json', (json) => {
json.dependencies = json.dependencies || {};
json.devDependencies = json.devDependencies || {};
if (
!json.dependencies['@storybook/builder-webpack5'] &&
!json.devDependencies['@storybook/builder-webpack5']
) {
needsInstall = true;
json.devDependencies['@storybook/builder-webpack5'] =
workspaceHasStorybookForReact(packageJson);
}
if (
!json.dependencies['@storybook/manager-webpack5'] &&
!json.devDependencies['@storybook/manager-webpack5']
) {
needsInstall = true;
json.devDependencies['@storybook/manager-webpack5'] =
workspaceHasStorybookForReact(packageJson);
}
return json;
});
await migrateToWebPack5(tree);
if (needsInstall) {
logger.info(
'Please make sure to run npm install or yarn install to get the latest packages added by this migration'
);
}
}
}
export default migrateStorybookToWebPack5;

View File

@ -0,0 +1,322 @@
import { getProjects, Tree } from '@nrwl/devkit';
import {
logger,
formatFiles,
applyChangesToString,
ChangeType,
} from '@nrwl/devkit';
import ts = require('typescript');
import { findNodes } from '@nrwl/workspace/src/utilities/typescript/find-nodes';
export async function migrateToWebPack5(tree: Tree) {
allReactProjectsWithStorybookConfiguration(tree).forEach((project) => {
editProjectMainJs(
tree,
`${project.storybookConfigPath}/main.js`,
project.projectName
);
});
await formatFiles(tree);
}
export function workspaceHasStorybookForReact(
packageJson: any
): string | undefined {
return (
packageJson.dependencies['@storybook/react'] ||
packageJson.devDependencies['@storybook/react']
);
}
export function allReactProjectsWithStorybookConfiguration(tree: Tree): {
projectName: string;
storybookConfigPath: string;
}[] {
const projects = getProjects(tree);
const reactProjectsThatHaveStorybookConfiguration: {
projectName: string;
storybookConfigPath: string;
}[] = [...projects.entries()]
?.filter(
([_, projectConfig]) =>
projectConfig.targets &&
projectConfig.targets.storybook &&
projectConfig.targets.storybook.options
)
?.map(([projectName, projectConfig]) => {
if (
projectConfig.targets &&
projectConfig.targets.storybook &&
projectConfig.targets.storybook.options?.config?.configFolder &&
projectConfig.targets.storybook.options?.uiFramework ===
'@storybook/react'
) {
return {
projectName,
storybookConfigPath:
projectConfig.targets.storybook.options.config.configFolder,
};
}
});
return reactProjectsThatHaveStorybookConfiguration;
}
export function editProjectMainJs(
tree: Tree,
projectMainJsFile: string,
projectName: string
) {
let newContents: string;
let moduleExportsIsEmptyOrNonExistentOrInvalid = false;
let alreadyHasBuilder: any;
const rootMainJsExists = tree.exists(projectMainJsFile);
let moduleExportsFull: ts.Node[] = [];
if (rootMainJsExists) {
const file = getTsSourceFile(tree, projectMainJsFile);
const appFileContent = tree.read(projectMainJsFile, 'utf-8');
newContents = appFileContent;
moduleExportsFull = findNodes(file, [ts.SyntaxKind.ExpressionStatement]);
if (moduleExportsFull && moduleExportsFull[0]) {
const moduleExports = moduleExportsFull[0];
const listOfStatements = findNodes(moduleExports, [
ts.SyntaxKind.SyntaxList,
]);
/**
* Keep the index of the stories node
* to put the core object before it
* if it does not exist already
*/
let indexOfStoriesNode = -1;
const hasCoreObject = listOfStatements[0]?.getChildren()?.find((node) => {
if (
node &&
node.getText().length > 0 &&
indexOfStoriesNode < 0 &&
node?.getText().startsWith('stories')
) {
indexOfStoriesNode = node.getStart();
}
return (
node?.kind === ts.SyntaxKind.PropertyAssignment &&
node?.getText().startsWith('core')
);
});
if (hasCoreObject) {
const contentsOfCoreNode = hasCoreObject.getChildren().find((node) => {
return node.kind === ts.SyntaxKind.ObjectLiteralExpression;
});
const everyAttributeInsideCoreNode = contentsOfCoreNode
.getChildren()
.find((node) => node.kind === ts.SyntaxKind.SyntaxList);
alreadyHasBuilder = everyAttributeInsideCoreNode
.getChildren()
.find((node) => node.getText() === "builder: 'webpack5'");
if (!alreadyHasBuilder) {
newContents = applyChangesToString(newContents, [
{
type: ChangeType.Insert,
index: contentsOfCoreNode.getEnd() - 1,
text: ", builder: 'webpack5'",
},
]);
}
} else if (indexOfStoriesNode >= 0) {
/**
* Does not have core object,
* so just write one, at the start.
*/
newContents = applyChangesToString(newContents, [
{
type: ChangeType.Insert,
index: indexOfStoriesNode - 1,
text: "core: { ...rootMain.core, builder: 'webpack5' }, ",
},
]);
} else {
/**
* Module exports is empty or does not
* contain stories - most probably invalid
*/
moduleExportsIsEmptyOrNonExistentOrInvalid = true;
}
} else {
/**
* module.exports does not exist
*/
moduleExportsIsEmptyOrNonExistentOrInvalid = true;
}
} else {
moduleExportsIsEmptyOrNonExistentOrInvalid = true;
}
if (moduleExportsIsEmptyOrNonExistentOrInvalid) {
const usesOldSyntax = checkMainJsForOldSyntax(
moduleExportsFull,
newContents
);
if (moduleExportsFull.length > 0 && usesOldSyntax) {
newContents = usesOldSyntax;
tree.write(projectMainJsFile, newContents);
return;
} else {
logger.info(
`Please configure Storybook for project "${projectName}"", since it has not been configured properly.`
);
return;
}
}
if (!alreadyHasBuilder) {
tree.write(projectMainJsFile, newContents);
}
}
export function checkMainJsForOldSyntax(
nodeList: ts.Node[],
fileContent: string
): string | undefined {
let alreadyContainsBuilder = false;
let coreNode: ts.Node | undefined;
let hasCoreNode = false;
const lastIndexOfFirstNode = nodeList[0].getEnd();
if (!fileContent.includes('stories.push') || nodeList.length === 0) {
return undefined;
}
// Go through the node list and find if the core object exists
// If it does, then we need to check if it has the builder property
// If it does not, then we need to add it
for (let topNode of nodeList) {
if (
topNode.kind === ts.SyntaxKind.ExpressionStatement &&
topNode.getChildren()?.length > 0
) {
for (let node of topNode.getChildren()) {
if (
node.kind === ts.SyntaxKind.BinaryExpression &&
node.getChildren()?.length
) {
for (let childNode of node.getChildren()) {
if (
childNode.kind === ts.SyntaxKind.PropertyAccessExpression &&
childNode.getChildren()?.length
) {
for (let grandChildNode of childNode.getChildren()) {
if (
grandChildNode.kind === ts.SyntaxKind.Identifier &&
grandChildNode.getText() === 'core'
) {
coreNode = node;
hasCoreNode = true;
break;
}
}
}
if (hasCoreNode) {
break;
}
}
}
if (hasCoreNode) {
if (coreNode.getChildren()?.length) {
for (let coreChildNode of coreNode.getChildren()) {
if (
coreChildNode.kind === ts.SyntaxKind.ObjectLiteralExpression &&
coreChildNode.getChildren()?.length
) {
for (let coreChildNodeChild of coreChildNode.getChildren()) {
if (coreChildNodeChild.kind === ts.SyntaxKind.SyntaxList) {
for (let coreChildNodeGrandChild of coreChildNodeChild.getChildren()) {
if (
coreChildNodeGrandChild.kind ===
ts.SyntaxKind.PropertyAssignment &&
coreChildNodeGrandChild.getText().startsWith('builder')
) {
for (let coreChildNodeGrandChildChild of coreChildNodeGrandChild.getChildren()) {
if (
coreChildNodeGrandChildChild.kind ===
ts.SyntaxKind.StringLiteral &&
coreChildNodeGrandChildChild.getText() ===
'webpack5'
) {
alreadyContainsBuilder = true;
break;
}
}
}
if (alreadyContainsBuilder) {
break;
}
}
}
if (alreadyContainsBuilder) {
break;
}
}
}
if (alreadyContainsBuilder) {
break;
}
}
}
break;
}
}
}
if (hasCoreNode) {
if (alreadyContainsBuilder) {
break;
} else {
// Add builder
const indexOfCoreNodeEnd = coreNode.getEnd();
fileContent = applyChangesToString(fileContent, [
{
type: ChangeType.Insert,
index: indexOfCoreNodeEnd - 1,
text: ", builder: 'webpack5'",
},
]);
break;
}
}
}
if (!hasCoreNode) {
fileContent = applyChangesToString(fileContent, [
{
type: ChangeType.Insert,
index: lastIndexOfFirstNode + 1,
text: "rootMain.core = { ...rootMain.core, builder: 'webpack5' };\n",
},
]);
}
return fileContent;
}
export function getTsSourceFile(host: Tree, path: string): ts.SourceFile {
const buffer = host.read(path);
if (!buffer) {
throw new Error(`Could not read TS file (${path}).`);
}
const content = buffer.toString();
const source = ts.createSourceFile(
path,
content,
ts.ScriptTarget.Latest,
true
);
return source;
}