diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 47830d3ac9..d6052a791d 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -9485,6 +9485,14 @@ "isExternal": false, "disableCollapsible": false }, + { + "id": "convert-to-inferred", + "path": "/nx-api/storybook/generators/convert-to-inferred", + "name": "convert-to-inferred", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, { "id": "migrate-7", "path": "/nx-api/storybook/generators/migrate-7", diff --git a/docs/generated/manifests/nx-api.json b/docs/generated/manifests/nx-api.json index 7123a7e7c5..3b5ece33a1 100644 --- a/docs/generated/manifests/nx-api.json +++ b/docs/generated/manifests/nx-api.json @@ -2867,6 +2867,15 @@ "path": "/nx-api/storybook/generators/cypress-project", "type": "generator" }, + "/nx-api/storybook/generators/convert-to-inferred": { + "description": "Convert existing Storybook project(s) using `@nx/storybook:*` executors to use `@nx/storybook/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "file": "generated/packages/storybook/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/storybook/src/generators/convert-to-inferred/schema.json", + "path": "/nx-api/storybook/generators/convert-to-inferred", + "type": "generator" + }, "/nx-api/storybook/generators/migrate-7": { "description": "Migrate to Storybook version 7.", "file": "generated/packages/storybook/generators/migrate-7.json", diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 914977bdfe..469d989cf4 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -2837,6 +2837,15 @@ "path": "storybook/generators/cypress-project", "type": "generator" }, + { + "description": "Convert existing Storybook project(s) using `@nx/storybook:*` executors to use `@nx/storybook/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "file": "generated/packages/storybook/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/storybook/src/generators/convert-to-inferred/schema.json", + "path": "storybook/generators/convert-to-inferred", + "type": "generator" + }, { "description": "Migrate to Storybook version 7.", "file": "generated/packages/storybook/generators/migrate-7.json", diff --git a/docs/generated/packages/storybook/generators/convert-to-inferred.json b/docs/generated/packages/storybook/generators/convert-to-inferred.json new file mode 100644 index 0000000000..e870c6b723 --- /dev/null +++ b/docs/generated/packages/storybook/generators/convert-to-inferred.json @@ -0,0 +1,30 @@ +{ + "name": "convert-to-inferred", + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": { + "$schema": "https://json-schema.org/schema", + "$id": "NxStorybookConvertToInferred", + "description": "Convert existing Storybook project(s) using `@nx/storybook:*` executors to use `@nx/storybook/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "title": "Convert Storybook project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/storybook:*` executors to use `@nx/storybook/plugin`.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files at the end of the migration.", + "default": false + } + }, + "presets": [] + }, + "description": "Convert existing Storybook project(s) using `@nx/storybook:*` executors to use `@nx/storybook/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "implementation": "/packages/storybook/src/generators/convert-to-inferred/convert-to-inferred.ts", + "aliases": [], + "hidden": false, + "path": "/packages/storybook/src/generators/convert-to-inferred/schema.json", + "type": "generator" +} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index 66098c3215..64f087724d 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -663,6 +663,7 @@ - [init](/nx-api/storybook/generators/init) - [configuration](/nx-api/storybook/generators/configuration) - [cypress-project](/nx-api/storybook/generators/cypress-project) + - [convert-to-inferred](/nx-api/storybook/generators/convert-to-inferred) - [migrate-7](/nx-api/storybook/generators/migrate-7) - [tao](/nx-api/tao) - [vite](/nx-api/vite) diff --git a/packages/storybook/generators.json b/packages/storybook/generators.json index ac3469bcfe..8d3dd26df2 100644 --- a/packages/storybook/generators.json +++ b/packages/storybook/generators.json @@ -22,6 +22,11 @@ "x-deprecated": "Deprecated: Use 'interactionTests' instead when running '@nx/storybook:configuration'. This generator will be removed in v21.", "hidden": false }, + "convert-to-inferred": { + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": "./src/generators/convert-to-inferred/schema.json", + "description": "Convert existing Storybook project(s) using `@nx/storybook:*` executors to use `@nx/storybook/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target." + }, "migrate-7": { "factory": "./src/generators/migrate-7/migrate-7", "schema": "./src/generators/migrate-7/schema.json", diff --git a/packages/storybook/src/generators/convert-to-inferred/convert-to-inferred.spec.ts b/packages/storybook/src/generators/convert-to-inferred/convert-to-inferred.spec.ts new file mode 100644 index 0000000000..736cda4d6a --- /dev/null +++ b/packages/storybook/src/generators/convert-to-inferred/convert-to-inferred.spec.ts @@ -0,0 +1,631 @@ +import { + type ProjectGraph, + type Tree, + type ProjectConfiguration, + joinPathFragments, + writeJson, + addProjectConfiguration, + readProjectConfiguration, + readNxJson, + type ExpandedPluginConfiguration, + updateNxJson, +} from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { TempFs } from '@nx/devkit/internal-testing-utils'; +import { getRelativeProjectJsonSchemaPath } from 'nx/src/generators/utils/project-configuration'; +import { join } from 'path'; +import { convertToInferred } from './convert-to-inferred'; + +let fs: TempFs; +let projectGraph: ProjectGraph; + +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + createProjectGraphAsync: jest + .fn() + .mockImplementation(() => Promise.resolve(projectGraph)), + updateProjectConfiguration: jest + .fn() + .mockImplementation((tree, projectName, projectConfiguration) => { + function handleEmptyTargets( + projectName: string, + projectConfiguration: ProjectConfiguration + ): void { + if ( + projectConfiguration.targets && + !Object.keys(projectConfiguration.targets).length + ) { + // Re-order `targets` to appear after the `// target` comment. + delete projectConfiguration.targets; + projectConfiguration[ + '// targets' + ] = `to see all targets run: nx show project ${projectName} --web`; + projectConfiguration.targets = {}; + } else { + delete projectConfiguration['// targets']; + } + } + + const projectConfigFile = joinPathFragments( + projectConfiguration.root, + 'project.json' + ); + + if (!tree.exists(projectConfigFile)) { + throw new Error( + `Cannot update Project ${projectName} at ${projectConfiguration.root}. It either doesn't exist yet, or may not use project.json for configuration. Use \`addProjectConfiguration()\` instead if you want to create a new project.` + ); + } + handleEmptyTargets(projectName, projectConfiguration); + writeJson(tree, projectConfigFile, { + name: projectConfiguration.name ?? projectName, + $schema: getRelativeProjectJsonSchemaPath(tree, projectConfiguration), + ...projectConfiguration, + root: undefined, + }); + projectGraph.nodes[projectName].data = projectConfiguration; + }), +})); + +function addProject(tree: Tree, name: string, project: ProjectConfiguration) { + addProjectConfiguration(tree, name, project); + projectGraph.nodes[name] = { + name, + type: project.projectType === 'application' ? 'app' : 'lib', + data: { + projectType: project.projectType, + root: project.root, + targets: project.targets, + }, + }; +} + +interface TestProjectOptions { + appName: string; + appRoot: string; + configDir: string; + buildTargetName: string; + serveTargetName: string; +} + +const defaultTestProjectOptions: TestProjectOptions = { + appName: 'app1', + appRoot: 'apps/app1', + configDir: '.storybook', + buildTargetName: 'build-storybook', + serveTargetName: 'storybook', +}; + +function writeStorybookConfig( + tree: Tree, + projectRoot: string, + useVite: boolean = false +) { + const storybookConfig = { + stories: ['../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: { + name: useVite ? '@storybook/react-vite' : '@storybook/react-webpack5', + options: useVite + ? { + builder: { + viteConfigPath: `${projectRoot}/vite.config.ts`, + }, + } + : {}, + }, + }; + const storybookConfigContents = `const config = ${JSON.stringify( + storybookConfig + )}; +export default config;`; + + if (useVite) { + tree.write(`${projectRoot}/vite.config.ts`, `module.exports = {}`); + fs.createFileSync(`${projectRoot}/vite.config.ts`, `module.exports = {}`); + } + + tree.write(`${projectRoot}/.storybook/main.ts`, storybookConfigContents); + fs.createFileSync( + `${projectRoot}/.storybook/main.ts`, + storybookConfigContents + ); + jest.doMock( + join(fs.tempDir, projectRoot, '.storybook', 'main.ts'), + () => storybookConfig, + { virtual: true } + ); +} + +function createTestProject( + tree: Tree, + opts: Partial = defaultTestProjectOptions, + extraTargetOptions: any = {}, + extraConfigurations: any = {}, + useVite = false +) { + let projectOpts = { ...defaultTestProjectOptions, ...opts }; + const project: ProjectConfiguration = { + name: projectOpts.appName, + root: projectOpts.appRoot, + projectType: 'application', + targets: { + [projectOpts.buildTargetName]: { + executor: '@nx/storybook:build', + outputs: ['{options.outputDir}'], + options: { + configDir: `${projectOpts.appRoot}/${projectOpts.configDir}`, + outputDir: `dist/storybook/${projectOpts.appRoot}`, + ...extraTargetOptions, + }, + configurations: { + ...extraConfigurations, + }, + }, + [projectOpts.serveTargetName]: { + executor: '@nx/storybook:storybook', + options: { + port: 4400, + configDir: `${projectOpts.appRoot}/${projectOpts.configDir}`, + ...extraTargetOptions, + }, + configurations: { + ci: { + quiet: true, + }, + ...extraConfigurations, + }, + }, + }, + }; + + writeStorybookConfig(tree, project.root, useVite); + + addProject(tree, project.name, project); + fs.createFileSync( + `${projectOpts.appRoot}/project.json`, + JSON.stringify(project) + ); + return project; +} + +describe('Storybook - Convert To Inferred', () => { + let tree: Tree; + beforeEach(() => { + fs = new TempFs('storybook'); + tree = createTreeWithEmptyWorkspace(); + tree.root = fs.tempDir; + + projectGraph = { + nodes: {}, + dependencies: {}, + externalNodes: {}, + }; + }); + + afterEach(() => { + fs.cleanup(); + jest.resetModules(); + }); + + describe('--project', () => { + it('should correctly migrate a single project', async () => { + // ARRANGE + const project = createTestProject(tree); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + + const project2Targets = project2.targets; + + // ACT + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // ASSERT + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets).toMatchInlineSnapshot(` + { + "build-storybook": { + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/apps/app1", + }, + "outputs": [ + "{projectRoot}/{options.outputDir}", + "{workspaceRoot}/{projectRoot}/storybook-static", + "{options.output-dir}", + "{options.outputDir}", + "{options.o}", + ], + }, + "storybook": { + "configurations": { + "ci": { + "args": [ + "--quiet", + ], + }, + }, + "options": { + "config-dir": ".storybook", + "port": 4400, + }, + }, + } + `); + + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets).toStrictEqual(project2Targets); + + const nxJsonPlugins = readNxJson(tree).plugins; + const storybookPlugin = nxJsonPlugins.find( + (plugin): plugin is ExpandedPluginConfiguration => + typeof plugin !== 'string' && + plugin.plugin === '@nx/storybook/plugin' && + plugin.include?.length === 1 + ); + expect(storybookPlugin).toBeTruthy(); + expect(storybookPlugin.include).toEqual([`${project.root}/**/*`]); + }); + + it('should add a new plugin registration when the target name differs', async () => { + // ARRANGE + const project = createTestProject(tree); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + + const project2Targets = project2.targets; + + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/storybook/plugin', + options: { + buildTargetName: 'storybook-build', + serveTargetName: defaultTestProjectOptions.serveTargetName, + staticStorybookTargetName: 'static-storybook', + testStorybookTargetName: 'test-storybook', + }, + }); + updateNxJson(tree, nxJson); + + // ACT + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // ASSERT + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets).toMatchInlineSnapshot(` + { + "build-storybook": { + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/apps/app1", + }, + "outputs": [ + "{projectRoot}/{options.outputDir}", + "{workspaceRoot}/{projectRoot}/storybook-static", + "{options.output-dir}", + "{options.outputDir}", + "{options.o}", + ], + }, + "storybook": { + "configurations": { + "ci": { + "args": [ + "--quiet", + ], + }, + }, + "options": { + "config-dir": ".storybook", + "port": 4400, + }, + }, + } + `); + + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets).toStrictEqual(project2Targets); + + const nxJsonPlugins = readNxJson(tree).plugins; + const storybookPluginRegistrations = nxJsonPlugins.filter( + (plugin): plugin is ExpandedPluginConfiguration => + typeof plugin !== 'string' && plugin.plugin === '@nx/storybook/plugin' + ); + expect(storybookPluginRegistrations.length).toBe(2); + expect(storybookPluginRegistrations[1].include).toMatchInlineSnapshot(` + [ + "apps/app1/**/*", + ] + `); + }); + + it('should merge target defaults', async () => { + // ARRANGE + const project = createTestProject(tree); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + + const project2Targets = project2.targets; + + const nxJson = readNxJson(tree); + nxJson.targetDefaults ??= {}; + nxJson.targetDefaults['@nx/storybook:build'] = { + options: { + webpackStatsJson: true, + }, + }; + updateNxJson(tree, nxJson); + + // ACT + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // ASSERT + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets).toMatchInlineSnapshot(` + { + "build-storybook": { + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/apps/app1", + "webpack-stats-json": true, + }, + "outputs": [ + "{projectRoot}/{options.outputDir}", + "{workspaceRoot}/{projectRoot}/storybook-static", + "{options.output-dir}", + "{options.outputDir}", + "{options.o}", + ], + }, + "storybook": { + "configurations": { + "ci": { + "args": [ + "--quiet", + ], + }, + }, + "options": { + "config-dir": ".storybook", + "port": 4400, + }, + }, + } + `); + + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets).toStrictEqual(project2Targets); + }); + + it('should manage configurations correctly', async () => { + // ARRANGE + const project = createTestProject(tree, undefined, undefined, { + dev: { + docsMode: true, + }, + }); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + + const project2Targets = project2.targets; + // ACT + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // ASSERT + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets).toMatchInlineSnapshot(` + { + "build-storybook": { + "configurations": { + "dev": {}, + }, + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/apps/app1", + }, + "outputs": [ + "{projectRoot}/{options.outputDir}", + "{workspaceRoot}/{projectRoot}/storybook-static", + "{options.output-dir}", + "{options.outputDir}", + "{options.o}", + ], + }, + "storybook": { + "configurations": { + "ci": { + "args": [ + "--quiet", + ], + }, + "dev": { + "docs": true, + }, + }, + "options": { + "config-dir": ".storybook", + "port": 4400, + }, + }, + } + `); + + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets).toStrictEqual(project2Targets); + + const storybookConfigContents = tree.read( + `${project.root}/.storybook/main.ts`, + 'utf-8' + ); + expect(storybookConfigContents).toMatchInlineSnapshot(` + " + + // These options were migrated by @nx/storybook:convert-to-inferred from the project.json file. + const configValues = {"default":{},"dev":{"docsMode":true}}; + + // Determine the correct configValue to use based on the configuration + const nxConfiguration = process.env.NX_TASK_TARGET_CONFIGURATION ?? 'default'; + + const options = { + ...configValues.default, + ...(configValues[nxConfiguration] ?? {}) + } + const config = {docs: { docsMode: options.docsMode },"stories":["../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)"],"addons":["@storybook/addon-essentials","@storybook/addon-interactions"],"framework":{"name":"@storybook/react-webpack5","options":{}}}; + export default config;" + `); + }); + + it('should update vite config file', async () => { + // ARRANGE + const project = createTestProject( + tree, + undefined, + undefined, + undefined, + true + ); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + + const project2Targets = project2.targets; + // ACT + await convertToInferred(tree, { + project: project.name, + skipFormat: true, + }); + + // ASSERT + const storybookConfigContents = tree.read( + `${project.root}/.storybook/main.ts`, + 'utf-8' + ); + expect(storybookConfigContents).toMatchInlineSnapshot(` + " + + // These options were migrated by @nx/storybook:convert-to-inferred from the project.json file. + const configValues = {"default":{}}; + + // Determine the correct configValue to use based on the configuration + const nxConfiguration = process.env.NX_TASK_TARGET_CONFIGURATION ?? 'default'; + + const options = { + ...configValues.default, + ...(configValues[nxConfiguration] ?? {}) + } + const config = {"stories":["../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)"],"addons":["@storybook/addon-essentials","@storybook/addon-interactions"],"framework":{"name":"@storybook/react-vite","options":{"builder":{"viteConfigPath":"./vite.config.ts"}}}}; + export default config;" + `); + }); + }); + + describe('all projects', () => { + it('should correctly migrate all projects', async () => { + // ARRANGE + const project = createTestProject(tree); + const project2 = createTestProject(tree, { + appRoot: 'apps/project2', + appName: 'project2', + }); + // ACT + await convertToInferred(tree, { + skipFormat: true, + }); + + // ASSERT + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets).toMatchInlineSnapshot(` + { + "build-storybook": { + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/apps/app1", + }, + "outputs": [ + "{projectRoot}/{options.outputDir}", + "{workspaceRoot}/{projectRoot}/storybook-static", + "{options.output-dir}", + "{options.outputDir}", + "{options.o}", + ], + }, + "storybook": { + "configurations": { + "ci": { + "args": [ + "--quiet", + ], + }, + }, + "options": { + "config-dir": ".storybook", + "port": 4400, + }, + }, + } + `); + + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets).toMatchInlineSnapshot(` + { + "build-storybook": { + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/apps/project2", + }, + "outputs": [ + "{projectRoot}/{options.outputDir}", + "{workspaceRoot}/{projectRoot}/storybook-static", + "{options.output-dir}", + "{options.outputDir}", + "{options.o}", + ], + }, + "storybook": { + "configurations": { + "ci": { + "args": [ + "--quiet", + ], + }, + }, + "options": { + "config-dir": ".storybook", + "port": 4400, + }, + }, + } + `); + + const nxJsonPlugins = readNxJson(tree).plugins; + const storybookPlugin = nxJsonPlugins.find( + (plugin): plugin is ExpandedPluginConfiguration => + typeof plugin !== 'string' && plugin.plugin === '@nx/storybook/plugin' + ); + expect(storybookPlugin).toBeTruthy(); + expect(storybookPlugin.include).toBeUndefined(); + }); + }); +}); diff --git a/packages/storybook/src/generators/convert-to-inferred/convert-to-inferred.ts b/packages/storybook/src/generators/convert-to-inferred/convert-to-inferred.ts new file mode 100644 index 0000000000..9015fbada9 --- /dev/null +++ b/packages/storybook/src/generators/convert-to-inferred/convert-to-inferred.ts @@ -0,0 +1,76 @@ +import { + addDependenciesToPackageJson, + createProjectGraphAsync, + formatFiles, + runTasksInSerial, + type Tree, +} from '@nx/devkit'; +import { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { migrateExecutorToPluginV1 } from '@nx/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator'; +import { buildPostTargetTransformer } from './lib/build-post-target-transformer'; +import { servePostTargetTransformer } from './lib/serve-post-target-transformer'; +import { createNodes } from '../../plugins/plugin'; +import { storybookVersion } from '../../utils/versions'; + +interface Schema { + project?: string; + skipFormat?: boolean; +} + +export async function convertToInferred(tree: Tree, options: Schema) { + const projectGraph = await createProjectGraphAsync(); + const migrationLogs = new AggregatedLog(); + const migratedBuildProjects = await migrateExecutorToPluginV1( + tree, + projectGraph, + '@nx/storybook:build', + '@nx/storybook/plugin', + (targetName) => ({ + buildStorybookTargetName: targetName, + serveStorybookTargetName: 'storybook', + staticStorybookTargetName: 'static-storybook', + testStorybookTargetName: 'test-storybook', + }), + buildPostTargetTransformer(migrationLogs), + createNodes, + options.project + ); + + const migratedServeProjects = await migrateExecutorToPluginV1( + tree, + projectGraph, + '@nx/storybook:storybook', + '@nx/storybook/plugin', + (targetName) => ({ + buildStorybookTargetName: 'build-storybook', + serveStorybookTargetName: targetName, + staticStorybookTargetName: 'static-storybook', + testStorybookTargetName: 'test-storybook', + }), + servePostTargetTransformer(migrationLogs), + createNodes, + options.project + ); + + const migratedProjects = + migratedBuildProjects.size + migratedServeProjects.size; + if (migratedProjects === 0) { + throw new Error('Could not find any targets to migrate.'); + } + + if (!options.skipFormat) { + await formatFiles(tree); + } + + const installTask = addDependenciesToPackageJson( + tree, + {}, + { storybook: storybookVersion } + ); + + return runTasksInSerial(installTask, () => { + migrationLogs.flushLogs(); + }); +} + +export default convertToInferred; diff --git a/packages/storybook/src/generators/convert-to-inferred/lib/build-post-target-transformer.spec.ts b/packages/storybook/src/generators/convert-to-inferred/lib/build-post-target-transformer.spec.ts new file mode 100644 index 0000000000..86d1e3eb54 --- /dev/null +++ b/packages/storybook/src/generators/convert-to-inferred/lib/build-post-target-transformer.spec.ts @@ -0,0 +1,632 @@ +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { buildPostTargetTransformer } from './build-post-target-transformer'; + +describe('buildPostTargetTransformer', () => { + describe('--react-vite', () => { + it('should migrate docsMode and staticDir to storybook config correctly', () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + const targetConfiguration = { + outputs: ['{options.outputDir}'], + options: { + outputDir: 'dist/storybook/myapp', + configDir: 'apps/myapp/.storybook', + docsMode: true, + staticDir: ['assets'], + }, + }; + + const inferredTargetConfiguration = { + outputs: ['{projectRoot}/{options.outputDir}'], + }; + + const migrationLogs = new AggregatedLog(); + + tree.write( + 'apps/myapp/.storybook/main.ts', + storybookConfigFileV17_ReactVite + ); + + // ACT + const target = buildPostTargetTransformer(migrationLogs)( + targetConfiguration, + tree, + { projectName: 'myapp', root: 'apps/myapp' }, + inferredTargetConfiguration + ); + + // ASSERT + const configFile = tree.read('apps/myapp/.storybook/main.ts', 'utf-8'); + expect(configFile).toMatchInlineSnapshot(` + "import type { StorybookConfig } from '@storybook/react-vite'; + + // These options were migrated by @nx/storybook:convert-to-inferred from the project.json file. + const configValues = {"default":{"docsMode":true,"staticDir":["assets"]}}; + + // Determine the correct configValue to use based on the configuration + const nxConfiguration = process.env.NX_TASK_TARGET_CONFIGURATION ?? 'default'; + + const options = { + ...configValues.default, + ...(configValues[nxConfiguration] ?? {}) + } + + + const config: StorybookConfig = {staticDirs: options.staticDir,docs: { docsMode: options.docsMode }, + stories: ['../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: { + name: '@storybook/react-vite', + options: { + builder: { + viteConfigPath: './vite.config.ts', + }, + }, + }, + }; + + export default config;" + `); + expect(target).toMatchInlineSnapshot(` + { + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/myapp", + }, + } + `); + }); + + it('should handle configurations correctly and migrate docsMode and staticDir to storybook config correctly', () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + const targetConfiguration = { + outputs: ['{options.outputDir}'], + options: { + outputDir: 'dist/storybook/myapp', + configDir: 'apps/myapp/.storybook', + docsMode: true, + staticDir: ['assets'], + }, + configurations: { + dev: { + outputDir: 'dist/storybook/myapp/dev', + configDir: 'apps/myapp/dev/.storybook', + docsMode: false, + staticDir: ['dev/assets'], + }, + }, + }; + + const inferredTargetConfiguration = { + outputs: ['{projectRoot}/{options.outputDir}'], + }; + + const migrationLogs = new AggregatedLog(); + + tree.write( + 'apps/myapp/.storybook/main.ts', + storybookConfigFileV17_ReactVite + ); + tree.write( + 'apps/myapp/dev/.storybook/main.ts', + storybookConfigFileV17_ReactVite + ); + + // ACT + const target = buildPostTargetTransformer(migrationLogs)( + targetConfiguration, + tree, + { projectName: 'myapp', root: 'apps/myapp' }, + inferredTargetConfiguration + ); + + // ASSERT + const configFile = tree.read('apps/myapp/.storybook/main.ts', 'utf-8'); + expect(configFile).toMatchInlineSnapshot(` + "import type { StorybookConfig } from '@storybook/react-vite'; + + // These options were migrated by @nx/storybook:convert-to-inferred from the project.json file. + const configValues = {"default":{"docsMode":true,"staticDir":["assets"]},"dev":{"docsMode":false,"staticDir":["dev/assets"]}}; + + // Determine the correct configValue to use based on the configuration + const nxConfiguration = process.env.NX_TASK_TARGET_CONFIGURATION ?? 'default'; + + const options = { + ...configValues.default, + ...(configValues[nxConfiguration] ?? {}) + } + + + const config: StorybookConfig = {staticDirs: options.staticDir,docs: { docsMode: options.docsMode }, + stories: ['../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: { + name: '@storybook/react-vite', + options: { + builder: { + viteConfigPath: './vite.config.ts', + }, + }, + }, + }; + + export default config;" + `); + expect(target).toMatchInlineSnapshot(` + { + "configurations": { + "dev": { + "config-dir": "./dev/.storybook", + "output-dir": "../../dist/storybook/myapp/dev", + }, + }, + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/myapp", + }, + } + `); + const devConfigFile = tree.read( + 'apps/myapp/dev/.storybook/main.ts', + 'utf-8' + ); + expect(devConfigFile).toMatchInlineSnapshot(` + "import type { StorybookConfig } from '@storybook/react-vite'; + + const config: StorybookConfig = {staticDirs: options.staticDir,docs: { docsMode: options.docsMode }, + stories: ['../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: { + name: '@storybook/react-vite', + options: { + builder: { + viteConfigPath: 'apps/myapp/vite.config.ts', + }, + }, + }, + }; + + export default config;" + `); + }); + }); + + describe('--vue-vite', () => { + it('should migrate docsMode and staticDir to storybook config correctly', () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + const targetConfiguration = { + outputs: ['{options.outputDir}'], + options: { + outputDir: 'dist/storybook/myapp', + configDir: 'apps/myapp/.storybook', + docsMode: true, + staticDir: ['assets'], + }, + }; + + const inferredTargetConfiguration = { + outputs: ['{projectRoot}/{options.outputDir}'], + }; + + const migrationLogs = new AggregatedLog(); + + tree.write( + 'apps/myapp/.storybook/main.ts', + storybookConfigFileV17_VueVite + ); + + // ACT + const target = buildPostTargetTransformer(migrationLogs)( + targetConfiguration, + tree, + { projectName: 'myapp', root: 'apps/myapp' }, + inferredTargetConfiguration + ); + + // ASSERT + const configFile = tree.read('apps/myapp/.storybook/main.ts', 'utf-8'); + expect(configFile).toMatchInlineSnapshot(` + "import type { StorybookConfig } from '@storybook/vue3-vite'; + + // These options were migrated by @nx/storybook:convert-to-inferred from the project.json file. + const configValues = {"default":{"docsMode":true,"staticDir":["assets"]}}; + + // Determine the correct configValue to use based on the configuration + const nxConfiguration = process.env.NX_TASK_TARGET_CONFIGURATION ?? 'default'; + + const options = { + ...configValues.default, + ...(configValues[nxConfiguration] ?? {}) + } + + + const config: StorybookConfig = {staticDirs: options.staticDir,docs: { docsMode: options.docsMode }, + stories: ['../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: { + name: '@storybook/vue3-vite', + options: { + builder: { + viteConfigPath: './vite.config.ts', + }, + }, + }, + }; + + export default config;" + `); + expect(target).toMatchInlineSnapshot(` + { + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/myapp", + }, + } + `); + }); + + it('should handle configurations correctly and migrate docsMode and staticDir to storybook config correctly', () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + const targetConfiguration = { + outputs: ['{options.outputDir}'], + options: { + outputDir: 'dist/storybook/myapp', + configDir: 'apps/myapp/.storybook', + docsMode: true, + staticDir: ['assets'], + }, + configurations: { + dev: { + outputDir: 'dist/storybook/myapp/dev', + configDir: 'apps/myapp/dev/.storybook', + docsMode: false, + staticDir: ['dev/assets'], + }, + }, + }; + + const inferredTargetConfiguration = { + outputs: ['{projectRoot}/{options.outputDir}'], + }; + + const migrationLogs = new AggregatedLog(); + + tree.write( + 'apps/myapp/.storybook/main.ts', + storybookConfigFileV17_VueVite + ); + tree.write( + 'apps/myapp/dev/.storybook/main.ts', + storybookConfigFileV17_VueVite + ); + + // ACT + const target = buildPostTargetTransformer(migrationLogs)( + targetConfiguration, + tree, + { projectName: 'myapp', root: 'apps/myapp' }, + inferredTargetConfiguration + ); + + // ASSERT + const configFile = tree.read('apps/myapp/.storybook/main.ts', 'utf-8'); + expect(configFile).toMatchInlineSnapshot(` + "import type { StorybookConfig } from '@storybook/vue3-vite'; + + // These options were migrated by @nx/storybook:convert-to-inferred from the project.json file. + const configValues = {"default":{"docsMode":true,"staticDir":["assets"]},"dev":{"docsMode":false,"staticDir":["dev/assets"]}}; + + // Determine the correct configValue to use based on the configuration + const nxConfiguration = process.env.NX_TASK_TARGET_CONFIGURATION ?? 'default'; + + const options = { + ...configValues.default, + ...(configValues[nxConfiguration] ?? {}) + } + + + const config: StorybookConfig = {staticDirs: options.staticDir,docs: { docsMode: options.docsMode }, + stories: ['../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: { + name: '@storybook/vue3-vite', + options: { + builder: { + viteConfigPath: './vite.config.ts', + }, + }, + }, + }; + + export default config;" + `); + expect(target).toMatchInlineSnapshot(` + { + "configurations": { + "dev": { + "config-dir": "./dev/.storybook", + "output-dir": "../../dist/storybook/myapp/dev", + }, + }, + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/myapp", + }, + } + `); + const devConfigFile = tree.read( + 'apps/myapp/dev/.storybook/main.ts', + 'utf-8' + ); + expect(devConfigFile).toMatchInlineSnapshot(` + "import type { StorybookConfig } from '@storybook/vue3-vite'; + + const config: StorybookConfig = {staticDirs: options.staticDir,docs: { docsMode: options.docsMode }, + stories: ['../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: { + name: '@storybook/vue3-vite', + options: { + builder: { + viteConfigPath: 'apps/myapp/vite.config.ts', + }, + }, + }, + }; + + export default config;" + `); + }); + }); + + describe('--react-webpack', () => { + it('should migrate docsMode and staticDir to storybook config correctly', () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + const targetConfiguration = { + outputs: ['{options.outputDir}'], + options: { + outputDir: 'dist/storybook/myapp', + configDir: 'apps/myapp/.storybook', + docsMode: true, + staticDir: ['assets'], + }, + }; + + const inferredTargetConfiguration = { + outputs: ['{projectRoot}/{options.outputDir}'], + }; + + const migrationLogs = new AggregatedLog(); + + tree.write( + 'apps/myapp/.storybook/main.ts', + storybookConfigFileV17_ReactWebpack + ); + + // ACT + const target = buildPostTargetTransformer(migrationLogs)( + targetConfiguration, + tree, + { projectName: 'myapp', root: 'apps/myapp' }, + inferredTargetConfiguration + ); + + // ASSERT + const configFile = tree.read('apps/myapp/.storybook/main.ts', 'utf-8'); + expect(configFile).toMatchInlineSnapshot(` + "import type { StorybookConfig } from '@storybook/react-webpack5'; + + // These options were migrated by @nx/storybook:convert-to-inferred from the project.json file. + const configValues = {"default":{"docsMode":true,"staticDir":["assets"]}}; + + // Determine the correct configValue to use based on the configuration + const nxConfiguration = process.env.NX_TASK_TARGET_CONFIGURATION ?? 'default'; + + const options = { + ...configValues.default, + ...(configValues[nxConfiguration] ?? {}) + } + + + const config: StorybookConfig = {staticDirs: options.staticDir,docs: { docsMode: options.docsMode }, + stories: ['../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: [ + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@nx/react/plugins/storybook', + ], + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, + }; + + export default config;" + `); + expect(target).toMatchInlineSnapshot(` + { + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/myapp", + }, + } + `); + }); + + it('should handle configurations correctly and migrate docsMode and staticDir to storybook config correctly', () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + const targetConfiguration = { + outputs: ['{options.outputDir}'], + options: { + outputDir: 'dist/storybook/myapp', + configDir: 'apps/myapp/.storybook', + docsMode: true, + staticDir: ['assets'], + }, + configurations: { + dev: { + outputDir: 'dist/storybook/myapp/dev', + configDir: 'apps/myapp/dev/.storybook', + docsMode: false, + staticDir: ['dev/assets'], + }, + }, + }; + + const inferredTargetConfiguration = { + outputs: ['{projectRoot}/{options.outputDir}'], + }; + + const migrationLogs = new AggregatedLog(); + + tree.write( + 'apps/myapp/.storybook/main.ts', + storybookConfigFileV17_ReactWebpack + ); + tree.write( + 'apps/myapp/dev/.storybook/main.ts', + storybookConfigFileV17_ReactWebpack + ); + + // ACT + const target = buildPostTargetTransformer(migrationLogs)( + targetConfiguration, + tree, + { projectName: 'myapp', root: 'apps/myapp' }, + inferredTargetConfiguration + ); + + // ASSERT + const configFile = tree.read('apps/myapp/.storybook/main.ts', 'utf-8'); + expect(configFile).toMatchInlineSnapshot(` + "import type { StorybookConfig } from '@storybook/react-webpack5'; + + // These options were migrated by @nx/storybook:convert-to-inferred from the project.json file. + const configValues = {"default":{"docsMode":true,"staticDir":["assets"]},"dev":{"docsMode":false,"staticDir":["dev/assets"]}}; + + // Determine the correct configValue to use based on the configuration + const nxConfiguration = process.env.NX_TASK_TARGET_CONFIGURATION ?? 'default'; + + const options = { + ...configValues.default, + ...(configValues[nxConfiguration] ?? {}) + } + + + const config: StorybookConfig = {staticDirs: options.staticDir,docs: { docsMode: options.docsMode }, + stories: ['../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: [ + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@nx/react/plugins/storybook', + ], + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, + }; + + export default config;" + `); + expect(target).toMatchInlineSnapshot(` + { + "configurations": { + "dev": { + "config-dir": "./dev/.storybook", + "output-dir": "../../dist/storybook/myapp/dev", + }, + }, + "options": { + "config-dir": ".storybook", + "output-dir": "../../dist/storybook/myapp", + }, + } + `); + const devConfigFile = tree.read( + 'apps/myapp/dev/.storybook/main.ts', + 'utf-8' + ); + expect(devConfigFile).toMatchInlineSnapshot(` + "import type { StorybookConfig } from '@storybook/react-webpack5'; + + const config: StorybookConfig = {staticDirs: options.staticDir,docs: { docsMode: options.docsMode }, + stories: ['../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: [ + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@nx/react/plugins/storybook', + ], + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, + }; + + export default config;" + `); + }); + }); +}); + +const storybookConfigFileV17_ReactVite = `import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: { + name: '@storybook/react-vite', + options: { + builder: { + viteConfigPath: 'apps/myapp/vite.config.ts', + }, + }, + }, +}; + +export default config;`; + +const storybookConfigFileV17_ReactWebpack = `import type { StorybookConfig } from '@storybook/react-webpack5'; + +const config: StorybookConfig = { + stories: ['../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: [ + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@nx/react/plugins/storybook', + ], + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, +}; + +export default config;`; + +const storybookConfigFileV17_VueVite = `import type { StorybookConfig } from '@storybook/vue3-vite'; + +const config: StorybookConfig = { + stories: ['../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: { + name: '@storybook/vue3-vite', + options: { + builder: { + viteConfigPath: 'apps/myapp/vite.config.ts', + }, + }, + }, +}; + +export default config;`; diff --git a/packages/storybook/src/generators/convert-to-inferred/lib/build-post-target-transformer.ts b/packages/storybook/src/generators/convert-to-inferred/lib/build-post-target-transformer.ts new file mode 100644 index 0000000000..4802d4be14 --- /dev/null +++ b/packages/storybook/src/generators/convert-to-inferred/lib/build-post-target-transformer.ts @@ -0,0 +1,313 @@ +import { joinPathFragments, TargetConfiguration, Tree } from '@nx/devkit'; +import { + processTargetOutputs, + toProjectRelativePath, +} from '@nx/devkit/src/generators/plugin-migrations/plugin-migration-utils'; +import { tsquery } from '@phenomnomnominal/tsquery'; +import { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { + addConfigValuesToConfigFile, + ensureViteConfigPathIsRelative, + getConfigFilePath, + STORYBOOK_PROP_MAPPINGS, +} from './utils'; +import { getInstalledPackageVersionInfo } from './utils'; + +type StorybookConfigValues = { docsMode?: boolean; staticDir?: string }; + +export function buildPostTargetTransformer(migrationLogs: AggregatedLog) { + return ( + target: TargetConfiguration, + tree: Tree, + projectDetails: { projectName: string; root: string }, + inferredTargetConfiguration: TargetConfiguration + ) => { + let defaultConfigDir = joinPathFragments(projectDetails.root, '.storybook'); + + const configValues: Record = { + default: {}, + }; + + if (target.options) { + if (target.options.configDir) { + defaultConfigDir = target.options.configDir; + } + + handlePropertiesFromTargetOptions( + tree, + target.options, + defaultConfigDir, + projectDetails.projectName, + projectDetails.root, + configValues['default'], + migrationLogs + ); + } + + if (target.configurations) { + for (const configurationName in target.configurations) { + const configuration = target.configurations[configurationName]; + configValues[configurationName] = {}; + handlePropertiesFromTargetOptions( + tree, + configuration, + defaultConfigDir, + projectDetails.projectName, + projectDetails.root, + configValues[configurationName], + migrationLogs + ); + } + + for (const configurationName in target.configurations) { + const configuration = target.configurations[configurationName]; + if ( + configuration.configDir && + configuration.configDir !== + toProjectRelativePath(defaultConfigDir, projectDetails.root) + ) { + const configFilePath = getConfigFilePath( + tree, + joinPathFragments(projectDetails.root, configuration.configDir) + ); + addConfigValuesToConfigFile(tree, configFilePath, configValues); + ensureViteConfigPathIsRelative( + tree, + configFilePath, + projectDetails.projectName, + projectDetails.root, + '@nx/storybook:build', + migrationLogs + ); + } + + if ( + configurationName === 'ci' && + Object.keys(configuration).length === 0 + ) { + delete target.configurations[configurationName]; + } + } + + if (Object.keys(target.configurations).length === 0) { + if ('defaultConfiguration' in target) { + delete target.defaultConfiguration; + } + delete target.configurations; + } + + if ( + 'defaultConfiguration' in target && + !target.configurations[target.defaultConfiguration] + ) { + delete target.defaultConfiguration; + } + } + + if (target.outputs) { + processTargetOutputs( + target, + [{ newName: 'outputDir', oldName: 'outputDir' }], + inferredTargetConfiguration, + { + projectName: projectDetails.projectName, + projectRoot: projectDetails.root, + } + ); + } + + addConfigValuesToConfigFile( + tree, + getConfigFilePath(tree, defaultConfigDir), + configValues + ); + ensureViteConfigPathIsRelative( + tree, + getConfigFilePath(tree, defaultConfigDir), + projectDetails.projectName, + projectDetails.root, + '@nx/storybook:build', + migrationLogs + ); + + return target; + }; +} + +function handlePropertiesFromTargetOptions( + tree: Tree, + options: any, + defaultConfigDir: string, + projectName: string, + projectRoot: string, + configValues: StorybookConfigValues, + migrationLogs: AggregatedLog +) { + let configDir = defaultConfigDir; + if ('configDir' in options) { + if (options.configDir !== defaultConfigDir) { + configDir = options.configDir as string; + } + options.configDir = toProjectRelativePath(options.configDir, projectRoot); + } + + if (options.outputDir) { + options.outputDir = toProjectRelativePath(options.outputDir, projectRoot); + } + + if ('styles' in options) { + delete options.styles; + } + if ('stylePreprocessorOptions' in options) { + delete options.stylePreprocessorOptions; + } + + if ('docsMode' in options) { + configValues.docsMode = options.docsMode; + moveDocsModeToConfigFile( + tree, + configDir, + projectName, + migrationLogs, + configDir === defaultConfigDir + ); + delete options.docsMode; + } + + if ('staticDir' in options) { + configValues.staticDir = options.staticDir; + moveStaticDirToConfigFile( + tree, + configDir, + projectName, + migrationLogs, + configDir === defaultConfigDir + ); + delete options.staticDir; + } + + const storybookPropMappings = + getInstalledPackageVersionInfo(tree, 'storybook')?.major === 8 + ? STORYBOOK_PROP_MAPPINGS.v8 + : STORYBOOK_PROP_MAPPINGS.v7; + for (const [prevKey, newKey] of Object.entries(storybookPropMappings)) { + if (prevKey in options) { + let prevValue = options[prevKey]; + delete options[prevKey]; + options[newKey] = prevValue; + } + } +} + +function moveDocsModeToConfigFile( + tree: Tree, + configDir: string, + projectName: string, + migrationLogs: AggregatedLog, + useConfigValues = true +) { + const configFilePath = getConfigFilePath(tree, configDir); + const configFileContents = tree.read(configFilePath, 'utf-8'); + + const ast = tsquery.ast(configFileContents); + const CONFIG_OBJECT_SELECTOR = + 'VariableDeclaration:has(Identifier[name=config]) ObjectLiteralExpression'; + const DOCS_MODE_SELECTOR = + 'PropertyAssignment:has(Identifier[name=docs]) PropertyAssignment:has(Identifier[name=docsMode])'; + const DOCS_SELECTOR = 'PropertyAssignment:has(Identifier[name=docs])'; + + const configNodes = tsquery(ast, CONFIG_OBJECT_SELECTOR, { + visitAllChildren: true, + }); + + if (configNodes.length === 0) { + // Invalid config file + migrationLogs.addLog({ + project: projectName, + executorName: '@nx/storybook:build', + log: 'Could not find a valid Storybook Config to migrate `docsMode`. Update your `main.ts` file to add `docsMode`.', + }); + return; + } + + const configNode = configNodes[0]; + const hasDocsMode = + tsquery(configNode, DOCS_MODE_SELECTOR, { visitAllChildren: true }).length > + 0; + if (hasDocsMode) { + return; + } + + let startPosition = configNode.getStart() + 1; + let needsDocObject = true; + + const docsNodes = tsquery(configNode, DOCS_SELECTOR, { + visitAllChildren: true, + }); + if (docsNodes.length > 0) { + needsDocObject = false; + startPosition = docsNodes[0].getStart() + 1; + } + + const docsModeInsert = `options.docsMode`; + const nodeToInsert = needsDocObject + ? `docs: { docsMode: ${docsModeInsert} },` + : `docsMode: ${docsModeInsert},`; + tree.write( + configFilePath, + `${configFileContents.slice( + 0, + startPosition + )}${nodeToInsert}${configFileContents.slice(startPosition)}` + ); +} + +function moveStaticDirToConfigFile( + tree: Tree, + configDir: string, + projectName: string, + migrationLogs: AggregatedLog, + useConfigValues = true +) { + const configFilePath = getConfigFilePath(tree, configDir); + const configFileContents = tree.read(configFilePath, 'utf-8'); + + const ast = tsquery.ast(configFileContents); + const CONFIG_OBJECT_SELECTOR = + 'VariableDeclaration:has(Identifier[name=config]) ObjectLiteralExpression'; + const STATIC_DIRS_SELECTOR = + 'PropertyAssignment:has(Identifier[name=staticDirs])'; + + const configNodes = tsquery(ast, CONFIG_OBJECT_SELECTOR, { + visitAllChildren: true, + }); + + if (configNodes.length === 0) { + // Invalid config file + migrationLogs.addLog({ + project: projectName, + executorName: '@nx/storybook:build', + log: 'Could not find a valid Storybook Config to migrate `staticDir`. Update your `main.ts` file to add `staticDirs`.', + }); + return; + } + + const configNode = configNodes[0]; + const hasStaticDir = + tsquery(configNode, STATIC_DIRS_SELECTOR, { visitAllChildren: true }) + .length > 0; + if (hasStaticDir) { + return; + } + + let startPosition = configNode.getStart() + 1; + + const nodeToInsert = `staticDirs: options.staticDir,`; + tree.write( + configFilePath, + `${configFileContents.slice( + 0, + startPosition + )}${nodeToInsert}${configFileContents.slice(startPosition)}` + ); +} diff --git a/packages/storybook/src/generators/convert-to-inferred/lib/serve-post-target-transformer.ts b/packages/storybook/src/generators/convert-to-inferred/lib/serve-post-target-transformer.ts new file mode 100644 index 0000000000..45bca2f55d --- /dev/null +++ b/packages/storybook/src/generators/convert-to-inferred/lib/serve-post-target-transformer.ts @@ -0,0 +1,156 @@ +import { joinPathFragments, TargetConfiguration, Tree } from '@nx/devkit'; +import { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { toProjectRelativePath } from '@nx/devkit/src/generators/plugin-migrations/plugin-migration-utils'; +import { + ensureViteConfigPathIsRelative, + getConfigFilePath, + getInstalledPackageVersionInfo, + STORYBOOK_PROP_MAPPINGS, +} from './utils'; +export function servePostTargetTransformer(migrationLogs: AggregatedLog) { + return ( + target: TargetConfiguration, + tree: Tree, + projectDetails: { projectName: string; root: string }, + inferredTargetConfiguration: TargetConfiguration + ) => { + let defaultConfigDir = getConfigFilePath( + tree, + joinPathFragments(projectDetails.root, '.storybook') + ); + + if (target.options) { + if (target.options.configDir) { + defaultConfigDir = target.options.configDir; + } + + handlePropertiesFromTargetOptions( + tree, + target.options, + projectDetails.projectName, + projectDetails.root, + migrationLogs + ); + } + + if (target.configurations) { + for (const configurationName in target.configurations) { + const configuration = target.configurations[configurationName]; + if ( + configuration.configDir && + configuration.configDir !== defaultConfigDir + ) { + ensureViteConfigPathIsRelative( + tree, + getConfigFilePath(tree, configuration.configDir), + projectDetails.projectName, + projectDetails.root, + '@nx/storybook:storybook', + migrationLogs + ); + } + + handlePropertiesFromTargetOptions( + tree, + configuration, + projectDetails.projectName, + projectDetails.root, + migrationLogs + ); + } + + if (Object.keys(target.configurations).length === 0) { + if ('defaultConfiguration' in target) { + delete target.defaultConfiguration; + } + delete target.configurations; + } + + if ( + 'defaultConfiguration' in target && + !target.configurations[target.defaultConfiguration] + ) { + delete target.defaultConfiguration; + } + } + + ensureViteConfigPathIsRelative( + tree, + getConfigFilePath(tree, defaultConfigDir), + projectDetails.projectName, + projectDetails.root, + '@nx/storybook:storybook', + migrationLogs + ); + + return target; + }; +} + +function handlePropertiesFromTargetOptions( + tree: Tree, + options: any, + projectName: string, + projectRoot: string, + migrationLogs: AggregatedLog +) { + if ('configDir' in options) { + options.configDir = toProjectRelativePath(options.configDir, projectRoot); + } + + if (options.outputDir) { + options.outputDir = toProjectRelativePath(options.outputDir, projectRoot); + } + + if ('uiFramework' in options) { + delete options.uiFramework; + } + if ('staticDir' in options) { + migrationLogs.addLog({ + project: projectName, + executorName: '@nx/storybook:storybook', + log: 'Could not migrate `staticDir`. Update your `main.ts` file to add `staticDirs`.', + }); + delete options.staticDir; + } + if ('open' in options) { + if (!options.open) { + options['args'] ??= []; + options['args'].push('--no-open'); + } + delete options.open; + } + + if ('no-open' in options) { + if (options['no-open']) { + options['args'] ??= []; + options['args'].push('--no-open'); + } + delete options['no-open']; + } + + if ('quiet' in options) { + if (options['quiet']) { + options['args'] ??= []; + options['args'].push('--quiet'); + } + delete options.quiet; + } + + if ('docsMode' in options) { + options.docs = options.docsMode; + delete options.docsMode; + } + + const storybookPropMappings = + getInstalledPackageVersionInfo(tree, 'storybook')?.major === 8 + ? STORYBOOK_PROP_MAPPINGS.v8 + : STORYBOOK_PROP_MAPPINGS.v7; + for (const [prevKey, newKey] of Object.entries(storybookPropMappings)) { + if (prevKey in options) { + let prevValue = options[prevKey]; + delete options[prevKey]; + options[newKey] = prevValue; + } + } +} diff --git a/packages/storybook/src/generators/convert-to-inferred/lib/utils.ts b/packages/storybook/src/generators/convert-to-inferred/lib/utils.ts new file mode 100644 index 0000000000..0e1db7160f --- /dev/null +++ b/packages/storybook/src/generators/convert-to-inferred/lib/utils.ts @@ -0,0 +1,200 @@ +import { tsquery } from '@phenomnomnominal/tsquery'; +import { readJson, joinPathFragments, type Tree } from '@nx/devkit'; +import { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { toProjectRelativePath } from '@nx/devkit/src/generators/plugin-migrations/plugin-migration-utils'; +import { dirname } from 'path/posix'; +import { coerce, major } from 'semver'; + +export function getConfigFilePath(tree: Tree, configDir: string) { + return [ + joinPathFragments(configDir, `main.ts`), + joinPathFragments(configDir, `main.cts`), + joinPathFragments(configDir, `main.mts`), + joinPathFragments(configDir, `main.js`), + joinPathFragments(configDir, `main.cjs`), + joinPathFragments(configDir, `main.mjs`), + ].find((f) => tree.exists(f)); +} + +export function addConfigValuesToConfigFile( + tree: Tree, + configFile: string, + configValues: Record> +) { + const IMPORT_PROPERTY_SELECTOR = 'ImportDeclaration'; + const configFileContents = tree.read(configFile, 'utf-8'); + + const ast = tsquery.ast(configFileContents); + // AST TO GET SECTION TO APPEND TO + const importNodes = tsquery(ast, IMPORT_PROPERTY_SELECTOR, { + visitAllChildren: true, + }); + let startPosition = 0; + if (importNodes.length !== 0) { + const lastImportNode = importNodes[importNodes.length - 1]; + startPosition = lastImportNode.getEnd(); + } + + const configValuesString = ` + // These options were migrated by @nx/storybook:convert-to-inferred from the project.json file. + const configValues = ${JSON.stringify(configValues)}; + + // Determine the correct configValue to use based on the configuration + const nxConfiguration = process.env.NX_TASK_TARGET_CONFIGURATION ?? 'default'; + + const options = { + ...configValues.default, + ...(configValues[nxConfiguration] ?? {}) + }`; + + tree.write( + configFile, + `${configFileContents.slice(0, startPosition)} + ${configValuesString} + ${configFileContents.slice(startPosition)}` + ); +} + +export const STORYBOOK_PROP_MAPPINGS = { + v7: { + port: 'port', + previewUrl: 'preview-url', + host: 'host', + docs: 'docs', + configDir: 'config-dir', + logLevel: 'loglevel', + quiet: 'quiet', + webpackStatsJson: 'webpack-stats-json', + debugWebpack: 'debug-webpack', + disableTelemetry: 'disable-telemetry', + https: 'https', + sslCa: 'ssl-ca', + sslCert: 'ssl-cert', + sslKey: 'ssl-key', + smokeTest: 'smoke-test', + noOpen: 'no-open', + outputDir: 'output-dir', + }, + v8: { + port: 'port', + previewUrl: 'preview-url', + host: 'host', + docs: 'docs', + configDir: 'config-dir', + logLevel: 'loglevel', + quiet: 'quiet', + webpackStatsJson: 'stats-json', + debugWebpack: 'debug-webpack', + disableTelemetry: 'disable-telemetry', + https: 'https', + sslCa: 'ssl-ca', + sslCert: 'ssl-cert', + sslKey: 'ssl-key', + smokeTest: 'smoke-test', + noOpen: 'no-open', + outputDir: 'output-dir', + }, +}; + +export function ensureViteConfigPathIsRelative( + tree: Tree, + configPath: string, + projectName: string, + projectRoot: string, + executorName: string, + migrationLogs: AggregatedLog +) { + const configFileContents = tree.read(configPath, 'utf-8'); + + if (configFileContents.includes('viteFinal:')) { + return; + } + + const ast = tsquery.ast(configFileContents); + const REACT_FRAMEWORK_SELECTOR_IDENTIFIERS = + 'PropertyAssignment:has(Identifier[name=framework]) PropertyAssignment:has(Identifier[name=name]) StringLiteral[value=@storybook/react-vite]'; + const REACT_FRAMEWORK_SELECTOR_STRING_LITERALS = + 'PropertyAssignment:has(StringLiteral[value=framework]) PropertyAssignment:has(StringLiteral[value=name]) StringLiteral[value=@storybook/react-vite]'; + + const VUE_FRAMEWORK_SELECTOR_IDENTIFIERS = + 'PropertyAssignment:has(Identifier[name=framework]) PropertyAssignment:has(Identifier[name=name]) StringLiteral[value=@storybook/vue3-vite]'; + const VUE_FRAMEWORK_SELECTOR_STRING_LITERALS = + 'PropertyAssignment:has(StringLiteral[value=framework]) PropertyAssignment:has(StringLiteral[value=name]) StringLiteral[value=@storybook/vue3-vite]'; + const isUsingVite = + tsquery(ast, REACT_FRAMEWORK_SELECTOR_IDENTIFIERS, { + visitAllChildren: true, + }).length > 0 || + tsquery(ast, REACT_FRAMEWORK_SELECTOR_STRING_LITERALS, { + visitAllChildren: true, + }).length > 0 || + tsquery(ast, VUE_FRAMEWORK_SELECTOR_STRING_LITERALS, { + visitAllChildren: true, + }).length > 0 || + tsquery(ast, VUE_FRAMEWORK_SELECTOR_IDENTIFIERS, { visitAllChildren: true }) + .length > 0; + if (!isUsingVite) { + return; + } + + const VITE_CONFIG_PATH_SELECTOR = + 'PropertyAssignment:has(Identifier[name=framework]) PropertyAssignment PropertyAssignment PropertyAssignment:has(Identifier[name=viteConfigPath]) > StringLiteral'; + let viteConfigPathNodes = tsquery(ast, VITE_CONFIG_PATH_SELECTOR, { + visitAllChildren: true, + }); + if (viteConfigPathNodes.length === 0) { + const VITE_CONFIG_PATH_SELECTOR_STRING_LITERALS = + 'PropertyAssignment:has(StringLiteral[value=framework]) PropertyAssignment PropertyAssignment PropertyAssignment:has(StringLiteral[value=viteConfigPath]) > StringLiteral:not(StringLiteral[value=viteConfigPath])'; + viteConfigPathNodes = tsquery( + ast, + VITE_CONFIG_PATH_SELECTOR_STRING_LITERALS, + { + visitAllChildren: true, + } + ); + + if (viteConfigPathNodes.length === 0) { + migrationLogs.addLog({ + project: projectName, + executorName, + log: 'Unable to find `viteConfigPath` in Storybook Config. Please ensure the `viteConfigPath` is relative to the project root.', + }); + return; + } + } + + const viteConfigPathNode = viteConfigPathNodes[0]; + const pathToViteConfig = viteConfigPathNode.getText().replace(/('|")/g, ''); + if (pathToViteConfig.match(/^(\.\.\/|\.\/)/)) { + return; + } + const relativePathToViteConfig = toProjectRelativePath( + pathToViteConfig, + projectRoot + ); + + tree.write( + configPath, + `${configFileContents.slice( + 0, + viteConfigPathNode.getStart() + 1 + )}${relativePathToViteConfig}${configFileContents.slice( + viteConfigPathNode.getEnd() - 1 + )}` + ); +} + +export function getInstalledPackageVersion( + tree: Tree, + pkgName: string +): string | null { + const { dependencies, devDependencies } = readJson(tree, 'package.json'); + const version = dependencies?.[pkgName] ?? devDependencies?.[pkgName]; + + return version; +} + +export function getInstalledPackageVersionInfo(tree: Tree, pkgName: string) { + const version = getInstalledPackageVersion(tree, pkgName); + + return version ? { major: major(coerce(version)), version } : null; +} diff --git a/packages/storybook/src/generators/convert-to-inferred/schema.json b/packages/storybook/src/generators/convert-to-inferred/schema.json new file mode 100644 index 0000000000..c456e3b3dd --- /dev/null +++ b/packages/storybook/src/generators/convert-to-inferred/schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "NxStorybookConvertToInferred", + "description": "Convert existing Storybook project(s) using `@nx/storybook:*` executors to use `@nx/storybook/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "title": "Convert Storybook project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/storybook:*` executors to use `@nx/storybook/plugin`.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files at the end of the migration.", + "default": false + } + } +} diff --git a/packages/vite/src/generators/convert-to-inferred/lib/build-post-target-transformer.ts b/packages/vite/src/generators/convert-to-inferred/lib/build-post-target-transformer.ts index 9bf4bb8a21..0e2ebda78a 100644 --- a/packages/vite/src/generators/convert-to-inferred/lib/build-post-target-transformer.ts +++ b/packages/vite/src/generators/convert-to-inferred/lib/build-post-target-transformer.ts @@ -38,13 +38,13 @@ export function buildPostTargetTransformer( if (target.configurations) { for (const configurationName in target.configurations) { const configuration = target.configurations[configurationName]; - configValues[configuration] = {}; + configValues[configurationName] = {}; removePropertiesFromTargetOptions( tree, configuration, viteConfigPath, projectDetails.root, - configValues[configuration] + configValues[configurationName] ); if (Object.keys(configuration).length === 0) {