From 4a551ede7d00f232a2de4366e01fedabdc695443 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Tue, 23 Apr 2024 11:31:52 +0200 Subject: [PATCH] feat(workspace-plugin): implement split-library-in-two migration generator (#31086) --- .../generators/split-library-in-two/README.md | 61 ++ .../files/src/index.ts.template | 1 - .../split-library-in-two/generator.spec.ts | 593 +++++++++++++++++- .../split-library-in-two/generator.ts | 563 ++++++++++++++++- .../split-library-in-two/schema.d.ts | 3 +- .../split-library-in-two/schema.json | 12 +- 6 files changed, 1211 insertions(+), 22 deletions(-) create mode 100644 tools/workspace-plugin/src/generators/split-library-in-two/README.md delete mode 100644 tools/workspace-plugin/src/generators/split-library-in-two/files/src/index.ts.template diff --git a/tools/workspace-plugin/src/generators/split-library-in-two/README.md b/tools/workspace-plugin/src/generators/split-library-in-two/README.md new file mode 100644 index 0000000000000..93322865f237a --- /dev/null +++ b/tools/workspace-plugin/src/generators/split-library-in-two/README.md @@ -0,0 +1,61 @@ +# split-library-in-two + +Workspace Generator for splitting existing v9 web packages into `/library` and `/stories` packages under the same folder name. + +```sh +|- react-components/ +|- |- react-text/ +``` + +↓↓↓ + +``` +|- react-components/ +|- |- react-text/ +|- |- |- library/ +|- |- |- stories/ +``` + +Generator also parses source AST and adds ghost dependencies as `devDependencies` to `library` project for cypress/jest test files in order to create proper dependency graph ( without this `type-check` would fail ) + + + +- [Usage](#usage) + - [Examples](#examples) +- [Options](#options) + - [`project`](#project) + - [`all`](#all) + + + +## Usage + +```sh +yarn nx g @fluentui/workspace-plugin:split-library-in-two --help +``` + +Show what will be generated without writing to disk: + +```sh +yarn nx g @fluentui/workspace-plugin:split-library-in-two --dry-run +``` + +### Examples + +```sh +yarn nx g @fluentui/workspace-plugin:split-library-in-two +``` + +## Options + +#### `project` + +Type: `string` + +project name + +#### `all` + +Type: `boolean` + +will execute generator on all applicable projects diff --git a/tools/workspace-plugin/src/generators/split-library-in-two/files/src/index.ts.template b/tools/workspace-plugin/src/generators/split-library-in-two/files/src/index.ts.template deleted file mode 100644 index 877d430279d9e..0000000000000 --- a/tools/workspace-plugin/src/generators/split-library-in-two/files/src/index.ts.template +++ /dev/null @@ -1 +0,0 @@ -const variable = "<%= name %>"; \ No newline at end of file diff --git a/tools/workspace-plugin/src/generators/split-library-in-two/generator.spec.ts b/tools/workspace-plugin/src/generators/split-library-in-two/generator.spec.ts index 1be7abbcbc265..4578f4c88d37d 100644 --- a/tools/workspace-plugin/src/generators/split-library-in-two/generator.spec.ts +++ b/tools/workspace-plugin/src/generators/split-library-in-two/generator.spec.ts @@ -1,20 +1,601 @@ import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; -import { Tree, readProjectConfiguration } from '@nx/devkit'; +import { + Tree, + readProjectConfiguration, + stripIndents, + addProjectConfiguration, + serializeJson, + readJson, + updateJson, + writeJson, + output, +} from '@nx/devkit'; import { splitLibraryInTwoGenerator } from './generator'; -import { SplitLibraryInTwoGeneratorSchema } from './schema'; +import { setupCodeowners } from '../../utils-testing'; +import { TsConfig } from '../../types'; +import { addCodeowner } from '../add-codeowners'; +import { workspacePaths } from '../../utils'; + +const noop = () => { + return; +}; describe('split-library-in-two generator', () => { let tree: Tree; - const options: SplitLibraryInTwoGeneratorSchema = { name: 'test' }; + const options = { project: '@proj/react-hello' }; beforeEach(() => { tree = createTreeWithEmptyWorkspace(); + tree = setup(tree); + + jest.spyOn(output, 'log').mockImplementation(noop); + jest.spyOn(output, 'warn').mockImplementation(noop); + jest.spyOn(output, 'error').mockImplementation(noop); }); - it('should run successfully', async () => { + it('should split v9 project into 2', async () => { + const oldConfig = readProjectConfiguration(tree, options.project); + await splitLibraryInTwoGenerator(tree, options); - const config = readProjectConfiguration(tree, 'test'); - expect(config).toBeDefined(); + + const newConfig = readProjectConfiguration(tree, options.project); + const storiesConfig = readProjectConfiguration(tree, `${options.project}-stories`); + + // new Shared + expect(tree.children(oldConfig.root)).toEqual(['stories', 'library']); + + expect(readJson(tree, '/tsconfig.base.json').compilerOptions.paths).toEqual( + expect.objectContaining({ + '@proj/react-hello': ['packages/react-components/react-hello/library/src/index.ts'], + '@proj/react-hello-stories': ['packages/react-components/react-hello/stories/src/index.ts'], + }), + ); + + expect(tree.read(workspacePaths.github.codeowners, 'utf-8')).toEqual( + expect.stringContaining(stripIndents` + packages/react-components/react-hello/library Mr.Wick + packages/react-components/react-hello/stories Mr.Wick + `), + ); + + // ============== + // new SRC ( library ) + // ============== + expect(tree.children(newConfig.root)).toMatchInlineSnapshot(` + Array [ + "package.json", + "tsconfig.json", + "tsconfig.lib.json", + ".babelrc.json", + "jest.config.js", + "config", + "just.config.ts", + "src", + "project.json", + ] + `); + + expect(newConfig).toMatchInlineSnapshot(` + Object { + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "name": "@proj/react-hello", + "projectType": "library", + "root": "packages/react-components/react-hello/library", + "sourceRoot": "packages/react-components/react-hello/library/src", + "tags": Array [ + "vNext", + "platform:web", + ], + } + `); + + expect(readJson(tree, `${newConfig.root}/.babelrc.json`)).toEqual({ extends: '../../../../../.babelrc-v9.json' }); + expect(readJson(tree, `${newConfig.root}/config/api-extractor.json`)).toEqual( + expect.objectContaining({ + mainEntryPointFilePath: + '/../../../../../../dist/out-tsc/types/packages/react-components//library/src/index.d.ts', + }), + ); + + expect(readJson(tree, `${newConfig.root}/tsconfig.json`)).toEqual( + expect.objectContaining({ + extends: '../../../../tsconfig.base.json', + references: [ + { + path: './tsconfig.lib.json', + }, + { + path: './tsconfig.spec.json', + }, + ], + }), + ); + + expect(readJson(tree, `${newConfig.root}/tsconfig.lib.json`).compilerOptions).toEqual( + expect.objectContaining({ + declarationDir: '../../../../dist/out-tsc/types', + outDir: '../../../../dist/out-tsc', + }), + ); + + expect(readJson(tree, `${newConfig.root}/package.json`)).toEqual( + expect.objectContaining({ + name: '@proj/react-hello', + scripts: expect.objectContaining({ + 'test-ssr': 'test-ssr "../stories/src/**/*.stories.tsx"', + 'type-check': 'just-scripts type-check', + storybook: 'yarn --cwd ../stories storybook', + }), + devDependencies: { + '@proj/react-one-for-test': '*', + '@proj/react-provider': '*', + '@proj/react-theme': '*', + }, + }), + ); + + expect(tree.read(`${newConfig.root}/jest.config.js`, 'utf-8')).toMatchInlineSnapshot(` + "module.exports = { + displayName: 'react-text', + preset: '../../../../jest.preset.js', + transform: { + '^.+\\\\\\\\.tsx?$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + isolatedModules: true, + }, + ], + }, + coverageDirectory: './coverage', + setupFilesAfterEnv: ['./config/tests.js'], + snapshotSerializers: ['@griffel/jest-serializer'], + }; + " + `); + + // ============== + // new SB + // ============== + expect(storiesConfig).toMatchInlineSnapshot(` + Object { + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "name": "@proj/react-hello-stories", + "projectType": "library", + "root": "packages/react-components/react-hello/stories", + "sourceRoot": "packages/react-components/react-hello/stories/src", + "tags": Array [ + "vNext", + "platform:web", + "type:stories", + ], + } + `); + + expect(readJson(tree, `${storiesConfig.root}/package.json`)).toMatchInlineSnapshot(` + Object { + "devDependencies": Object { + "@fluentui/eslint-plugin": "*", + "@fluentui/react-storybook-addon": "*", + "@fluentui/react-storybook-addon-export-to-sandbox": "*", + "@fluentui/scripts-storybook": "*", + "@fluentui/scripts-tasks": "*", + "@proj/react-components": "*", + "@proj/react-one-compat": "*", + "@proj/react-two-preview": "*", + }, + "name": "@proj/react-hello-stories", + "private": true, + "scripts": Object { + "format": "just-scripts prettier", + "lint": "eslint src/", + "start": "yarn storybook", + "storybook": "start-storybook", + "type-check": "just-scripts type-check", + }, + "version": "0.0.0", + } + `); + + expect(readJson(tree, `${storiesConfig.root}/tsconfig.json`)).toMatchInlineSnapshot(` + Object { + "compilerOptions": Object { + "importHelpers": true, + "isolatedModules": true, + "jsx": "react", + "noEmit": true, + "noUnusedLocals": true, + "preserveConstEnums": true, + "target": "ES2019", + }, + "extends": "../../../../tsconfig.base.json", + "files": Array [], + "include": Array [], + "references": Array [ + Object { + "path": "./tsconfig.lib.json", + }, + Object { + "path": "./.storybook/tsconfig.json", + }, + ], + } + `); + + expect(readJson(tree, `${storiesConfig.root}/tsconfig.lib.json`)).toMatchInlineSnapshot(` + Object { + "compilerOptions": Object { + "inlineSources": true, + "lib": Array [ + "ES2019", + "dom", + ], + "outDir": "../../../../dist/out-tsc", + "types": Array [ + "static-assets", + "environment", + ], + }, + "extends": "./tsconfig.json", + "include": Array [ + "./src/**/*.ts", + "./src/**/*.tsx", + ], + } + `); + expect(readJson(tree, `${storiesConfig.root}/.eslintrc.json`)).toMatchInlineSnapshot(` + Object { + "extends": Array [ + "plugin:@fluentui/eslint-plugin/react", + ], + "root": true, + "rules": Object { + "import/no-extraneous-dependencies": Array [ + "error", + Object { + "packageDir": Array [ + ".", + "../../../../", + ], + }, + ], + }, + } + `); + expect(tree.read(`${storiesConfig.root}/README.md`, 'utf-8')).toMatchInlineSnapshot(` + "# @proj/react-hello-stories + + Storybook stories for packages/react-components/react-hello + + ## Usage + + To include within storybook specify stories globs: + + \\\\\`\\\\\`\\\\\`js + module.exports = { + stories: ['../packages/react-components/react-hello/stories/src/**/*.stories.mdx', '../packages/react-components/react-hello/stories/src/**/index.stories.@(ts|tsx)'], + } + \\\\\`\\\\\`\\\\\` + + ## API + + no public API available + " + `); + + expect(readJson(tree, `${storiesConfig.root}/.storybook/tsconfig.json`)).toMatchInlineSnapshot(` + Object { + "compilerOptions": Object { + "allowJs": true, + "checkJs": true, + "outDir": "", + "types": Array [ + "static-assets", + "environment", + "storybook__addons", + ], + }, + "extends": "../tsconfig.json", + "include": Array [ + "*.js", + ], + } + `); + expect(tree.read(`${storiesConfig.root}/.storybook/main.js`, 'utf-8')).toMatchInlineSnapshot(` + "const rootMain = require('../../../../../.storybook/main'); + + module.exports = + /** @type {Omit} */ ({ + ...rootMain, + stories: [ + ...rootMain.stories, + '../src/**/*.stories.mdx', + '../src/**/index.stories.@(ts|tsx)', + ], + addons: [...rootMain.addons], + webpackFinal: (config, options) => { + const localConfig = { ...rootMain.webpackFinal(config, options) }; + + // add your own webpack tweaks if needed + + return localConfig; + }, + }); + " + `); + expect(tree.read(`${storiesConfig.root}/.storybook/preview.js`, 'utf-8')).toMatchInlineSnapshot(` + "import * as rootPreview from '../../../../../.storybook/preview'; + + /** @type {typeof rootPreview.decorators} */ + export const decorators = [...rootPreview.decorators]; + + /** @type {typeof rootPreview.parameters} */ + export const parameters = { ...rootPreview.parameters }; + " + `); }); }); + +function setup(tree: Tree) { + setupCodeowners(tree, { content: '' }); + writeJson(tree, 'tsconfig.base.v0.json', { compilerOptions: { paths: {} } }); + writeJson(tree, 'tsconfig.base.v8.json', { compilerOptions: { paths: {} } }); + writeJson(tree, 'tsconfig.base.all.json', { compilerOptions: { paths: {} } }); + + updateJson(tree, '/package.json', json => { + json.devDependencies = json.devDependencies ?? {}; + json.devDependencies['@proj/react-icons'] = '2.0.224'; + return json; + }); + + tree = setupDummyPackage(tree, { projectName: 'react-components' }); + tree = setupDummyPackage(tree, { projectName: 'react-one-compat' }); + tree = setupDummyPackage(tree, { projectName: 'react-two-preview' }); + tree = setupDummyPackage(tree, { projectName: 'react-one-for-test' }); + tree = setupDummyPackage(tree, { projectName: 'react-provider' }); + tree = setupDummyPackage(tree, { projectName: 'react-theme' }); + tree = setupDummyPackage(tree, { projectName: 'react-hello' }); + + return tree; +} + +function setupDummyPackage(tree: Tree, options: { projectName: string }) { + const npmScope = '@proj'; + const npmProjectName = `${npmScope}/${options.projectName}`; + const rootPath = `packages/react-components/${options.projectName}`; + + const templates = { + packageJson: { + name: npmProjectName, + version: '9.0.0', + typings: 'lib/index.d.ts', + main: 'lib-commonjs/index.js', + scripts: { + build: 'just-scripts build', + 'bundle-size': 'monosize measure', + clean: 'just-scripts clean', + 'code-style': 'just-scripts code-style', + just: 'just-scripts', + lint: 'just-scripts lint', + start: 'yarn storybook', + test: 'jest --passWithNoTests', + storybook: 'start-storybook', + 'type-check': 'tsc -b tsconfig.json', + 'generate-api': 'just-scripts generate-api', + 'test-ssr': 'test-ssr "./stories/**/*.stories.tsx"', + 'verify-packaging': 'just-scripts verify-packaging', + }, + dependencies: {}, + }, + tsConfig: { + extends: '../../../tsconfig.base.json', + compilerOptions: { + target: 'ES2019', + noEmit: true, + isolatedModules: true, + importHelpers: true, + jsx: 'react', + noUnusedLocals: true, + preserveConstEnums: true, + }, + include: [], + files: [], + references: [ + { + path: './tsconfig.lib.json', + }, + { + path: './tsconfig.spec.json', + }, + { + path: './.storybook/tsconfig.json', + }, + ], + }, + tsConfigLib: { + extends: './tsconfig.json', + compilerOptions: { + declaration: true, + declarationDir: '../../../dist/out-tsc/types', + outDir: '../../../dist/out-tsc', + }, + }, + jestConfig: stripIndents` + module.exports = { + displayName: 'react-text', + preset: '../../../jest.preset.js', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + isolatedModules: true, + }, + ], + }, + coverageDirectory: './coverage', + setupFilesAfterEnv: ['./config/tests.js'], + snapshotSerializers: ['@griffel/jest-serializer'], + }; + `, + babelConfig: { + extends: '../../../../.babelrc-v9.json', + }, + justConfig: ` + import { preset, task } from '@fluentui/scripts-tasks'; + + preset(); + + task('build', 'build:react-components').cached?.(); + `, + apiExtractorConfig: { + $schema: 'https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json', + extends: '@fluentui/scripts-api-extractor/api-extractor.common.v-next.json', + }, + storybook: { + main: stripIndents` + const rootMain = require('../../../../.storybook/main'); + + module.exports = /** @type {Omit} */ ({ + ...rootMain, + stories: [...rootMain.stories, '../stories/**/*.stories.mdx', '../stories/**/index.stories.@(ts|tsx)'], + addons: [...rootMain.addons], + webpackFinal: (config, options) => { + const localConfig = { ...rootMain.webpackFinal(config, options) }; + + // add your own webpack tweaks if needed + + return localConfig; + }, +}); + + `, + preview: stripIndents` + import * as rootPreview from '../../../../.storybook/preview'; + + /** @type {typeof rootPreview.decorators} */ + export const decorators = [...rootPreview.decorators]; + + /** @type {typeof rootPreview.parameters} */ + export const parameters = { ...rootPreview.parameters }; + `, + tsConfig: { + extends: '../tsconfig.json', + compilerOptions: { + outDir: '', + allowJs: true, + checkJs: true, + types: ['static-assets', 'environment', 'storybook__addons'], + }, + include: ['../stories/**/*.stories.ts', '../stories/**/*.stories.tsx', '*.js'], + }, + }, + }; + + tree.write(`${rootPath}/package.json`, serializeJson(templates.packageJson)); + tree.write(`${rootPath}/tsconfig.json`, serializeJson(templates.tsConfig)); + tree.write(`${rootPath}/tsconfig.lib.json`, serializeJson(templates.tsConfigLib)); + tree.write(`${rootPath}/.babelrc.json`, serializeJson(templates.babelConfig)); + tree.write(`${rootPath}/jest.config.js`, templates.jestConfig); + tree.write(`${rootPath}/config/api-extractor.json`, serializeJson(templates.apiExtractorConfig)); + tree.write(`${rootPath}/just.config.ts`, templates.justConfig); + + tree.write(`${rootPath}/.storybook/main.js`, templates.storybook.main); + tree.write(`${rootPath}/.storybook/preview.js`, templates.storybook.preview); + tree.write(`${rootPath}/.storybook/tsconfig.json`, serializeJson(templates.storybook.tsConfig)); + + // src + tree.write(`${rootPath}/src/index.ts`, `export const greet = 'hello' `); + tree.write( + `${rootPath}/src/index.test.ts`, + ` + import {greet} from './index'; + describe('test me', () => { + it('should greet', () => { + expect(greet).toBe('hello'); + }); + }); + `, + ); + + // cypress + + tree.write( + `${rootPath}/src/Foo.cy.tsx`, + stripIndents` + import * as React from 'react'; + import { mount as mountBase } from '@cypress/react'; + import { FluentProvider } from '@proj/react-provider'; + import { teamsLightTheme } from '@proj/react-theme'; + + const mount = (element: JSX.Element) => { + mountBase({element}); + }; + + describe('FlatTree', () => { + it('should have all but first level items hidden', () => { + mount(
); + cy.get('[data-testid="test"]').should('not.exist'); + }); + }); + `, + ); + // jest + + tree.write( + `${rootPath}/src/Foo.test.tsx`, + stripIndents` + import { Foo } from './Foo'; + import { OneForTest } from '@proj/react-one-for-test' + + describe('zzz', () => { + it('should zzz', () => { + expect(OneForTest).toBe(OneForTest); + }); + }); + `, + ); + + // stories + + tree.write( + `${rootPath}/stories/index.stories.tsx`, + stripIndents` + import { Meta } from '@storybook/react'; + import { ArrowLeftRegular, ArrowRightRegular, DismissCircleRegular } from '@fluentui/react-icons'; + + export { Default } from './Default.stories'; + export default {} as Meta + `, + ); + tree.write( + `${rootPath}/stories/Default.stories.tsx`, + stripIndents` + import * as React from 'react'; + import { Text } from '@proj/react-components'; + import { OneCompat } from '@proj/react-one-compat' + import { OnePreview } from '@proj/react-two-preview' + + export const Default = () => This is an example of the Text component's usage.; + `, + ); + tree.write(`${rootPath}/stories/Hello.md`, stripIndents``); + + addProjectConfiguration(tree, npmProjectName, { + root: rootPath, + sourceRoot: `${rootPath}/src`, + projectType: 'library', + tags: ['vNext', 'platform:web'], + }); + + addCodeowner(tree, { owner: 'Mr.Wick', packageName: npmProjectName }); + + updateJson(tree, '/tsconfig.base.json', (json: TsConfig) => { + json.compilerOptions.paths = json.compilerOptions.paths ?? {}; + json.compilerOptions.paths[npmProjectName] = [`${rootPath}/src/index.ts`]; + return json; + }); + + return tree; +} diff --git a/tools/workspace-plugin/src/generators/split-library-in-two/generator.ts b/tools/workspace-plugin/src/generators/split-library-in-two/generator.ts index 38e408d746bba..c1e8d19353dcd 100644 --- a/tools/workspace-plugin/src/generators/split-library-in-two/generator.ts +++ b/tools/workspace-plugin/src/generators/split-library-in-two/generator.ts @@ -1,17 +1,560 @@ -import { addProjectConfiguration, formatFiles, generateFiles, Tree } from '@nx/devkit'; -import * as path from 'path'; +import { + moveFilesToNewDirectory, + formatFiles, + readProjectConfiguration, + Tree, + visitNotIgnoredFiles, + stripIndents, + writeJson, + addProjectConfiguration, + updateProjectConfiguration, + offsetFromRoot, + joinPathFragments, + updateJson, + readJson, + getProjects, + ProjectConfiguration, + output, + installPackagesTask, +} from '@nx/devkit'; +import * as path from 'node:path'; +import { tsquery } from '@phenomnomnominal/tsquery'; + +import tsConfigBaseAllGenerator from '../tsconfig-base-all/index'; +import { TsConfig } from '../../types'; +import { workspacePaths } from '../../utils'; import { SplitLibraryInTwoGeneratorSchema } from './schema'; +interface Options extends SplitLibraryInTwoGeneratorSchema { + projectConfig: ReturnType; + projectOffsetFromRoot: { old: string; updated: string }; + oldContent: { + tsConfig: Record; + }; +} + +const noop = () => { + return; +}; + export async function splitLibraryInTwoGenerator(tree: Tree, options: SplitLibraryInTwoGeneratorSchema) { - const projectRoot = `libs/${options.name}`; - addProjectConfiguration(tree, options.name, { - root: projectRoot, - projectType: 'library', - sourceRoot: `${projectRoot}/src`, - targets: {}, - }); - generateFiles(tree, path.join(__dirname, 'files'), projectRoot, options); + if (options.project && options.all) { + throw new Error('Cannot specify both project and all'); + } + if (!(options.project || options.all)) { + throw new Error('missing `project` or `all` option'); + } + + if (options.all) { + const projects = getProjects(tree); + const projectsToSplit = Array.from(projects).filter(([_, project]) => + assertProject(tree, project, { warn: noop } as unknown as typeof output), + ); + + output.log({ + title: `Splitting ${projectsToSplit.length} libraries in two...`, + bodyLines: projectsToSplit.map(([name]) => name), + }); + + for (const [projectName, project] of projectsToSplit) { + splitLibraryInTwoInternal(tree, { projectName, project }); + } + } + if (options.project) { + splitLibraryInTwoInternal(tree, { projectName: options.project }); + } + + await tsConfigBaseAllGenerator(tree, { verify: false }); + await formatFiles(tree); + + return () => { + installPackagesTask(tree, true); + }; +} + +function splitLibraryInTwoInternal(tree: Tree, options: { projectName: string; project?: ProjectConfiguration }) { + const { projectName, project } = options; + const projectConfig = project ?? readProjectConfiguration(tree, options.projectName); + + output.log({ title: `Splitting library in two: ${projectConfig.name}`, color: 'magenta' }); + + if (!assertProject(tree, projectConfig, output)) { + return; + } + + const normalizedOptions = { + projectName, + projectConfig, + projectOffsetFromRoot: { + old: offsetFromRoot(projectConfig.root), + updated: offsetFromRoot(projectConfig.root) + '../', + }, + oldContent: { + tsConfig: readJson(tree, joinPathFragments(projectConfig.root, 'tsconfig.json')), + }, + }; + + cleanup(tree, normalizedOptions); + + makeSrcLibrary(tree, normalizedOptions); + makeStoriesLibrary(tree, normalizedOptions); } export default splitLibraryInTwoGenerator; + +function cleanup(tree: Tree, options: Options) { + output.log({ title: 'Cleaning up build assets...' }); + const oldProjectRoot = options.projectConfig.root; + tree.delete(joinPathFragments(oldProjectRoot, 'dist')); + tree.delete(joinPathFragments(oldProjectRoot, 'lib')); + tree.delete(joinPathFragments(oldProjectRoot, 'lib-commonjs')); + tree.delete(joinPathFragments(oldProjectRoot, 'temp')); + tree.delete(joinPathFragments(oldProjectRoot, '.eslintcache')); + tree.delete(joinPathFragments(oldProjectRoot, '.swc')); + tree.delete(joinPathFragments(oldProjectRoot, 'node_modules')); +} + +function makeSrcLibrary(tree: Tree, options: Options) { + output.log({ title: 'creating library/ project' }); + + const oldProjectRoot = options.projectConfig.root; + const newProjectRoot = joinPathFragments(oldProjectRoot, 'library'); + const newProjectSourceRoot = joinPathFragments(newProjectRoot, 'src'); + + visitNotIgnoredFiles(tree, oldProjectRoot, file => { + if (file.includes('/stories/') || file.includes('/.storybook/')) { + return; + } + + const newFileName = `${newProjectRoot}/${path.relative(oldProjectRoot, file)}`; + + tree.rename(file, newFileName); + }); + + updateProjectConfiguration(tree, options.projectConfig.name!, { + ...options.projectConfig, + // @ts-expect-error - nx doesn't type $schema prop + $schema: joinPathFragments(options.projectOffsetFromRoot.updated, 'node_modules/nx/schemas/project-schema.json'), + root: newProjectRoot, + sourceRoot: newProjectSourceRoot, + }); + + const filePaths = { + pkgJson: joinPathFragments(newProjectRoot, 'package.json'), + tsConfig: joinPathFragments(newProjectRoot, 'tsconfig.json'), + tsConfigLib: joinPathFragments(newProjectRoot, 'tsconfig.lib.json'), + babelRc: joinPathFragments(newProjectRoot, '.babelrc.json'), + apiExtractorConfig: joinPathFragments(newProjectRoot, 'config/api-extractor.json'), + jestConfig: joinPathFragments(newProjectRoot, 'jest.config.js'), + rootTsConfig: '/tsconfig.base.json', + }; + + updateJson(tree, filePaths.pkgJson, json => { + json.scripts ??= {}; + json.scripts.storybook = 'yarn --cwd ../stories storybook'; + json.scripts['type-check'] = 'just-scripts type-check'; + if (json.scripts['test-ssr']) { + json.scripts['test-ssr'] = `test-ssr \"../stories/src/**/*.stories.tsx\"`; + } + + const deps = getMissingDevDependenciesFromCypressAndJestFiles(tree, { + sourceRoot: newProjectSourceRoot, + projectName: options.projectConfig.name!, + dependencies: json.dependencies, + }); + + json.devDependencies ??= {}; + json.devDependencies = { ...deps, ...json.devDependencies }; + + return json; + }); + + updateJson(tree, filePaths.tsConfig, (json: TsConfig) => { + json.extends = json.extends?.replace(options.projectOffsetFromRoot.old, options.projectOffsetFromRoot.updated); + json.references = json.references?.filter(ref => { + return !ref.path.startsWith('./.storybook'); + }); + return json; + }); + updateJson(tree, filePaths.tsConfigLib, (json: TsConfig) => { + json.compilerOptions.declarationDir = json.compilerOptions.declarationDir?.replace( + options.projectOffsetFromRoot.old, + options.projectOffsetFromRoot.updated, + ); + json.compilerOptions.outDir = json.compilerOptions.outDir?.replace( + options.projectOffsetFromRoot.old, + options.projectOffsetFromRoot.updated, + ); + + return json; + }); + + if (tree.exists(filePaths.babelRc)) { + updateJson(tree, filePaths.babelRc, json => { + json.extends = json.extends?.replace(options.projectOffsetFromRoot.old, options.projectOffsetFromRoot.updated); + + return json; + }); + } + + if (tree.exists(filePaths.apiExtractorConfig)) { + updateJson(tree, filePaths.apiExtractorConfig, json => { + json.mainEntryPointFilePath = `/${offsetFromRoot( + filePaths.apiExtractorConfig, + )}dist/out-tsc/types/packages/react-components//library/src/index.d.ts`; + + return json; + }); + } + + updateFileContent(tree, filePaths.jestConfig, content => { + const newContent = content.replace(options.projectOffsetFromRoot.old, options.projectOffsetFromRoot.updated); + + return newContent; + }); + + updateJson(tree, filePaths.rootTsConfig, (json: TsConfig) => { + json.compilerOptions.paths = json.compilerOptions.paths ?? {}; + json.compilerOptions.paths[options.projectConfig.name!] = [`${newProjectSourceRoot}/index.ts`]; + return json; + }); + + updateCodeowners(tree, options); +} + +function makeStoriesLibrary(tree: Tree, options: Options) { + output.log({ title: 'creating stories/ project' }); + const oldProjectRoot = options.projectConfig.root; + const newProjectRoot = joinPathFragments(oldProjectRoot, 'stories'); + const newProjectSourceRoot = joinPathFragments(newProjectRoot, 'src'); + const newProjectName = `${options.projectConfig.name}-stories`; + + // move stories/ + moveFilesToNewDirectory(tree, joinPathFragments(oldProjectRoot, 'stories'), newProjectSourceRoot); + + // move .storybook/ + moveFilesToNewDirectory( + tree, + joinPathFragments(oldProjectRoot, '.storybook'), + joinPathFragments(newProjectRoot, '.storybook'), + ); + + const storiesWorkspaceDeps = getWorkspaceDependencies( + tree, + Array.from( + getImportsFromSourceFiles( + tree, + newProjectSourceRoot, + file => file.endsWith('.stories.tsx') || file.endsWith('.stories.ts'), + ), + ), + ); + + const templates = { + readme: stripIndents` + # ${newProjectName} + + Storybook stories for ${options.projectConfig.root} + + ## Usage + + To include within storybook specify stories globs: + + \`\`\`js + module.exports = { + stories: ['../${newProjectSourceRoot}/**/*.stories.mdx', '../${newProjectSourceRoot}/**/index.stories.@(ts|tsx)'], + } + \`\`\` + + ## API + + no public API available + `, + packageJson: { + name: newProjectName, + version: '0.0.0', + private: true, + scripts: { + start: 'yarn storybook', + storybook: 'start-storybook', + 'type-check': 'just-scripts type-check', + lint: 'eslint src/', + format: 'just-scripts prettier', + }, + devDependencies: { + ...storiesWorkspaceDeps, + // always added + '@fluentui/react-storybook-addon': '*', + '@fluentui/react-storybook-addon-export-to-sandbox': '*', + '@fluentui/scripts-storybook': '*', + '@fluentui/eslint-plugin': '*', + '@fluentui/scripts-tasks': '*', + }, + }, + justConfig: stripIndents` + import { preset, task } from '@fluentui/scripts-tasks'; + + preset(); + `, + publicApi: stripIndents`export {}`, + eslintrc: { + extends: ['plugin:@fluentui/eslint-plugin/react'], + root: true, + rules: { + 'import/no-extraneous-dependencies': ['error', { packageDir: ['.', options.projectOffsetFromRoot.updated] }], + }, + }, + tsconfig: { + root: { + ...options.oldContent.tsConfig, + extends: joinPathFragments(options.projectOffsetFromRoot.updated, 'tsconfig.base.json'), + references: [ + { + path: './tsconfig.lib.json', + }, + { + path: './.storybook/tsconfig.json', + }, + ], + }, + lib: { + extends: './tsconfig.json', + compilerOptions: { + lib: ['ES2019', 'dom'], + outDir: joinPathFragments(options.projectOffsetFromRoot.updated, 'dist/out-tsc'), + inlineSources: true, + types: ['static-assets', 'environment'], + }, + include: ['./src/**/*.ts', './src/**/*.tsx'], + }, + }, + }; + + // TODO = probably having a generator to invoke here would be more efficient + + tree.write(joinPathFragments(newProjectRoot, 'README.md'), templates.readme); + tree.write(joinPathFragments(newProjectSourceRoot, 'index.ts'), templates.publicApi); + tree.write(joinPathFragments(newProjectRoot, 'just.config.ts'), templates.justConfig); + writeJson(tree, joinPathFragments(newProjectRoot, '.eslintrc.json'), templates.eslintrc); + writeJson(tree, joinPathFragments(newProjectRoot, 'tsconfig.json'), templates.tsconfig.root); + writeJson(tree, joinPathFragments(newProjectRoot, 'tsconfig.lib.json'), templates.tsconfig.lib); + writeJson(tree, joinPathFragments(newProjectRoot, 'package.json'), templates.packageJson); + updateJson(tree, joinPathFragments(newProjectRoot, '.storybook/tsconfig.json'), (json: TsConfig) => { + json.include = ['*.js']; + return json; + }); + updateFileContent(tree, joinPathFragments(newProjectRoot, '.storybook/main.js'), content => { + content = content + .replace(new RegExp('../stories/', 'g'), '../src/') + .replace(new RegExp(options.projectOffsetFromRoot.old, 'g'), options.projectOffsetFromRoot.updated); + + return content; + }); + updateFileContent(tree, joinPathFragments(newProjectRoot, '.storybook/preview.js'), content => { + content = content.replace( + new RegExp(options.projectOffsetFromRoot.old, 'g'), + options.projectOffsetFromRoot.updated, + ); + return content; + }); + addProjectConfiguration(tree, `${options.projectConfig.name}-stories`, { + ...options.projectConfig, + root: newProjectRoot, + sourceRoot: newProjectSourceRoot, + name: `${options.projectConfig.name}-stories`, + tags: ['vNext', 'platform:web', 'type:stories'], + }); + + updateJson(tree, '/tsconfig.base.json', (json: TsConfig) => { + json.compilerOptions.paths = json.compilerOptions.paths ?? {}; + json.compilerOptions.paths[newProjectName] = [`${newProjectSourceRoot}/index.ts`]; + return json; + }); +} + +function assertProject(tree: Tree, projectConfig: ProjectConfiguration, logger: typeof output) { + const tags = projectConfig.tags ?? []; + + if (projectConfig.projectType !== 'library') { + logger.warn({ title: 'This generator is only for libraries' }); + return; + } + + if (projectConfig.name?.endsWith('-preview')) { + logger.warn({ title: 'preview projects are not supported YET, skipping...' }); + return; + } + + if (tags.includes('compat')) { + logger.warn({ title: 'compat projects are not supported YET, skipping...' }); + return; + } + + if (projectConfig.root?.endsWith('/stories') || projectConfig.root?.endsWith('/library')) { + logger.warn({ title: 'attempting to migrate already migrated projects, skipping...' }); + return; + } + + const isV9Stable = + tags.includes('vNext') && + tags.includes('platform:web') && + !(tags.includes('v8') || tags.includes('react-northstar')); + + if (!isV9Stable) { + logger.warn({ title: 'This generator is only for v9 stable web libraries' }); + return; + } + + if (!tree.exists(joinPathFragments(projectConfig.root, 'stories'))) { + logger.warn({ title: '/stories directory does not exist within project, skipping...' }); + return; + } + + return true; +} + +function updateFileContent(tree: Tree, filePath: string, updater: (content: string) => string) { + if (!tree.exists(filePath)) { + return; + } + + const content = tree.read(filePath, 'utf-8') ?? ''; + tree.write(filePath, updater(content)); +} + +function updateCodeowners(tree: Tree, options: Options) { + const codeownersPath = workspacePaths.github.codeowners; + + const content = tree.read(codeownersPath, 'utf-8') ?? ''; + + const lines = content.split('\n'); + const lineIndex = lines.findIndex(line => line.includes(options.projectConfig.root)); + + if (lineIndex !== -1) { + const currentLine = lines[lineIndex]; + const updatedLine = currentLine.replace(options.projectConfig.root, `${options.projectConfig.root}/library`); + const newLine = currentLine.replace(options.projectConfig.root, `${options.projectConfig.root}/stories`); + + lines.splice(lineIndex, 1, updatedLine, newLine); + + const newContent = lines.join('\n'); + + tree.write(codeownersPath, newContent); + } +} + +function getImportPaths(tree: Tree, filePath: string) { + const fileContent = tree.read(filePath, 'utf8') ?? ''; + const ast = tsquery.ast(fileContent); + + const importNodes = tsquery.match(ast, 'ImportDeclaration') as import('typescript').ImportDeclaration[]; + const importPaths = importNodes.map(node => node.moduleSpecifier.getText().replace(/['"]/g, '')); + + const requireNodes = tsquery.match( + ast, + 'CallExpression[expression.name="require"]', + ) as import('typescript').CallExpression[]; + const requirePaths = requireNodes.map(node => (node.arguments[0] as import('typescript').StringLiteral).text); + + return [...importPaths, ...requirePaths]; +} + +function getImportsFromSourceFiles(tree: Tree, root: string, filter: (file: string) => boolean) { + const imports: string[] = []; + + visitNotIgnoredFiles(tree, root, file => { + if (!filter(file)) { + return; + } + + const importPaths = getImportPaths(tree, file); + imports.push(...importPaths); + }); + + return new Set(imports); +} + +function getWorkspaceDependencies(tree: Tree, imports: string[]) { + const allProjects = getProjects(tree); + const dependencies: Record = {}; + imports.forEach(importPath => { + if (allProjects.has(importPath)) { + dependencies[importPath] = '*'; + } + }); + + return dependencies; +} + +function getMissingDevDependenciesFromCypressAndJestFiles( + tree: Tree, + options: { sourceRoot: string; projectName: string; dependencies: Record }, +) { + const { projectName, sourceRoot, dependencies } = options; + + const cypressWorkspaceDeps = getWorkspaceDependencies( + tree, + Array.from( + getImportsFromSourceFiles(tree, sourceRoot, file => file.endsWith('.cy.tsx') || file.endsWith('.cy.ts')), + ), + ); + + const jestWorkspaceDeps = getWorkspaceDependencies( + tree, + Array.from( + getImportsFromSourceFiles( + tree, + sourceRoot, + file => + file.endsWith('.test.tsx') || + file.endsWith('.test.ts') || + file.endsWith('.spec.tsx') || + file.endsWith('.spec.ts'), + ), + ), + ); + + const deps = { ...cypressWorkspaceDeps, ...jestWorkspaceDeps }; + + if (deps[projectName]) { + // don't add self to deps + delete deps[projectName]; + + output.warn({ + title: 'Not adding self to dependencies', + bodyLines: ['You should not import from you package absolute path within test files. Prefer relative imports.'], + }); + } + + if (dependencies) { + const log: string[] = []; + + Object.keys(dependencies).forEach(dep => { + if (deps[dep]) { + delete deps[dep]; + log.push(dep); + } + }); + + if (log.length > 0) { + output.warn({ + title: 'Not adding dependencies that are already present in package.json', + bodyLines: log, + }); + } + } + + if (deps['@fluentui/react-components']) { + output.error({ + title: 'react-components cannot be used within cypress or jest test files as it creates circular dependency.', + bodyLines: [ + 'Please remove/replace problematic imports from the test files and remove the dependency from "package.json#devDependencies".', + ], + }); + } + + output.log({ title: 'Adding missing dependencies', bodyLines: Object.keys(deps) }); + + return deps; +} diff --git a/tools/workspace-plugin/src/generators/split-library-in-two/schema.d.ts b/tools/workspace-plugin/src/generators/split-library-in-two/schema.d.ts index 0f5f303f5fc0f..b50c5364303c1 100644 --- a/tools/workspace-plugin/src/generators/split-library-in-two/schema.d.ts +++ b/tools/workspace-plugin/src/generators/split-library-in-two/schema.d.ts @@ -1,3 +1,4 @@ export interface SplitLibraryInTwoGeneratorSchema { - name: string; + project?: string; + all?: string; } diff --git a/tools/workspace-plugin/src/generators/split-library-in-two/schema.json b/tools/workspace-plugin/src/generators/split-library-in-two/schema.json index 33e4b3c1d1fe1..e1b3c491598eb 100644 --- a/tools/workspace-plugin/src/generators/split-library-in-two/schema.json +++ b/tools/workspace-plugin/src/generators/split-library-in-two/schema.json @@ -4,15 +4,19 @@ "title": "", "type": "object", "properties": { - "name": { + "project": { "type": "string", - "description": "", + "description": "Which project would you like to split?", "$default": { "$source": "argv", "index": 0 }, - "x-prompt": "What name would you like to use?" + "x-dropdown": "projects" + }, + "all": { + "type": "boolean", + "description": "Run generator on all vNext packages" } }, - "required": ["name"] + "required": [] }