Merge pull request #228 from scratchfoundation/flatter

Flatter
This commit is contained in:
Christopher Willis-Ford 2025-05-05 13:43:06 -07:00 committed by GitHub
commit 71edf06a48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3612 additions and 533 deletions

203
README.md
View file

@ -12,26 +12,43 @@ Install the config along with its peer dependencies, `eslint` and `prettier`:
npm install -D eslint-config-scratch eslint@^9 prettier@^3
```
Add `eslint.config.mjs` to your project root (pick the `export` line appropriate for your project):
Add `eslint.config.mjs` to your project root.
For a TypeScript project, you can add `languageOptions` to enable type checking:
```js
// myProjectRoot/eslint.config.mjs
import { makeEslintConfig } from 'eslint-config-scratch'
import { eslintConfigScratch } from 'eslint-config-scratch'
// for a TypeScript project:
export default makeEslintConfig({ globals: 'browser', tsconfigRootDir: import.meta.dirname })
// for plain JavaScript:
export default makeEslintConfig({ globals: 'browser' })
export default eslintConfigScratch.config(eslintConfigScratch.recommended, {
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
})
```
For a JavaScript project, it might look like this:
```js
// myProjectRoot/eslint.config.mjs
import { eslintConfigScratch } from 'eslint-config-scratch'
export default eslintConfigScratch.recommended
```
The function `eslintConfigScratch.config` is a re-export of the `config` function from `typescript-eslint`, and helps
with merging and extending configurations.
Add `prettier.config.mjs` to your project root as well:
```js
// myProjectRoot/prettier.config.mjs
import { makePrettierConfig } from 'eslint-config-scratch'
import { prettierConfigScratch } from 'eslint-config-scratch'
export default makePrettierConfig()
export default prettierConfigScratch.recommended
```
Finally, add scripts like these to your `package.json`:
@ -45,134 +62,90 @@ Finally, add scripts like these to your `package.json`:
## Basic Configuration
The `makeEslintConfig` function takes options to adjust the ESLint configuration object for your project. Most
projects should start with something like this:
The `eslintConfigScratch.config` is a re-export of the `config` function from `typescript-eslint`. Full documentation
is available here: <https://typescript-eslint.io/packages/typescript-eslint#config>.
```mjs
The `config` function can be used to add or override rules, plugins, and other configuration options. For example:
```js
// myProjectRoot/eslint.config.mjs
import { makeEslintConfig } from 'eslint-config-scratch'
export default makeEslintConfig({
// Optional: specify global variables available in your environment
globals: 'browser',
// Optional: enables rules that use type info, some of which work in JS too
tsconfigRootDir: import.meta.dirname,
})
```
If you have no `tsconfig.json` (or `jsconfig.json`) in your project, you can skip the `tsconfigRootDir` option. Rules
that require type information will be disabled or replaced with less strict alternatives that work without type info.
### Globals
The `globals` property is optional. If present, it can take several forms:
- a string, interpreted as a key in the `globals` object exported by the `globals` package.
- Examples: `'browser'`, `'node'`, `'es2021'`, `'jest'`, etc.
- an object, set up as described in the "Specifying Globals" section of the [ESLint documentation](https://eslint.org/docs/latest/use/configure/language-options#using-configuration-files)
- Example: `{ myGlobal: 'readonly', anotherGlobal: 'writable' }`
- an array of zero or more of any mixture of the above
```mjs
// myProjectRoot/eslint.config.mjs
import { makeEslintConfig } from 'eslint-config-scratch'
export default makeEslintConfig({
// Optional: enables rules that use type info, some of which work in JS too
tsconfigRootDir: import.meta.dirname,
// Optional: specify global variables available in your environment
// Warning: this is a very silly configuration
globals: [
'shared-node-browser',
{
fun: 'readonly',
thing: false,
},
'es2021',
{
whyNot: 'writable',
},
],
})
```
### Further Customization
The return value of the `makeEslintConfig` function is a standard ESLint configuration array. This means you can
customize your configuration further like this:
```mjs
// myProjectRoot/eslint.config.mjs
import { makeEslintConfig } from 'eslint-config-scratch'
export default [
...makeEslintConfig({
// Optional: enables rules that use type info, some of which work in JS too
tsconfigRootDir: import.meta.dirname,
// Optional: specify global variables available in your environment
globals: 'browser',
}),
// Add custom rules or overrides here
{
files: ['*.test.js'],
rules: {
'no-console': 'off', // Allow console logs in test files
},
},
]
```
All ESLint configuration options are available this way. You can use this to handle globals yourself if the simplified
`globals` configuration from above doesn't meet your needs:
```mjs
// myProjectRoot/eslint.config.mjs
import { makeEslintConfig } from 'eslint-config-scratch'
import { eslintConfigScratch } from 'eslint-config-scratch'
import { globalIgnores } from 'eslint/config'
import globals from 'globals'
export default [
...makeEslintConfig({
// Optional: enables rules that use type info, some of which work in JS too
tsconfigRootDir: import.meta.dirname,
}),
export default eslintConfigScratch.config(
eslintConfigScratch.recommended,
{
files: ['src/main/**.js'],
languageOptions: {
globals: globals.node,
},
},
{
files: ['src/renderer/**.js'],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
MY_CUSTOM_GLOBAL: 'readonly',
},
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
// Ignore all files in the dist directory
globalIgnores(['dist/**/*']),
)
```
Of course, another option would be to place a different `eslint.config.mjs` file in each subdirectory. If you have
multiple `tsconfig.json` or `jsconfig.json` files in your project, it likely makes sense to have an
`eslint.config.mjs` file beside each one.
## Granular Configuration
The `eslintConfigScratch` object contains granular configurations as well:
- `recommendedTypeFree`: A configuration suitable for contexts without type information, such as a JavaScript project.
- `recommendedTypeChecked`: A configuration suitable for contexts with type information, such as a TypeScript project.
You must provide extra configuration to `parserOptions` to enable type checking. See here:
<https://typescript-eslint.io/getting-started/typed-linting/>
The `recommended` configuration is a combination of the two, and should be suitable for most projects. Features
requiring type information are enabled for TypeScript files, and features that don't require type information are
enabled for all files.
## Legacy Styles
Scratch used very different styling rules in `eslint-config-scratch@^9` and below. If you need to use those rules, you
can use the rule sets under `legacy/`:
can use these legacy configurations:
- `eslint-config-scratch/legacy`: Legacy base configuration, not configured for any particular environment
- `eslint-config-scratch/legacy/es6`: Legacy rules for targeting Scratch's supported web browsers
- `eslint-config-scratch/legacy/node`: Legacy rules for targeting Node.js
- `eslint-config-scratch/legacy/react`: Legacy rules for targeting Scratch's supported web browsers with React
- `eslintConfigScratch.legacy.base`: Legacy base configuration, not configured for any particular environment
- `eslintConfigScratch.legacy.es6`: Legacy rules for targeting Scratch's supported web browsers
- `eslintConfigScratch.legacy.node`: Legacy rules for targeting Node.js
- `eslintConfigScratch.legacy.react`: Legacy rules for targeting Scratch's supported web browsers with React
New projects should not use these rule sets. They may disappear in the future. Scratch did not use Prettier at this
time, so there is no legacy Prettier configuration.
Legacy Scratch projects usually `extend` more than one of these at a time, and potentially a different set per
subdirectory. To do that in this new flat configuration format:
```js
// scratch-gui/eslint.config.mjs
import { eslintConfigScratch } from 'eslint-config-scratch'
import { globalIgnores } from 'eslint/config'
import globals from 'globals'
export default eslintConfigScratch.config(
eslintConfigScratch.legacy.base,
eslintConfigScratch.legacy.es6,
{
files: ['src/**/*.js', 'src/**/*.jsx'],
extends: [eslintConfigScratch.legacy.react],
languageOptions: {
globals: globals.browser,
},
rules: {
// ...customized rules for `src/`...
},
// ...other settings for `src/`...
},
// ...settings for `test/`, etc...
globalIgnores(['dist/**/*']),
)
```
## Committing
This project uses [semantic release](https://github.com/semantic-release/semantic-release)

View file

@ -1,4 +1,13 @@
import { makeEslintConfig } from './lib/index.mjs'
import { globalIgnores } from 'eslint/config'
import globals from 'globals'
import { eslintConfigScratch } from './lib/index.mjs'
/** @type {import('typescript-eslint').ConfigArray} */
export default makeEslintConfig({ globals: 'node' })
export default eslintConfigScratch.config(
eslintConfigScratch.recommended,
{
languageOptions: {
globals: globals.node,
},
},
globalIgnores(['test/**/*.bad.*']),
)

View file

@ -3,272 +3,360 @@ import formatjs from 'eslint-plugin-formatjs'
import html from 'eslint-plugin-html'
import htmlSettings from 'eslint-plugin-html/src/settings.js'
import importPlugin from 'eslint-plugin-import'
import jsdoc from 'eslint-plugin-jsdoc'
import jsxA11y from 'eslint-plugin-jsx-a11y'
import markdown from 'eslint-plugin-markdown'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import eslintComments from '@eslint-community/eslint-plugin-eslint-comments/configs'
import eslint from '@eslint/js'
import markdown from '@eslint/markdown'
import legacyES6 from './legacy/es6.mjs'
import legacyBase from './legacy/index.mjs'
import legacyNode from './legacy/node.mjs'
import legacyReact from './legacy/react.mjs'
const legacy = {
base: legacyBase,
es6: legacyES6,
node: legacyNode,
react: legacyReact,
}
// WARNING: eslint rules from `typescript-eslint`, even the "untyped" rules, assume that your code will be run through
// `tsc` or equivalent for type checking. Using any rule set from `typescript-eslint` will, for example, turn off the
// `no-undef` rule. That makes sense if you'll use TypeScript to catch undefined globals, but it could be dangerous
// for plain JavaScript.
// More information here: https://github.com/typescript-eslint/typescript-eslint/issues/8825#issuecomment-2033315610
/**
* Convert an array of file extensions to an array of globs
* @param {string[]} extArray - an array of file extensions, like `.foo`
* @returns {string[]} an array of globs, like `** /*.foo` (without the space)
*/
const extArrayToGlobArray = extArray => extArray.map(ext => `**/*${ext}`)
// See https://www.npmjs.com/package/eslint-plugin-html#user-content-settings
const htmlExtensions = htmlSettings.getSettings({}).htmlExtensions
const htmlSettingsDefault = htmlSettings.getSettings({})
// '.html' => '**/*.html'
const htmlGlobs = htmlExtensions.map(ext => `**/*${ext}`)
const fileExtensions = (x => {
x.allScript = [...x.javaScript, ...x.typeScript]
return x
})({
html: /** @type {string[]} */ (htmlSettingsDefault.htmlExtensions),
javaScript: ['.js', '.jsx', '.mjs', '.cjs'],
markdown: ['.md'],
typeScript: ['.ts', '.tsx', '.mts', '.cts'],
react: ['.jsx', '.tsx'],
xml: /** @type {string[]} */ (htmlSettingsDefault.xmlExtensions),
})
/**
* @typedef {import('eslint').Linter.Globals} Globals
* @typedef {keyof globals} GlobalsKey
* @typedef {Globals | GlobalsKey} GlobalsObjOrKey
*/
/**
* Flatten the globals passed to `makeScratchConfig` into an object suitable for ESLint's `globals` option.
* @param {GlobalsObjOrKey | GlobalsObjOrKey[]} [globalsIn] The globals to flatten.
* @returns {Globals|undefined} Flattened globals object for ESLint.
*/
const flattenGlobals = globalsIn => {
if (!globalsIn) return
/**
*
* @param {Globals} globalsAcc Globals accumulator.
* @param {GlobalsObjOrKey} objOrKey A globals object or key to add to the accumulator.
* @returns {Globals} The accumulator after adding the current globals object or key.
*/
const globalsReducer = (globalsAcc, objOrKey) => {
if (typeof objOrKey === 'string') {
const globalsForKey = globals[objOrKey]
if (!globalsForKey) {
throw new Error(`Invalid globals name. Not a key from the globals package: ${objOrKey}`)
}
Object.assign(globalsAcc, globalsForKey)
} else {
Object.assign(globalsAcc, objOrKey)
}
return globalsAcc
}
if (Array.isArray(globalsIn)) {
return globalsIn.reduce(globalsReducer, {})
}
return globalsReducer({}, globalsIn)
// This explicitly lists each entry so that we can get unused warnings
const fileGlobs = {
allScript: extArrayToGlobArray(fileExtensions.allScript),
html: extArrayToGlobArray(fileExtensions.html),
javaScript: extArrayToGlobArray(fileExtensions.javaScript),
markdown: extArrayToGlobArray(fileExtensions.markdown),
react: extArrayToGlobArray(fileExtensions.react),
typeScript: extArrayToGlobArray(fileExtensions.typeScript),
xml: extArrayToGlobArray(fileExtensions.xml),
}
/**
* Create an ESLint configuration for Scratch style.
* Supports JavaScript, TypeScript, and React (JSX/TSX) files.
* Setting `tsconfigRootDir` enables type-aware rules, some of which apply even in JavaScript files.
* @param {object} options Configuration options
* @param {string} [options.tsconfigRootDir] Enable type checking by setting the root TypeScript config directory.
* @param {GlobalsObjOrKey | GlobalsObjOrKey[]} [options.globals] Globals to provide to ESLint.
* This can be expressed as:
* - a single string, such as `'browser'`, corresponding to a key in the `globals` package.
* - a single object as described in the "Specifying Globals" section of the ESLint documentation:
* https://eslint.org/docs/latest/use/configure/language-options#using-configuration-files
* - an array of zero or more elements, each of which can be either of the above
* @example
* // eslint.config.mjs
* export default makeScratchConfig({tsconfigRootDir: import.meta.dirname, globals: 'node'})
* @example
* // eslint.config.mjs
* export default [
* ...makeScratchConfig({tsconfigRootDir: import.meta.dirname, globals: 'browser'}),
* {
* // customization
* }
* ]
* @returns {import('typescript-eslint').ConfigArray} An ESLint configuration array.
* Rules for specific file types outside of the core JS/TS rule sets.
*/
const makeEslintConfig = ({ tsconfigRootDir, globals: globalsIn } = {}) => {
const flattenedGlobals = flattenGlobals(globalsIn)
const miscFileRules = tseslint.config([
// eslint-plugin-html
{
name: 'scratch/miscFileRules[eslint-plugin-html]',
files: [...fileGlobs.html, ...fileGlobs.xml],
plugins: { html },
settings: {
'html/html-extensions': fileExtensions.html,
'xml/xml-extensions': fileExtensions.xml,
},
},
// eslint-plugin-markdown
{
name: 'scratch/miscFileRules[eslint-plugin-markdown]',
files: fileGlobs.markdown,
extends: [markdown.configs.recommended],
language: 'markdown/gfm', // Github Flavored Markdown
},
markdown.configs.processor, // Process script blocks inside Markdown files
])
return tseslint.config(
// Start with recommended rules from ESLint and TypeScript ESLint.
{
extends: [
eslint.configs.recommended,
tsconfigRootDir ? tseslint.configs.recommendedTypeChecked : tseslint.configs.recommended,
tsconfigRootDir ? tseslint.configs.stylisticTypeChecked : tseslint.configs.stylistic,
],
languageOptions: {
parserOptions: {
...(tsconfigRootDir
? {
projectService: true,
tsconfigRootDir,
}
: {}),
},
...(globalsIn ? { globals: flattenedGlobals } : {}),
/**
* Rules recommended for all script files, whether or not type information is available or checked.
*/
const allScriptRules = tseslint.config([
// eslint-plugin-formatjs
{
name: 'scratch/allScriptRules[eslint-plugin-formatjs]',
plugins: {
formatjs,
},
rules: {
'formatjs/no-offset': ['error'],
},
},
// eslint-plugin-import
{
name: 'scratch/allScriptRules[eslint-plugin-import]',
plugins: importPlugin.flatConfigs.recommended.plugins,
rules: {
'import/no-duplicates': 'error', // Forbid duplicate imports
},
},
// eslint-plugin-jsx-a11y
{
name: 'scratch/allScriptRules[eslint-plugin-jsx-a11y]',
files: fileGlobs.react,
extends: [jsxA11y.flatConfigs.recommended],
},
// eslint-plugin-react
{
name: 'scratch/allScriptRules[eslint-plugin-react]',
files: fileGlobs.react,
plugins: {
react,
},
rules: {
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-danger.md
'react/no-danger': ['error'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/self-closing-comp.md
'react/self-closing-comp': ['error'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-boolean-value.md
'react/jsx-boolean-value': ['error', 'never'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-closing-bracket-location.md
'react/jsx-closing-bracket-location': ['error', 'line-aligned'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-curly-spacing.md
'react/jsx-curly-spacing': ['error'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-equals-spacing.md
'react/jsx-equals-spacing': ['error'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-filename-extension.md
'react/jsx-filename-extension': ['error', { extensions: ['.jsx', '.tsx'] }],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-first-prop-new-line.md
'react/jsx-first-prop-new-line': ['error', 'multiline'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-handler-names.md
'react/jsx-handler-names': ['error', { checkLocalVariables: true, eventHandlerPrefix: false }],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-indent.md
'react/jsx-indent': ['error'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-no-bind.md
'react/jsx-no-bind': ['error', { ignoreRefs: true, allowArrowFunctions: true }],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-pascal-case.md
'react/jsx-pascal-case': ['error'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-tag-spacing.md
'react/jsx-tag-spacing': ['error'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-wrap-multilines.md
'react/jsx-wrap-multilines': ['error'],
},
settings: {
react: {
version: 'detect',
},
},
// eslint-plugin-formatjs
{
plugins: {
formatjs,
},
rules: {
'formatjs/no-offset': ['error'],
},
// eslint-plugin-react-hooks
{
name: 'scratch/allScriptRules[eslint-plugin-react-hooks]',
files: fileGlobs.react,
extends: [reactHooks.configs['recommended-latest']],
rules: {
// https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/README.md#advanced-configuration
'react-hooks/exhaustive-deps': ['error', { additionalHooks: '^useAsync$' }],
},
},
// @eslint-community/eslint-plugin-eslint-comments
{
name: 'scratch/allScriptRules[eslint-plugin-eslint-comments]',
extends: [
// @ts-expect-error This plugin's recommended rules don't quite match the type `tseslint.config` expects.
eslintComments.recommended,
],
rules: {
// require a description for eslint control comments other than `eslint-enable`
'@eslint-community/eslint-comments/require-description': ['error', { ignore: ['eslint-enable'] }],
},
},
// @eslint/js
{
name: 'scratch/allScriptRules[@eslint/js]',
rules: {
// https://eslint.org/docs/latest/rules/arrow-body-style
'arrow-body-style': ['error', 'as-needed'],
// https://eslint.org/docs/latest/rules/no-duplicate-imports
'no-duplicate-imports': ['error'],
// https://eslint.org/docs/latest/rules/no-template-curly-in-string
'no-template-curly-in-string': ['error'],
// https://eslint.org/docs/latest/rules/no-useless-computed-key
'no-useless-computed-key': ['error'],
// https://eslint.org/docs/latest/rules/no-useless-rename
'no-useless-rename': ['error'],
// https://eslint.org/docs/latest/rules/prefer-arrow-callback
'prefer-arrow-callback': ['error'],
// https://eslint.org/docs/latest/rules/prefer-const#destructuring
'prefer-const': ['error'],
// https://eslint.org/docs/latest/rules/prefer-spread
'prefer-spread': ['error'],
// https://eslint.org/docs/latest/rules/require-atomic-updates
'require-atomic-updates': ['error'],
// https://eslint.org/docs/latest/rules/symbol-description
'symbol-description': ['error'],
},
},
])
/**
* Additional rules recommended when type information is not available or checked.
*/
const typeFreeRules = tseslint.config([
{
name: 'scratch/typeFreeRules[base]',
extends: [eslint.configs.recommended],
},
...allScriptRules,
{
name: 'scratch/typeFreeRules[eslint-plugin-jsdoc]',
extends: [jsdoc.configs['flat/recommended-error']],
rules: {
// If JSDoc comments are present, they must be informative (non-trivial).
// For example, the description "The foo." on a variable called "foo" is not informative.
// https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/informative-docs.md
'jsdoc/informative-docs': ['error'],
// Don't require JSDoc comments. Library authors should consider turning this on for external interfaces.
// https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-jsdoc.md
'jsdoc/require-jsdoc': ['off'],
},
},
])
/**
* Rules recommended when type information is available and checked. This configuration turns off some rules with the
* assumption that other software, such as TypeScript, will flag those problems. For example, the `no-undef` rule is
* disabled in this configuration. These rules include `allScriptRules`.
* These rules require additional configuration.
* @see https://typescript-eslint.io/getting-started/typed-linting/
*/
const typeCheckedRules = tseslint.config([
{
name: 'scratch/typeCheckedRules[base]',
extends: [
eslint.configs.recommended,
tseslint.configs.recommendedTypeChecked,
tseslint.configs.stylisticTypeChecked,
],
rules: {
// https://typescript-eslint.io/rules/no-unnecessary-condition/
'@typescript-eslint/no-unnecessary-condition': ['error'],
// https://typescript-eslint.io/rules/require-await/
'@typescript-eslint/require-await': ['error'],
},
},
...allScriptRules,
{
name: 'scratch/typeCheckedRules[eslint-plugin-jsdoc][1]',
extends: [jsdoc.configs['flat/recommended-error']],
},
{
name: 'scratch/typeCheckedRules[eslint-plugin-jsdoc][2]',
extends: [jsdoc.configs['flat/recommended-typescript-error']],
},
{
name: 'scratch/typeCheckedRules[eslint-plugin-jsdoc][3]',
rules: {
// If JSDoc comments are present, they must be informative (non-trivial).
// For example, the description "The foo." on a variable called "foo" is not informative.
// https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/informative-docs.md
'jsdoc/informative-docs': ['error'],
// Don't require JSDoc comments. Library authors should consider turning this on for external interfaces.
// https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-jsdoc.md
'jsdoc/require-jsdoc': ['off'],
},
},
// typescript-eslint
{
name: 'scratch/typeCheckedRules[typescript-eslint]',
rules: {
// https://typescript-eslint.io/rules/no-non-null-asserted-nullish-coalescing/
'@typescript-eslint/no-non-null-asserted-nullish-coalescing': ['error'],
// https://typescript-eslint.io/rules/no-useless-constructor/
'@typescript-eslint/no-useless-constructor': ['error'],
// https://typescript-eslint.io/rules/no-non-null-assertion
'@typescript-eslint/no-non-null-assertion': ['error'],
},
},
])
/**
* Scratch's recommended configuration when type information is not available.
*/
const recommendedTypeFree = tseslint.config(typeFreeRules, eslintConfigPrettier)
/**
* Scratch's recommended configuration when type information is available.
* These rules require additional configuration.
* WARNING: These rules do not specify the `files` property.
* @see https://typescript-eslint.io/getting-started/typed-linting/
*/
const recommendedTypeChecked = tseslint.config(typeCheckedRules, eslintConfigPrettier)
/**
* Scratch's recommended configuration for general use.
* Type-checked rules are enabled for files with known TypeScript extensions.
* If your project includes such files, you must include additional configuration.
* @see https://typescript-eslint.io/getting-started/typed-linting/
*/
const recommended = tseslint.config(
{
name: 'scratch/recommended',
},
{
files: fileGlobs.allScript,
extends: [typeFreeRules],
},
{
files: fileGlobs.typeScript,
extends: [typeCheckedRules],
languageOptions: {
parserOptions: {
projectService: true,
},
},
// eslint-plugin-html
{
files: htmlGlobs,
plugins: { html },
settings: {
'html/html-extensions': htmlExtensions,
},
},
// eslint-plugin-import
{
plugins: importPlugin.flatConfigs.recommended.plugins,
rules: {
'import/no-duplicates': 'error', // Forbid duplicate imports
},
},
// eslint-plugin-jsx-a11y
jsxA11y.flatConfigs.recommended,
// eslint-plugin-markdown
markdown.configs.recommended,
// eslint-plugin-react
{
plugins: {
react,
},
rules: {
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-danger.md
'react/no-danger': ['error'],
},
miscFileRules,
eslintConfigPrettier,
)
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/self-closing-comp.md
'react/self-closing-comp': ['error'],
// Helper to get type hints while conveniently merging and extending configurations
export { config } from 'typescript-eslint'
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-boolean-value.md
'react/jsx-boolean-value': ['error', 'never'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-closing-bracket-location.md
'react/jsx-closing-bracket-location': ['error', 'line-aligned'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-curly-spacing.md
'react/jsx-curly-spacing': ['error'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-equals-spacing.md
'react/jsx-equals-spacing': ['error'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-filename-extension.md
'react/jsx-filename-extension': ['error', { extensions: ['.jsx', '.tsx'] }],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-first-prop-new-line.md
'react/jsx-first-prop-new-line': ['error', 'multiline'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-handler-names.md
'react/jsx-handler-names': ['error', { checkLocalVariables: true, eventHandlerPrefix: false }],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-indent.md
'react/jsx-indent': ['error'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-no-bind.md
'react/jsx-no-bind': ['error', { ignoreRefs: true, allowArrowFunctions: true }],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-pascal-case.md
'react/jsx-pascal-case': ['error'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-tag-spacing.md
'react/jsx-tag-spacing': ['error'],
// https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-wrap-multilines.md
'react/jsx-wrap-multilines': ['error'],
},
settings: {
react: {
version: 'detect',
},
},
},
// eslint-plugin-react-hooks
{
extends: [reactHooks.configs['recommended-latest']],
rules: {
// https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/README.md#advanced-configuration
'react-hooks/exhaustive-deps': ['error', { additionalHooks: '^useAsync$' }],
},
},
// typescript-eslint
{
rules: {
// https://typescript-eslint.io/rules/no-non-null-asserted-nullish-coalescing/
'@typescript-eslint/no-non-null-asserted-nullish-coalescing': ['error'],
// https://typescript-eslint.io/rules/no-useless-constructor/
'@typescript-eslint/no-useless-constructor': ['error'],
// https://typescript-eslint.io/rules/no-non-null-assertion
'@typescript-eslint/no-non-null-assertion': ['error'],
// Rules that require type information
...(tsconfigRootDir
? {
// https://typescript-eslint.io/rules/no-unnecessary-condition/
'@typescript-eslint/no-unnecessary-condition': ['error'],
// https://typescript-eslint.io/rules/require-await/
'@typescript-eslint/require-await': ['error'],
}
: {}),
},
},
// @eslint-community/eslint-plugin-eslint-comments
{
extends: [
// @ts-expect-error This plugin's recommended rules don't quite match the type `tseslint.config` expects.
eslintComments.recommended,
],
rules: {
// require a description for eslint control comments other than `eslint-enable`
'@eslint-community/eslint-comments/require-description': ['error', { ignore: ['eslint-enable'] }],
},
},
// @eslint/js
{
rules: {
// https://eslint.org/docs/latest/rules/arrow-body-style
'arrow-body-style': ['error', 'as-needed'],
// https://eslint.org/docs/latest/rules/no-duplicate-imports
'no-duplicate-imports': ['error'],
// https://eslint.org/docs/latest/rules/no-template-curly-in-string
'no-template-curly-in-string': ['error'],
// https://eslint.org/docs/latest/rules/no-useless-computed-key
'no-useless-computed-key': ['error'],
// https://eslint.org/docs/latest/rules/no-useless-rename
'no-useless-rename': ['error'],
// https://eslint.org/docs/latest/rules/prefer-arrow-callback
'prefer-arrow-callback': ['error'],
// https://eslint.org/docs/latest/rules/prefer-const#destructuring
'prefer-const': ['error'],
// https://eslint.org/docs/latest/rules/prefer-spread
'prefer-spread': ['error'],
// https://eslint.org/docs/latest/rules/require-atomic-updates
'require-atomic-updates': ['error'],
// https://eslint.org/docs/latest/rules/symbol-description
'symbol-description': ['error'],
},
},
// Keep `eslintConfigPrettier` last to turn off rules that conflict with Prettier
eslintConfigPrettier,
)
}
export { makeEslintConfig }
// Our exported configurations
export { recommended, recommendedTypeChecked, recommendedTypeFree, miscFileRules, legacy }

View file

@ -1,4 +1,4 @@
import { makeEslintConfig } from './eslint.mjs'
import { makePrettierConfig } from './prettier.mjs'
import * as eslintConfigScratch from './eslint.mjs'
import * as prettierConfigScratch from './prettier.mjs'
export { makeEslintConfig, makePrettierConfig }
export { eslintConfigScratch, prettierConfigScratch }

View file

@ -1,3 +1,4 @@
/** @type {import('eslint').Linter.Config[]} */
export default [
{
languageOptions: {

View file

@ -14,6 +14,7 @@ const compat = new FlatCompat({
allConfig: js.configs.all,
})
/** @type {import('eslint').Linter.Config[]} */
export default [
...compat.extends('eslint:recommended'),
jsdoc.configs['flat/recommended'],

View file

@ -1,5 +1,6 @@
import globals from 'globals'
/** @type {import('eslint').Linter.Config[]} */
export default [
{
languageOptions: {

View file

@ -12,6 +12,7 @@ const compat = new FlatCompat({
allConfig: js.configs.all,
})
/** @type {import('eslint').Linter.Config[]} */
export default [
...compat.extends('plugin:react/recommended'),
{

View file

@ -4,7 +4,7 @@ import sortImports from '@trivago/prettier-plugin-sort-imports'
* @see https://prettier.io/docs/configuration
* @type {import("prettier").Config}
*/
const prettierConfig = {
const recommended = {
// #region Prettier
arrowParens: 'avoid',
bracketSameLine: false,
@ -22,10 +22,4 @@ const prettierConfig = {
// #endregion @trivago/prettier-plugin-sort-imports
}
/**
* Make a Prettier configuration for Scratch style.
* @returns {import("prettier").Config}
*/
const makePrettierConfig = () => prettierConfig
export { makePrettierConfig }
export { recommended }

2982
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,18 +3,12 @@
"version": "10.0.14",
"description": "Shareable ESLint config for Scratch",
"main": "./lib/index.mjs",
"exports": {
".": "./lib/index.mjs",
"./legacy": "./lib/legacy/index.mjs",
"./legacy/es6": "./lib/legacy/web.mjs",
"./legacy/node": "./lib/legacy/node.mjs",
"./legacy/react": "./lib/legacy/react.mjs"
},
"scripts": {
"prepare": "husky install",
"format": "prettier --write . && eslint --fix",
"lint": "eslint && prettier --check .",
"test": "npm run lint"
"test:lint": "eslint && prettier --check .",
"test:vitest": "vitest run",
"test": "npm run test:lint && npm run test:vitest"
},
"repository": {
"type": "git",
@ -40,6 +34,7 @@
"@eslint-community/eslint-plugin-eslint-comments": "4.5.0",
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.25.1",
"@eslint/markdown": "6.4.0",
"@trivago/prettier-plugin-sort-imports": "5.2.2",
"eslint-config-prettier": "10.1.2",
"eslint-plugin-formatjs": "5.3.1",
@ -47,7 +42,6 @@
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jsdoc": "50.6.11",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-markdown": "5.1.0",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"globals": "16.0.0",
@ -61,7 +55,8 @@
"eslint": "9.25.1",
"husky": "8.0.3",
"scratch-semantic-release-config": "3.0.0",
"semantic-release": "24.2.3"
"semantic-release": "24.2.3",
"vitest": "^3.1.1"
},
"config": {
"commitizen": {

View file

@ -1,3 +1,3 @@
import { makePrettierConfig } from './lib/prettier.mjs'
import { prettierConfigScratch } from './lib/index.mjs'
export default makePrettierConfig()
export default prettierConfigScratch.recommended

View file

@ -0,0 +1,112 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`'recommended' > Plain TS (bad) 1`] = `
[
{
"column": 7,
"endColumn": 25,
"endLine": 5,
"line": 5,
"messageId": "noInferrableType",
"nodeType": "VariableDeclarator",
"ruleId": "@typescript-eslint/no-inferrable-types",
},
{
"column": 7,
"endColumn": 12,
"endLine": 5,
"line": 5,
"messageId": "unusedVar",
"nodeType": null,
"ruleId": "@typescript-eslint/no-unused-vars",
},
{
"column": 10,
"endColumn": 13,
"endLine": 8,
"line": 8,
"messageId": "unusedVar",
"nodeType": null,
"ruleId": "@typescript-eslint/no-unused-vars",
},
{
"column": 14,
"endColumn": 26,
"endLine": 17,
"line": 17,
"messageId": "anyAssignment",
"nodeType": "VariableDeclarator",
"ruleId": "@typescript-eslint/no-unsafe-assignment",
},
{
"column": 20,
"endColumn": 24,
"endLine": 17,
"line": 17,
"messageId": "unsafeCall",
"nodeType": "Identifier",
"ruleId": "@typescript-eslint/no-unsafe-call",
},
]
`;
exports[`'recommended' > Plain TS (good) 1`] = `[]`;
exports[`'recommended' > React JSX (bad) 1`] = `
[
{
"column": 10,
"endColumn": 13,
"endLine": 4,
"line": 4,
"messageId": "unusedVar",
"nodeType": "Identifier",
"ruleId": "no-unused-vars",
},
{
"column": 20,
"endColumn": 24,
"endLine": 14,
"line": 14,
"messageId": "undef",
"nodeType": "Identifier",
"ruleId": "no-undef",
},
]
`;
exports[`'recommended' > React JSX (good) 1`] = `[]`;
exports[`'recommended' > plain JS (bad) 1`] = `
[
{
"column": 10,
"endColumn": 13,
"endLine": 4,
"line": 4,
"messageId": "unusedVar",
"nodeType": "Identifier",
"ruleId": "no-unused-vars",
},
{
"column": 26,
"endColumn": 46,
"endLine": 12,
"line": 12,
"messageId": "noJSXWithExtension",
"nodeType": "JSXElement",
"ruleId": "react/jsx-filename-extension",
},
{
"column": 20,
"endColumn": 24,
"endLine": 15,
"line": 15,
"messageId": "undef",
"nodeType": "Identifier",
"ruleId": "no-undef",
},
]
`;
exports[`'recommended' > plain JS (good) 1`] = `[]`;

110
test/eslint.test.mjs Normal file
View file

@ -0,0 +1,110 @@
import { ESLint } from 'eslint'
import path from 'path'
import util from 'util'
import { beforeAll, describe, expect, test } from 'vitest'
/**
* @typedef {object} EslintTestInfo
* @property {string} name - the title/message for this test
* @property {string} filePath - the path to the file to lint
* @property {number} warningCount - the number of warnings to expect
* @property {number} errorCount - the number of errors to expect
*/
// TSX is omitted because TypeScript insists on fully knowing the React types,
// and I would rather not add React as a dependency of eslint-config-scratch.
/** @type {Record<string, EslintTestInfo[]>} */
const testInfo = {
recommended: [
{
name: 'plain JS (good)',
filePath: 'plain.good.mjs',
warningCount: 0,
errorCount: 0,
},
{
name: 'React JSX (good)',
filePath: 'react.good.jsx',
warningCount: 0,
errorCount: 0,
},
{
name: 'plain JS (bad)',
filePath: 'plain.bad.mjs',
warningCount: 0,
errorCount: 3,
},
{
name: 'React JSX (bad)',
filePath: 'react.bad.jsx',
warningCount: 0,
errorCount: 2,
},
{
name: 'Plain TS (good)',
filePath: 'plain.good.ts',
warningCount: 0,
errorCount: 0,
},
{
name: 'Plain TS (bad)',
filePath: 'plain.bad.ts',
warningCount: 0,
errorCount: 5,
},
],
}
/**
* Create a snapshot of a lint message.
* Excludes properties that may change without affecting correctness, such as human-readable text.
* @param {ESLint.LintMessage} result - the lint message to filter
* @returns {object} a filtered snapshot of the lint message
*/
const messageSnapshot = result =>
Object.fromEntries(
Object.entries(result).filter(([k]) =>
['line', 'column', 'endLine', 'endColumn', 'messageId', 'nodeType', 'ruleId'].includes(k),
),
)
test('make sure eslint works at all', async () => {
const source = 'foo(42)'
const eslint = new ESLint({
overrideConfigFile: true,
})
const results = await eslint.lintText(source)
expect(results).toBeDefined()
expect(results.length).toBe(1)
expect(results[0].warningCount).toEqual(0)
expect(results[0].errorCount).toEqual(0)
})
describe.concurrent.for(Object.entries(testInfo))('$0', ([subdir, testList]) => {
/**
* @type {ESLint.LintResult[]}
*/
let results
// Linting one file at a time takes much longer
beforeAll(async () => {
const eslint = new ESLint({
overrideConfigFile: path.resolve(import.meta.dirname, subdir, 'eslint.config.mjs'),
})
results = await eslint.lintFiles(testList.map(info => path.resolve(import.meta.dirname, subdir, info.filePath)))
})
test('results container', () => {
expect(results).toBeDefined()
expect(results.length).toBe(testList.length)
})
testList.forEach(({ name, filePath, warningCount, errorCount }, i) => {
test(name, () => {
expect(path.resolve(results[i].filePath)).toBe(path.resolve(import.meta.dirname, subdir, filePath))
expect(results[i].warningCount, util.inspect(results[i])).toBe(warningCount)
expect(results[i].errorCount, util.inspect(results[i])).toBe(errorCount)
expect(results[i].messages.map(messageSnapshot), util.inspect(results[i].messages)).toMatchSnapshot()
})
})
})

View file

@ -0,0 +1,14 @@
import { eslintConfigScratch } from '../../lib/index.mjs'
const config = eslintConfigScratch.config(eslintConfigScratch.recommended, {
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
project: ['./tsconfig.json'],
},
},
})
export default config

View file

@ -0,0 +1,15 @@
import ESLint from 'eslint'
// foo isn't used
function foo() {
const eslint = new ESLint({
overrideConfigFile: true,
})
return eslint
}
// React isn't allowed in plain JS
export const myElement = <div>{'hello'}</div>
// foo2 isn't defined
export const bar = foo2()

View file

@ -0,0 +1,17 @@
import { ESLint } from 'eslint'
// @typescript-eslint/no-inferrable-types
// @typescript-eslint/no-unused-vars
const forty: number = 40
// @typescript-eslint/no-unused-vars
function foo(): ESLint {
const eslint = new ESLint({
overrideConfigFile: true,
})
return eslint
}
// @typescript-eslint/no-unsafe-call (`foo2` is an error-typed value)
// @typescript-eslint/no-unsafe-assignment (`foo2()` is an error-typed value)
export const bar = foo2()

View file

@ -0,0 +1,10 @@
import ESLint from 'eslint'
function foo() {
const eslint = new ESLint({
overrideConfigFile: true,
})
return eslint
}
export const bar = foo()

View file

@ -0,0 +1,10 @@
import { ESLint } from 'eslint'
function foo(): ESLint {
const eslint = new ESLint({
overrideConfigFile: true,
})
return eslint
}
export const bar = foo()

View file

@ -0,0 +1,14 @@
import ESLint from 'eslint'
// foo isn't used
function foo() {
const eslint = new ESLint({
overrideConfigFile: true,
})
return eslint
}
export const myElement = <div>{'hello'}</div>
// foo2 isn't defined
export const bar = foo2()

View file

@ -0,0 +1,12 @@
import ESLint from 'eslint'
function foo() {
const eslint = new ESLint({
overrideConfigFile: true,
})
return eslint
}
export const myElement = <div>{'hello'}</div>
export const bar = foo()

View file

@ -0,0 +1,6 @@
{
"compilerOptions": {
"strict": true
},
"include": ["**/*.ts", "**/*.tsx"]
}