diff --git a/.github/workflows/check-tooling.yml b/.github/workflows/check-tooling.yml index df86975691840..9b113e0a525fc 100644 --- a/.github/workflows/check-tooling.yml +++ b/.github/workflows/check-tooling.yml @@ -31,6 +31,8 @@ jobs: - run: yarn nx run scripts-executors:test -t start + - run: yarn nx run workspace-plugin:test -t 'prepare-initial-release generator' + - run: yarn nx list @fluentui/workspace-plugin - run: yarn nx g @fluentui/workspace-plugin:react-library --name hello-world --owner '@mrWick' --kind standard --no-interactive @@ -39,6 +41,12 @@ jobs: - run: yarn nx g @fluentui/workspace-plugin:bundle-size-configuration --project hello-world-preview --no-interactive - run: yarn nx g @fluentui/workspace-plugin:prepare-initial-release --project hello-world-preview --phase=preview --no-interactive - run: yarn nx g @fluentui/workspace-plugin:prepare-initial-release --project hello-world-preview --phase=stable --no-interactive + - run: cat packages/react-components/hello-world/library/bundle-size/index.fixture.js + - run: cat packages/react-components/hello-world/library/jest.config.js + - run: cat packages/react-components/hello-world/library/README.md + - run: cat packages/react-components/hello-world/library/docs/Spec.md + - run: cat packages/react-components/hello-world/stories/README.md + - run: tree packages/react-components/hello-world - run: yarn nx g @nx/workspace:remove --project hello-world --forceRemove --no-interactive - run: yarn nx g @nx/workspace:remove --project hello-world-stories --forceRemove --no-interactive - run: yarn nx g @fluentui/workspace-plugin:tsconfig-base-all --no-interactive diff --git a/tools/workspace-plugin/src/generators/prepare-initial-release/index.spec.ts b/tools/workspace-plugin/src/generators/prepare-initial-release/index.spec.ts index 6f301d8777feb..9286ab14123b9 100644 --- a/tools/workspace-plugin/src/generators/prepare-initial-release/index.spec.ts +++ b/tools/workspace-plugin/src/generators/prepare-initial-release/index.spec.ts @@ -9,12 +9,14 @@ import { readJson, updateJson, installPackagesTask, - visitNotIgnoredFiles, } from '@nrwl/devkit'; import childProcess from 'child_process'; +import path from 'node:path'; + +import type { PackageJson, TsConfig } from '../../types'; import generator from './index'; -import { PackageJson, TsConfig } from '../../types'; +import { visitNotGitIgnoredFiles } from './lib/utils'; const getBlankGraphMock = () => ({ dependencies: {}, @@ -54,6 +56,21 @@ describe('prepare-initial-release generator', () => { ...getBlankGraphMock(), }; tree = createTreeWithEmptyWorkspace(); + tree.write( + '.nxignore', + stripIndents` + ; build output + **/dist/** + + ; known directories to not incorporate into the workspace graph creation + **/fixtures/** + **/__fixtures__/** + **/bundle-size/** + + ; scaffolding templates + **/generators/**/files/** + `, + ); tree.write(codeownersPath, `foo/bar @org/all\n`); writeJson(tree, 'tsconfig.base.v8.json', { compilerOptions: { paths: {} } }); writeJson(tree, 'tsconfig.base.v0.json', { compilerOptions: { paths: {} } }); @@ -199,6 +216,10 @@ describe('prepare-initial-release generator', () => { `); expect(utils.project.stories.pkgJson()).toMatchInlineSnapshot(` Object { + "devDependencies": Object { + "@proj/react-components": "*", + "@proj/react-one-compat": "*", + }, "name": "@proj/react-one-compat-stories", "private": true, "version": "0.0.0", @@ -295,6 +316,10 @@ describe('prepare-initial-release generator', () => { `); expect(utils.project.stories.pkgJson()).toMatchInlineSnapshot(` Object { + "devDependencies": Object { + "@proj/react-components": "*", + "@proj/react-one-preview": "*", + }, "name": "@proj/react-one-preview-stories", "private": true, "version": "0.0.0", @@ -465,6 +490,7 @@ describe('prepare-initial-release generator', () => { " `); + expect(tree.exists('packages/react-one-preview')).toEqual(false); expect(tree.children('packages/react-one-preview')).toEqual([]); expect(utils.project.global.codeowners()).toEqual( @@ -708,18 +734,42 @@ describe('prepare-initial-release generator', () => { expect(treeStructureAfter).toEqual(treeStructureBefore); expect(tree.children('packages/react-one-preview')).toEqual([]); + expect(tree.read('packages/react-one/library/src/index.ts', 'utf-8')).toMatchInlineSnapshot(` + "export { Hello } from './hello'; + " + `); + expect(tree.read('packages/react-one/library/src/hello.ts', 'utf-8')).toMatchInlineSnapshot(` + "export function Hello() { + console.log('@proj/react-one: some debug message for user'); + return; + } + " + `); + expect(utils.project.library.projectJson()).toEqual( expect.objectContaining({ name: 'react-one', sourceRoot: 'packages/react-one/library/src', }), ); + expect(utils.project.library.pkgJson()).toEqual({ + name: '@proj/react-one', + version: '9.0.0-alpha.0', + }); + expect(utils.project.stories.projectJson()).toEqual( expect.objectContaining({ name: 'react-one-stories', sourceRoot: 'packages/react-one/stories/src', }), ); + expect(utils.project.stories.pkgJson()).toEqual({ + name: '@proj/react-one-stories', + version: expect.any(String), + devDependencies: { + '@proj/react-components': '*', + }, + }); expect(utils.project.library.bundleSize()).toMatchInlineSnapshot(` Object { @@ -733,6 +783,23 @@ describe('prepare-initial-release generator', () => { } `); + expect(utils.project.library.md.license()).toMatchInlineSnapshot(` + "# @proj/react-one + + Copyright (c) CompanyName + + All rights reserved. + + MIT License" + `); + expect(utils.project.library.md.spec()).toMatchInlineSnapshot(` + "# @proj/react-one Spec + + ## Background + + A Foo is a component that displays a set of vertically stacked Moos. + " + `); expect(utils.project.library.md.readme()).toMatchInlineSnapshot(` "# @proj/react-one @@ -744,7 +811,7 @@ describe('prepare-initial-release generator', () => { expect(utils.project.stories.md.readme()).toMatchInlineSnapshot(` "# @proj/react-one-stories - Storybook stories for packages/react-components/react-one-stories + Storybook stories for packages/react-components/react-one ## Usage @@ -752,7 +819,7 @@ describe('prepare-initial-release generator', () => { \\\\\`\\\\\`\\\\\`js module.exports = { - stories: ['../packages/react-components/react-one-stories/stories/src/**/*.stories.mdx', '../packages/react-components/react-one-stories/stories/src/**/index.stories.@(ts|tsx)'], + stories: ['../packages/react-components/react-one/stories/src/**/*.stories.mdx', '../packages/react-components/react-one/stories/src/**/index.stories.@(ts|tsx)'], } \\\\\`\\\\\`\\\\\` " @@ -831,6 +898,13 @@ function createSplitProject( const stories = createProject(tree, storiesProjectName, { ...options, + pkgJson: { + ...options.pkgJson, + devDependencies: { + [`@proj/react-components`]: '*', + [`@proj/${projectName}`]: '*', + }, + }, root: storiesProject.root, files: [ ...(options.files?.stories ?? []), @@ -839,7 +913,7 @@ function createSplitProject( content: stripIndents` # @proj/${storiesProjectName} - Storybook stories for packages/react-components/${storiesProjectName} + Storybook stories for packages/react-components/${projectName} ## Usage @@ -847,7 +921,7 @@ function createSplitProject( \`\`\`js module.exports = { - stories: ['../packages/react-components/${storiesProjectName}/stories/src/**/*.stories.mdx', '../packages/react-components/${storiesProjectName}/stories/src/**/index.stories.@(ts|tsx)'], + stories: ['../packages/react-components/${projectName}/stories/src/**/*.stories.mdx', '../packages/react-components/${projectName}/stories/src/**/index.stories.@(ts|tsx)'], } \`\`\` `, @@ -878,7 +952,9 @@ function createProject( const sourceRoot = joinPathFragments(options.root, 'src'); const indexFile = joinPathFragments(sourceRoot, 'index.ts'); const jestPath = joinPathFragments(options.root, 'jest.config.js'); + const specPath = joinPathFragments(options.root, 'docs/Spec.md'); const readmePath = joinPathFragments(options.root, 'README.md'); + const licensePath = joinPathFragments(options.root, 'LICENSE'); const apiMdPath = joinPathFragments(options.root, `etc/${projectName}.api.md`); const tsConfigBaseAllPath = 'tsconfig.base.all.json'; const tsConfigBasePath = 'tsconfig.base.json'; @@ -897,10 +973,52 @@ function createProject( tree.write( indexFile, stripIndents` - export {}; + export { Hello } from './hello'; + `, + ); + tree.write( + joinPathFragments(sourceRoot, 'hello.ts'), + stripIndents` + export function Hello(){ + console.log('${npmName}: some debug message for user') + return + }; `, ); + tree.write( + licensePath, + stripIndents` + # ${npmName} + +Copyright (c) CompanyName + +All rights reserved. + +MIT License + `, + ); + tree.write( + specPath, + stripIndents` +# ${npmName} Spec + +## Background + +A Foo is a component that displays a set of vertically stacked Moos. + `, + ); + tree.write( + readmePath, + stripIndents` + # ${npmName} + +**React Tags components for [Fluent UI React](https://react.fluentui.dev/)** + +These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release. + + `, + ); tree.write( readmePath, stripIndents` @@ -941,7 +1059,10 @@ These are not production-ready components and **should never be used in product* return json; }); - const depKeys = [...Object.keys(options.pkgJson.dependencies ?? {})]; + const depKeys = [ + ...Object.keys(options.pkgJson.dependencies ?? {}), + ...Object.keys(options.pkgJson.devDependencies ?? {}), + ]; graphMock.dependencies[projectName] = depKeys.map(value => { return { source: projectName, target: value.replace('@proj/', ''), type: 'static' }; @@ -971,6 +1092,8 @@ These are not production-ready components and **should never be used in product* return tree.read(joinPathFragments(newRoot, 'jest.config.js'), 'utf-8'); }, md: { + license: () => tree.read(joinPathFragments(newRoot, 'LICENSE'), 'utf-8'), + spec: () => tree.read(joinPathFragments(newRoot, 'docs/Spec.md'), 'utf-8'), readme: () => tree.read(joinPathFragments(newRoot, 'README.md'), 'utf-8'), api: () => tree.read(joinPathFragments(newRoot, `etc/${projectName.replace('-preview', '')}.api.md`), 'utf-8'), }, @@ -978,8 +1101,9 @@ These are not production-ready components and **should never be used in product* const root = joinPathFragments(newRoot, 'bundle-size'); const contents: Record = {}; - visitNotIgnoredFiles(tree, root, file => { - contents[file] = stripIndents`${tree.read(file, 'utf-8')}` ?? ''; + visitNotGitIgnoredFiles(tree, root, file => { + // normalize path key to POSIX + contents[file.split(path.sep).join('/')] = stripIndents`${tree.read(file, 'utf-8')}` ?? ''; }); return contents; diff --git a/tools/workspace-plugin/src/generators/prepare-initial-release/index.ts b/tools/workspace-plugin/src/generators/prepare-initial-release/index.ts index 2da3e1c429764..18d6ffac39939 100644 --- a/tools/workspace-plugin/src/generators/prepare-initial-release/index.ts +++ b/tools/workspace-plugin/src/generators/prepare-initial-release/index.ts @@ -12,6 +12,7 @@ import { readJson, stripIndents, workspaceRoot, + logger, type ProjectConfiguration, type Tree, type ProjectGraph, @@ -27,6 +28,7 @@ import tsConfigBaseAll from '../tsconfig-base-all'; import { assertStoriesProject, isSplitProject as isSplitProjectFn } from '../split-library-in-two/shared'; import { type ReleasePackageGeneratorSchema } from './schema'; +import { visitNotGitIgnoredFiles } from './lib/utils'; interface NormalizedSchema extends ReturnType {} @@ -120,6 +122,9 @@ async function stableRelease(tree: Tree, options: NormalizedSchema & { isSplitPr return content.replace(regexp, 'react-components'); }; + // clean node_modules so we don't perform unnecessary RENAMES later + tree.delete(joinPathFragments(options.projectConfig.root, 'node_modules')); + // we need to update projects that might still contain dependency to old -preview package first await updateProjectsThatUsedPreviewPackage(); @@ -139,9 +144,19 @@ async function stableRelease(tree: Tree, options: NormalizedSchema & { isSplitPr updateFileContent(tree, { filePath: options.paths.jestConfig, updater: contentNameUpdater }); + // update any references to self within source code + if (options.projectConfig.sourceRoot) { + visitNotIgnoredFiles(tree, joinPathFragments(options.projectConfig.sourceRoot), filePath => { + updateFileContent(tree, { + filePath, + updater: contentNameUpdater, + }); + }); + } + const bundleSizeFixturesRoot = joinPathFragments(options.projectConfig.root, 'bundle-size'); if (tree.exists(bundleSizeFixturesRoot)) { - visitNotIgnoredFiles(tree, bundleSizeFixturesRoot, filePath => { + visitNotGitIgnoredFiles(tree, bundleSizeFixturesRoot, filePath => { updateFileContent(tree, { filePath, updater: contentNameUpdater, @@ -150,11 +165,21 @@ async function stableRelease(tree: Tree, options: NormalizedSchema & { isSplitPr } const mdFilePath = { + spec: joinPathFragments(options.projectConfig.root, 'docs/Spec.md'), readme: joinPathFragments(options.projectConfig.root, 'README.md'), api: joinPathFragments(options.projectConfig.root, 'etc', options.project + '.api.md'), apiNew: joinPathFragments(options.projectConfig.root, 'etc', newPackage.name + '.api.md'), + license: joinPathFragments(options.projectConfig.root, 'LICENSE'), }; + updateFileContent(tree, { + filePath: mdFilePath.license, + updater: contentNameUpdater, + }); + updateFileContent(tree, { + filePath: mdFilePath.spec, + updater: contentNameUpdater, + }); updateFileContent(tree, { filePath: mdFilePath.readme, updater: contentNameUpdater, @@ -222,6 +247,11 @@ async function stableRelease(tree: Tree, options: NormalizedSchema & { isSplitPr async function updateProjectsThatUsedPreviewPackage() { const graph = await createProjectGraphAsync(); const projectsToUpdate = await getProjectThatNeedsToBeUpdated(graph, options); + const ignoreProjects = [ + // we don't wanna update `*-stories` project based on Graph - stories project is updated later which doesn't uses Graph rather relies on our custom imports parser and package.json template + // TODO: re-evaluate this approach if we should not rather use Graph for everything + options.isSplitProject ? options.projectConfig.name + '-stories' : null, + ].filter(Boolean) as string[]; const knownProjectsToBeUpdated = { docsite: 'public-docsite-v9', @@ -231,6 +261,10 @@ async function stableRelease(tree: Tree, options: NormalizedSchema & { isSplitPr // update other projects that might still contain dependency to old -preview package const unknownProjectsToBeUpdated = projectsToUpdate ? projectsToUpdate.filter(projectName => { + if (ignoreProjects.includes(projectName)) { + return false; + } + const knownKeys = Object.values(knownProjectsToBeUpdated); return !knownKeys.includes(projectName); }) @@ -314,13 +348,18 @@ function stableReleaseForStoriesProject(tree: Tree, options: NormalizedSchema) { }; const contentNameUpdaterStories = (content: string) => { - const regexp = new RegExp(currentStoriesPackage.name, 'g'); - return content.replace(regexp, newStoriesProject.name); + const regexpStoryProject = new RegExp(currentStoriesPackage.name, 'g'); + const regexpLibraryProject = new RegExp(options.project, 'g'); + return content + .replace(regexpStoryProject, newStoriesProject.name) + .replace(regexpLibraryProject, options.project.replace('-preview', '')); }; updateJson(tree, storiesProjectPaths.packageJson, json => { json.name = newStoriesProject.npmName; + delete json.devDependencies?.[options.npmPackageName]; + return json; }); @@ -365,6 +404,13 @@ function updateFileContent( }, ) { const { filePath, newFilePath, updater } = options; + + if (!tree.exists(filePath)) { + logger.warn(`attempt to update ${filePath} contents failed, because that path does not exist`); + + return tree; + } + const oldContent = tree.read(filePath, 'utf-8') as string; const newContent = updater(oldContent); diff --git a/tools/workspace-plugin/src/generators/prepare-initial-release/lib/utils.ts b/tools/workspace-plugin/src/generators/prepare-initial-release/lib/utils.ts new file mode 100644 index 0000000000000..b73014dd9dfd4 --- /dev/null +++ b/tools/workspace-plugin/src/generators/prepare-initial-release/lib/utils.ts @@ -0,0 +1,43 @@ +import type { Tree } from '@nx/devkit'; +import ignore from 'ignore'; +import { join, relative, sep } from 'node:path'; + +/** + * Utility to act on all files in a tree that are not ignored by git. + * copied from https://github.com/nrwl/nx/blob/master/packages/devkit/src/generators/visit-not-ignored-files.ts + * utility override from original in order to process files that are ignored within .nxignore + */ +export function visitNotGitIgnoredFiles(tree: Tree, dirPath = tree.root, visitor: (path: string) => void): void { + // TODO (v17): use packages/nx/src/utils/ignore.ts + let ig = ignore(); + if (tree.exists('.gitignore')) { + // ig = ignore(); + ig.add('.git'); + ig.add(tree.read('.gitignore', 'utf-8')!); + } + // if (tree.exists('.nxignore')) { + // ig ??= ignore(); + // ig.add(tree.read('.nxignore', 'utf-8')!); + // } + dirPath = normalizePathRelativeToRoot(dirPath, tree.root); + + if (dirPath !== '' && ig?.ignores(dirPath)) { + return; + } + + for (const child of tree.children(dirPath)) { + const fullPath = join(dirPath, child); + if (ig?.ignores(fullPath)) { + continue; + } + if (tree.isFile(fullPath)) { + visitor(fullPath); + } else { + visitNotGitIgnoredFiles(tree, fullPath, visitor); + } + } +} + +function normalizePathRelativeToRoot(path: string, root: string): string { + return relative(root, join(root, path)).split(sep).join('/'); +}