Skip to content

Commit

Permalink
feat(core): add monorepo generator to convert from standalone projects
Browse files Browse the repository at this point in the history
  • Loading branch information
jaysoo committed Jul 21, 2023
1 parent 8407d7a commit d712335
Show file tree
Hide file tree
Showing 11 changed files with 566 additions and 26 deletions.
10 changes: 10 additions & 0 deletions packages/workspace/generators.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
"aliases": ["rm"],
"description": "Remove an application or library."
},
"monorepo": {
"factory": "./src/generators/monorepo/monorepo#monorepoSchematic",
"schema": "./src/generators/monorepo/schema.json",
"description": "Convert a Nx project to a monorepo."
},
"workspace-generator": {
"factory": "./src/generators/workspace-generator/workspace-generator",
"schema": "./src/generators/workspace-generator/schema.json",
Expand Down Expand Up @@ -53,6 +58,11 @@
"aliases": ["rm"],
"description": "Remove an application or library."
},
"monorepo": {
"factory": "./src/generators/monorepo/monorepo",
"schema": "./src/generators/monorepo/schema.json",
"description": "Convert a Nx project to a monorepo."
},
"new": {
"factory": "./src/generators/new/new#newGenerator",
"schema": "./src/generators/new/schema.json",
Expand Down
149 changes: 149 additions & 0 deletions packages/workspace/src/generators/monorepo/monorepo.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { readJson, Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { monorepoGenerator } from './monorepo';

// nx-ignore-next-line
const { libraryGenerator } = require('@nx/js');
// nx-ignore-next-line
const { applicationGenerator: reactAppGenerator } = require('@nx/react');
// nx-ignore-next-line
const { applicationGenerator: nextAppGenerator } = require('@nx/next');

describe('monorepo generator', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});

it('should convert root JS lib', async () => {
// Files that should not move
tree.write('.gitignore', '');
tree.write('README.md', '');
tree.write('tools/scripts/custom_script.sh', '');

await libraryGenerator(tree, { name: 'my-lib', rootProject: true });
await libraryGenerator(tree, { name: 'other-lib' });

await monorepoGenerator(tree, {
appsDir: 'apps',
libsDir: 'packages',
});

expect(readJson(tree, 'packages/my-lib/project.json')).toMatchObject({
sourceRoot: 'packages/my-lib/src',
targets: {
build: {
executor: '@nx/js:tsc',
options: {
main: 'packages/my-lib/src/index.ts',
tsConfig: 'packages/my-lib/tsconfig.lib.json',
},
},
},
});
expect(readJson(tree, 'packages/other-lib/project.json')).toMatchObject({
sourceRoot: 'packages/other-lib/src',
});

// Did not move files that don't belong to root project
expect(tree.exists('.gitignore')).toBeTruthy();
expect(tree.exists('README.md')).toBeTruthy();
expect(tree.exists('tools/scripts/custom_script.sh')).toBeTruthy();

// Extracted base config files
expect(tree.exists('tsconfig.base.json')).toBeTruthy();
});

it('should convert root React app (Vite, Vitest)', async () => {
await reactAppGenerator(tree, {
name: 'demo',
style: 'css',
bundler: 'vite',
unitTestRunner: 'vitest',
e2eTestRunner: 'none',
linter: 'eslint',
rootProject: true,
});

await monorepoGenerator(tree, {
appsDir: 'apps',
libsDir: 'libs',
});

expect(readJson(tree, 'apps/demo/project.json')).toMatchObject({
sourceRoot: 'apps/demo/src',
});

// Extracted base config files
expect(tree.exists('tsconfig.base.json')).toBeTruthy();
expect(tree.exists('.eslintrc.base.json')).toBeTruthy();
});

it('should convert root React app (Webpack, Jest)', async () => {
await reactAppGenerator(tree, {
name: 'demo',
style: 'css',
bundler: 'webpack',
unitTestRunner: 'jest',
e2eTestRunner: 'none',
linter: 'eslint',
rootProject: true,
});

await monorepoGenerator(tree, {
appsDir: 'apps',
libsDir: 'libs',
});

expect(readJson(tree, 'apps/demo/project.json')).toMatchObject({
sourceRoot: 'apps/demo/src',
targets: {
build: {
executor: '@nx/webpack:webpack',
options: {
main: 'apps/demo/src/main.tsx',
tsConfig: 'apps/demo/tsconfig.app.json',
webpackConfig: 'apps/demo/webpack.config.js',
},
},
},
});

// Extracted base config files
expect(tree.exists('tsconfig.base.json')).toBeTruthy();
expect(tree.exists('.eslintrc.base.json')).toBeTruthy();
expect(tree.exists('jest.config.app.ts')).toBeTruthy();
});

it('should convert root Next.js app with existing libraries', async () => {
await nextAppGenerator(tree, {
name: 'demo',
style: 'css',
unitTestRunner: 'jest',
e2eTestRunner: 'none',
appDir: true,
linter: 'eslint',
rootProject: true,
});
await libraryGenerator(tree, { name: 'util' });

await monorepoGenerator(tree, {
appsDir: 'apps',
libsDir: 'libs',
});

expect(readJson(tree, 'apps/demo/project.json')).toMatchObject({
sourceRoot: 'apps/demo',
});
expect(tree.read('apps/demo/app/page.tsx', 'utf-8')).toContain('demo');
expect(readJson(tree, 'libs/util/project.json')).toMatchObject({
sourceRoot: 'libs/util/src',
});
expect(tree.read('libs/util/src/lib/util.ts', 'utf-8')).toContain('util');

// Extracted base config files
expect(tree.exists('tsconfig.base.json')).toBeTruthy();
expect(tree.exists('.eslintrc.base.json')).toBeTruthy();
expect(tree.exists('jest.config.app.ts')).toBeTruthy();
});
});
76 changes: 76 additions & 0 deletions packages/workspace/src/generators/monorepo/monorepo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
convertNxGenerator,
getProjects,
joinPathFragments,
ProjectConfiguration,
readNxJson,
Tree,
updateNxJson,
} from '@nx/devkit';
import { moveGenerator } from '../move/move';
import { Schema } from './schema';

export async function monorepoGenerator(tree: Tree, options: Schema) {
const projects = getProjects(tree);
const { extractTsConfigBase } = require('@nx/' + 'js');
extractTsConfigBase(tree);

maybeExtractJestConfigBase(tree);

const nxJson = readNxJson(tree);
nxJson.workspaceLayout = {
// normalize paths without trailing slash
appsDir: joinPathFragments(options.appsDir),
libsDir: joinPathFragments(options.libsDir),
};

updateNxJson(tree, nxJson);

for (const [_name, project] of projects) {
maybeExtractEslintConfigIfRootProject(tree, project);
await moveGenerator(tree, {
projectName: project.name,
destination: project.name,
updateImportPath: project.projectType === 'library',
});
}
}

function maybeExtractJestConfigBase(tree: Tree): void {
let jestInitGenerator: any;
try {
jestInitGenerator = require('@nx/' + 'jest').jestInitGenerator;
} catch {
// not installed
}
jestInitGenerator?.(tree, {});
}

function maybeExtractEslintConfigIfRootProject(
tree: Tree,
rootProject: ProjectConfiguration
): void {
if (rootProject.root !== '.') return;
if (tree.exists('.eslintrc.base.json')) return;
let migrateConfigToMonorepoStyle: any;
try {
migrateConfigToMonorepoStyle = require('@nx/' +
'linter/src/generators/init/init-migration').migrateConfigToMonorepoStyle;
} catch {
// linter not install
}
// Only need to handle migrating the root rootProject.
// If other libs/apps exist, then this migration is already done by `@nx/linter:lint-rootProject` generator.
migrateConfigToMonorepoStyle?.(
[rootProject.name],
tree,
tree.exists(joinPathFragments(rootProject.root, 'jest.config.ts')) ||
tree.exists(joinPathFragments(rootProject.root, 'jest.config.js'))
? 'jest'
: 'none'
);
}

export default monorepoGenerator;

export const monorepoSchematic = convertNxGenerator(monorepoGenerator);
4 changes: 4 additions & 0 deletions packages/workspace/src/generators/monorepo/schema.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface Schema {
appsDir: string;
libsDir: string;
}
28 changes: 28 additions & 0 deletions packages/workspace/src/generators/monorepo/schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"$schema": "http://json-schema.org/schema",
"$id": "NxWorkspaceMonorepo",
"cli": "nx",
"title": "Nx Monorepo",
"description": "Convert a Nx project to a monorepo.",
"type": "object",
"examples": [
{
"command": "nx g @nx/workspace:monorepo --appsDir=apps --libsDir=packages",
"description": "Convert to a monorepo with apps and libs directories."
}
],
"properties": {
"appsDir": {
"type": "string",
"description": "The directory where apps are placed.",
"default": "apps"
},
"libsDir": {
"type": "string",
"alias": "packagesDir",
"description": "The directory where libs are placed.",
"default": "libs"
}
},
"required": ["appsDir", "libsDir"]
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
addProjectConfiguration,
joinPathFragments,
ProjectConfiguration,
Tree,
} from '@nx/devkit';
Expand All @@ -11,21 +12,58 @@ export function createProjectConfigurationInNewDestination(
projectConfig: ProjectConfiguration
) {
projectConfig.name = schema.newProjectName;
const isRootProject = projectConfig.root === '.';

// Subtle bug if project name === path, where the updated name was being overrideen.
const { name, ...rest } = projectConfig;

// replace old root path with new one
const projectString = JSON.stringify(rest);
const newProjectString = projectString.replace(
new RegExp(projectConfig.root, 'g'),
schema.relativeToRootDestination
);
let newProjectString = JSON.stringify(rest);
if (isRootProject) {
// Don't replace . with new root since it'll match all characters.
// Only look for "./" and replace with new root.
newProjectString = newProjectString.replace(
/\.\//g,
schema.relativeToRootDestination + '/'
);
newProjectString = newProjectString.replace(
/"(tsconfig\..*\.json)"/g,
`"${schema.relativeToRootDestination}/$1"`
);
newProjectString = newProjectString.replace(
/"(webpack\..*\.[jt]s)"/g,
`"${schema.relativeToRootDestination}/$1"`
);
newProjectString = newProjectString.replace(
/"(vite\..*\.[jt]s)"/g,
`"${schema.relativeToRootDestination}/$1"`
);
newProjectString = newProjectString.replace(
/"(\.\/)?src\/([^"]+)"/g,
`"${schema.relativeToRootDestination}/src/$1"`
);
} else {
newProjectString = newProjectString.replace(
new RegExp(projectConfig.root, 'g'),
schema.relativeToRootDestination
);
}
const newProject: ProjectConfiguration = {
name,
...JSON.parse(newProjectString),
};

newProject.root = schema.relativeToRootDestination;

// Original sourceRoot is typically 'src' or 'app', but it could be any folder.
// Make sure it is updated to be under the new destination.
if (isRootProject && projectConfig.sourceRoot) {
newProject.sourceRoot = joinPathFragments(
schema.relativeToRootDestination,
projectConfig.sourceRoot
);
}

// Create a new project with the root replaced
addProjectConfiguration(tree, schema.newProjectName, newProject);
}
Loading

0 comments on commit d712335

Please sign in to comment.