Integrate a New Tool into an Nx Repository with a Tooling Plugin
Nx Plugins can be used to easily integrate a tool or framework into an Nx repository. If there is no plugin available for your favorite tool or framework, you can write your own.
In this tutorial, we'll create a plugin that helps to integrate the Astro framework. Astro
is a JavaScript web framework optimized for building fast, content-driven websites. We'll call our plugin nx-astro
.
To create a plugin in a brand new repository, use the create-nx-plugin
command:
❯
npx create-nx-plugin nx-astro
Skip the create-*
package prompt, since we won't be creating a preset.
Understand Tooling Configuration Files
When integrating your tool into an Nx repository, you first need to have a clear understanding of how your tool works. Pay special attention to all the possible formats for configuration files, so that your plugin can process any valid configuration options.
For our nx-astro
plugin, we'll read information from the astro.config.mjs
or astro.config.ts
file. We'll mainly be interested in the srcDir
, publicDir
and outDir
properties specified in the defineConfig
object. srcDir
and publicDir
define input files that are used in the build process and outDir
defines what the build output will be created.
1import { defineConfig } from 'astro/config';
2
3// https://astro.build/config
4export default defineConfig({
5 srcDir: './src',
6 publicDir: './public',
7 outDir: './dist',
8});
9
Create an Inferred Task
The easiest way for people integrate your tool into their repository is for them to use inferred tasks. When leveraging inferred tasks, all your users need to do is install your plugin and the tool configuration file to their projects. Your plugin will take care of registering tasks with Nx and setting up the correct caching settings.
Once the inferred task logic is written, we want to be able to automatically create a task for any project that has a astro.config.*
file defined in the root of the project. We'll name the task based on our plugin configuration in the nx.json
file:
1{
2 "plugins": [
3 {
4 "plugin": "nx-astro",
5 "options": {
6 "buildTargetName": "build",
7 "devTargetName": "dev"
8 }
9 }
10 ]
11}
12
If the astro.config.mjs
for a project looks like our example in the previous section, then the inferred configuration for the build
task should look like this:
1{
2 "command": "astro build",
3 "cache": true,
4 "inputs": [
5 "{projectRoot}/astro.config.mjs",
6 "{projectRoot}/src/**/*",
7 "{projectRoot}/public/**/*",
8 {
9 "externalDependencies": ["astro"]
10 }
11 ],
12 "outputs": ["{projectRoot}/dist"]
13}
14
To create an inferred task, we need to export a createNodesV2
function from the plugin's index.ts
file. The entire file is shown below with inline comments to explain what is happening in each section.
1import {
2 CreateNodesContextV2,
3 CreateNodesV2,
4 TargetConfiguration,
5 createNodesFromFiles,
6 joinPathFragments,
7} from '@nx/devkit';
8import { readdirSync, readFileSync } from 'fs';
9import { dirname, join, resolve } from 'path';
10
11// Expected format of the plugin options defined in nx.json
12export interface AstroPluginOptions {
13 buildTargetName?: string;
14 devTargetName?: string;
15}
16
17// File glob to find all the configuration files for this plugin
18const astroConfigGlob = '**/astro.config.{mjs,ts}';
19
20// Entry function that Nx calls to modify the graph
21export const createNodesV2: CreateNodesV2<AstroPluginOptions> = [
22 astroConfigGlob,
23 async (configFiles, options, context) => {
24 return await createNodesFromFiles(
25 (configFile, options, context) =>
26 createNodesInternal(configFile, options, context),
27 configFiles,
28 options,
29 context
30 );
31 },
32];
33
34async function createNodesInternal(
35 configFilePath: string,
36 options: AstroPluginOptions,
37 context: CreateNodesContextV2
38) {
39 const projectRoot = dirname(configFilePath);
40
41 // Do not create a project if package.json or project.json isn't there.
42 const siblingFiles = readdirSync(join(context.workspaceRoot, projectRoot));
43 if (
44 !siblingFiles.includes('package.json') &&
45 !siblingFiles.includes('project.json')
46 ) {
47 return {};
48 }
49
50 // Contents of the astro config file
51 const astroConfigContent = readFileSync(
52 resolve(context.workspaceRoot, configFilePath)
53 ).toString();
54
55 // Read config values using Regex.
56 // There are better ways to read config values, but this works for the tutorial
57 function getConfigValue(propertyName: string, defaultValue: string) {
58 const result = new RegExp(`${propertyName}: '(.*)'`).exec(
59 astroConfigContent
60 );
61 if (result && result[1]) {
62 return result[1];
63 }
64 return defaultValue;
65 }
66
67 const srcDir = getConfigValue('srcDir', './src');
68 const publicDir = getConfigValue('publicDir', './public');
69 const outDir = getConfigValue('outDir', './dist');
70
71 // Inferred task final output
72 const buildTarget: TargetConfiguration = {
73 command: `astro build`,
74 options: { cwd: projectRoot },
75 cache: true,
76 inputs: [
77 '{projectRoot}/astro.config.mjs',
78 joinPathFragments('{projectRoot}', srcDir, '**', '*'),
79 joinPathFragments('{projectRoot}', publicDir, '**', '*'),
80 {
81 externalDependencies: ['astro'],
82 },
83 ],
84 outputs: [`{projectRoot}/${outDir}`],
85 };
86 const devTarget: TargetConfiguration = {
87 command: `astro dev`,
88 options: { cwd: projectRoot },
89 };
90
91 // Project configuration to be merged into the rest of the Nx configuration
92 return {
93 projects: {
94 [projectRoot]: {
95 targets: {
96 [options.buildTargetName]: buildTarget,
97 [options.devTargetName]: devTarget,
98 },
99 },
100 },
101 };
102}
103
We'll test out this inferred task a little later in the tutorial.
Inferred tasks work well for getting users started using your tool quickly, but you can also provide users with executors, which are another way of encapsulating a task script for easy use in an Nx workspace. Without inferred tasks, executors must be explicitly configured for each task.
Create an Init Generator
You'll want to create generators to automate the common coding tasks for developers that use your tool. The most obvious coding task is the initial setup of the plugin. We'll create an init
generator to automatically register the nx-astro
plugin and start inferring tasks.
If you create a generator named init
, Nx will automatically run that generator when someone installs your plugin with the nx add nx-astro
command. This generator should provide a good default set up for using your plugin. In our case, we need to register the plugin in the nx.json
file.
To create the generator run the following command:
❯
npx nx g generator src/generators/init
Then we can edit the generator.ts
file to define the generator functionality:
1import { formatFiles, readNxJson, Tree, updateNxJson } from '@nx/devkit';
2import { InitGeneratorSchema } from './schema';
3
4export async function initGenerator(tree: Tree, options: InitGeneratorSchema) {
5 const nxJson = readNxJson(tree) || {};
6 const hasPlugin = nxJson.plugins?.some((p) =>
7 typeof p === 'string' ? p === 'nx-astro' : p.plugin === 'nx-astro'
8 );
9 if (!hasPlugin) {
10 if (!nxJson.plugins) {
11 nxJson.plugins = [];
12 }
13 nxJson.plugins = [
14 ...nxJson.plugins,
15 {
16 plugin: 'nx-astro',
17 options: {
18 buildTargetName: 'build',
19 devTargetName: 'dev',
20 },
21 },
22 ];
23 }
24 updateNxJson(tree, nxJson);
25 await formatFiles(tree);
26}
27
28export default initGenerator;
29
This will automatically add the plugin configuration to the nx.json
file if the plugin is not already registered.
We need to remove the generated name
option from the generator schema files so that the init
generator can be executed without passing any arguments.
1export interface InitGeneratorSchema {}
2
Create an Application Generator
Let's make one more generator to automatically create a simple Astro application. First we'll create the generator:
❯
npx nx g generator src/generators/application
Then we'll update the generator.ts
file to define the generator functionality:
1import {
2 addProjectConfiguration,
3 formatFiles,
4 generateFiles,
5 Tree,
6} from '@nx/devkit';
7import * as path from 'path';
8import { ApplicationGeneratorSchema } from './schema';
9
10export async function applicationGenerator(
11 tree: Tree,
12 options: ApplicationGeneratorSchema
13) {
14 const projectRoot = `${options.name}`;
15 addProjectConfiguration(tree, options.name, {
16 root: projectRoot,
17 projectType: 'application',
18 sourceRoot: `${projectRoot}/src`,
19 targets: {},
20 });
21 generateFiles(tree, path.join(__dirname, 'files'), projectRoot, options);
22 await formatFiles(tree);
23}
24
25export default applicationGenerator;
26
The generateFiles
function will use the template files in the files
folder to add files to the generated project.
1{
2 "name": "<%= name %>",
3 "dependencies": {}
4}
5
The generator options in the schema files can be left unchanged.
Test Your Plugin
The plugin is generated with a default e2e test (e2e/src/nx-astro.spec.ts
) that:
- Launches a local npm registry with Verdaccio
- Publishes the current version of the
nx-astro
plugin to the local registry - Creates an empty Nx workspace
- Installs
nx-astro
in the Nx workspace
Let's update the e2e tests to make sure that the inferred tasks are working correctly. We'll update the beforeAll
function to use nx add
to add the nx-astro
plugin and call our application
generator.
1beforeAll(() => {
2 projectDirectory = createTestProject();
3
4 // The plugin has been built and published to a local registry in the jest globalSetup
5 // Install the plugin built with the latest source code into the test repo
6 execSync('npx nx add nx-astro@e2e', {
7 cwd: projectDirectory,
8 stdio: 'inherit',
9 env: process.env,
10 });
11 execSync('npx nx g nx-astro:application my-lib', {
12 cwd: projectDirectory,
13 stdio: 'inherit',
14 env: process.env,
15 });
16});
17
Now we can add a new test that verifies the inferred task configuration:
1it('should infer tasks', () => {
2 const projectDetails = JSON.parse(
3 execSync('nx show project my-lib --json', {
4 cwd: projectDirectory,
5 }).toString()
6 );
7
8 expect(projectDetails).toMatchObject({
9 name: 'my-lib',
10 root: 'my-lib',
11 sourceRoot: 'my-lib/src',
12 targets: {
13 build: {
14 cache: true,
15 executor: 'nx:run-commands',
16 inputs: [
17 '{projectRoot}/astro.config.mjs',
18 '{projectRoot}/src/**/*',
19 '{projectRoot}/public/**/*',
20 {
21 externalDependencies: ['astro'],
22 },
23 ],
24 options: {
25 command: 'astro build',
26 cwd: 'my-lib',
27 },
28 outputs: ['{projectRoot}/./dist'],
29 },
30 dev: {
31 executor: 'nx:run-commands',
32 options: {
33 command: 'astro dev',
34 cwd: 'my-lib',
35 },
36 },
37 },
38 });
39});
40
Next Steps
Now that you have a working plugin, here are a few other topics you may want to investigate:
- Publish your Nx plugin to npm and the Nx plugin registry
- Write migration generators to automatically account for breaking changes
- Create a preset to scaffold out an entire new repository