Plugin Development Guide
Getting started
A CLI plugin is an npm package that can add additional features to the project using Kdu CLI. These features can include:
- changing project webpack config - for example, you can add a new webpack resolve rule for a certain file extension, if your plugin is supposed to work with this type of files. Say,
@kdujs/cli-plugin-typescript
adds such rule to resolve.ts
and.tsx
extensions; - adding new kdu-cli-service command - for example,
@kdujs/cli-plugin-unit-jest
adds a new commandtest:unit
that allows developer to run unit tests; - extending
package.json
- a useful option when your plugin adds some dependencies to the project and you need to add them to package dependencies section; - creating new files in the project and/or modifying old ones. Sometimes it's a good idea to create an example component or modify a main file to add some imports;
- prompting user to select certain options - for example, you can ask user if they want to create the example component mentioned above.
TIP
Don't overuse kdu-cli plugins! If you want just to include a certain dependency, e.g. Lodash - it's easier to do it manually with npm than create a specific plugin only to do so.
CLI Plugin should always contain a Service Plugin as its main export, and can optionally contain a Generator, a Prompt File and a Kdu UI integration.
As an npm package, CLI plugin must have a package.json
file. It's also recommended to have a plugin description in README.md
to help others find your plugin on npm.
So, typical CLI plugin folder structure looks like the following:
.
├── README.md
├── generator.js # generator (optional)
├── index.js # service plugin
├── package.json
├── prompts.js # prompts file (optional)
└── ui.js # Kdu UI integration (optional)
Naming and discoverability
For a CLI plugin to be usable in a Kdu CLI project, it must follow the name convention kdu-cli-plugin-<name>
or @scope/kdu-cli-plugin-<name>
. It allows your plugin to be:
- Discoverable by
@kdujs/cli-service
; - Discoverable by other developers via searching;
- Installable via
kdu add <name>
orkdu invoke <name>
.
Warning
Make sure to name the plugin correctly, otherwise it will be impossible to install it via kdu add
command or find it with Kdu UI plugins search!
For better discoverability when a user searches for your plugin, put keywords describing your plugin in the description
field of the plugin package.json
file.
Generator
A Generator part of the CLI plugin is usually needed when you want to extend your package with new dependencies, create new files in your project or edit existing ones.
Inside the CLI plugin the generator should be placed in a generator.js
or generator/index.js
file. It will be invoked in two possible scenarios:
During a project's initial creation, if the CLI plugin is installed as part of the project creation preset.
When the plugin is installed after project's creation and invoked individually via
kdu add
orkdu invoke
.
A generator should export a function which receives three arguments:
A GeneratorAPI instance;
The generator options for this plugin. These options are resolved during the prompt phase of project creation, or loaded from a saved preset in
~/.kdurc
. For example, if the saved~/.kdurc
looks like this:
{
"presets" : {
"foo": {
"plugins": {
"@kdujs/cli-plugin-foo": { "option": "bar" }
}
}
}
}
And if the user creates a project using the foo
preset, then the generator of @kdujs/cli-plugin-foo
will receive { option: 'bar' }
as its second argument.
For a 3rd party plugin, the options will be resolved from the prompts or command line arguments when the user executes kdu invoke
(see Prompts).
- The entire preset (
presets.foo
) will be passed as the third argument.
Creating new templates
When you call api.render('./template')
, the generator will render files in ./template
(resolved relative to the generator file) with EJS.
Let's imagine we're creating plugin and we want to make the following changes to the project on plugin invoke:
- create a
layouts
folder with a default layout file; - create a
pages
folder withabout
andhome
pages; - add a
router.js
to thesrc
folder root
To render this structure, you need to create it first inside the generator/template
folder:
.
└─ generator
└─ template
├─ src
│ ├─ layouts
│ │ └─ default.kdu
│ ├─ pages
│ │ ├─ about.kdu
│ │ └─ index.kdu
│ └─ router.js
└─ index.js
After template is created, you should add api.render
call to the generator/index.js
file:
module.exports = api => {
api.render('./template')
}
Editing existing templates
In addition, you can inherit and replace parts of an existing template file (even from another package) using YAML front-matter:
---
extend: '@kdujs/cli-service/generator/template/src/App.kdu'
replace: !!js/regexp /<script>[^]*?<\/script>/
---
<script>
export default {
// Replace default script
}
</script>
It's also possible to do multiple replaces, although you will need to wrap your replace strings within <%# REPLACE %>
and <%# END_REPLACE %>
blocks:
---
extend: '@kdujs/cli-service/generator/template/src/App.kdu'
replace:
- !!js/regexp /Welcome to Your Kdu\.js App/
- !!js/regexp /<script>[^]*?<\/script>/
---
<%# REPLACE %>
Replace Welcome Message
<%# END_REPLACE %>
<%# REPLACE %>
<script>
export default {
// Replace default script
}
</script>
<%# END_REPLACE %>
Filename edge cases
If you want to render a template file that either begins with a dot (i.e. .env
) you will have to follow a specific naming convention, since dotfiles are ignored when publishing your plugin to npm:
# dotfile templates have to use an underscore instead of the dot:
/generator/template/_env
# When calling api.render('./template'), this will be rendered in the project folder as:
/generator/template/.env
Consequently, this means that you also have to follow a special naming convention if you want to render file whose name actually begins with an underscore:
# such templates have to use two underscores instead of one:
/generator/template/__variables.scss
# When calling api.render('./template'), this will be rendered in the project folder as:
/generator/template/_variables.scss
Extending package
If you need to add an additional dependency to the project, create a new npm script or modify package.json
in any other way, you can use API extendPackage
method.
In the example above we added one dependency: kdu-router-layout
. During the plugin invocation this npm module will be installed and this dependency will be added to the user package.json
file.
With the same API method we can add new npm tasks to the project. To do so, we need to specify task name and a command that should be run in the scripts
section of the user package.json
:
// generator/index.js
module.exports = api => {
api.extendPackage({
scripts: {
greet: 'kdu-cli-service greet'
}
})
}
In the example above we're adding a new greet
task to run a custom kdu-cli service command created in Service section.
Changing main file
With generator methods you can make changes to the project files. The most usual case is some modifications to main.js
or main.ts
file: new imports, new Kdu.use()
calls etc.
Let's consider the case where we have created a router.js
file via templating and now we want to import this router to the main file. We will use two Generator API methods: entryFile
will return the main file of the project (main.js
or main.ts
) and injectImports
serves for adding new imports to this file:
// generator/index.js
api.injectImports(api.entryFile, `import router from './router'`)
Now, when we have a router imported, we can inject this router to the Kdu instance in the main file. We will use afterInvoke
hook which is to be called when the files have been written to disk.
First, we need to read main file content with Node fs
module (which provides an API for interacting with the file system) and split this content on lines:
// generator/index.js
module.exports.hooks = (api) => {
api.afterInvoke(() => {
const fs = require('fs')
const contentMain = fs.readFileSync(api.resolve(api.entryFile), { encoding: 'utf-8' })
const lines = contentMain.split(/\r?\n/g)
})
}
Then we should to find the string containing render
word (it's usually a part of Kdu instance) and add our router
as a next string:
// generator/index.js
module.exports.hooks = (api) => {
api.afterInvoke(() => {
const fs = require('fs')
const contentMain = fs.readFileSync(api.resolve(api.entryFile), { encoding: 'utf-8' })
const lines = contentMain.split(/\r?\n/g)
const renderIndex = lines.findIndex(line => line.match(/render/))
lines[renderIndex] += `\n router,`
})
}
Finally, you need to write the content back to the main file:
// generator/index.js
module.exports.hooks = (api) => {
api.afterInvoke(() => {
const { EOL } = require('os')
const fs = require('fs')
const contentMain = fs.readFileSync(api.resolve(api.entryFile), { encoding: 'utf-8' })
const lines = contentMain.split(/\r?\n/g)
const renderIndex = lines.findIndex(line => line.match(/render/))
lines[renderIndex] += `${EOL} router,`
fs.writeFileSync(api.resolve(api.entryFile), lines.join(EOL), { encoding: 'utf-8' })
})
}
Service Plugin
Service plugin serves for modifying webpack config, creating new kdu-cli service commands or changing existing commands (such as serve
and build
).
Service plugins are loaded automatically when a Service instance is created - i.e. every time the kdu-cli-service
command is invoked inside a project. It's located in the index.js
file in CLI plugin root folder.
A service plugin should export a function which receives two arguments:
A PluginAPI instance
An object containing project local options specified in
kdu.config.js
, or in the"kdu"
field inpackage.json
.
The minimal required code in the service plugin file is the following:
module.exports = () => {}
Modifying webpack config
The API allows service plugins to extend/modify the internal webpack config for different environments. For example, here we're modifying webpack config with webpack-chain to include kdu-auto-routing
webpack plugin with given parameters:
const KduAutoRoutingPlugin = require('kdu-auto-routing/lib/webpack-plugin')
module.exports = (api, options) => {
api.chainWebpack(webpackConfig => {
webpackConfig
.plugin('kdu-auto-routing')
.use(KduAutoRoutingPlugin, [
{
pages: 'src/pages',
nested: true
}
])
})
}
You can also use configureWebpack
method to modify the webpack config or return object to be merged with webpack-merge.
Add a new cli-service command
With service plugin you can register a new cli-service command in addition to standard ones (i.e. serve
and build
). You can do it with a registerCommand
API method.
Here is an example of creating a simple new command that will print a greeting to developer console:
api.registerCommand(
'greet',
{
description: 'Writes a greeting to the console',
usage: 'kdu-cli-service greet'
},
() => {
console.log(`👋 Hello`)
}
)
In this example we provided the command name ('greet'
), an object of command options with description
and usage
, and a function that will be run on kdu-cli-service greet
command.
TIP
You can add new command to the list of project npm scripts inside the package.json
file via Generator.
If you try to run a new command in the project with your plugin installed, you will see the following output:
$ kdu-cli-service greet
👋 Hello!
You can also specify a list of available options for a new command. Let's add the option --name
and change the function to print this name if it's provided.
api.registerCommand(
'greet',
{
description: 'Writes a greeting to the console',
usage: 'kdu-cli-service greet [options]',
options: { '--name': 'specifies a name for greeting' }
},
args => {
if (args.name) {
console.log(`👋 Hello, ${args.name}!`);
} else {
console.log(`👋 Hello!`);
}
}
);
Now, if you a greet
command with a specified --name
option, this name will be added to console message:
$ kdu-cli-service greet --name 'John Doe'
👋 Hello, John Doe!
Modifying existing cli-service command
If you want to modify an existing cli-service command, you can retrieve it with api.service.commands
and add some changes. We're going to print a message to the console with a port where application is running:
const { serve } = api.service.commands
const serveFn = serve.fn
serve.fn = (...args) => {
return serveFn(...args).then(res => {
if (res && res.url) {
console.log(`Project is running now at ${res.url}`)
}
})
}
In the example above we retrieve the serve
command from the list of existing commands; then we modify its fn
part (fn
is the third parameter passed when you create a new command; it specifies the function to run when running the command). With the modification done the console message will be printed after serve
command has run successfully.
Specifying Mode for Commands
If a plugin-registered command needs to run in a specific default mode, the plugin needs to expose it via module.exports.defaultModes
in the form of { [commandName]: mode }
:
module.exports = api => {
api.registerCommand('build', () => {
// ...
})
}
module.exports.defaultModes = {
build: 'production'
}
This is because the command's expected mode needs to be known before loading environment variables, which in turn needs to happen before loading user options / applying the plugins.
Prompts
Prompts are required to handle user choices when creating a new project or adding a new plugin to the existing one. All prompts logic is stored inside the prompts.js
file. The prompts are presented using inquirer under the hood.
When user initialize the plugin by calling kdu invoke
, if the plugin contains a prompts.js
in its root directory, it will be used during invocation. The file should export an array of Questions that will be handled by Inquirer.js.
You should export directly array of questions, or export function that return those.
e.g. directly array of questions:
// prompts.js
module.exports = [
{
type: 'input',
name: 'locale',
message: 'The locale of project localization.',
validate: input => !!input,
default: 'en'
},
// ...
]
e.g. function that return array of questions:
// prompts.js
// pass `package.json` of project to function argument
module.exports = pkg => {
const prompts = [
{
type: 'input',
name: 'locale',
message: 'The locale of project localization.',
validate: input => !!input,
default: 'en'
}
]
// add dynamically prompt
if ('@kdujs/cli-plugin-eslint' in (pkg.devDependencies || {})) {
prompts.push({
type: 'confirm',
name: 'useESLintPluginKduI18n',
message: 'Use ESLint plugin for Kdu I18n ?'
})
}
return prompts
}
The resolved answers object will be passed to the plugin's generator as options.
Alternatively, the user can skip the prompts and directly initialize the plugin by passing options via the command line, e.g.:
kdu invoke my-plugin --mode awesome
Prompt can have different types but the most widely used in CLI are checkbox
and confirm
. Let's add a confirm
prompt and then use it in plugin generator to create a condition for template rendering.
// prompts.js
module.exports = [
{
name: `addExampleRoutes`,
type: 'confirm',
message: 'Add example routes?',
default: false
}
]
On plugin invoke user will be prompted with the question about example routes and the default answer will be No
.
If you want to use the result of the user's choice in generator, it will be accessible with the prompt name. We can add a modification to generator/index.js
:
if (options.addExampleRoutes) {
api.render('./template', {
...options
})
}
Now template will be rendered only if user agreed to create example routes.
Publish Plugin to npm
To publish your plugin, you need to be registered an npmjs.com and you should have npm
installed globally. If it's your first npm module, please run
npm login
Enter your username and password. This will store the credentials so you don’t have to enter it for every publish.
TIP
Before publishing a plugin, make sure you choose a right name for it! Name convention is kdu-cli-plugin-<name>
. Check Discoverability section for more information
To publish a plugin, go to the plugin root folder and run this command in the terminal:
npm publish
After successful publish, you should be able to add your plugin to the project created with Kdu CLI with kdu add <plugin-name>
command.