Enforce Organizational Best Practices with a Local Plugin
Every repository has a unique set of conventions and best practices that developers need to learn in order to write code that integrates well with the rest of the code base. It is important to document those best practices, but developers don't always read the documentation and even if they have read the documentation, they don't consistently follow the documentation every time they perform a task. Nx allows you to encode these best practices in code generators that have been tailored to your specific repository.
In this tutorial, we will create a generator that helps enforce the follow best practices:
- Every project in this repository should use Vitest for unit tests.
- Every project in this repository should be tagged with a
scope:*
tag that is chosen from the list of available scopes. - Projects should be placed in folders that match the scope that they are assigned.
- Vitest should clear mocks before running tests.
Get Started
Let's first create a new workspace with the create-nx-workspace
command:
❯
npx create-nx-workspace myorg --preset=react-integrated --ci=github
Then we , install the @nx/plugin
package and generate a plugin:
❯
npx nx add @nx/plugin
❯
npx nx g @nx/plugin:plugin tools/recommended
This will create a recommended
project that contains all your plugin code.
Create a Customized Library Generator
To create a new generator run:
❯
npx nx generate @nx/plugin:generator tools/recommended/src/generators/library
The new generator is located in tools/recommended/src/generators/library
. The generator.ts
file contains the code that runs the generator. We can delete the files
directory since we won't be using it and update the generator.ts
file with the following code:
1import { Tree } from '@nx/devkit';
2import { Linter } from '@nx/eslint';
3import { libraryGenerator as reactLibraryGenerator } from '@nx/react';
4import { LibraryGeneratorSchema } from './schema';
5
6export async function libraryGenerator(
7 tree: Tree,
8 options: LibraryGeneratorSchema
9) {
10 const callbackAfterFilesUpdated = await reactLibraryGenerator(tree, {
11 ...options,
12 projectNameAndRootFormat: 'as-provided',
13 linter: Linter.EsLint,
14 style: 'css',
15 unitTestRunner: 'vitest',
16 });
17
18 return callbackAfterFilesUpdated;
19}
20
21export default libraryGenerator;
22
Notice how this generator is calling the @nx/react
plugin's library
generator with a predetermined list of options. This helps developers to always create projects with the recommended settings.
We're returning the callbackAfterFilesUpdated
function because the @nx/react:library
generator sometimes needs to install packages from NPM after the file system has been updated by the generator. You can provide your own callback function instead, if you have tasks that rely on actual files being present.
To try out the generator in dry-run mode, use the following command:
❯
npx nx g @myorg/recommended:library test-library --dry-run
Remove the --dry-run
flag to actually create a new project.
Add Generator Options
The schema.d.ts
file contains all the options that the generator supports. By default, it includes only a name
option. Let's add a directory option to pass on to the @nx/react
generator.
1export interface LibraryGeneratorSchema {
2 name: string;
3 directory?: string;
4}
5
The schema.d.ts
file is used for type checking inside the implementation file. It should match the properties in schema.json
.
The schema files not only provide structure to the CLI, but also allow Nx Console to show an accurate UI for the generator.
Notice how we made the description
argument optional in both the JSON and type files. If we call the generator without passing a directory, the project will be created in a directory with same name as the project. We can test the changes to the generator with the following command:
❯
npx nx g @myorg/recommended:library test-library --directory=nested/directory/test-library --dry-run
Choose a Scope
It can be helpful to tag a library with a scope that matches the application it should be associated with. With these tags in place, you can set up rules for how projects can depend on each other. For our repository, let's say the scopes can be store
, api
or shared
and the default directory structure should match the chosen scope. We can update the generator to encourage developers to maintain this structure.
1export interface LibraryGeneratorSchema {
2 name: string;
3 scope: string;
4 directory?: string;
5}
6
We can check that the scope logic is being applied correctly by running the generator again and specifying a scope.
❯
npx nx g @myorg/recommended:library test-library --scope=shared --dry-run
This should create the test-library
in the shared
folder.
Configure Tasks
You can also use your Nx plugin to configure how your tasks are run. Usually, organization focused plugins configure tasks by modifying the configuration files for each project. If you have developed your own tooling scripts for your organization, you may want to create an executor or infer tasks, but that process is covered in more detail in the tooling plugin tutorial.
Let's update our library generator to set the clearMocks
property to true
in the vitest
configuration. First we'll run the reactLibraryGenerator
and then we'll modify the created files.
1import { formatFiles, Tree, runTasksInSerial } from '@nx/devkit';
2import { Linter } from '@nx/eslint';
3import { libraryGenerator as reactLibraryGenerator } from '@nx/react';
4import { LibraryGeneratorSchema } from './schema';
5
6export async function libraryGenerator(
7 tree: Tree,
8 options: LibraryGeneratorSchema
9) {
10 const directory = options.directory || `${options.scope}/${options.name}`;
11
12 const tasks = [];
13 tasks.push(
14 await reactLibraryGenerator(tree, {
15 ...options,
16 tags: `scope:${options.scope}`,
17 directory,
18 projectNameAndRootFormat: 'as-provided',
19 linter: Linter.EsLint,
20 style: 'css',
21 unitTestRunner: 'vitest',
22 })
23 );
24
25 updateViteConfiguration(tree, directory);
26 await formatFiles(tree);
27
28 return runTasksInSerial(...tasks);
29}
30
31function updateViteConfiguration(tree, directory) {
32 // Read the vite configuration file
33 let viteConfiguration =
34 tree.read(`${directory}/vite.config.ts`)?.toString() || '';
35
36 // Modify the configuration
37 // This is done with a naive search and replace, but could be done in a more robust way using AST nodes.
38 viteConfiguration = viteConfiguration.replace(
39 `globals: true,`,
40 `globals: true,\n clearMocks:true,`
41 );
42
43 // Write the modified configuration back to the file
44 tree.write(`${directory}/vite.config.ts`, viteConfiguration);
45}
46
47export default libraryGenerator;
48
We updated the generator to use some new helper functions from the Nx devkit. Here are a few functions you may find useful. See the full API reference for all the options.
runTasksInSerial
- Allows you to collect many callbacks and return them all at the end of the generator.formatFiles
- Run Prettier on the repositoryreadProjectConfiguration
- Get the calculated project configuration for a single projectupdateNxJson
- Update thenx.json
file
Now let's check to make sure that the clearMocks
property is set correctly by the generator. First, we'll commit our changes so far. Then, we'll run the generator without the --dry-run
flag so we can inspect the file contents.
❯
git add .
❯
git commit -am "library generator"
❯
npx nx g @myorg/recommended:library store-test --scope=store
Next Steps
Now that we have a working library generator, here are some more topics you may want to investigate.
- Generate files from EJS templates
- Modify files with string replacement or AST transformations
Encourage Adoption
Once you have a set of generators in place in your organization's plugin, the rest of the work is all communication. Let your developers know that the plugin is available and encourage them to use it. These are the most important points to communicate to your developers:
- Whenever there are multiple plugins that provide a generator with the same name, use the
recommended
version - If there are repetitive or error prone processes that they identify, ask the plugin team to write a generator for that process
Now you can go through all the README files in the repository and replace any multiple step instructions with a single line calling a generator.