Writing A Plugin
Plugins provide Knip with entry files and dependencies it would be unable to find otherwise. Plugins always do at least one of the following:
- Define entry file patterns
- Find dependencies in configuration files
Knip v5.1.0 introduced a new plugin API, which makes them a breeze to write and maintain.
This tutorial walks through example plugins so you’ll be ready to write your own! The following examples demonstrate the elements a plugin can implement.
There’s a handy command available to easily create a new plugin and get started right away.
Example 1: entry
Section titled “Example 1: entry”Let’s dive right in. Here’s the entire source code of the Tailwind plugin:
import type { IsPluginEnabled, Plugin } from '../../types/config.js';import { hasDependency } from '../../util/plugin.js';
const title = 'Tailwind';
const enablers = ['tailwindcss'];
const isEnabled: IsPluginEnabled = ({ dependencies }) => hasDependency(dependencies, enablers);
const entry = ['tailwind.config.{js,cjs,mjs,ts}'];
export default { title, enablers, isEnabled, entry,} satisfies Plugin;
Yes, that’s the entire plugin! Let’s go over each item one by one:
1. title
Section titled “1. title”The title of the plugin displayed in the list of plugins and in debug output.
2. enablers
Section titled “2. enablers”An array of strings to match one or more dependencies in package.json
so the
isEnabled
function can determine whether the plugin should be enabled or not.
Regular expressions are allowed as well.
3. isEnabled
Section titled “3. isEnabled”This function checks whether a match is found in the dependencies
or
devDependencies
in package.json
. The plugin is be enabled if the dependency
is listed in package.json
.
This function can be kept straightforward with the hasDependency
helper.
4. entry
Section titled “4. entry”This plugin exports entry
file patterns. This means that if the Tailwind
plugin is enabled, then tailwind.config.*
files are added as entry files. A
Tailwind configuration file does not contain anything particular, so adding it
as an entry
to treat it as a regular source file is enough.
The next example shows how to handle a tool that has its own particular configuration object.
Example 2: config
Section titled “Example 2: config”Here’s the full source code of the nyc
plugin:
import { toDeferResolve } from '../../util/input.js';import { hasDependency } from '../../util/plugin.js';import type { NycConfig } from './types.js';import type { IsPluginEnabled, Plugin, ResolveConfig,} from '../../types/config.js';
const title = 'nyc';
const enablers = ['nyc'];
const isEnabled: IsPluginEnabled = ({ dependencies }) => hasDependency(dependencies, enablers);
const config = [ '.nycrc', '.nycrc.{json,yml,yaml}', 'nyc.config.js', 'package.json',];
const resolveConfig: ResolveConfig<NycConfig> = config => { const extend = config?.extends ?? []; const requires = config?.require ?? []; return [extend, requires].flat().map(id => toDeferResolve(id));};
export default { title, enablers, isEnabled, config, resolveConfig,} satisfies Plugin;
Here’s an example config
file that will be handled by this plugin:
{ "extends": "@istanbuljs/nyc-config-typescript", "check-coverage": true}
Compared to the first example, this plugin has two new variables:
5. config
Section titled “5. config”The config
array contains all possible locations of the config file for the
tool. Knip loads matching files and passes the results (i.e. its default export)
into the resolveConfig
function:
6. resolveConfig
Section titled “6. resolveConfig”This function receives the exported value of the config
file, and executes the
resolveConfig
function with this object. The plugin should return the entry
paths and dependencies referenced in this object.
Knip supports JSON, YAML, TOML, JavaScript and TypeScript config files. Files without an extension are provided as plain text strings.
Example 3: entry paths
Section titled “Example 3: entry paths”7. entry and production
Section titled “7. entry and production”Some tools operate mostly on entry files, some examples:
- Mocha looks for test files at
test/*.{js,cjs,mjs}
- Storybook looks for stories at
*.stories.@(mdx|js|jsx|tsx)
And some of those tools allow to configure those locations and patterns in
configuration files, such as next.config.js
or vite.config.ts
. If that’s the
case we can define resolveConfig
in our plugin to take this from the
configuration object and return it to Knip:
Here’s an example from the Mocha plugin:
const entry = ['**/test/*.{js,cjs,mjs}'];
const resolveConfig: ResolveConfig<MochaConfig> = localConfig => { const entryPatterns = localConfig.spec ? [localConfig.spec].flat() : entry; return entryPatterns.map(id => toEntry(id));};
export default { entry, resolveConfig,};
With Mocha, you can configure spec
file patterns. The result of implementing
resolveConfig
is that users don’t need to duplicate this configuration in both
the tool (e.g. Mocha) and Knip.
Use production
entries to target source files that represent production code.
Example 4: Use the AST directly
Section titled “Example 4: Use the AST directly”If the resolveFromConfig
function is impemented, Knip loads the configuration
file and passes the default-exported object to this plugin function. However,
that object might then not contain the information we need.
Here’s an example astro.config.ts
configuration file with a Starlight
integration:
import starlight from '@astrojs/starlight';import { defineConfig } from 'astro/config';
export default defineConfig({ integrations: [ starlight({ components: { Head: './src/components/Head.astro', Footer: './src/components/Footer.astro', }, }), ],});
With Starlight, components can be defined to override the default internal ones. They’re not otherwise referenced in your source code, so you’d have to manually add them as entry files (Knip itself did this).
In the Astro plugin, there’s no way to access this object containing
components
to add the component files as entry files if we were to try:
const resolveConfig: ResolveConfig<AstroConfig> = async config => { console.log(config); // ¯\_(ツ)_/¯};
This is why plugins can implement the resolveFromAST
function.
7. resolveFromAST
Section titled “7. resolveFromAST”Let’s take a look at the Astro plugin implementation. This example assumes some familiarity with Abstract Syntax Trees (AST) and the TypeScript compiler API. Knip will provide more and more AST helpers to make implementing plugins more fun and a little less tedious.
Anyway, let’s dive in. Here’s how we’re adding the Starlight components
paths
to the default production
file patterns:
import ts from 'typescript';import { getDefaultImportName, getImportMap, getPropertyValues,} from '../../typescript/ast-helpers.js';
const title = 'Astro';
const production = [ 'src/pages/**/*.{astro,mdx,js,ts}', 'src/content/**/*.mdx', 'src/middleware.{js,ts}', 'src/actions/index.{js,ts}',];
const getComponentPathsFromSourceFile = (sourceFile: ts.SourceFile) => { const componentPaths: Set<string> = new Set(); const importMap = getImportMap(sourceFile); const importName = getDefaultImportName(importMap, '@astrojs/starlight');
function visit(node: ts.Node) { if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === importName // match the starlight() function call ) { const starlightConfig = node.arguments[0]; if (ts.isObjectLiteralExpression(starlightConfig)) { const values = getPropertyValues(starlightConfig, 'components'); for (const value of values) componentPaths.add(value); } }
ts.forEachChild(node, visit); }
visit(sourceFile);
return componentPaths;};
const resolveFromAST: ResolveFromAST = (sourceFile: ts.SourceFile) => { // Include './src/components/Head.astro' and './src/components/Footer.astro' // as production entry files so they're also part of the analysis const componentPaths = getComponentPathsFromSourceFile(sourceFile); return [...production, ...componentPaths].map(id => toProductionEntry(id));};
export default { title, production, resolveFromAST,} satisfies Plugin;
Inputs
Section titled “Inputs”You may have noticed functions like toDeferResolve
and toEntry
. They’re a
way for plugins to tell what they’ve found and how Knip should handle those. The
more precision a plugin can provide here, the better results and performance
will be.
Find all the details over at Writing A Plugin → Inputs.
Argument Parsing
Section titled “Argument Parsing”As part of the script parser, Knip parses command-line arguments. Plugins
can implement the arg
object to add custom argument parsing tailored to the
tool.
Read more in Writing A Plugin → Argument Parsing.
Create a new plugin
Section titled “Create a new plugin”The easiest way to create a new plugin is to use the create-plugin
script:
cd packages/knipbun create-plugin --name tool
This adds source and test files and fixtures to get you started. It also adds the plugin to the JSON Schema and TypeScript types.
Run the test for your new plugin:
bun test test/plugins/tool.test.ts
You’re ready to implement and submit a new Knip plugin! 🆕 🎉
Wrapping Up
Section titled “Wrapping Up”Feel free to check out the implementation of other similar plugins, and borrow ideas and code from those!
The documentation website takes care of generating the plugin list and the individual plugin pages from the exported plugin values.
Thanks for reading. If you have been following this guide to create a new plugin, this might be the right time to open a pull request!
ISC License © 2024 Lars Kappert