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.
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.
In summary: if tailwind is listed as a dependency then tailwind.config.*
files are added as entry files.
With many tools, the dynamic configuration file import dependencies such as
plugins or reporters with regular require or import statements. In this
case, we have no extra work in the Knip plugin, as they’ll be treated as regular
entry files. All internal and external dependencies of the tailwind.config.ts
entry file will be marked as used.
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(toDeferResolve);};
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. what resolves as
the default export) into the resolveConfig function:
6. resolveConfig
Section titled “6. resolveConfig”This function receives the exported value of the config files, and executes
the resolveConfig function with this object. The plugin should return the
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: custom entry paths
Section titled “Example 3: custom entry paths”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. If
that’s the case, than we can define resolveEntryPaths in our plugin to take
this from the configuration object and return it to Knip:
7. resolveEntryPaths
Section titled “7. resolveEntryPaths”Here’s an example from the Preconstruct plugin:
const resolveEntryPaths: ResolveConfig<PreconstructConfig> = async config => { return (config.entrypoints ?? []).map(id => toEntry(id));};With Preconstruct, you can configure entrypoints. If this function is
implemented in a plugin, Knip will use its return value over the default entry
patterns. The result is that you don’t need to duplicate this customization in
both the tool (e.g. Preconstruct) and Knip.
Example 4: Use the AST directly
Section titled “Example 4: Use the AST directly”For the resolveEntryPaths and resolveFromConfig functions, 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 resolveEntryPaths: ResolveEntryPaths<AstroConfig> = async config => { console.log(config); // ¯\_(ツ)_/¯};This is why plugins can implement the resolveFromAST function.
8. resolveFromAST
Section titled “8. 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 precise a plugin can be, the better it is for results and performance.
Here’s an overview of all input type functions:
toEntry
Section titled “toEntry”An entry input is just like an entry in the configuration. It should either
be an absolute or relative path, and glob patterns are allowed.
toProductionEntry
Section titled “toProductionEntry”A production entry input is just like an production in the configuration. It
should either be an absolute or relative path, and it can have glob patterns.
toProject
Section titled “toProject”A project input is the equivalent of project patterns in the configuration.
It should either be an absolute or relative path, and (negated) glob patterns
are allowed.
toDependency
Section titled “toDependency”The dependency indicates the entry is a dependency, belonging in either the
"dependencies" or "devDependencies" section of package.json.
toProductionDependency
Section titled “toProductionDependency”The production dependency indicates the entry is a production dependency,
expected to be listed in "dependencies".
toDeferResolve
Section titled “toDeferResolve”The deferResolve input type is used to defer the resolution of a specifier.
This could be resolved to a dependency or an entry file. For instance, the
specifier "input" could be resolved to "input.js", "input.tsx",
"input/index.js" or the "input" package name. Local files are added as entry
files, package names are external dependencies.
If this does not lead to a resolution, the specifier will be reported under “unresolved imports”.
toDeferResolveEntry
Section titled “toDeferResolveEntry”The deferResolveEntry input type is similar to deferResolve, but it’s used
for entry files only (not dependencies) and unresolved inputs are ignored. It’s
different from toEntry as glob patterns are not supported.
toConfig
Section titled “toConfig”The config input type is a way for plugins to reference a configuration file
that should be handled by a different plugin. For instance, Angular
configurations might contain references to tsConfig and karmaConfig files,
so these config files can then be handled by the TypeScript and Karma plugins,
respectively.
Requires the pluginName option.
toBinary
Section titled “toBinary”The binary input type isn’t used by plugins directly, but by the shell script
parser (through the getInputsFromScripts helper). Think of GitHub Actions
worfklow YAML files or husky scripts. Using this input type, a binary is
“assigned” to the dependency that has it as a "bin" in their package.json.
Options
Section titled “Options”When creating inputs from specifiers, an extra options object as the second
argument can be provided.
The optional dir option assigns the input to a different workspace. For
instance, GitHub Action workflows are always stored in the root workspace, and
support working-directory in job steps. For example:
jobs: stylelint: runs-on: ubuntu-latest steps: - run: npx esbuild working-directory: packages/appThe GitHub Action plugin understands working-directory and adds this dir to
the input:
toDependency('esbuild', { dir: 'packages/app' });Knip now understands esbuild is a dependency of the workspace in the
packages/app directory.
allowIncludeExports
Section titled “allowIncludeExports”By default, exports of entry files such as src/index.ts or the files in
package.json#exports are not reported as unused. When using the
--include-entry-exports flag or isIncludeExports: true option, unused
exports on such entry files are also reported.
Exports of entry files coming from plugins are not included in the analysis, even with the option enabled. This is because certain tools and frameworks consume named exports from entry files, causing false positives.
The allowIncludeExports option allows the exports of entry files to be
reported as unused when using --include-entry-exports. This option is
typically used with the toProductionEntry input type. Example:
toProductionEntry('./entry.ts', { allowIncludeExports: true });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
executables of the tool.
For now, there are two resources available to learn more:
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 toolThis 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.tsYou’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! Feel free to join the Knip Discord channel if you have any questions.
ISC License © 2024 Lars Kappert