@fohte/eslint-config
v0.5.2
Published
ESLint config for fohte
Downloads
2,561
Readme
@fohte/eslint-config
Personal ESLint configuration package with TypeScript support.
Installation
npm install --save-dev @fohte/eslint-config
# Install peer dependencies
npm install --save-dev @eslint-community/eslint-plugin-eslint-comments @typescript-eslint/eslint-plugin @typescript-eslint/parser @vitest/eslint-plugin eslint eslint-config-prettier eslint-plugin-import-x eslint-plugin-simple-import-sort
# Optional: If using TypeScript
npm install --save-dev typescript
# Optional: If using the errorHandling option
npm install --save-dev @ninoseki/eslint-plugin-neverthrow
npm install neverthrow
# Optional: If using the tailwind option
npm install --save-dev eslint-plugin-tailwindcss
npm install tailwindcss
# Optional: If using the shadcn option
npm install --save-dev @shadcn/lintUsage
eslint.config.js:
import { config } from '@fohte/eslint-config'
// Basic (JS + TypeScript strict rules)
export default config()
// Alternatively, enable type-checked rules
// (strict-type-checked + strict-boolean-expressions):
// export default config({ typescript: { typeChecked: true } })
// Optionally, ban throw/try-catch and enforce neverthrow Result handling
// (requires typescript.typeChecked: true):
// export default config({
// typescript: { typeChecked: true },
// errorHandling: {},
// })
// Optionally, ban raw tracer.startSpan()/startActiveSpan() calls that skip context.with():
// export default config({ opentelemetry: { enabled: true } })
// Optionally, ban Tailwind CSS arbitrary values (e.g. `w-[600px]`),
// steering towards design tokens defined in `@theme` instead:
// export default config({
// tailwind: { cssConfigPath: 'src/index.css' },
// })
// Optionally, enforce shared component styling rules in selected files:
// export default config({
// shadcn: { files: ['src/**/*.{jsx,tsx}'] },
// })
// Optionally, ban raw form and button elements in selected files:
// export default config({
// noRawFormElements: {
// files: ['app/src/**/*.tsx'],
// ignores: ['app/src/components/ui/**'],
// },
// })Import policy
config() bans relative imports (./foo, ../foo) and the @/* alias via no-restricted-imports, and steers both towards a Node subpath import (#foo, declared under the imports field in package.json). The @/* alias only resolves at the TypeScript/bundler level, so a plain Node/tsx runtime that doesn't share that resolution step fails at runtime; # imports are resolved natively by Node's own module resolver, so this failure mode can't happen.
To opt out (e.g. for a package that doesn't use a src/ layout), override the rule in a trailing userConfigs argument passed to config():
export default config(
{},
{
rules: {
'no-restricted-imports': 'off',
},
},
)errorHandling option
Requires typescript.typeChecked: true, because neverthrow/must-use-result needs type information to detect unused Result values.
When enabled, it applies two rules to all .ts{,x} files except test files:
no-restricted-syntax: bansthrowandtry/catch. Return aResultviaerr()/errAsync()instead, or useResultAsync.fromPromise()to interop with a throwing API without a localthrow. If an external SDK's throw-based contract genuinely can't be wrapped that way, add aneslint-disable-next-linecomment explaining why:// eslint-disable-next-line no-restricted-syntax -- interops with an external SDK's throw-based contract try { return externalSdkCall() } catch (error) { return err(error) }neverthrow/must-use-result: bans discarding aneverthrowResult/ResultAsyncwithout handling it.
opentelemetry option
When enabled, no-restricted-syntax bans direct calls to tracer.startSpan()/tracer.startActiveSpan() on all .ts{,x} files, since both can silently produce a span that fails to parent child spans created during its execution:
startSpan()never enters the active context on its own — a child span (e.g. an HTTP call fired during this span) won't be nested under it unless the caller explicitly wraps the surrounding code incontext.with(trace.setSpan(context.active(), span), ...).startActiveSpan()'s callback runs inside the active context automatically, but only for the callback's own duration — storing the span toend()it later (e.g. start and end split across separate callbacks) drops it from the active context before that later code runs.
This surfaces only as a mis-parented span in a trace backend, not as a runtime error. If neither pattern fits, add an eslint-disable-next-line comment explaining why.
When combined with errorHandling, both options configure no-restricted-syntax — ESLint's flat config fully replaces a rule's settings (rather than merging them) when two config objects set the same rule for the same file, so the startSpan/startActiveSpan selectors are merged into errorHandling's no-restricted-syntax entry instead of their own config. This means the opentelemetry ban then shares errorHandling's exemption for test files too.
tailwind option
cssConfigPath (the path to the CSS file where Tailwind's @theme tokens are defined) is required; files defaults to all .ts{,x} files, but can be narrowed (e.g. a single package in a monorepo):
export default config({
tailwind: {
files: ['web/**/*.ts', 'web/**/*.tsx'], // optional, defaults to all .ts{,x} files
cssConfigPath: 'web/src/index.css',
},
})When enabled, it applies two rules to the given files (except test files):
tailwindcss/no-arbitrary-value: bans arbitrary values (e.g.w-[600px]) inclassName/classattributes and classname functions (clsx,cn, etc). Add a token to@themein your CSS config instead:@theme { --width-panel: 600px; }- <div className="w-[600px]" /> + <div className="w-panel" />no-restricted-syntax:no-arbitrary-valueonly checks class attributes/functions, so a bracket value stashed in a bare string constant (then interpolated intoclassName) would otherwise slip through undetected. This rule catches that case too.
Like opentelemetry, this shares its no-restricted-syntax entry with errorHandling (and opentelemetry) rather than silently overriding it, so all three bans keep applying together within tailwind.files. Since tailwind.files can be narrower than errorHandling/opentelemetry's default (all .ts{,x} files), the merge happens at tailwind.files's scope: files outside it keep only the errorHandling/opentelemetry bans, and files inside it get the full union.
noRawFormElements option
Set files to the files where raw JSX form and button elements should be banned. Test files are excluded automatically; use ignores for additional exclusions such as shared UI components:
export default config({
noRawFormElements: {
files: ['app/src/**/*.tsx'],
ignores: ['app/src/components/ui/**'],
},
})This enables fohte/no-raw-form-elements, which flags lowercase <button>, <input>, <select>, and <textarea> elements and asks you to use a shared UI component instead. <a> elements and capitalized components are unaffected.
shadcn option
Install @shadcn/lint 0.1.2 or later to opt in. Select supported source files with files; test files are excluded automatically, and ignores can add more exclusions:
export default config({
shadcn: { files: ['src/**/*.{jsx,tsx}'] },
})This enables shadcn/no-restyle, shadcn/no-inline-styles, shadcn/require-static-classes, shadcn/no-unknown-classes, and shadcn/no-raw-colors as errors. no-restyle recognizes components imported from @fohte/ui and its subpaths, while allowing layout classes. Add a trailing userConfigs entry to override or disable an individual rule. For example, disable no-restyle in a repository's shared component definitions while keeping the other rules enabled:
export default config(
{ shadcn: { files: ['src/**/*.{jsx,tsx}'] } },
{
files: ['src/components/ui/**'],
rules: { 'shadcn/no-restyle': 'off' },
},
)Built-in rules
In addition to the upstream presets, this config ships a local plugin (fohte) applied to test files and Storybook story files, and optionally to the files selected by noRawFormElements. Rules enabled by the built-in presets are set to error; rules that require an explicit option or user config are documented below.
fohte/require-story-name is registered for Storybook story files but is off by default. Enable it with a trailing userConfigs entry:
export default config(
{},
{
files: ['**/*.stories.tsx'],
rules: { 'fohte/require-story-name': 'error' },
},
)fohte/no-inline-object-in-expect(test files): flagsexpect(<object/array literal>).toEqual(...)(andtoStrictEqual/toMatchObject, includingawait … .resolves/.rejects/.notchains, andas const/satisfies/!wrapped literals). Also flags the same literal aliased through a variable declared right before theexpect()call. Pass the value under test directly, or split the assertion into multipleexpect()calls.// bad expect({ result, calls: spy.mock.calls.length }).toEqual({ result: 'ok', calls: 0, }) // bad: aliasing the literal through a variable doesn't escape the rule const actual = { result, calls: spy.mock.calls.length } expect(actual).toEqual({ result: 'ok', calls: 0 }) // good expect(result).toBe('ok') expect(spy).not.toHaveBeenCalled()fohte/no-raw-form-elements(whennoRawFormElementsis configured): flags lowercase JSX<button>,<input>,<select>, and<textarea>elements. Use the shared UI component instead.fohte/no-play-in-stories(story files): flags aplayfunction andparameters: { screenshot: { skip: true } }on a story or its meta (default export). A story should represent a visual state throughargs/renderonly; move behavioral assertions (clicks, input,expectcalls) to a.test.tsxfile, and render the state aplayfunction used to set up (e.g. an open menu) through a prop instead (e.g.defaultOpen). This also targets a Storybook VRT setup that flags two stories in the same file whose screenshots are byte-identical (usually an undetected visual bug, e.g. two states rendering identically): a skipped story emits no screenshot, so that check can never see it. Since stories can't defineplay, every story's only assertion is its rendered appearance, soscreenshot.skipalways hides that finding — fix the underlying visual duplication instead of skipping.// bad: play belongs in a .test.tsx file, not the story export const Disabled: Story = { args: { disabled: true }, play: async ({ canvasElement }) => { await expect(within(canvasElement).getByRole('checkbox')).toBeDisabled() }, } // good: the state play used to set up is driven by a prop instead export const Disabled: Story = { args: { disabled: true }, }fohte/require-story-name(story files, off by default): requires each exported object story to define its ownnameproperty. It honors literal string and regular expression filters inincludeStoriesandexcludeStories. If either filter is dynamic, the rule skips that file because it cannot determine which exports Storybook treats as stories.
Development
Setup
# Install dependencies
npm install
# Build TypeScript
npm run build
# Watch mode
npm run watchScripts
npm run build- Compile TypeScript filesnpm run watch- Watch mode for developmentnpm run lint- Run ESLint on source filesnpm run test- Run build and lint
Project Structure
src/
├── index.ts # Main export
├── main.ts # Base ESLint configuration
├── typescript.ts # TypeScript-specific configuration
├── error-handling.ts # errorHandling option (throw/try-catch ban, neverthrow enforcement)
├── opentelemetry.ts # opentelemetry option (startSpan/startActiveSpan ban)
├── no-raw-form-elements.ts # noRawFormElements option (raw JSX element ban)
├── tailwind.ts # tailwind option (Tailwind arbitrary-value ban)
├── shadcn.ts # shadcn option (shared component style rules)
└── types/ # Type definitions for untyped packagesRelease Process
This project uses release-please for automated releases.
1. Create a feature branch and make changes
- Create a new branch from
master - Make your changes (commit messages don't need to follow any specific format)
2. Create a PR and merge to master
- Push your branch and create a PR
- PR title must follow Conventional Commits:
fix:for bug fixes (patch release)feat:for new features (minor release)feat!:orfix!:for breaking changes (major release)
- After review, merge the PR (squash merge only)
3. Automated release process
When changes are merged to master, release-please automatically:
- Creates/updates a Release PR
- Updates version in package.json
- Updates CHANGELOG.md
- When the Release PR is merged:
- Creates GitHub release and git tag
- Publishes to npm
Pre-commit Hooks
This project uses pre-commit hooks to ensure code quality. The hooks will automatically run when you commit changes.
