From 8cd97c03100eeda10eb1cf3e850ebeca302f3d0d Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Fri, 8 Mar 2024 15:56:55 +0100 Subject: [PATCH 01/12] feat(workspace-library): initial split-library-in-two implementation --- .../files/src/index.ts.template | 1 - .../split-library-in-two/generator.spec.ts | 468 +++++++++++++++++- .../split-library-in-two/generator.ts | 292 ++++++++++- .../split-library-in-two/schema.d.ts | 2 +- .../split-library-in-two/schema.json | 7 +- 5 files changed, 750 insertions(+), 20 deletions(-) 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/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..a3ed8a528c482 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,478 @@ import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; -import { Tree, readProjectConfiguration } from '@nx/devkit'; +import { + Tree, + readProjectConfiguration, + stripIndents, + addProjectConfiguration, + serializeJson, + readJson, + updateJson, + writeJson, +} 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'; describe('split-library-in-two generator', () => { let tree: Tree; - const options: SplitLibraryInTwoGeneratorSchema = { name: 'test' }; + const options: SplitLibraryInTwoGeneratorSchema = { project: '@proj/react-hello' }; beforeEach(() => { tree = createTreeWithEmptyWorkspace(); + tree = setup(tree); }); - 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({ + '@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')).toMatchInlineSnapshot(` + "packages/react-components/react-hello/library Mr.Wick + packages/react-components/react-hello/stories Mr.Wick + # <%= NX-CODEOWNER-PLACEHOLDER %>" + `); + + // new SRC + expect(tree.exists(`${newConfig.root}/.storybook/main.js`)).toBe(false); + expect(tree.exists(`${newConfig.root}/stories/index.stories.tsx`)).toBe(false); + + 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}/tsconfig.json`)).toEqual( + expect.objectContaining({ + extends: '../../../../tsconfig.base.json', + references: [ + { + path: './tsconfig.lib.json', + }, + { + path: './tsconfig.spec.json', + }, + ], + }), + ); + + expect(readJson(tree, `${newConfig.root}/package.json`)).toEqual( + expect.objectContaining({ + name: '@proj/react-hello', + scripts: expect.objectContaining({ + 'type-check': 'just-scripts type-check', + storybook: 'yarn --cwd ../stories storybook', + }), + }), + ); + + 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-components": "*", + "@fluentui/react-icons": "^2.0.224", + "@fluentui/react-storybook-addon": "*", + "@fluentui/react-storybook-addon-export-to-sandbox": "*", + "@fluentui/scripts-storybook": "*", + "@fluentui/scripts-tasks": "*", + }, + "name": "@proj/react-hello-stories", + "private": true, + "scripts": Object { + "format": "just-scripts prettier", + "lint": "just-scripts lint", + "start": "yarn storybook", + "storybook": "start-storybook", + "test-ssr": "test-ssr \\"./src/**/*.stories.tsx\\"", + "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, + } + `); + 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: {} } }); + 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', + }, + ], + }, + 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: {}, + 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}/.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'); + }); + }); + `, + ); + + // stories + + tree.write( + `${rootPath}/stories/index.stories.tsx`, + stripIndents` + import { Meta } from '@storybook/react'; + + 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 '@fluentui/react-components'; + + 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..1526fbef4a5dd 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,289 @@ -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, +} from '@nx/devkit'; + +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; + eslintrc: Record; + }; +} + 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); + const projectConfig = readProjectConfiguration(tree, options.project); + + const eslintRcPath = joinPathFragments(projectConfig.root, '.eslintrc.json'); + + const normalizedOptions = { + ...options, + projectConfig, + projectOffsetFromRoot: { + old: offsetFromRoot(projectConfig.root), + updated: offsetFromRoot(projectConfig.root) + '../', + }, + oldContent: { + eslintrc: tree.exists(eslintRcPath) ? readJson(tree, eslintRcPath) : {}, + tsConfig: readJson(tree, joinPathFragments(projectConfig.root, 'tsconfig.json')), + }, + }; + + cleanup(tree, normalizedOptions); + + makeSrcLibrary(tree, normalizedOptions); + makeStoriesLibrary(tree, normalizedOptions); + + await tsConfigBaseAllGenerator(tree, { verify: false }); + await formatFiles(tree); } export default splitLibraryInTwoGenerator; + +function cleanup(tree: Tree, options: Options) { + tree.delete(joinPathFragments(options.projectConfig.root, 'dist')); + tree.delete(joinPathFragments(options.projectConfig.root, 'lib')); + tree.delete(joinPathFragments(options.projectConfig.root, 'lib-commonjs')); + tree.delete(joinPathFragments(options.projectConfig.root, 'temp')); + tree.delete(joinPathFragments(options.projectConfig.root, '.eslintcache')); + tree.delete(joinPathFragments(options.projectConfig.root, '.swc')); + tree.delete(joinPathFragments(options.projectConfig.root, 'node_modules')); +} + +function makeSrcLibrary(tree: Tree, options: Options) { + const newProjectRoot = joinPathFragments(options.projectConfig.root, 'library'); + const newProjectSourceRoot = joinPathFragments(newProjectRoot, 'src'); + + visitNotIgnoredFiles(tree, options.projectConfig.root, file => { + if (file.includes('/stories/') || file.includes('/.storybook/')) { + return; + } + + tree.rename(file, file.replace(options.projectConfig.root, newProjectRoot)); + }); + + 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, + }); + + updateJson(tree, joinPathFragments(newProjectRoot, 'package.json'), json => { + json.scripts ??= {}; + json.scripts.storybook = 'yarn --cwd ../stories storybook'; + json.scripts['type-check'] = 'just-scripts type-check'; + return json; + }); + + updateJson(tree, joinPathFragments(newProjectRoot, 'tsconfig.json'), (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; + }); + + updateFileContent(tree, joinPathFragments(newProjectRoot, 'jest.config.js'), content => { + const newContent = content.replace(options.projectOffsetFromRoot.old, options.projectOffsetFromRoot.updated); + + return newContent; + }); + + updateJson(tree, '/tsconfig.base.json', (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) { + const newProjectRoot = joinPathFragments(options.projectConfig.root, 'stories'); + const newProjectSourceRoot = joinPathFragments(newProjectRoot, 'src'); + const newProjectName = `${options.projectConfig.name}-stories`; + + // move stories/ + moveFilesToNewDirectory(tree, joinPathFragments(options.projectConfig.root, 'stories'), newProjectSourceRoot); + + // move .storybook/ + moveFilesToNewDirectory( + tree, + joinPathFragments(options.projectConfig.root, '.storybook'), + joinPathFragments(newProjectRoot, '.storybook'), + ); + + // TODO = probably having a generator to invoke here would be more efficient + tree.write(joinPathFragments(newProjectSourceRoot, 'index.ts'), stripIndents`export {}`); + tree.write( + joinPathFragments(newProjectRoot, 'just.config.ts'), + stripIndents` + import { preset, task } from '@fluentui/scripts-tasks'; + + preset(); + `, + ); + + 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', + 'test-ssr': 'test-ssr "./src/**/*.stories.tsx"', + lint: 'just-scripts lint', + format: 'just-scripts prettier', + }, + devDependencies: { + // TODO: parse AST to get proper version of deps needed + '@fluentui/react-components': '*', + '@fluentui/react-icons': '^2.0.224', + // always added + '@fluentui/react-storybook-addon': '*', + '@fluentui/react-storybook-addon-export-to-sandbox': '*', + '@fluentui/scripts-storybook': '*', + '@fluentui/eslint-plugin': '*', + '@fluentui/scripts-tasks': '*', + }, + }, + eslintrc: { + extends: ['plugin:@fluentui/eslint-plugin/react'], + root: true, + }, + 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'], + }, + }, + }; + + tree.write(joinPathFragments(newProjectRoot, 'README.md'), templates.readme); + 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; + }); + + // TODO - update all relative paths +} + +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); + } +} 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..1c50c567602de 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,3 @@ export interface SplitLibraryInTwoGeneratorSchema { - name: string; + project: 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..8e385095f85c1 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,16 @@ "title": "", "type": "object", "properties": { - "name": { + "project": { "type": "string", "description": "", "$default": { "$source": "argv", "index": 0 }, - "x-prompt": "What name would you like to use?" + "x-prompt": "Which project would you like to split?", + "x-dropdown": "projects" } }, - "required": ["name"] + "required": ["project"] } From 6de8694f98bb956db1ef326859c305e0d98e80fd Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Mon, 25 Mar 2024 17:12:29 +0100 Subject: [PATCH 02/12] feat(workspace-plugin): add --all flag support to split-library-in-two --- .../split-library-in-two/generator.spec.ts | 38 +++-- .../split-library-in-two/generator.ts | 141 +++++++++++++++--- .../split-library-in-two/schema.d.ts | 3 +- .../split-library-in-two/schema.json | 9 +- 4 files changed, 156 insertions(+), 35 deletions(-) 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 a3ed8a528c482..02ca7beae72cf 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 @@ -11,7 +11,6 @@ import { } 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'; @@ -19,7 +18,7 @@ import { workspacePaths } from '../../utils'; describe('split-library-in-two generator', () => { let tree: Tree; - const options: SplitLibraryInTwoGeneratorSchema = { project: '@proj/react-hello' }; + const options = { project: '@proj/react-hello' }; beforeEach(() => { tree = createTreeWithEmptyWorkspace(); @@ -37,13 +36,18 @@ describe('split-library-in-two generator', () => { // new Shared expect(tree.children(oldConfig.root)).toEqual(['stories', 'library']); - expect(readJson(tree, '/tsconfig.base.json').compilerOptions.paths).toEqual({ - '@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(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')).toMatchInlineSnapshot(` - "packages/react-components/react-hello/library Mr.Wick + "packages/react-components/react-components Mr.Wick + packages/react-components/react-one-compat Mr.Wick + packages/react-components/react-two-preview Mr.Wick + packages/react-components/react-hello/library Mr.Wick packages/react-components/react-hello/stories Mr.Wick # <%= NX-CODEOWNER-PLACEHOLDER %>" `); @@ -130,12 +134,13 @@ describe('split-library-in-two generator', () => { Object { "devDependencies": Object { "@fluentui/eslint-plugin": "*", - "@fluentui/react-components": "*", - "@fluentui/react-icons": "^2.0.224", "@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, @@ -284,6 +289,16 @@ function setup(tree: Tree) { 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-hello' }); return tree; @@ -443,6 +458,7 @@ function setupDummyPackage(tree: Tree, options: { projectName: string }) { `${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 @@ -452,7 +468,9 @@ function setupDummyPackage(tree: Tree, options: { projectName: string }) { `${rootPath}/stories/Default.stories.tsx`, stripIndents` import * as React from 'react'; - import { Text } from '@fluentui/react-components'; + 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.; `, 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 1526fbef4a5dd..2ce76dd1a4a94 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 @@ -12,7 +12,11 @@ import { joinPathFragments, updateJson, readJson, + getProjects, + ProjectConfiguration, + output, } from '@nx/devkit'; +import { tsquery } from '@phenomnomnominal/tsquery'; import tsConfigBaseAllGenerator from '../tsconfig-base-all/index'; import { TsConfig } from '../../types'; @@ -24,14 +28,33 @@ interface Options extends SplitLibraryInTwoGeneratorSchema { projectOffsetFromRoot: { old: string; updated: string }; oldContent: { tsConfig: Record; - eslintrc: Record; }; } export async function splitLibraryInTwoGenerator(tree: Tree, options: SplitLibraryInTwoGeneratorSchema) { - const projectConfig = readProjectConfiguration(tree, options.project); + if (options.project && options.all) { + throw new Error('Cannot specify both project and all'); + } + + if (options.all) { + const projects = getProjects(tree); + for (const [projectName] of projects) { + splitLibraryInTwoInternal(tree, { projectName }); + } + } + if (options.project) { + splitLibraryInTwoInternal(tree, { projectName: options.project }); + } - const eslintRcPath = joinPathFragments(projectConfig.root, '.eslintrc.json'); + await tsConfigBaseAllGenerator(tree, { verify: false }); + + await formatFiles(tree); +} + +function splitLibraryInTwoInternal(tree: Tree, options: { projectName: string }) { + const projectConfig = readProjectConfiguration(tree, options.projectName); + + assertProject(tree, projectConfig); const normalizedOptions = { ...options, @@ -41,7 +64,6 @@ export async function splitLibraryInTwoGenerator(tree: Tree, options: SplitLibra updated: offsetFromRoot(projectConfig.root) + '../', }, oldContent: { - eslintrc: tree.exists(eslintRcPath) ? readJson(tree, eslintRcPath) : {}, tsConfig: readJson(tree, joinPathFragments(projectConfig.root, 'tsconfig.json')), }, }; @@ -50,10 +72,6 @@ export async function splitLibraryInTwoGenerator(tree: Tree, options: SplitLibra makeSrcLibrary(tree, normalizedOptions); makeStoriesLibrary(tree, normalizedOptions); - - await tsConfigBaseAllGenerator(tree, { verify: false }); - - await formatFiles(tree); } export default splitLibraryInTwoGenerator; @@ -133,15 +151,9 @@ function makeStoriesLibrary(tree: Tree, options: Options) { joinPathFragments(newProjectRoot, '.storybook'), ); - // TODO = probably having a generator to invoke here would be more efficient - tree.write(joinPathFragments(newProjectSourceRoot, 'index.ts'), stripIndents`export {}`); - tree.write( - joinPathFragments(newProjectRoot, 'just.config.ts'), - stripIndents` - import { preset, task } from '@fluentui/scripts-tasks'; - - preset(); - `, + const storiesWorkspaceDeps = getWorkspaceDependencies( + tree, + Array.from(getImportsFromStories(tree, newProjectSourceRoot)), ); const templates = { @@ -177,9 +189,7 @@ function makeStoriesLibrary(tree: Tree, options: Options) { format: 'just-scripts prettier', }, devDependencies: { - // TODO: parse AST to get proper version of deps needed - '@fluentui/react-components': '*', - '@fluentui/react-icons': '^2.0.224', + ...storiesWorkspaceDeps, // always added '@fluentui/react-storybook-addon': '*', '@fluentui/react-storybook-addon-export-to-sandbox': '*', @@ -188,6 +198,12 @@ function makeStoriesLibrary(tree: Tree, options: Options) { '@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, @@ -218,7 +234,11 @@ function makeStoriesLibrary(tree: Tree, options: Options) { }, }; + // 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); @@ -254,8 +274,42 @@ function makeStoriesLibrary(tree: Tree, options: Options) { json.compilerOptions.paths[newProjectName] = [`${newProjectSourceRoot}/index.ts`]; return json; }); +} + +function assertProject(tree: Tree, projectConfig: ProjectConfiguration) { + const tags = projectConfig.tags ?? []; + + if (projectConfig.projectType !== 'library') { + output.warn({ title: 'This generator is only for libraries' }); + return; + } + + if (projectConfig.name?.endsWith('-preview')) { + output.warn({ title: 'preview projects are not supported YET, skipping...' }); + return; + } + + if (tags.includes('compat')) { + output.warn({ title: 'compat projects are not supported YET, skipping...' }); + return; + } + + if (projectConfig.root?.endsWith('/stories') || projectConfig.root?.endsWith('/library')) { + output.warn({ title: 'attempting to migrate already migrated projects, skipping...' }); + return; + } + + const isV9Stable = tags.includes('vNext') && tags.includes('platform:web'); - // TODO - update all relative paths + if (!isV9Stable) { + output.warn({ title: 'This generator is only for v9 stable web libraries' }); + return; + } + + if (!tree.exists(joinPathFragments(projectConfig.root, 'stories'))) { + output.warn({ title: '/stories directory does not exist within project, skipping...' }); + return; + } } function updateFileContent(tree: Tree, filePath: string, updater: (content: string) => string) { @@ -287,3 +341,48 @@ function updateCodeowners(tree: Tree, options: Options) { 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 getImportsFromStories(tree: Tree, root: string) { + const storiesDir = joinPathFragments(root); + + const imports: string[] = []; + + visitNotIgnoredFiles(tree, storiesDir, file => { + if (!(file.endsWith('.stories.tsx') || file.endsWith('.stories.ts'))) { + 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; +} 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 1c50c567602de..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 { - project: 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 8e385095f85c1..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 @@ -6,14 +6,17 @@ "properties": { "project": { "type": "string", - "description": "", + "description": "Which project would you like to split?", "$default": { "$source": "argv", "index": 0 }, - "x-prompt": "Which project would you like to split?", "x-dropdown": "projects" + }, + "all": { + "type": "boolean", + "description": "Run generator on all vNext packages" } }, - "required": ["project"] + "required": [] } From b42df3f336bd72d24bb36069478ae91def0bb06e Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Mon, 25 Mar 2024 17:37:24 +0100 Subject: [PATCH 03/12] feat(workspace-plugin): setup correctly eslint extraneous dependencies within /stories project --- .../split-library-in-two/generator.spec.ts | 13 ++++++++++++- .../generators/split-library-in-two/generator.ts | 5 ++++- 2 files changed, 16 insertions(+), 2 deletions(-) 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 02ca7beae72cf..449c2616416a7 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 @@ -146,7 +146,7 @@ describe('split-library-in-two generator', () => { "private": true, "scripts": Object { "format": "just-scripts prettier", - "lint": "just-scripts lint", + "lint": "eslint src/", "start": "yarn storybook", "storybook": "start-storybook", "test-ssr": "test-ssr \\"./src/**/*.stories.tsx\\"", @@ -208,6 +208,17 @@ describe('split-library-in-two generator', () => { "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(` 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 2ce76dd1a4a94..1052241729141 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 @@ -185,7 +185,7 @@ function makeStoriesLibrary(tree: Tree, options: Options) { storybook: 'start-storybook', 'type-check': 'just-scripts type-check', 'test-ssr': 'test-ssr "./src/**/*.stories.tsx"', - lint: 'just-scripts lint', + lint: 'eslint src/', format: 'just-scripts prettier', }, devDependencies: { @@ -207,6 +207,9 @@ function makeStoriesLibrary(tree: Tree, options: Options) { eslintrc: { extends: ['plugin:@fluentui/eslint-plugin/react'], root: true, + rules: { + 'import/no-extraneous-dependencies': ['error', { packageDir: ['.', options.projectOffsetFromRoot.updated] }], + }, }, tsconfig: { root: { From 4ce12f0042a92c9123c6a5d4b04ab291bcda32e5 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Mon, 25 Mar 2024 17:50:58 +0100 Subject: [PATCH 04/12] feat(workspace-plugin): dont add test-ssr target to /stories project --- .../src/generators/split-library-in-two/generator.spec.ts | 2 +- .../src/generators/split-library-in-two/generator.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 449c2616416a7..dabf6b8cf560d 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 @@ -88,6 +88,7 @@ describe('split-library-in-two generator', () => { 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', }), @@ -149,7 +150,6 @@ describe('split-library-in-two generator', () => { "lint": "eslint src/", "start": "yarn storybook", "storybook": "start-storybook", - "test-ssr": "test-ssr \\"./src/**/*.stories.tsx\\"", "type-check": "just-scripts type-check", }, "version": "0.0.0", 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 1052241729141..bf8060818fc29 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 @@ -110,6 +110,7 @@ function makeSrcLibrary(tree: Tree, options: Options) { json.scripts ??= {}; json.scripts.storybook = 'yarn --cwd ../stories storybook'; json.scripts['type-check'] = 'just-scripts type-check'; + json.scripts['test-ssr'] = 'test-ssr \\"../stories/src/**/*.stories.tsx\\"'; return json; }); @@ -184,7 +185,6 @@ function makeStoriesLibrary(tree: Tree, options: Options) { start: 'yarn storybook', storybook: 'start-storybook', 'type-check': 'just-scripts type-check', - 'test-ssr': 'test-ssr "./src/**/*.stories.tsx"', lint: 'eslint src/', format: 'just-scripts prettier', }, From 16874454ce00debd39f13fb8b170f306b723dcb8 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Fri, 5 Apr 2024 12:31:19 +0200 Subject: [PATCH 05/12] fix(workspace-plugin): exit flow when assetProject catches invalid project --- .../generators/split-library-in-two/generator.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 bf8060818fc29..b0da42fb61385 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 @@ -35,6 +35,9 @@ export async function splitLibraryInTwoGenerator(tree: Tree, options: SplitLibra 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); @@ -54,7 +57,9 @@ export async function splitLibraryInTwoGenerator(tree: Tree, options: SplitLibra function splitLibraryInTwoInternal(tree: Tree, options: { projectName: string }) { const projectConfig = readProjectConfiguration(tree, options.projectName); - assertProject(tree, projectConfig); + if (!assertProject(tree, projectConfig)) { + return; + } const normalizedOptions = { ...options, @@ -302,7 +307,10 @@ function assertProject(tree: Tree, projectConfig: ProjectConfiguration) { return; } - const isV9Stable = tags.includes('vNext') && tags.includes('platform:web'); + const isV9Stable = + tags.includes('vNext') && + tags.includes('platform:web') && + !(tags.includes('v8') || tags.includes('react-northstar')); if (!isV9Stable) { output.warn({ title: 'This generator is only for v9 stable web libraries' }); @@ -313,6 +321,8 @@ function assertProject(tree: Tree, projectConfig: ProjectConfiguration) { output.warn({ title: '/stories directory does not exist within project, skipping...' }); return; } + + return true; } function updateFileContent(tree: Tree, filePath: string, updater: (content: string) => string) { From 6ad2e21bc635a29293dacf7e5a8cbfbf9eb53127 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Fri, 5 Apr 2024 12:35:37 +0200 Subject: [PATCH 06/12] fix(workspace-plugin): update test-ssr target only if present --- .../src/generators/split-library-in-two/generator.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 b0da42fb61385..7ccedd928d8dd 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 @@ -115,7 +115,9 @@ function makeSrcLibrary(tree: Tree, options: Options) { json.scripts ??= {}; json.scripts.storybook = 'yarn --cwd ../stories storybook'; json.scripts['type-check'] = 'just-scripts type-check'; - json.scripts['test-ssr'] = 'test-ssr \\"../stories/src/**/*.stories.tsx\\"'; + if (json.scripts['test-ssr']) { + json.scripts['test-ssr'] = 'test-ssr \\"../stories/src/**/*.stories.tsx\\"'; + } return json; }); From d095a390381ef074e1996ec7cf42485dfa7f57dc Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Fri, 5 Apr 2024 15:42:44 +0200 Subject: [PATCH 07/12] feat(workspace-plugin): update paths in tsconfigLib,babelrc,api-extractor if present and run yanr install after generator finished --- .../split-library-in-two/generator.spec.ts | 45 ++++++++++++++-- .../split-library-in-two/generator.ts | 53 +++++++++++++++++-- 2 files changed, 89 insertions(+), 9 deletions(-) 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 dabf6b8cf560d..d7bf04e1c2699 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 @@ -53,8 +53,19 @@ describe('split-library-in-two generator', () => { `); // new SRC - expect(tree.exists(`${newConfig.root}/.storybook/main.js`)).toBe(false); - expect(tree.exists(`${newConfig.root}/stories/index.stories.tsx`)).toBe(false); + 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 { @@ -70,6 +81,14 @@ describe('split-library-in-two generator', () => { } `); + 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', @@ -84,11 +103,18 @@ describe('split-library-in-two generator', () => { }), ); + 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\\"', + 'test-ssr': 'test-ssr "../stories/src/**/*.stories.tsx"', 'type-check': 'just-scripts type-check', storybook: 'yarn --cwd ../stories storybook', }), @@ -368,6 +394,14 @@ function setupDummyPackage(tree: Tree, options: { projectName: string }) { }, ], }, + tsConfigLib: { + extends: './tsconfig.json', + compilerOptions: { + declaration: true, + declarationDir: '../../../dist/out-tsc/types', + outDir: '../../../dist/out-tsc', + }, + }, jestConfig: stripIndents` module.exports = { displayName: 'react-text', @@ -386,7 +420,9 @@ function setupDummyPackage(tree: Tree, options: { projectName: string }) { snapshotSerializers: ['@griffel/jest-serializer'], }; `, - babelConfig: {}, + babelConfig: { + extends: '../../../../.babelrc-v9.json', + }, justConfig: ` import { preset, task } from '@fluentui/scripts-tasks'; @@ -440,6 +476,7 @@ function setupDummyPackage(tree: Tree, options: { projectName: string }) { 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)); 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 7ccedd928d8dd..7afd6db1b10c5 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 @@ -15,6 +15,7 @@ import { getProjects, ProjectConfiguration, output, + installPackagesTask, } from '@nx/devkit'; import { tsquery } from '@phenomnomnominal/tsquery'; @@ -52,6 +53,10 @@ export async function splitLibraryInTwoGenerator(tree: Tree, options: SplitLibra await tsConfigBaseAllGenerator(tree, { verify: false }); await formatFiles(tree); + + return () => { + installPackagesTask(tree, true); + }; } function splitLibraryInTwoInternal(tree: Tree, options: { projectName: string }) { @@ -111,31 +116,69 @@ function makeSrcLibrary(tree: Tree, options: Options) { sourceRoot: newProjectSourceRoot, }); - updateJson(tree, joinPathFragments(newProjectRoot, 'package.json'), json => { + 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\\"'; + json.scripts['test-ssr'] = `test-ssr \"../stories/src/**/*.stories.tsx\"`; } return json; }); - updateJson(tree, joinPathFragments(newProjectRoot, 'tsconfig.json'), (json: TsConfig) => { + 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; + }); + } - updateFileContent(tree, joinPathFragments(newProjectRoot, 'jest.config.js'), content => { + if (tree.exists(filePaths.apiExtractorConfig)) { + updateJson(tree, filePaths.apiExtractorConfig, json => { + json.mainEntryPointFilePath = `${options.projectOffsetFromRoot.updated}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, '/tsconfig.base.json', (json: TsConfig) => { + updateJson(tree, filePaths.rootTsConfig, (json: TsConfig) => { json.compilerOptions.paths = json.compilerOptions.paths ?? {}; json.compilerOptions.paths[options.projectConfig.name!] = [`${newProjectSourceRoot}/index.ts`]; return json; From 10de41d4c14154f04893a44c00b102b35a3767a6 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Mon, 8 Apr 2024 17:59:43 +0200 Subject: [PATCH 08/12] feat(workspace-plugin): build proper dep graph from jest and cypress test files within /library package, emit error if there are circular dep present --- .../split-library-in-two/generator.spec.ts | 66 +++++++++++-- .../split-library-in-two/generator.ts | 98 +++++++++++++++++-- 2 files changed, 149 insertions(+), 15 deletions(-) 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 d7bf04e1c2699..60e622b4cd174 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 @@ -43,16 +43,16 @@ describe('split-library-in-two generator', () => { }), ); - expect(tree.read(workspacePaths.github.codeowners, 'utf-8')).toMatchInlineSnapshot(` - "packages/react-components/react-components Mr.Wick - packages/react-components/react-one-compat Mr.Wick - packages/react-components/react-two-preview Mr.Wick - packages/react-components/react-hello/library Mr.Wick - packages/react-components/react-hello/stories Mr.Wick - # <%= NX-CODEOWNER-PLACEHOLDER %>" - `); + 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 + // ============== + // new SRC ( library ) + // ============== expect(tree.children(newConfig.root)).toMatchInlineSnapshot(` Array [ "package.json", @@ -118,6 +118,11 @@ describe('split-library-in-two generator', () => { 'type-check': 'just-scripts type-check', storybook: 'yarn --cwd ../stories storybook', }), + devDependencies: { + '@proj/react-one-for-test': '*', + '@proj/react-provider': '*', + '@proj/react-theme': '*', + }, }), ); @@ -141,7 +146,9 @@ describe('split-library-in-two generator', () => { " `); + // ============== // new SB + // ============== expect(storiesConfig).toMatchInlineSnapshot(` Object { "$schema": "../../../../node_modules/nx/schemas/project-schema.json", @@ -336,6 +343,9 @@ function setup(tree: Tree) { 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; @@ -500,6 +510,44 @@ function setupDummyPackage(tree: Tree, options: { projectName: string }) { `, ); + // 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( 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 7afd6db1b10c5..1b91dd25a8ea0 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 @@ -133,6 +133,16 @@ function makeSrcLibrary(tree: Tree, options: Options) { 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; }); @@ -204,7 +214,13 @@ function makeStoriesLibrary(tree: Tree, options: Options) { const storiesWorkspaceDeps = getWorkspaceDependencies( tree, - Array.from(getImportsFromStories(tree, newProjectSourceRoot)), + Array.from( + getImportsFromSourceFiles( + tree, + newProjectSourceRoot, + file => file.endsWith('.stories.tsx') || file.endsWith('.stories.ts'), + ), + ), ); const templates = { @@ -416,13 +432,11 @@ function getImportPaths(tree: Tree, filePath: string) { return [...importPaths, ...requirePaths]; } -function getImportsFromStories(tree: Tree, root: string) { - const storiesDir = joinPathFragments(root); - +function getImportsFromSourceFiles(tree: Tree, root: string, filter: (file: string) => boolean) { const imports: string[] = []; - visitNotIgnoredFiles(tree, storiesDir, file => { - if (!(file.endsWith('.stories.tsx') || file.endsWith('.stories.ts'))) { + visitNotIgnoredFiles(tree, root, file => { + if (!filter(file)) { return; } @@ -444,3 +458,75 @@ function getWorkspaceDependencies(tree: Tree, imports: string[]) { 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; +} From 234e281520d48eb3a85f67cae10c59bb2a824bc1 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 11 Apr 2024 14:45:39 +0200 Subject: [PATCH 09/12] fix(workspace-plugin): properly set mainEntryPointFilePath path within library/api-extractor config --- .../generators/split-library-in-two/generator.spec.ts | 11 ++++++++++- .../src/generators/split-library-in-two/generator.ts | 4 +++- 2 files changed, 13 insertions(+), 2 deletions(-) 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 60e622b4cd174..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 @@ -8,6 +8,7 @@ import { readJson, updateJson, writeJson, + output, } from '@nx/devkit'; import { splitLibraryInTwoGenerator } from './generator'; @@ -16,6 +17,10 @@ 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 = { project: '@proj/react-hello' }; @@ -23,6 +28,10 @@ describe('split-library-in-two generator', () => { 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 split v9 project into 2', async () => { @@ -85,7 +94,7 @@ describe('split-library-in-two generator', () => { 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', + '/../../../../../../dist/out-tsc/types/packages/react-components//library/src/index.d.ts', }), ); 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 1b91dd25a8ea0..39f09d5d2cfbf 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 @@ -176,7 +176,9 @@ function makeSrcLibrary(tree: Tree, options: Options) { if (tree.exists(filePaths.apiExtractorConfig)) { updateJson(tree, filePaths.apiExtractorConfig, json => { - json.mainEntryPointFilePath = `${options.projectOffsetFromRoot.updated}dist/out-tsc/types/packages/react-components//library/src/index.d.ts`; + json.mainEntryPointFilePath = `/${offsetFromRoot( + filePaths.apiExtractorConfig, + )}dist/out-tsc/types/packages/react-components//library/src/index.d.ts`; return json; }); From f39158db22c46c35c2b3acb765a5267c95ac097e Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 11 Apr 2024 17:56:57 +0200 Subject: [PATCH 10/12] fix(workspace-plugin): avoid using live string references and use path.relative when constructing new file path during moves --- .../split-library-in-two/generator.ts | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) 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 39f09d5d2cfbf..0490329b57a02 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 @@ -17,6 +17,7 @@ import { output, installPackagesTask, } from '@nx/devkit'; +import * as path from 'node:path'; import { tsquery } from '@phenomnomnominal/tsquery'; import tsConfigBaseAllGenerator from '../tsconfig-base-all/index'; @@ -87,25 +88,32 @@ function splitLibraryInTwoInternal(tree: Tree, options: { projectName: string }) export default splitLibraryInTwoGenerator; function cleanup(tree: Tree, options: Options) { - tree.delete(joinPathFragments(options.projectConfig.root, 'dist')); - tree.delete(joinPathFragments(options.projectConfig.root, 'lib')); - tree.delete(joinPathFragments(options.projectConfig.root, 'lib-commonjs')); - tree.delete(joinPathFragments(options.projectConfig.root, 'temp')); - tree.delete(joinPathFragments(options.projectConfig.root, '.eslintcache')); - tree.delete(joinPathFragments(options.projectConfig.root, '.swc')); - tree.delete(joinPathFragments(options.projectConfig.root, 'node_modules')); + 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) { - const newProjectRoot = joinPathFragments(options.projectConfig.root, 'library'); + output.log({ title: 'creating library/ project' }); + + const oldProjectRoot = options.projectConfig.root; + const newProjectRoot = joinPathFragments(oldProjectRoot, 'library'); const newProjectSourceRoot = joinPathFragments(newProjectRoot, 'src'); - visitNotIgnoredFiles(tree, options.projectConfig.root, file => { + visitNotIgnoredFiles(tree, oldProjectRoot, file => { if (file.includes('/stories/') || file.includes('/.storybook/')) { return; } - tree.rename(file, file.replace(options.projectConfig.root, newProjectRoot)); + const newFileName = `${newProjectRoot}/${path.relative(oldProjectRoot, file)}`; + + tree.rename(file, newFileName); }); updateProjectConfiguration(tree, options.projectConfig.name!, { @@ -200,17 +208,19 @@ function makeSrcLibrary(tree: Tree, options: Options) { } function makeStoriesLibrary(tree: Tree, options: Options) { - const newProjectRoot = joinPathFragments(options.projectConfig.root, 'stories'); + 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(options.projectConfig.root, 'stories'), newProjectSourceRoot); + moveFilesToNewDirectory(tree, joinPathFragments(oldProjectRoot, 'stories'), newProjectSourceRoot); // move .storybook/ moveFilesToNewDirectory( tree, - joinPathFragments(options.projectConfig.root, '.storybook'), + joinPathFragments(oldProjectRoot, '.storybook'), joinPathFragments(newProjectRoot, '.storybook'), ); From 5a6689bab61d7fede2d5121d1d3b50924aa97364 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Tue, 16 Apr 2024 17:14:41 +0200 Subject: [PATCH 11/12] feat(workspace-plugin): improve performance and terminal outputs for split-in-two --all flag --- .../split-library-in-two/generator.ts | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) 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 0490329b57a02..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 @@ -33,6 +33,10 @@ interface Options extends SplitLibraryInTwoGeneratorSchema { }; } +const noop = () => { + return; +}; + export async function splitLibraryInTwoGenerator(tree: Tree, options: SplitLibraryInTwoGeneratorSchema) { if (options.project && options.all) { throw new Error('Cannot specify both project and all'); @@ -43,8 +47,17 @@ export async function splitLibraryInTwoGenerator(tree: Tree, options: SplitLibra if (options.all) { const projects = getProjects(tree); - for (const [projectName] of projects) { - splitLibraryInTwoInternal(tree, { projectName }); + 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) { @@ -60,15 +73,18 @@ export async function splitLibraryInTwoGenerator(tree: Tree, options: SplitLibra }; } -function splitLibraryInTwoInternal(tree: Tree, options: { projectName: string }) { - const projectConfig = readProjectConfiguration(tree, options.projectName); +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)) { + if (!assertProject(tree, projectConfig, output)) { return; } const normalizedOptions = { - ...options, + projectName, projectConfig, projectOffsetFromRoot: { old: offsetFromRoot(projectConfig.root), @@ -357,26 +373,26 @@ function makeStoriesLibrary(tree: Tree, options: Options) { }); } -function assertProject(tree: Tree, projectConfig: ProjectConfiguration) { +function assertProject(tree: Tree, projectConfig: ProjectConfiguration, logger: typeof output) { const tags = projectConfig.tags ?? []; if (projectConfig.projectType !== 'library') { - output.warn({ title: 'This generator is only for libraries' }); + logger.warn({ title: 'This generator is only for libraries' }); return; } if (projectConfig.name?.endsWith('-preview')) { - output.warn({ title: 'preview projects are not supported YET, skipping...' }); + logger.warn({ title: 'preview projects are not supported YET, skipping...' }); return; } if (tags.includes('compat')) { - output.warn({ title: 'compat projects are not supported YET, skipping...' }); + logger.warn({ title: 'compat projects are not supported YET, skipping...' }); return; } if (projectConfig.root?.endsWith('/stories') || projectConfig.root?.endsWith('/library')) { - output.warn({ title: 'attempting to migrate already migrated projects, skipping...' }); + logger.warn({ title: 'attempting to migrate already migrated projects, skipping...' }); return; } @@ -386,12 +402,12 @@ function assertProject(tree: Tree, projectConfig: ProjectConfiguration) { !(tags.includes('v8') || tags.includes('react-northstar')); if (!isV9Stable) { - output.warn({ title: 'This generator is only for v9 stable web libraries' }); + logger.warn({ title: 'This generator is only for v9 stable web libraries' }); return; } if (!tree.exists(joinPathFragments(projectConfig.root, 'stories'))) { - output.warn({ title: '/stories directory does not exist within project, skipping...' }); + logger.warn({ title: '/stories directory does not exist within project, skipping...' }); return; } From 187bb82963b0644a8dbd417298d2941989c2c934 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Tue, 23 Apr 2024 11:25:01 +0200 Subject: [PATCH 12/12] docs: add generator readme --- .../generators/split-library-in-two/README.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tools/workspace-plugin/src/generators/split-library-in-two/README.md 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