diff --git a/.gitignore b/.gitignore index 55c02e7..fbbaacd 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ coverage out build dist +.next # Misc .DS_Store diff --git a/apps/backend/app.ts b/apps/backend/app.ts index 3dbef23..cb900a8 100644 --- a/apps/backend/app.ts +++ b/apps/backend/app.ts @@ -1,7 +1,8 @@ -import { Multipart } from "@fastify/multipart"; -import fastify, { FastifyHttpOptions, FastifyRequest } from "fastify"; -import { ZodTypeProvider } from "fastify-type-provider-zod"; -import * as http from "node:http"; +import type * as http from "node:http"; +import type { Multipart } from "@fastify/multipart"; +import type { FastifyHttpOptions, FastifyRequest } from "fastify"; +import fastify from "fastify"; +import type { ZodTypeProvider } from "fastify-type-provider-zod"; import { z } from "zod"; import { basePlugin } from "./plugins/base.js"; diff --git a/apps/backend/plugins/base.test.ts b/apps/backend/plugins/base.test.ts index 3e80bdd..ea881ae 100644 --- a/apps/backend/plugins/base.test.ts +++ b/apps/backend/plugins/base.test.ts @@ -1,7 +1,6 @@ import assert from "node:assert/strict"; import * as fs from "node:fs"; import { test } from "node:test"; - import { buildApp } from "../app.js"; void test("base", async (t) => { @@ -19,7 +18,7 @@ void test("base", async (t) => { form.append("foo", "bar"); form.append("file1", await fs.openAsBlob("package.json"), "package.json"); - const response = await fetch(server + "/base/multipart", { + const response = await fetch(`${server}/base/multipart`, { method: "post", body: form, }); @@ -47,6 +46,7 @@ void test("base", async (t) => { }); assert.equal(response.statusCode, 200); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access assert.equal(response.json().healthChecks.label, "HEALTHY"); }); @@ -58,6 +58,7 @@ void test("base", async (t) => { }); assert.equal(response.statusCode, 500); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access assert.equal(response.json().healthChecks.label2, "FAIL"); }); }); diff --git a/apps/backend/plugins/base.ts b/apps/backend/plugins/base.ts index 97c4d61..55b9391 100644 --- a/apps/backend/plugins/base.ts +++ b/apps/backend/plugins/base.ts @@ -1,15 +1,12 @@ -import fastifyMultipart, { FastifyMultipartBaseOptions } from "@fastify/multipart"; -import { RegisterOptions } from "fastify"; -import fastifyCustomHealthCheck from "fastify-custom-healthcheck"; import fastifyHelmet from "@fastify/helmet"; - +import type { FastifyMultipartBaseOptions } from "@fastify/multipart"; +import fastifyMultipart from "@fastify/multipart"; +import type { RegisterOptions } from "fastify"; +import fastifyCustomHealthCheck from "fastify-custom-healthcheck"; import fp from "fastify-plugin"; -import { - serializerCompiler, - validatorCompiler, - ZodTypeProvider, -} from "fastify-type-provider-zod"; -import { FastifyBase } from "../types.js"; +import type { ZodTypeProvider } from "fastify-type-provider-zod"; +import { serializerCompiler, validatorCompiler } from "fastify-type-provider-zod"; +import type { FastifyBase } from "../types.js"; async function base( fastify: FastifyBase, diff --git a/apps/backend/server.ts b/apps/backend/server.ts index aa29d38..7f58fca 100644 --- a/apps/backend/server.ts +++ b/apps/backend/server.ts @@ -1,6 +1,6 @@ import closeWithGrace from "close-with-grace"; -import { buildApp } from "./app.js"; import dotenv from "dotenv"; +import { buildApp } from "./app.js"; dotenv.config({ path: [".env.local", ".env"] }); diff --git a/apps/backend/types.ts b/apps/backend/types.ts index 9f50fed..1dedf71 100644 --- a/apps/backend/types.ts +++ b/apps/backend/types.ts @@ -1,5 +1,5 @@ +import type http from "node:http"; import type { FastifyInstance } from "fastify"; -import http from "node:http"; export type FastifyBase = FastifyInstance< http.Server, diff --git a/packages/eslint-config/MIGRATION_GUIDE.md b/packages/eslint-config/MIGRATION_GUIDE.md new file mode 100644 index 0000000..8d68ef2 --- /dev/null +++ b/packages/eslint-config/MIGRATION_GUIDE.md @@ -0,0 +1,102 @@ +# Migrating to @lightbase/eslint-config + +## From @compas/eslint-plugin + +Execute the following steps to migrate in a mostly compatible way from +`@compas/eslint-plugin` to this package. The main incompatibilities ares: + +- Prefer `Array<>` types in JSDoc over `[]` types. +- Renamed rules like `@compas/event-stop` to `@lightbase/compas-event-stop`. + +The migration can be done as follows: + +- Remove the `@compas/eslint-plugin` dependency from your package.json. +- Install this package with `npm install --save-dev --exact @lightbase/eslint-config` +- Remove `.eslintrc`, `.eslintignore`, `.prettierignore` and `.prettierrc(.js)` files. +- Remove the `prettier` key from your package.json. +- Remove all existing `lint`, `format` and `pretty` scripts from your package.json. +- Create `eslint.config.js` in the root of your project and paste the below contents. +- Apply the [Commands](./README.md#commands) section from the README. +- Apply the [IDE](./README.md#ide) section from the README. +- Run `npm run lint` and fixup the remaining issues. +- Update your CI scripts to use the `npm run lint:ci` command. + +```js +import { defineConfig } from "@lightbase/eslint-config"; + +export default defineConfig( + { + prettier: { + globalOverride: { + useTabs: false, + printWidth: 80, + }, + }, + }, + { + ignores: ["**/*.d.ts"], + }, +); +``` + +## From (internal) @lightbase/eslint-plugin + +In these steps we will be removing the vendored eslint-plugin and use +`@lightbase/eslint-config` instead. The result should give almost the same experience as +before. The main incompatibilities are: + +- Import related rules ban CommonJS style `require`'s. Check if the tool supports `.mjs` + config files or add file specific ignores. +- Prettier is enabled with + [`experimentalTernaries`](https://prettier.io/blog/2023/11/13/curious-ternaries) + +The migration can be done by following these steps: + +- Remove the `vendor/eslint-plugin` directory. +- Install this package with `npm install --save-dev --exact @lightbase/eslint-config` +- Remove `.eslintrc`, `.eslintignore`, `.prettierignore` and `.prettierrc(.js)` files. +- Remove the `prettier` key from your package.json. +- Remove all existing `lint`, `format` and `pretty` scripts from your package.json. +- Create `eslint.config.mjs` in the root of your project and paste the below contents. + - Note the _.mjs_ extension. +- Apply the [Commands](./README.md#commands) section from the README. +- Apply the [IDE](./README.md#ide) section from the README. +- Run `npm run lint` and fixup the remaining issues. + - This will most likely fail a few times. In some cases, the built-in Prettier setup is + not able to auto-fix in this migration. This won't be an issue while using the new + setup. + - You might need to add TS type checking for JavaScript files by adding ` "**/*.*js"` to + your `includes` in the `tsconfig.json`. +- Update your CI scripts to use the `npm run lint:ci` command. + +```js +import { defineConfig } from "@lightbase/eslint-config"; + +export default defineConfig( + { + prettier: { + globalOverride: { + printWidth: 110, + useTabs: false, + arrowParens: "avoid", + }, + }, + typescript: { + // TODO: Start enabling some type check rules. See https://typescript-eslint.io/users/configs/#recommended-type-checked + disableTypeCheckedRules: true, + }, + react: { + withNextJs: true, + }, + }, + { + files: ["**/generated/**/*.*"], + rules: { + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + "unused-imports/no-unused-vars": "off", + }, + }, +); +``` diff --git a/packages/eslint-config/README.md b/packages/eslint-config/README.md index 16473f3..7df2783 100644 --- a/packages/eslint-config/README.md +++ b/packages/eslint-config/README.md @@ -8,20 +8,13 @@ Opinionated but configurable ESLint config. Fully includes linting and formattin npm install --save-dev --exact @lightbase/eslint-config ``` -The following dependencies are automatically installed as part of `peerDependencies`, -however custom versions can be installed via +Some configurations require manually installed plugins. For example ```shell -npm install --save-dev --exact eslint typescript-eslint +npm install --save-dev --exact eslint-plugin-react eslint-plugin-react-hooks ``` -Some configurations require manually installed plugins. - -[//]: # "TODO: update example if we have a good one" - -```shell -npm install --save-dev --exact eslint-plugin-jsdoc -``` +This is documented below. ## Usage @@ -35,12 +28,14 @@ import { defineConfig } from "@lightbase/eslint-config"; export default defineConfig({}); ``` +### Commands + Add the following scripts to your `package.json`: ```json { "scripts": { - "lint": "eslint . --fix --cache --cache-strategy content --cache-location .cache/eslint/ --color", + "lint": "eslint . --fix --cache --cache-strategy content --cache-location .cache/eslint/", "lint:ci": "eslint ." } } @@ -50,32 +45,16 @@ Add the following scripts to your `package.json`: ### In a CommonJS project -Note, these steps will be obsolete with ESLint v9, which at the time of writing is in -alpha. +> [!NOTE] +> +> These steps will be obsolete with ESLint v9, which at the time of writing is released +> but not yet supported by all our plugins. - Use `eslint.config.mjs` instead of `eslint.config.js` - Specify `--config eslint.config.mjs` in the `package.json` scripts. ## Default configuration and options -### Custom configuration - -`defineConfig` accepts custom ESLint configuration as the 'rest' parameter. For example: - -```js -import { defineConfig } from "@lightbase/eslint-config"; - -export default defineConfig( - { - // Define config options, explained below. - }, - { - // Ignore the packages/ directory. - ignores: ["packages/**"], - }, -); -``` - ### Prettier Prettier is configured to run on all markdown, json, yaml, JavaScript and TypeScript @@ -141,6 +120,19 @@ export default defineConfig({ }); ``` +By default, we enable the recommended type checked rules from typescript-eslint. To +disable these rules, use: + +```js +import { defineConfig } from "@lightbase/eslint-config"; + +export default defineConfig({ + typescript: { + disableTypeCheckedRules: true, + }, +}); +``` + ### Markdown A Markdown processor is installed by default. Its purpose is to extract code-blocks and @@ -161,6 +153,89 @@ export default defineConfig( ); ``` +### React + +The config optionally supports enabling React and Next.js specific rules. Add the +following dependencies: + +```shell +npm install --save-dev --exact eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y +``` + +If you use Next.js, make sure to also add `@next/eslint-plugin-next` via: + +```shell +npm install --save-dev --exact @next/eslint-plugin-next +``` + +React is only support in combination with Typescript (see above), and can be enabled as +follows: + +```js +import { defineConfig } from "@lightbase/eslint-config"; + +export default defineConfig({ + react: { + withNextJs: true, + }, +}); +``` + +This enables all Next.js rules and various recommended rules for React, hooks usage and +JSX accessibility. + +### Globals + +The config by default includes all globals for Node.js, Browser and ES2021. You can use +other predefined presets via + +```js +import { defineConfig } from "@lightbase/eslint-config"; + +export default defineConfig({ + // Make sure to include the full setup. + globals: ["browser", "serviceworker"], +}); +``` + +This enables environment-specific globals for all files. For a stricter setup, use custom +configuration as explained below + +```js +import { defineConfig } from "@lightbase/eslint-config"; +import globals from "globals"; + +export default defineConfig( + {}, + { + files: ["**/*.js"], + languageOptions: { + globals: { + ...globals.es2015, + }, + }, + }, +); +``` + +### Custom configuration + +`defineConfig` accepts custom ESLint configuration as the 'rest' parameter. For example: + +```js +import { defineConfig } from "@lightbase/eslint-config"; + +export default defineConfig( + { + // Define config options, explained below. + }, + { + // Ignore the packages/ directory. + ignores: ["packages/**"], + }, +); +``` + ## IDE ### WebStorm diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index cb61b0a..77ce46e 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -27,17 +27,54 @@ }, "dependencies": { "@eslint/js": "^8.57.0", - "eslint-plugin-format": "^0.1.0", - "eslint-plugin-markdown": "^3.0.1" + "diff-match-patch": "^1.0.5", + "eslint": "^8.57.0", + "eslint-config-flat-gitignore": "^0.1.5", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-file-progress": "^1.3.0", + "eslint-plugin-import-x": "^0.5.0", + "eslint-plugin-jsdoc": "^48.2.3", + "eslint-plugin-markdown": "^3.0.1", + "eslint-plugin-unused-imports": "^3.1.0", + "globals": "^15.0.0", + "prettier": "^3.2.5", + "synckit": "^0.9.0", + "typescript-eslint": "^7.6.0" }, "peerDependencies": { - "eslint": ">=8.56.0", - "typescript-eslint": ">=7.2.0" + "@next/eslint-plugin-next": "^14.1.4", + "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-no-relative-import-paths": "^1.5.3", + "eslint-plugin-react": "^7.34.1", + "eslint-plugin-react-hooks": "^4.6.0" + }, + "peerDependenciesMeta": { + "@next/eslint-plugin-next": { + "optional": true + }, + "eslint-plugin-no-relative-import-paths": { + "optional": true + }, + "eslint-plugin-react": { + "optional": true + }, + "eslint-plugin-react-hooks": { + "optional": true + }, + "eslint-plugin-jsx-a11y": { + "optional": true + } }, - "peerDependenciesMeta": {}, "devDependencies": { - "@types/eslint": "^8.56.2", + "@next/eslint-plugin-next": "^14.2.2", + "@types/diff-match-patch": "^1.0.36", + "@types/eslint": "^8.56.9", "@types/eslint__js": "^8.42.3", - "@types/eslint-plugin-markdown": "^2.0.2" + "@types/eslint-plugin-markdown": "^2.0.2", + "@typescript-eslint/utils": "^7.7.0", + "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-no-relative-import-paths": "^1.5.4", + "eslint-plugin-react": "^7.34.1", + "eslint-plugin-react-hooks": "^4.6.0" } } diff --git a/packages/eslint-config/src/format-plugin/README.md b/packages/eslint-config/src/format-plugin/README.md new file mode 100644 index 0000000..0855d7b --- /dev/null +++ b/packages/eslint-config/src/format-plugin/README.md @@ -0,0 +1,19 @@ +# Forked format plugin with some of its dependencies + +Forked from: + +- https://github.com/antfu/eslint-plugin-format/tree/acfb3d2d3b8a06e72a5412deb8cd0fee88f05370 +- https://github.com/antfu/eslint-formatting-reporter/tree/e23ad192dd7b958757b444d6509137cf2cc55d45 +- https://github.com/prettier/prettier-linter-helpers/tree/71259f6e63d42317d65edcd2a93de0c372703b6d +- https://github.com/so1ve/eslint-parser-plain/tree/8339ee4a8226b05bd5f0944b6df1f58d98abe6cb + +See the above sources for the original licenses. + +Changes; + +- Uses diff-match-patch instead of fast-diff. This has a configurable deadline, making + sure that creating the patch doesn't take more than 2 seconds. +- Combined all files in a few to quickly port the changes. + +We will run with this setup for a bit before attempting to upstream to +prettier-linter-helpers. diff --git a/packages/eslint-config/src/format-plugin/constants.ts b/packages/eslint-config/src/format-plugin/constants.ts new file mode 100644 index 0000000..5693a25 --- /dev/null +++ b/packages/eslint-config/src/format-plugin/constants.ts @@ -0,0 +1,5 @@ +export const messages = { + delete: "Delete `{{ deleteText }}`", + insert: "Insert `{{ insertText }}`", + replace: "Replace `{{ deleteText }}` with `{{ insertText }}`", +}; diff --git a/packages/eslint-config/src/format-plugin/diff.ts b/packages/eslint-config/src/format-plugin/diff.ts new file mode 100644 index 0000000..e98303b --- /dev/null +++ b/packages/eslint-config/src/format-plugin/diff.ts @@ -0,0 +1,171 @@ +/* eslint-disable @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-return,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access */ +import DiffMatchPatch from "diff-match-patch"; + +const LINE_ENDING_RE = /\r\n|[\r\n\u2028\u2029]/; + +export function reportDifferences(context: any, source: string, formatted: string) { + if (source !== formatted) { + const createdDiff = new DiffMatchPatch().diff_main(source, formatted); + const generatedDiff = generateDifferences(createdDiff); + + for (const diff of generatedDiff) { + const { operation, offset, deleteText = "", insertText = "" } = diff; + const range = [offset, offset + deleteText.length] as [number, number]; + + const [start, end] = + range.map((index) => context.sourceCode.getLocFromIndex(index)) ?? []; + + context.report({ + messageId: operation, + data: { + deleteText: showInvisibles(deleteText), + insertText: showInvisibles(insertText), + }, + loc: { start, end }, + fix: (fixer: any) => fixer.replaceTextRange(range, insertText), + }); + } + } +} + +/** + * Converts invisible characters to a commonly recognizable visible form. + */ +function showInvisibles(str: string) { + let ret = ""; + for (let i = 0; i < str.length; i++) { + switch (str[i]) { + case " ": + ret += "·"; // Middle Dot, \u00B7 + break; + case "\n": + ret += "⏎"; // Return Symbol, \u23ce + break; + case "\t": + ret += "↹"; // Left Arrow To Bar Over Right Arrow To Bar, \u21b9 + break; + case "\r": + ret += "␍"; // Carriage Return Symbol, \u240D + break; + default: + ret += str[i]; + break; + } + } + return ret; +} + +enum Op { + INSERT = 1, + EQUAL = 0, + DELETE = -1, +} + +/** + * Generate results for differences between source code and formatted version. + */ +function generateDifferences(results: Array<[Op, string]>) { + // fast-diff returns the differences between two texts as a series of + // INSERT, DELETE or EQUAL operations. The results occur only in these + // sequences: + // /-> INSERT -> EQUAL + // EQUAL | /-> EQUAL + // \-> DELETE | + // \-> INSERT -> EQUAL + // Instead of reporting issues at each INSERT or DELETE, certain sequences + // are batched together and are reported as a friendlier "replace" operation: + // - A DELETE immediately followed by an INSERT. + // - Any number of INSERTs and DELETEs where the joining EQUAL of one's end + // and another's beginning does not have line endings (i.e. issues that occur + // on contiguous lines). + + const differences: Array<{ + offset: number; + operation: string; + insertText?: string; + deleteText?: string; + }> = []; + + const batch: typeof results = []; + let offset = 0; // NOTE: INSERT never advances the offset. + while (results.length) { + const result = results.shift()!; + const op = result[0]; + const text = result[1]; + switch (op) { + case Op.INSERT: + case Op.DELETE: + batch.push(result); + break; + case Op.EQUAL: + if (results.length) { + if (batch.length) { + if (LINE_ENDING_RE.test(text)) { + flush(); + offset += text.length; + } else { + batch.push(result); + } + } else { + offset += text.length; + } + } + break; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unexpected fast-diff operation "${op}"`); + } + if (batch.length && !results.length) { + flush(); + } + } + + return differences; + + function flush() { + let aheadDeleteText = ""; + let aheadInsertText = ""; + while (batch.length) { + const next = batch.shift()!; + const op = next[0]; + const text = next[1]; + switch (op) { + case Op.INSERT: + aheadInsertText += text; + break; + case Op.DELETE: + aheadDeleteText += text; + break; + case Op.EQUAL: + aheadDeleteText += text; + aheadInsertText += text; + break; + } + } + if (aheadDeleteText && aheadInsertText) { + differences.push({ + offset, + operation: generateDifferences.REPLACE, + insertText: aheadInsertText, + deleteText: aheadDeleteText, + }); + } else if (!aheadDeleteText && aheadInsertText) { + differences.push({ + offset, + operation: generateDifferences.INSERT, + insertText: aheadInsertText, + }); + } else if (aheadDeleteText && !aheadInsertText) { + differences.push({ + offset, + operation: generateDifferences.DELETE, + deleteText: aheadDeleteText, + }); + } + offset += aheadDeleteText.length; + } +} + +generateDifferences.INSERT = "insert"; +generateDifferences.DELETE = "delete"; +generateDifferences.REPLACE = "replace"; diff --git a/packages/eslint-config/src/format-plugin/index.ts b/packages/eslint-config/src/format-plugin/index.ts new file mode 100644 index 0000000..a3caa7e --- /dev/null +++ b/packages/eslint-config/src/format-plugin/index.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-argument */ +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; +import { createSyncFn } from "synckit"; +import { messages } from "./constants.js"; +import { reportDifferences } from "./diff.js"; + +let format: undefined | ((input: string, options: unknown) => string) = undefined; + +// Make sure to check the README.md +export const formatPlugin: FlatConfig.Plugin = { + rules: { + prettier: { + meta: { + type: "layout", + docs: { + description: "Use Prettier to format code", + category: "Stylistic", + }, + fixable: "whitespace", + schema: [ + { + type: "object", + properties: { + parser: { + type: "string", + required: true, + }, + }, + additionalProperties: true, + }, + ], + messages, + }, + create(context) { + if (!format) { + // @ts-expect-error untyped + format = createSyncFn( + join(fileURLToPath(new URL("./", import.meta.url)), "worker.cjs"), + ); + } + + return { + Program() { + const sourceCode = context.sourceCode.text; + const formatted = format!(sourceCode, context.options[0] ?? {}); + + reportDifferences(context, sourceCode, formatted); + }, + }; + }, + }, + }, +}; diff --git a/packages/eslint-config/src/format-plugin/parser.ts b/packages/eslint-config/src/format-plugin/parser.ts new file mode 100644 index 0000000..d8c8f61 --- /dev/null +++ b/packages/eslint-config/src/format-plugin/parser.ts @@ -0,0 +1,22 @@ +const parseForESLint = (code: string) => ({ + ast: { + type: "Program", + loc: { start: 0, end: code.length }, + range: [0, code.length], + body: [], + comments: [], + tokens: [], + }, + services: { isPlain: true }, + scopeManager: null, + visitorKeys: { + Program: [], + }, +}); + +export const plainParser = { + meta: { + name: "plain-parsers", + }, + parseForESLint, +}; diff --git a/packages/eslint-config/src/format-plugin/worker.cts b/packages/eslint-config/src/format-plugin/worker.cts new file mode 100644 index 0000000..765d0cf --- /dev/null +++ b/packages/eslint-config/src/format-plugin/worker.cts @@ -0,0 +1,7 @@ +/* eslint-disable */ +const { runAsWorker } = require("synckit"); +const { format } = require("prettier"); + +runAsWorker(async (code: string, options: any) => { + return format(code, options); +}); diff --git a/packages/eslint-config/src/globals.ts b/packages/eslint-config/src/globals.ts new file mode 100644 index 0000000..df1f3a2 --- /dev/null +++ b/packages/eslint-config/src/globals.ts @@ -0,0 +1,30 @@ +import type { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; +import globals from "globals"; + +export type GlobalsConfig = Array; + +/** + * Select globals to be available. Defaults to the fullstack experience for modern JS. + */ +export function defineGlobals(config?: GlobalsConfig): Array { + if (config === undefined) { + config = ["node", "browser", "es2021"]; + } + + let collectedGlobals: Record = {}; + + for (const item of config) { + collectedGlobals = { + ...collectedGlobals, + ...globals[item], + }; + } + + return [ + { + languageOptions: { + globals: collectedGlobals, + }, + }, + ]; +} diff --git a/packages/eslint-config/src/globs.ts b/packages/eslint-config/src/globs.ts index 13303d5..4c43aaf 100644 --- a/packages/eslint-config/src/globs.ts +++ b/packages/eslint-config/src/globs.ts @@ -1,3 +1,5 @@ +import type { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; + /** * We need to keep track of used globs to prevent conflicts between language parsers and the * Prettier config. @@ -18,7 +20,7 @@ export const GLOBS = { * Keep track of all used globs. We need this to use custom globs for running Prettier in * ESLint. */ -export function globUse(globs: string[]) { +export function globUse(globs: Array) { for (const glob of globs) { USED_GLOBS.add(glob); } @@ -44,5 +46,22 @@ export function globAsFormat(glob: string) { * Apply custom rules to snippets in markdown files. */ export function globMarkdownSnippetFromGlob(glob: string) { - return `**/*.md/*.${glob.split("/").pop() ?? ""}`; + return `**/*.md/**/.${glob.split("/").pop() ?? ""}`; +} + +/** + * Register all globs in use by custom configs. This is needed since we apply + * those after the Prettier configuration. The Prettier config then uses a processor + * to prevent parser conflicts. + */ +export function globUseFromUserConfig(...userConfigs: Array) { + for (const conf of userConfigs) { + if (conf.files) { + for (const glob of conf.files.flat()) { + if (typeof glob === "string") { + globUse([glob]); + } + } + } + } } diff --git a/packages/eslint-config/src/imports.ts b/packages/eslint-config/src/imports.ts new file mode 100644 index 0000000..ad56c1c --- /dev/null +++ b/packages/eslint-config/src/imports.ts @@ -0,0 +1,91 @@ +import type { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; +import * as pluginImport from "eslint-plugin-import-x"; +// @ts-expect-error No types available +import pluginUnusedImports from "eslint-plugin-unused-imports"; +import { GLOBS, globUse } from "./globs.js"; + +export function imports(): Array { + return [ + { + // Setup import plugins. Includes unused-imports, to automatically remove them. + // This might not be the best experience if imports are added manually, but most people + // use auto-imports anyway (?!). + files: globUse([GLOBS.javascript, GLOBS.typescript]), + plugins: { + "import-x": pluginImport.default, + "unused-imports": pluginUnusedImports as FlatConfig.Plugin, + }, + settings: { + "import-x/resolver": { + typescript: true, + node: true, + }, + }, + rules: { + ...pluginImport.configs.errors.rules, + ...pluginImport.configs.warnings.rules, + + "import-x/export": "error", + "import-x/no-empty-named-blocks": "error", + "import-x/no-commonjs": "error", + "import-x/no-amd": "error", + "import-x/named": "error", + "import-x/first": "error", + "import-x/namespace": "off", + "import-x/newline-after-import": ["error", { count: 1 }], + "import-x/no-default-export": "error", + "import-x/order": [ + "error", + { + "newlines-between": "never", + "alphabetize": { + order: "asc", + caseInsensitive: true, + }, + }, + ], + "import-x/no-unresolved": ["error"], + "import-x/consistent-type-specifier-style": ["error", "prefer-top-level"], + + // Make sure to disable no-unused-vars + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": "off", + + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "error", + { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_", + caughtErrors: "all", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + }, + ], + }, + }, + + { + // Handle JS config files + files: globUse(["**/eslint.config.js", "**/next.config.js"]), + rules: { + "import-x/no-default-export": "off", + }, + }, + + { + // Disabled rules because of missing FlatConfig compatibility. + rules: { + "import-x/default": "off", + "import-x/no-named-as-default": "off", + "import-x/no-named-as-default-member": "off", + + // Re-enable once is + // fixed. There is currently no related issue in eslint-plugin-import-x repo yet. + "import-x/no-duplicates": "off", + }, + }, + ] satisfies Array; +} diff --git a/packages/eslint-config/src/index.ts b/packages/eslint-config/src/index.ts index a3f099b..7563637 100644 --- a/packages/eslint-config/src/index.ts +++ b/packages/eslint-config/src/index.ts @@ -1,54 +1,80 @@ -import { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; -import { globUse } from "./globs.js"; +import type { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; +import gitignore from "eslint-config-flat-gitignore"; +// @ts-expect-error no types available +import pluginFileProgress from "eslint-plugin-file-progress"; +import { defineGlobals } from "./globals.js"; +import type { GlobalsConfig } from "./globals.js"; +import { globUseFromUserConfig } from "./globs.js"; +import { imports } from "./imports.js"; import { javascript } from "./javascript.js"; -import { markdownConfig } from "./markdown.js"; -import { prettierConfig, PrettierConfig } from "./prettier.js"; -import { typescript, TypescriptConfig, typescriptResolveConfig } from "./typescript.js"; +import { markdownConfig, markdownSnippetOverrides } from "./markdown.js"; +import { prettierConfig } from "./prettier.js"; +import type { PrettierConfig } from "./prettier.js"; +import type { ReactConfig } from "./react.js"; +import { typescript, typescriptResolveConfig } from "./typescript.js"; +import type { TypescriptConfig } from "./typescript.js"; -interface Opts { +interface LightbaseEslintConfigOptions { prettier?: PrettierConfig; typescript?: TypescriptConfig; + react?: ReactConfig; + globals?: GlobalsConfig; } -export function defineConfig(opts: Opts, ...userConfigs: FlatConfig.Config[]) { - // Register all globs in use by custom configs. This is needed since we apply - // those after the Prettier configuration. The Prettier config then uses a processor - // to prevent parser conflicts. - for (const conf of userConfigs) { - if (conf.files) { - for (const glob of conf.files.flat()) { - if (typeof glob === "string") { - globUse([glob]); - } - } - } - } - +/** + * Entrypoint for your everything included ESLint config. + */ +export async function defineConfig( + opts: LightbaseEslintConfigOptions, + ...userConfigs: Array +): Promise> { + globUseFromUserConfig(...userConfigs); opts.typescript = typescriptResolveConfig(opts.typescript); + // Only load React + related plugins if necessary. This adds quite the startup penalty otherwise. + const reactRelatedConfig = + opts.react ? await (await import("./react.js")).react(opts.react) : []; + return [ // Global options + gitignore(), { - // TODO: Add automatic ignores based on .gitignore - ignores: [".cache", ".idea", ".next", "dist", "out", "package-lock.json"], + // Never format lock-files + ignores: ["**/package-lock.json", "yarn.lock"], }, - { + // Make sure to cleanup unused directives when they are not necessary anymore. linterOptions: { reportUnusedDisableDirectives: "error", }, }, + ...defineGlobals(opts.globals), + + { + // Show a friendly spinner. + files: ["**/*"], + plugins: { + "file-progress": pluginFileProgress as unknown as FlatConfig.Plugin, + }, + rules: { + "file-progress/activate": "warn", + }, + }, // Language specifics ...markdownConfig(), - ...javascript(!!opts.typescript), + ...javascript(), ...typescript(opts.typescript), + ...imports(), // Ecosystem specific + ...reactRelatedConfig, // Format all the things ...prettierConfig(opts.prettier), ...userConfigs, - ] satisfies FlatConfig.Config[]; + + ...markdownSnippetOverrides(), + ] satisfies Array; } diff --git a/packages/eslint-config/src/javascript.ts b/packages/eslint-config/src/javascript.ts index 179fb1c..07d5d20 100644 --- a/packages/eslint-config/src/javascript.ts +++ b/packages/eslint-config/src/javascript.ts @@ -1,18 +1,129 @@ -import { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; import eslintConfigs from "@eslint/js"; +import typescriptEslintParser from "@typescript-eslint/parser"; +import type { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; +import pluginJsDoc from "eslint-plugin-jsdoc"; import { GLOBS, globUse } from "./globs.js"; +import { lightbaseInternalPlugin } from "./plugin/index.js"; -export function javascript(hasTypescriptEnabled: boolean) { - if (hasTypescriptEnabled) { - return []; - } - +export function javascript(): Array { return [ { + // Use the Typescript parser even if we don't Typescript. This allows us to use 'modern' + // JS features even if the built-in espree parser doesn't support it. files: globUse([GLOBS.javascript]), + languageOptions: { + parser: typescriptEslintParser, + parserOptions: { + project: false, + }, + }, + }, + + { + // We include TS here. typescript-eslint will disable conflicting rules. + files: globUse([GLOBS.javascript, GLOBS.typescript]), rules: { ...eslintConfigs.configs.recommended.rules, + + "default-case-last": "error", + "default-param-last": "error", + "no-console": ["error", { allow: ["dir", "time", "timeEnd"] }], + "no-else-return": "error", + "no-eq-null": "error", + "no-labels": "error", + "no-promise-executor-return": "error", + "no-return-assign": "error", + "no-sequences": "error", + "no-throw-literal": "error", + "no-unsafe-optional-chaining": ["error", { disallowArithmeticOperators: true }], + "no-var": "error", + "prefer-const": "error", + "prefer-promise-reject-errors": "error", + "prefer-template": "error", + "require-await": "error", + "no-constant-binary-expression": "error", + + "no-process-exit": "off", + "no-mixed-spaces-and-tabs": "off", + }, + }, + + { + // Internal plugin rules ;) + files: globUse([GLOBS.javascript]), + plugins: { + lightbase: lightbaseInternalPlugin, + }, + rules: { + "lightbase/node-builtin-module-url-import": "error", + "lightbase/compas-check-event-name": "error", + "lightbase/compas-enforce-event-stop": "error", + }, + }, + + { + // Pretty strict JSDoc configuration, so a jsconfig.json can be used in JS only projects. + files: globUse([GLOBS.javascript]), + plugins: { + jsdoc: pluginJsDoc, + }, + settings: { + jsdoc: { + mode: "typescript", + preferredTypes: { + "Object": "object", + "object<>": "Record<>", + "Object<>": "Record<>", + "object.<>": "Record<>", + "Object.<>": "Record<>", + "Array.<>": "Array<>", + "[]": "Array<>", + "String": "string", + "Boolean": "boolean", + "Number": "number", + }, + }, + }, + rules: { + "jsdoc/check-alignment": "error", + "jsdoc/check-examples": "off", + "jsdoc/check-indentation": "off", + "jsdoc/check-line-alignment": ["error", "never", { wrapIndent: " " }], + "jsdoc/check-param-names": "error", + "jsdoc/check-property-names": "error", + "jsdoc/check-syntax": "error", + "jsdoc/check-tag-names": ["error", { definedTags: [] }], + "jsdoc/check-types": ["error"], + "jsdoc/check-values": "error", + "jsdoc/empty-tags": "error", + "jsdoc/require-asterisk-prefix": "error", + "jsdoc/require-hyphen-before-param-description": ["error", "never"], + "jsdoc/require-param-name": "error", + "jsdoc/require-property": "error", + "jsdoc/require-property-name": "error", + "jsdoc/require-property-type": "error", + "jsdoc/require-returns-check": "off", + "jsdoc/require-returns-description": "off", + "jsdoc/require-returns-type": "error", + "jsdoc/tag-lines": [ + "error", + "never", + { + startLines: 1, + endLines: 0, + tags: { + deprecated: { lines: "any" }, + public: { lines: "any" }, + private: { lines: "any" }, + see: { lines: "any" }, + since: { lines: "any" }, + summary: { lines: "any" }, + template: { lines: "any" }, + }, + }, + ], + "jsdoc/valid-types": "off", }, }, - ] satisfies FlatConfig.Config[]; + ] satisfies Array; } diff --git a/packages/eslint-config/src/markdown.ts b/packages/eslint-config/src/markdown.ts index 5e7c32f..10123de 100644 --- a/packages/eslint-config/src/markdown.ts +++ b/packages/eslint-config/src/markdown.ts @@ -1,5 +1,6 @@ -import { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; +import type { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; import markdown from "eslint-plugin-markdown"; +import { globMarkdownSnippetFromGlob, GLOBS, globUse } from "./globs.js"; /** * Allows parsing of markdown files, adding code blocks as virtual files. @@ -11,5 +12,23 @@ export function markdownConfig() { markdown, }, }, - ] satisfies FlatConfig.Config[]; + ] satisfies Array; +} + +/** + * Load overrides for Markdown snippets. Is separate, to allow these rules to have priority. + */ +export function markdownSnippetOverrides(): Array { + return [ + { + // Disable rules in Markdown snippets + files: globUse([ + globMarkdownSnippetFromGlob(GLOBS.javascript), + globMarkdownSnippetFromGlob(GLOBS.typescript), + ]), + rules: { + "unused-imports/no-unused-vars": "off", + }, + }, + ] satisfies Array; } diff --git a/packages/eslint-config/src/plugin/compas-check-event-name.ts b/packages/eslint-config/src/plugin/compas-check-event-name.ts new file mode 100644 index 0000000..9c3e118 --- /dev/null +++ b/packages/eslint-config/src/plugin/compas-check-event-name.ts @@ -0,0 +1,141 @@ +/* eslint-disable @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-argument */ +import type { AnyRuleModule } from "@typescript-eslint/utils/ts-eslint"; + +// Ported from Compas: +// https://github.com/compasjs/compas/blob/72dd8e0df5b315f87d1caf3f101af283db14867c/packages/eslint-plugin/lint-rules/check-event-name.js +export const compasCheckEventName: AnyRuleModule = { + meta: { + type: "suggestion", + docs: { + description: `Suggest that the 'event.name' passed to 'eventStart' is a derivative from the function name.`, + }, + hasSuggestions: true, + + messages: { + consistentEventName: "Use an event name that can be derived from the function name", + replaceEventName: `Replace value with {{value}}`, + }, + + schema: [], + }, + + defaultOptions: [], + + create(context) { + let currentFunction: any; + + function processFunctionStart(node: any) { + currentFunction = { + parent: currentFunction, + node, + isAsyncEventFunction: node.async && node.params[0]?.name === "event", + functionName: node.id?.name, + }; + } + + function processFunctionEnd() { + currentFunction = currentFunction.parent; + } + + return { + // Manage function scopes + ":function": processFunctionStart, + ":function:exit": processFunctionEnd, + + // Process `eventStart` calls + "CallExpression[callee.name='eventStart']"(node) { + if ( + !currentFunction.isAsyncEventFunction || + !currentFunction.functionName || + currentFunction.functionName.length === 0 + ) { + return; + } + + // @ts-expect-error unknown + if (node.arguments?.length !== 2) { + return; + } + + let value = undefined; + // @ts-expect-error unknown + if (node.arguments[1].type === "Literal") { + // @ts-expect-error unknown + value = node.arguments[1].value; + } + + if ( + // @ts-expect-error unknown + node.arguments[1].type === "TemplateLiteral" && // @ts-expect-error unknown + node.arguments[1].expressions.length === 0 && // @ts-expect-error unknown + node.arguments[1].quasis.length === 1 + ) { + // @ts-expect-error unknown + value = node.arguments[1].quasis[0].value.raw; + } + + if (value === null || value === undefined || typeof value !== "string") { + return; + } + + const fnNameParts = (currentFunction.functionName as string) + .split(/(?=[A-Z])/) + .map((it) => it.toLowerCase()); + const validEventNames = calculateValidEventNames(fnNameParts); + + if (validEventNames.includes(value)) { + return; + } + + context.report({ + // @ts-expect-error unknown + node: node.arguments[1], + messageId: "consistentEventName", + suggest: validEventNames.map((it) => { + return { + messageId: "replaceEventName", + data: { + value: it, + }, + fix: function (fixer) { + // @ts-expect-error unknown + return fixer.replaceText(node.arguments[1], `"${it}"`); + }, + }; + }), + }); + }, + }; + }, +}; + +/** + * Computes all possible camelCase.dotVariants for the given parts. + */ +function calculateValidEventNames(parts: Array) { + const result = []; + + if (parts.length === 1) { + return parts; + } + + for (let i = 1; i < parts.length; ++i) { + let str = ""; + + for (let j = 0; j < parts.length; ++j) { + if (j === 0) { + str += parts[j]; + } else if (i === j) { + str += "."; + str += parts[j]; + } else { + // @ts-expect-error unknown + str += parts[j][0].toUpperCase() + parts[j].substring(1); + } + } + + result.push(str); + } + + return result; +} diff --git a/packages/eslint-config/src/plugin/compas-enforce-event-stop.ts b/packages/eslint-config/src/plugin/compas-enforce-event-stop.ts new file mode 100644 index 0000000..cf4abe2 --- /dev/null +++ b/packages/eslint-config/src/plugin/compas-enforce-event-stop.ts @@ -0,0 +1,225 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-argument,@typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-call */ + +import type { AnyRuleModule } from "@typescript-eslint/utils/ts-eslint"; + +// Ported from Compas: +// https://github.com/compasjs/compas/blob/72dd8e0df5b315f87d1caf3f101af283db14867c/packages/eslint-plugin/lint-rules/enforce-event-stop.js +export const compasEnforceEventStop: AnyRuleModule = { + meta: { + type: "problem", + docs: { + description: `Suggest that 'eventStop' is called in async functions that define 'event' as its first parameter.`, + }, + hasSuggestions: true, + + messages: { + missingEventStop: `Expected a call to 'eventStop' before this return.`, + addEventStop: "Add 'eventStop(event)' before the return statement.", + }, + + schema: [], + }, + + defaultOptions: [], + + create(context) { + // Tries to keep track of the current function in a stack like way. + // The current function is assigned to this variable, child functions refer to their 'parent'. + let currentFunction: any; + + function processFunctionStart(node: any) { + currentFunction = { + parent: currentFunction, + node, + isAsyncEventFunction: node.async && node.params[0]?.name === "event", + hasEventStart: false, + block: undefined, + }; + } + + function processFunctionEnd() { + currentFunction = currentFunction.parent; + } + + function blockEnter(node: any) { + if (!currentFunction) { + return; + } + + currentFunction.block = { + node, + parent: currentFunction.block, + hasEventStop: currentFunction.block?.hasEventStop ?? false, + hasEventStart: currentFunction.block?.hasEventStart ?? false, + returnStatement: undefined, + children: [], + }; + if (currentFunction.block.parent) { + currentFunction.block.parent.children.push(currentFunction.block); + } + } + + function blockExit(node: any) { + if (!currentFunction) { + return; + } + + const block = currentFunction.block; + currentFunction.block = currentFunction.block?.parent; + + if (!currentFunction.isAsyncEventFunction || !currentFunction.hasEventStart) { + return; + } + + const blocksFound = !( + block.children.length === 0 && block.node.parent.type.includes("Function") + ); + + const noBareIfStatementFound = !( + block.node.parent.type.includes("Function") && + block.children.length === 1 && + block.children[0].node === block.children[0].node.parent?.consequent && + block.children[0].returnStatement + ); + + // If there is no return statement, we are not sure if this code path is reachable + if (!block.returnStatement && blocksFound && noBareIfStatementFound) { + return; + } + + const hasEventStop = block.hasEventStop; + + if (hasEventStop) { + return; + } + + context.report({ + node: block.returnStatement ?? node, + messageId: "missingEventStop", + suggest: + block.returnStatement ? + [ + { + messageId: "addEventStop", + fix: (fixer) => + fixer.insertTextBefore(block.returnStatement, "eventStop(event);\n"), + }, + ] + : [], + }); + } + + return { + // Manage function scopes + ":function": processFunctionStart, + ":function:exit": processFunctionEnd, + + // Manage block scopes + "BlockStatement": blockEnter, + "BlockStatement:exit": blockExit, + + // Check if eventStop is called + "CallExpression[callee.name='eventStop']"() { + if (currentFunction?.block) { + currentFunction.block.hasEventStop = true; + } + }, // Check if eventStop is called + "CallExpression[callee.name='eventStart']"() { + if (currentFunction?.block) { + currentFunction.hasEventStart = true; + currentFunction.block.hasEventStart = true; + } + }, + + // Check if block has return statement + "ReturnStatement"(node) { + if (currentFunction?.block) { + currentFunction.block.returnStatement = node; + } + }, + + // Edge cases for inline blocks + "WhileStatement[body.type='ReturnStatement']"(node) { + if (!currentFunction.isAsyncEventFunction || !currentFunction.hasEventStart) { + return; + } + + if (currentFunction.block.hasEventStop) { + return; + } + + context.report({ + // @ts-expect-error unknown + node: node.body, + messageId: "missingEventStop", + suggest: [ + { + messageId: "addEventStop", + // @ts-expect-error unknown + fix: (fixer) => { + // @ts-expect-error unknown + fixer.insertTextBefore(node.body, "{\neventStop(event);\n"); + // @ts-expect-error unknown + fixer.insertTextAfter(node.body, "}"); + }, + }, + ], + }); + }, + "IfStatement[consequent.type='ReturnStatement']"(node) { + if (!currentFunction?.isAsyncEventFunction || !currentFunction.hasEventStart) { + return; + } + + if (currentFunction.block?.hasEventStop) { + return; + } + + context.report({ + // @ts-expect-error unknown + node: node.consequent, + messageId: "missingEventStop", + suggest: [ + { + messageId: "addEventStop", + // @ts-expect-error unknown + fix: (fixer) => { + // @ts-expect-error unknown + fixer.insertTextBefore(node.consequent, "{\neventStop(event);\n"); + // @ts-expect-error unknown + fixer.insertTextAfter(node.consequent, "}"); + }, + }, + ], + }); + }, + "IfStatement[alternate.type='ReturnStatement']"(node) { + if (!currentFunction?.isAsyncEventFunction || !currentFunction.hasEventStart) { + return; + } + + if (currentFunction.block?.hasEventStop) { + return; + } + + context.report({ + // @ts-expect-error unknown + node: node.alternate, + messageId: "missingEventStop", + suggest: [ + { + messageId: "addEventStop", + // @ts-expect-error unknown + fix: (fixer) => { + // @ts-expect-error unknown + fixer.insertTextBefore(node.alternate, "{\neventStop(event);\n"); + // @ts-expect-error unknown + fixer.insertTextAfter(node.alternate, "}"); + }, + }, + ], + }); + }, + }; + }, +}; diff --git a/packages/eslint-config/src/plugin/index.ts b/packages/eslint-config/src/plugin/index.ts new file mode 100644 index 0000000..57e6829 --- /dev/null +++ b/packages/eslint-config/src/plugin/index.ts @@ -0,0 +1,12 @@ +import type { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; +import { compasCheckEventName } from "./compas-check-event-name.js"; +import { compasEnforceEventStop } from "./compas-enforce-event-stop.js"; +import { nodeBuiltinModuleUrlImport } from "./node-builtin-module-url-import.js"; + +export const lightbaseInternalPlugin: FlatConfig.Plugin = { + rules: { + "node-builtin-module-url-import": nodeBuiltinModuleUrlImport, + "compas-check-event-name": compasCheckEventName, + "compas-enforce-event-stop": compasEnforceEventStop, + }, +}; diff --git a/packages/eslint-config/src/plugin/node-builtin-module-url-import.ts b/packages/eslint-config/src/plugin/node-builtin-module-url-import.ts new file mode 100644 index 0000000..1fc0b37 --- /dev/null +++ b/packages/eslint-config/src/plugin/node-builtin-module-url-import.ts @@ -0,0 +1,51 @@ +import { builtinModules } from "node:module"; +import type { AnyRuleModule } from "@typescript-eslint/utils/ts-eslint"; + +// Ported from Compas: +// https://github.com/compasjs/compas/blob/72dd8e0df5b315f87d1caf3f101af283db14867c/packages/eslint-plugin/lint-rules/node-builtin-module-url-import.js +export const nodeBuiltinModuleUrlImport: AnyRuleModule = { + meta: { + type: "suggestion", + docs: { + description: `Suggest that imports of Node.js builtin modules use the 'node:' specifier.`, + }, + fixable: "code", + hasSuggestions: true, + + messages: { + consistentImport: `Always use the 'node:' specifier when importing Node.js builtins.`, + replaceImport: `Replace '{{value}}' with 'node:{{value}}'`, + }, + + schema: [], + }, + + defaultOptions: [], + + create(context) { + return { + ImportDeclaration(node) { + // Note that builtinModules doesn't include the `node:` specifier, so we automatically skip these once fixed. + if (!builtinModules.includes(node.source.value)) { + return; + } + + context.report({ + node: node, + messageId: "consistentImport", + fix: (fixer) => fixer.replaceText(node.source, `"node:${node.source.value}"`), + suggest: [ + { + messageId: "replaceImport", + data: { + value: node.source.value, + }, + fix: (fixer) => + fixer.replaceText(node.source, `"node:${node.source.value}"`), + }, + ], + }); + }, + }; + }, +}; diff --git a/packages/eslint-config/src/prettier.ts b/packages/eslint-config/src/prettier.ts index fc30d1e..2b61613 100644 --- a/packages/eslint-config/src/prettier.ts +++ b/packages/eslint-config/src/prettier.ts @@ -1,7 +1,8 @@ -import { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; -import { Linter } from "eslint"; -import format from "eslint-plugin-format"; -import type { Options } from "prettier"; +import type { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; +import type { Linter } from "eslint"; +import type { Options as PrettierOptions } from "prettier"; +import { formatPlugin } from "./format-plugin/index.js"; +import { plainParser } from "./format-plugin/parser.js"; import { globIsUsed, globMarkdownSnippetFromGlob, @@ -9,20 +10,28 @@ import { globAsFormat, globUse, } from "./globs.js"; -import Processor = Linter.Processor; -type PrettierConfigLanguages = "js" | "ts" | "md" | "yaml" | "json"; +type SupportedLanguageOverrides = "js" | "ts" | "md" | "yaml" | "json"; export interface PrettierConfig { - globalOverride?: Options; - languageOverrides?: { [K in PrettierConfigLanguages]?: Options }; + /** + * Override default Prettier options for all supported languages. + */ + globalOverride?: PrettierOptions; + + /** + * Override default Prettier options for specific files. + */ + languageOverrides?: { [K in SupportedLanguageOverrides]?: PrettierOptions }; } /** * Apply Prettier formatting to all files. */ export function prettierConfig(config?: PrettierConfig) { - // TODO: css + tailwind plugin + // TODO: include CSS + tailwind + + // TODO: sql plugin config ??= {}; @@ -43,8 +52,10 @@ export function prettierConfig(config?: PrettierConfig) { }; config.languageOverrides ??= {}; - const processors: FlatConfig.Config[] = []; - const selectGlob = (a: string, processor: Processor) => { + // Dynamically add processors. We need just the original source to pass to Prettier. So if + // the file is already parsed by another ESLint parser, we need to create a virtual file. + const processors: Array = []; + const selectGlob = (a: string, processor: FlatConfig.Processor) => { if (globIsUsed(a)) { processors.push({ files: globUse([a]), @@ -68,10 +79,10 @@ export function prettierConfig(config?: PrettierConfig) { { files: globUse([GLOBS.markdown, globMarkdownSnippetFromGlob(GLOBS.markdown)]), plugins: { - format, + format: formatPlugin, }, languageOptions: { - parser: format.parserPlain, + parser: plainParser, }, rules: { "format/prettier": [ @@ -87,10 +98,10 @@ export function prettierConfig(config?: PrettierConfig) { { files: globUse([yamlGlob]), plugins: { - format, + format: formatPlugin, }, languageOptions: { - parser: format.parserPlain, + parser: plainParser, }, rules: { "format/prettier": [ @@ -106,10 +117,10 @@ export function prettierConfig(config?: PrettierConfig) { { files: globUse([jsonGlob]), plugins: { - format, + format: formatPlugin, }, languageOptions: { - parser: format.parserPlain, + parser: plainParser, }, rules: { "format/prettier": [ @@ -126,10 +137,10 @@ export function prettierConfig(config?: PrettierConfig) { { files: globUse([typescriptGlob]), plugins: { - format, + format: formatPlugin, }, languageOptions: { - parser: format.parserPlain, + parser: plainParser, }, rules: { "format/prettier": [ @@ -141,10 +152,10 @@ export function prettierConfig(config?: PrettierConfig) { { files: globUse([javascriptGlob]), plugins: { - format, + format: formatPlugin, }, languageOptions: { - parser: format.parserPlain, + parser: plainParser, }, rules: { "format/prettier": [ @@ -157,7 +168,7 @@ export function prettierConfig(config?: PrettierConfig) { ], }, }, - ] satisfies FlatConfig.Config[]; + ] satisfies Array; } /** @@ -169,24 +180,24 @@ export function prettierConfig(config?: PrettierConfig) { * * This is kinda hacky and not exactly sure what the consequences are yet... */ -function formatProcessor(): Processor { +function formatProcessor(): FlatConfig.Processor { return { meta: { name: "lightbase:format:processor", version: "-", }, - preprocess(text: string, filename: string): (string | Linter.ProcessorFile)[] { + preprocess(text: string, filename: string): Array { + // Passes through the original file, and includes one with the `.format` prefix. This + // is also called a 'virtual' file. return [ - // Pass one through for the original file. text, { - // Pass one with the .format suffix. text, - filename: `${filename}.format`, + filename: `${filename.split("/").pop()}.format`, }, ]; }, - postprocess(messages: Linter.LintMessage[][]): Linter.LintMessage[] { + postprocess(messages: Array>): Array { return messages.flat(); }, supportsAutofix: true, diff --git a/packages/eslint-config/src/react.ts b/packages/eslint-config/src/react.ts new file mode 100644 index 0000000..a59825a --- /dev/null +++ b/packages/eslint-config/src/react.ts @@ -0,0 +1,177 @@ +import type { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; +// @ts-expect-error no type defs +import pluginJSXA11y from "eslint-plugin-jsx-a11y"; +// @ts-expect-error no type defs +import pluginNoRelativeImportPaths from "eslint-plugin-no-relative-import-paths"; +// @ts-expect-error no type defs +import pluginReact from "eslint-plugin-react"; +// @ts-expect-error no type defs +import pluginReactHooks from "eslint-plugin-react-hooks"; +import { GLOBS, globUse } from "./globs.js"; + +export type ReactConfig = { + withNextJs?: boolean; +}; + +export async function react(config: ReactConfig): Promise> { + // Only expect the Next.js plugin if explicitly enabled. + // At some point we might infer this based on existence of the `next.config.js` file? + const pluginNext = + config.withNextJs ? + ( + (await import( + // @ts-expect-error no types available + "@next/eslint-plugin-next" + )) as unknown as { default: FlatConfig.Plugin } + ).default + : undefined; + + return [ + ...(pluginNext ? + ([ + { + // Next.js configuration + files: globUse([GLOBS.typescript]), + plugins: { + "@next/next": pluginNext, + }, + rules: { + ...(pluginNext.configs?.recommended?.rules as Record), + ...(pluginNext.configs?.["core-web-vitals"]?.rules as Record), + + "@next/next/no-img-element": "off", + }, + }, + ] satisfies Array) + : []), + + { + // React, React-hooks and JSX a11y rules. + files: globUse([GLOBS.typescript]), + plugins: { + "react": pluginReact as FlatConfig.Plugin, + "react-hooks": pluginReactHooks as FlatConfig.Plugin, + "jsx-a11y": pluginJSXA11y as FlatConfig.Plugin, + }, + settings: { + react: { + version: "detect", + }, + }, + rules: { + ...((pluginReact as FlatConfig.Plugin).configs?.recommended?.rules as Record< + string, + string + >), + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", + + ...((pluginJSXA11y as FlatConfig.Plugin).configs?.recommended?.rules as Record< + string, + string + >), + "jsx-a11y/anchor-is-valid": "off", + + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error", + }, + }, + + { + // Custom import ordering preferences. :^) + files: globUse([GLOBS.typescript]), + plugins: { + "no-relative-import-paths": pluginNoRelativeImportPaths as FlatConfig.Plugin, + }, + rules: { + "import-x/order": [ + "error", + { + "warnOnUnassignedImports": false, + "newlines-between": "always", + "alphabetize": { + order: "asc", + }, + "groups": [ + "type", + "builtin", + "external", + "internal", + "parent", + "sibling", + "index", + "object", + ], + "pathGroups": [ + { + pattern: "css/**", + group: "builtin", + position: "before", + }, + { + pattern: "react", + group: "builtin", + position: "before", + }, + { + pattern: "next/**", + group: "builtin", + position: "before", + }, + { + pattern: "generated/**", + group: "internal", + position: "before", + }, + { + pattern: "lib/**", + group: "internal", + position: "before", + }, + { + pattern: "cms/**", + group: "internal", + position: "before", + }, + { + pattern: "server/**", + group: "internal", + position: "before", + }, + { + pattern: "tenants/**", + group: "internal", + position: "before", + }, + { + pattern: "pages/**", + group: "internal", + position: "before", + }, + { + pattern: "hooks/**", + group: "internal", + position: "before", + }, + { + pattern: "components/**", + group: "internal", + position: "before", + }, + { + pattern: "{assets,locale}/**", + group: "internal", + }, + ], + "pathGroupsExcludedImportTypes": ["react", "next/**", "css/**"], + }, + ], + + "no-relative-import-paths/no-relative-import-paths": [ + "error", + { allowSameFolder: true, rootDir: "src" }, + ], + }, + }, + ]; +} diff --git a/packages/eslint-config/src/typescript.ts b/packages/eslint-config/src/typescript.ts index 5f0f380..3de5621 100644 --- a/packages/eslint-config/src/typescript.ts +++ b/packages/eslint-config/src/typescript.ts @@ -1,14 +1,24 @@ -import { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; import { existsSync } from "node:fs"; +import type { FlatConfig } from "@typescript-eslint/utils/ts-eslint"; import typescriptEslint from "typescript-eslint"; import { GLOBS, globUse } from "./globs.js"; +import { lightbaseInternalPlugin } from "./plugin/index.js"; export type TypescriptConfig = | boolean | { project?: boolean | string; + disableTypeCheckedRules?: boolean; }; +/** + * Infer the correct tsconfig to use, or let typescript-eslint figure it out if 'true' is + * explicitly passed. + * + * Prefers `tsconfig.eslint.json` over `tsconfig.json`. This distinction might be necessary, + * since typescript-eslint expects all files to be part of the compile unit, but that might + * not be needed for normal builds. + */ export function typescriptResolveConfig(config?: TypescriptConfig): TypescriptConfig { if (config === false) { return false; @@ -32,12 +42,28 @@ export function typescriptResolveConfig(config?: TypescriptConfig): TypescriptCo } else { config = false; } + } else if (config.project === undefined) { + // An empty options object is passed, or an options object without project. Resolve + // project as if nothing was passed. This means that we might disable Typescript support + // even if the user explicitly passed `typescript: {}`. + const emptyConfigResolved = typescriptResolveConfig(undefined); + + if ( + typeof emptyConfigResolved === "object" && + "project" in emptyConfigResolved && + emptyConfigResolved.project + ) { + config.project = emptyConfigResolved.project; + } else if (!emptyConfigResolved) { + // If undefined or false is returned, we disable, even if other config props are passed. + return false; + } } return config; } -export function typescript(config: TypescriptConfig) { +export function typescript(config: TypescriptConfig): Array { if (config === false) { return []; } @@ -50,6 +76,7 @@ export function typescript(config: TypescriptConfig) { return [ { + // Setup parser and options files: globUse([GLOBS.javascript, GLOBS.typescript]), plugins: { "@typescript-eslint": typescriptEslint.plugin, @@ -63,8 +90,21 @@ export function typescript(config: TypescriptConfig) { }, }, - // TODO: apply a custom ruleset - ...typescriptEslint.configs.strictTypeChecked + { + // Enable built-in rules. + files: globUse([GLOBS.typescript]), + plugins: { + lightbase: lightbaseInternalPlugin, + }, + rules: { + "lightbase/node-builtin-module-url-import": "error", + }, + }, + + ...(config.disableTypeCheckedRules ? + typescriptEslint.configs.recommended + : typescriptEslint.configs.recommendedTypeChecked + ) .filter((it) => !!it.rules) .map((config) => ({ files: globUse([GLOBS.javascript, GLOBS.typescript]), @@ -72,11 +112,25 @@ export function typescript(config: TypescriptConfig) { })), { - // TODO: extract glob - files: globUse(["**/*.test.?(c|m)[jt]s?(x)"]), + // Some stylistic types + files: globUse([GLOBS.javascript, GLOBS.typescript]), + rules: { + "@typescript-eslint/array-type": [ + "error", + { + default: "generic", + }, + ], + "@typescript-eslint/consistent-indexed-object-style": "error", + }, + }, + + { + // Compat with import plugin + files: globUse([GLOBS.javascript, GLOBS.typescript]), rules: { - "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/consistent-type-imports": ["error"], }, }, - ] satisfies FlatConfig.Config[]; + ] satisfies Array; } diff --git a/packages/eslint-config/test/index.ts b/packages/eslint-config/test/index.ts index 8ac1d56..e11c329 100644 --- a/packages/eslint-config/test/index.ts +++ b/packages/eslint-config/test/index.ts @@ -4,14 +4,14 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { describe, it } from "node:test"; import { promisify } from "node:util"; -import { defineConfig } from "../src/index.js"; +import type { defineConfig } from "../src/index.js"; const execPromise = promisify(exec); type Options = Parameters[0]; async function testOnStdout( options: Options, - files: { path: string; contents: string }[], + files: Array<{ path: string; contents: string }>, ) { files.push( { @@ -63,10 +63,12 @@ export default defineConfig(${JSON.stringify(options)}); stderr: e.stderr, exitCode: e.code, }; - } else { - console.error(e); - throw e; } + + // eslint-disable-next-line no-console + console.error(e); + + throw e; } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } @@ -114,7 +116,7 @@ void describe( contents: `# Foo \`\`\`js -const foo = 'bar'; +export const foo = 'bar'; \`\`\` `, }, @@ -132,6 +134,7 @@ const foo = 'bar'; singleQuote: true, }, }, + globals: [], }, [ { @@ -193,7 +196,7 @@ const foo = 'bar'; assert.match( stdoutLinesForFile(stdout, "index.ts"), - /\(@typescript-eslint\/no-unused-vars\)/, + /\(unused-imports\/no-unused-vars\)/, ); assert.match(stdoutLinesForFile(stdout, "index.ts"), /\(format\/prettier\)/); }); @@ -228,7 +231,7 @@ const foo = 'bar'; assert.match( stdoutLinesForFile(stdout, "index.ts"), - /\(@typescript-eslint\/no-unused-vars\)/, + /\(unused-imports\/no-unused-vars\)/, ); assert.match(stdoutLinesForFile(stdout, "index.ts"), /\(format\/prettier\)/); }); @@ -241,7 +244,10 @@ const foo = 'bar'; }, ]); - assert.match(stdoutLinesForFile(stdout, "index.js"), /\(no-unused-vars\)/); + assert.match( + stdoutLinesForFile(stdout, "index.js"), + /\(unused-imports\/no-unused-vars\)/, + ); assert.match(stdoutLinesForFile(stdout, "index.js"), /\(format\/prettier\)/); }); },