feat(storybook): add convert-to-inferred generator (#26595)

- feat(storybook): add convert-to-inferred generator
- feat(storybook): add post target transformers
- feat(storybook): convert-to-inferred handles kebab case flags

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
This commit is contained in:
Colum Ferry 2024-06-21 14:01:30 +01:00 committed by GitHub
parent b1dbf47aa2
commit 18fdd9425b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 2091 additions and 2 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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"
}

View File

@ -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)

View File

@ -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",

View File

@ -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<TestProjectOptions> = 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();
});
});
});

View File

@ -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;

View File

@ -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;`;

View File

@ -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<string, StorybookConfigValues> = {
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)}`
);
}

View File

@ -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;
}
}
}

View File

@ -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<string, Record<string, unknown>>
) {
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;
}

View File

@ -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
}
}
}

View File

@ -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) {