@dnbhq/lintstaged-config
v0.2.1
Published
Shared, configurable lint-staged task factory for DNBHQ projects.
Maintainers
Readme
@dnbhq/lintstaged-config
Shared, configurable lint-staged task factory for DNBHQ projects
- Why this package
- Installation
- Quick start (config file)
- Quick start (package.json)
- Default tasks
- Enabling an opt-in task
- Disabling a default task
- Overriding a task's glob or commands
- Adding entries with no built-in task
- Per-task configuration options
- Checking that the required tools are installed
- Testing your lint-staged setup
- TypeScript config file support
- Release
- Notes
Why this package
lint-staged configuration is a flat object: a glob pattern mapped to one or more commands. There is no extends field like Biome or markdownlint-cli2 have, so sharing a baseline across repositories means either copy-pasting the object everywhere, or wrapping it in a small factory function that can be imported and customised. This package is that factory.
It does not install the linters it wires up. Most of the underlying tools (secretlint, @biomejs/biome, markdownlint-cli2, stylelint) are npm packages you add as dev dependencies yourself, so their versions stay under your project's control (the same reasoning @dnbhq/tsconfig uses for TypeScript, and @dnbhq/biome-config for Biome). A few others (yamllint, jsonnetfmt) are not npm packages at all — they're Python/Go binaries — so this package could not install them even if it wanted to. See Checking that the required tools are installed for the doctor command that checks for them instead.
Installation
npm install --save-dev @dnbhq/lintstaged-config lint-stagedThen add whichever of the default tools you plan to use (see Default tasks) as dev dependencies, for example:
npm install --save-dev secretlint @biomejs/biome markdownlint-cli2Quick start (config file)
Create lint-staged.config.ts in the repository root:
import { createLintStagedConfig } from '@dnbhq/lintstaged-config';
import type { Configuration } from 'lint-staged';
const config: Configuration = createLintStagedConfig();
export default config;Wire it into a git hook with Husky:
npx husky init
echo "npx lint-staged" > .husky/pre-commitRunning .ts config files directly requires Node.js 22.6+ with --experimental-strip-types, or Node.js 23.6+ where it is unflagged — see TypeScript config file support if your Node version is older.
Quick start (package.json)
lint-staged's package.json field only accepts a plain JSON object — it cannot require() or import a function, so createLintStagedConfig() cannot be referenced from it directly. Two options if a standalone config file is not what you want:
Pipe the resolved default config into lint-staged via stdin. lint-staged reads JSON config from stdin when given --config -, and this package's CLI prints its default configuration as single-line JSON on stdout:
{
"scripts": {
"lint-staged": "lintstaged-config print | lint-staged --config -"
}
}This only covers the built-in defaults with no customisation — see the caveat in Per-task configuration options. Anything beyond flipping which tasks are enabled needs the config-file approach above.
Or copy the printed JSON once into the lint-staged key of package.json and edit it by hand from there:
npx lintstaged-config printThis gives you a static starting point rather than a live extension of the shared config — you will not pick up future changes to the defaults until you re-run and re-paste. Prefer the config-file approach in Quick start (config file) whenever you expect the defaults to evolve.
Default tasks
createLintStagedConfig() builds its result from eight named tasks. Three are enabled out of the box because they only depend on npm-installable tools; the rest are opt-in.
| Task | Enabled by default | Glob | Command(s) | Requires |
| --- | --- | --- | --- | --- |
| secrets | Yes | * | secretlint --no-glob | secretlint |
| javascript | Yes | *.{js,cjs,mjs,jsx,ts,tsx,cts,mts,json,jsonc} | biome check --write --no-errors-on-unmatched | @biomejs/biome |
| markdown | Yes | !(CHANGELOG)**/*.{md,markdown,mdx} | markdownlint-cli2 --fix | markdownlint-cli2 |
| styles | No | *.{css,scss} | stylelint --fix | stylelint |
| yaml | No | *.{yaml,yml} | yamllint | yamllint (not npm) |
| images | No | *.{png,jpeg,jpg,gif,svg} | sharp-lint-staged | @dnbhq/sharp-lint-staged |
| astro | No | *.astro | npx astro check --minimumFailingSeverity=error --minimumSeverity=error | astro |
| jsonnet | No | *.jsonnet | jsonnetfmt --in-place | jsonnetfmt (not npm) |
styles, yaml, images, and astro are opt-in because they only apply to some projects. yaml and jsonnet are opt-in for an additional reason: yamllint and jsonnetfmt are not npm packages, so pulling them in as a default would fail silently for anyone without them on PATH.
Enabling an opt-in task
Set enabled: true on the task:
import { createLintStagedConfig } from '@dnbhq/lintstaged-config';
import type { Configuration } from 'lint-staged';
const config: Configuration = createLintStagedConfig({
styles: { enabled: true },
images: { enabled: true },
});
export default config;Disabling a default task
Set enabled: false on the task. This removes its glob entry entirely, rather than replacing it with an empty command list:
import { createLintStagedConfig } from '@dnbhq/lintstaged-config';
import type { Configuration } from 'lint-staged';
const config: Configuration = createLintStagedConfig({
markdown: { enabled: false },
});
export default config;Overriding a task's glob or commands
Each task accepts glob and commands independently, so you can keep the default glob and swap the command, keep the command and swap the glob, or replace both:
import { createLintStagedConfig } from '@dnbhq/lintstaged-config';
import type { Configuration } from 'lint-staged';
const config: Configuration = createLintStagedConfig({
javascript: {
// Keep the default glob, run an extra Biome pass.
commands: ['biome check --write --no-errors-on-unmatched', 'biome lint --write --no-errors-on-unmatched'],
},
markdown: {
// Keep the default command, narrow the glob to docs/ only.
glob: 'docs/**/*.md',
},
});
export default config;Adding entries with no built-in task
Pass overrides for anything this package does not model as a task — a completely custom glob, or a function-based entry like lint-staged's advanced JS config supports. overrides is applied last with a shallow { ...generated, ...overrides } spread:
import { createLintStagedConfig } from '@dnbhq/lintstaged-config';
import type { Configuration } from 'lint-staged';
const config: Configuration = createLintStagedConfig({
overrides: {
'.vscode/settings.json': () => 'node scripts/vscode/merge-vscode-config.ts --audit',
'*.jsonnet': ['jsonnetfmt --in-place', 'git add'],
},
});
export default config;Because the spread happens last, an overrides key that reuses a default task's exact glob string fully replaces that task's commands — this is an alternative to the javascript: { commands: [...] } form above when you would rather not touch the task options at all. The two approaches produce the same result; overrides is there for entries a task option cannot express (functions, or globs no task owns).
Per-task configuration options
secrets, markdown, styles, and yaml accept a configPath (and secrets also an ignorePath) that gets appended as the tool's own config flag, instead of relying on the tool's automatic config discovery:
import { createLintStagedConfig } from '@dnbhq/lintstaged-config';
import type { Configuration } from 'lint-staged';
const config: Configuration = createLintStagedConfig({
secrets: {
configPath: 'config/.secretlintrc.json',
ignorePath: 'config/.secretlintignore',
},
markdown: {
configPath: 'config/.markdownlint-cli2.jsonc',
},
yaml: {
enabled: true,
configPath: 'config/yamllint.yaml',
},
});
export default config;Setting configPath and setting commands together is redundant: configPath only has an effect on the command the task builds internally, so if you also set commands your explicit command list wins and configPath is ignored.
Checking that the required tools are installed
Since this package does not install any of the underlying linters, it ships a doctor command that checks whether they are reachable — on PATH, or in the project's local node_modules/.bin — without running them:
npx lintstaged-config doctor✔ secretlint (secrets)
✖ markdownlint-cli2 (markdown)
npm install --save-dev markdownlint-cli2 (or @dnbhq/markdownlint-config)
✔ biome (javascript)
...Restrict the check to specific tasks by passing their names:
npx lintstaged-config doctor javascript markdowndoctor exits with code 1 when any checked tool is missing, so it is safe to wire into CI as a setup-verification step:
{
"scripts": {
"postinstall": "lintstaged-config doctor secrets javascript markdown || true"
}
}(the || true keeps a missing optional tool from failing npm install; drop it if you want a hard failure instead).
Testing your lint-staged setup
"Testing" a lint-staged config means two different things, and this package supports both:
Testing this package itself — if you are contributing to
@dnbhq/lintstaged-config,npm testbuilds the package and runs its vitest suite (tests/*.spec.ts), which covers task enabling/disabling, glob/command overrides, theoverridesmerge, thedoctortool-detection logic, and theprintCLI output.Testing a consuming project's
lint-staged.config.ts— do this with lint-staged's own dry-run tooling rather than a unit test:# Stage a file that should be picked up, then run lint-staged without # letting it touch your working tree: git add some-file.ts npx lint-staged --debug --no-stash--debugprints which glob matched which staged files and which commands ran, and--no-stashskips lint-staged's usual git stash so you can inspect the result directly. Revert or re-stage afterwards as needed.To sanity-check the resolved configuration itself without running any tools, print it and read the JSON:
npx lintstaged-config print # or, for a customised config file: node --experimental-strip-types -e "import('./lint-staged.config.ts').then(m => console.log(JSON.stringify(m.default, null, 2)))"
TypeScript config file support
lint-staged loads .ts config files by handing them to Node's own type-stripping, not by bundling ts-node/jiti/tsx. That means:
- Node.js 22.6+ requires the file to be run with
NODE_OPTIONS=--experimental-strip-types(ornode --experimental-strip-typeswhen invokinglint-stageddirectly). - Node.js 23.6+ has this unflagged, so
lint-staged.config.tsworks with no extra setup. - The config file itself must use only erasable TypeScript syntax — type annotations, interfaces, and
import typeare fine (that's all the examples in this README use), butenum, namespaces with runtime values, and parameter properties are not, because Node only strips types, it does not transpile.
If your project's Node version does not support this yet, use a .mjs config file instead and keep type-checking via a JSDoc annotation, per lint-staged's own TypeScript documentation:
/**
* @type {import('lint-staged').Configuration}
*/
import { createLintStagedConfig } from '@dnbhq/lintstaged-config';
export default createLintStagedConfig();Release
Dry run:
npm run release:dryRelease:
npm run releaseReleases are handled by release-it, configured through @dnbhq/release-config, with changelog generation via @release-it/conventional-changelog. Commit messages should follow Conventional Commits. Publishing is handled by the Publish GitHub Actions workflow when a v*.*.* tag is pushed.
Notes
- This package has no runtime dependencies.
lint-stagedis a peer dependency; the individual linters (secretlint,@biomejs/biome,markdownlint-cli2,stylelint, ...) are dependencies of your project, not of this package, so their versions stay under your control. TASK_NAMES,TOOL_REQUIREMENTS,checkTools, andisToolAvailableare also exported from the package root for anyone scripting arounddoctorinstead of shelling out to the CLI.- See
examples/for complete config files covering the default setup, enabling optional tasks, and combining disables with overrides.
