@syntopica/eslint-config 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/nextjs.ts ADDED
@@ -0,0 +1,85 @@
1
+ import nextPlugin from '@next/eslint-plugin-next'
2
+ import reactHooks from 'eslint-plugin-react-hooks'
3
+ import { globalIgnores } from 'eslint/config'
4
+
5
+ import { createFrontendBoundariesConfig } from './frontend-boundaries'
6
+ import { resolveReactVersion } from './react-version'
7
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
8
+ const react = require('eslint-plugin-react') as {
9
+ configs: { flat: { recommended: unknown; 'jsx-runtime': unknown } }
10
+ }
11
+
12
+ export type NextjsConfigOptions = {
13
+ /**
14
+ * React version handed to `eslint-plugin-react`. Defaults to the version of
15
+ * the React installed beside the project, and only falls back to `'detect'`
16
+ * when that cannot be resolved - see `resolveReactVersion`.
17
+ */
18
+ reactVersion?: string
19
+ tsconfigRootDir?: string
20
+ }
21
+
22
+ // This preset registers @next/next, react and react-hooks itself and pulls only
23
+ // Next's Core Web Vitals *rules* from @next/eslint-plugin-next. It does NOT spread
24
+ // the full `eslint-config-next` flat config, because that package also registers
25
+ // `import`, `jsx-a11y` and `@typescript-eslint` — plugins the layered baseline
26
+ // (base + accessibility) already owns — which throws "Cannot redefine plugin".
27
+ // `eslint-config-next` is kept as an optional peer dependency so standalone Next
28
+ // projects that do not layer the full baseline can import it directly.
29
+ const nextRules = {
30
+ ...nextPlugin.configs['core-web-vitals'].rules,
31
+ }
32
+
33
+ export const createNextjsConfig = (options: NextjsConfigOptions = {}) => {
34
+ return [
35
+ globalIgnores([
36
+ '.next/**',
37
+ 'out/**',
38
+ 'build/**',
39
+ 'dist/**',
40
+ 'coverage/**',
41
+ 'next-env.d.ts',
42
+ ]),
43
+
44
+ // Core React correctness rules + automatic JSX transform (React 17+)
45
+ react.configs.flat.recommended,
46
+ react.configs.flat['jsx-runtime'],
47
+
48
+ // Unscoped on purpose: the two configs above carry no `files` key, so their
49
+ // rules can run on any linted file and each one would otherwise re-enter
50
+ // version detection.
51
+ {
52
+ settings: {
53
+ react: {
54
+ version: options.reactVersion ?? resolveReactVersion() ?? 'detect',
55
+ },
56
+ },
57
+ },
58
+
59
+ {
60
+ files: ['**/*.{js,jsx,ts,tsx}'],
61
+ plugins: {
62
+ '@next/next': nextPlugin,
63
+ 'react-hooks': reactHooks,
64
+ },
65
+ rules: {
66
+ ...nextRules,
67
+ ...reactHooks.configs.recommended.rules,
68
+
69
+ // prevent {count && <Comp />} rendering "0" when count === 0
70
+ 'react/jsx-no-leaked-render': 'error',
71
+ // avoid array index as list key (causes stale renders)
72
+ 'react/no-array-index-key': 'warn',
73
+ // <MyComp></MyComp> → <MyComp /> (auto-fixable)
74
+ 'react/self-closing-comp': 'warn',
75
+ // dangerouslySetInnerHTML opens XSS vectors
76
+ 'react/no-danger': 'error',
77
+ // inline component definitions cause infinite re-mount loops
78
+ 'react/no-unstable-nested-components': 'error',
79
+ // remove pointless <> wrappers
80
+ 'react/jsx-no-useless-fragment': ['warn', { allowExpressions: true }],
81
+ },
82
+ },
83
+ ...createFrontendBoundariesConfig(),
84
+ ]
85
+ }
package/src/node.ts ADDED
@@ -0,0 +1,28 @@
1
+ import unicorn from 'eslint-plugin-unicorn'
2
+ import globals from 'globals'
3
+
4
+ export const createNodeConfig = () => [
5
+ {
6
+ // A CLI's stdout is its product, and a maintenance script's output is the
7
+ // reason it is run. `no-console` is an error everywhere else - a
8
+ // `console.log` in application code is a debugging statement that reached
9
+ // production - but forbidding it here would only produce a file of
10
+ // suppressions. Scoped by directory rather than by preset: a NestJS
11
+ // service composes this same config and its request handlers must not
12
+ // print to stdout.
13
+ files: ['**/bin/**/*.{ts,js,mjs,cjs}', '**/scripts/**/*.{ts,js,mjs,cjs}'],
14
+ rules: {
15
+ 'no-console': 'off',
16
+ },
17
+ },
18
+ {
19
+ files: ['**/*.{ts,js,mjs,cjs}'],
20
+ languageOptions: {
21
+ globals: { ...globals.node },
22
+ },
23
+ plugins: { unicorn },
24
+ rules: {
25
+ 'unicorn/prefer-node-protocol': 'error',
26
+ },
27
+ },
28
+ ]
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+
4
+ /**
5
+ * The React version installed next to the project being linted, or `undefined`
6
+ * when it cannot be resolved.
7
+ *
8
+ * `settings.react.version: 'detect'` makes `eslint-plugin-react` walk up from
9
+ * the linted file with its own resolver, and on ESLint 10 that walk crashes:
10
+ * `resolveBasedir` still calls `context.getFilename()`, removed in ESLint 10,
11
+ * so every file fails with `contextOrFilename.getFilename is not a function`
12
+ * before a single rule runs (eslint-plugin-react 7.37.5, the newest published).
13
+ * Handing the plugin a concrete version skips detection entirely.
14
+ *
15
+ * Resolution is from `process.cwd()` - where ESLint is invoked - and not from
16
+ * this package, so a consumer's own React is found rather than whichever copy
17
+ * a hoisted install placed beside the config.
18
+ */
19
+ export const resolveReactVersion = (): string | undefined => {
20
+ try {
21
+ const manifestPath = createRequire(import.meta.url).resolve(
22
+ 'react/package.json',
23
+ { paths: [process.cwd()] },
24
+ )
25
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path comes from require.resolve, not from user input
26
+ const manifest: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'))
27
+
28
+ return typeof manifest === 'object' &&
29
+ manifest !== null &&
30
+ 'version' in manifest &&
31
+ typeof manifest.version === 'string'
32
+ ? manifest.version
33
+ : undefined
34
+ } catch {
35
+ return undefined
36
+ }
37
+ }
@@ -0,0 +1,64 @@
1
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
2
+ const tailwindcss = require('eslint-plugin-tailwindcss') as {
3
+ // v4 stable exposes a single flat-config object (v4 beta had 'flat/recommended' as an array).
4
+ configs: { recommended: object }
5
+ }
6
+
7
+ /**
8
+ * Tailwind CSS lint rules.
9
+ * Enforces class ordering, detects contradicting classes (e.g. `flex block`),
10
+ * promotes shorthands (e.g. `pt-4 pb-4` → `py-4`).
11
+ *
12
+ * `cssConfigPath` is MANDATORY since eslint-plugin-tailwindcss v4 stable: the
13
+ * plugin resolves the design system from the Tailwind v4 CSS entry file (the
14
+ * one with `@import "tailwindcss"`), e.g. `./src/styles.css`.
15
+ *
16
+ * `no-custom-classname` is intentionally disabled: real apps routinely mix
17
+ * Tailwind utilities with their own CSS layer (BEM-style classes, component
18
+ * classes) and custom prefixes, which this rule cannot distinguish from typos.
19
+ * On those codebases it produces overwhelming false-positive noise with no
20
+ * reliable signal, so it is off by default. Re-enable per-project with an
21
+ * allowlist (`settings.tailwindcss.whitelist`) if a project is utility-only.
22
+ *
23
+ * `classnames-order` is intentionally disabled too: `prettier-plugin-tailwindcss`
24
+ * (shipped in `@syntopica/prettier-config/frontend`) is the class sorter of
25
+ * record and orders classes differently from this rule. A project running
26
+ * both `eslint --fix` and `prettier --write` would see each pass fight the
27
+ * other's ordering, so `lint` and `format:check` could never agree at the
28
+ * same time. Prettier owns class ordering; this rule stays off to avoid the
29
+ * conflict.
30
+ *
31
+ * Requires `tailwindcss` to be installed in the consuming project.
32
+ * Only add this to projects that use Tailwind CSS.
33
+ */
34
+ export const createTailwindConfig = (options: { cssConfigPath: string }) => {
35
+ // eslint.config.ts is loaded by jiti at runtime, which does not type-check
36
+ // the call, so a missing option surfaces as a bare
37
+ // "Cannot read properties of undefined" thrown from inside ESLint with no
38
+ // hint about which factory asked for what. Say it plainly instead.
39
+ // The type says `options` is required, and for a TypeScript caller it is.
40
+ // At runtime it is whatever the config file passed, so the check is real.
41
+ const given = options as { cssConfigPath?: string } | undefined
42
+ if (!given?.cssConfigPath) {
43
+ throw new Error(
44
+ 'createTailwindConfig requires { cssConfigPath }. ' +
45
+ 'eslint-plugin-tailwindcss v4 resolves the design system from your ' +
46
+ 'Tailwind CSS entry file - the one with `@import "tailwindcss"` - so ' +
47
+ "pass it: createTailwindConfig({ cssConfigPath: './src/styles.css' }).",
48
+ )
49
+ }
50
+
51
+ return [
52
+ tailwindcss.configs.recommended,
53
+ {
54
+ files: ['**/*.{js,jsx,ts,tsx,vue,astro,html}'],
55
+ settings: {
56
+ tailwindcss: { cssConfigPath: options.cssConfigPath },
57
+ },
58
+ rules: {
59
+ 'tailwindcss/no-custom-classname': 'off',
60
+ 'tailwindcss/classnames-order': 'off',
61
+ },
62
+ },
63
+ ]
64
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,35 @@
1
+ import vitest from '@vitest/eslint-plugin'
2
+ import testingLibrary from 'eslint-plugin-testing-library'
3
+
4
+ /**
5
+ * Test-file rules. These catch what review misses in a green build: a
6
+ * committed `.only` silently skipping the rest of the suite, a test with no
7
+ * assertion, and Testing Library queries whose promises are never awaited.
8
+ *
9
+ * `eslint-plugin-vitest` is deprecated; `@vitest/eslint-plugin` is its
10
+ * maintained successor.
11
+ */
12
+ export const createTestingConfig = () => [
13
+ {
14
+ files: [
15
+ '**/*.{test,spec}.{ts,tsx}',
16
+ '**/__tests__/**/*.{ts,tsx}',
17
+ '**/test/**/*.{ts,tsx}',
18
+ ],
19
+ plugins: { vitest, 'testing-library': testingLibrary },
20
+ rules: {
21
+ 'vitest/no-focused-tests': ['error', { fixable: false }],
22
+ 'vitest/no-disabled-tests': 'warn',
23
+ 'vitest/no-identical-title': 'error',
24
+ 'vitest/expect-expect': 'error',
25
+ 'vitest/valid-expect': 'error',
26
+ 'vitest/no-conditional-expect': 'error',
27
+ 'testing-library/await-async-queries': 'error',
28
+ 'testing-library/await-async-utils': 'error',
29
+ 'testing-library/no-await-sync-queries': 'error',
30
+ 'testing-library/no-container': 'error',
31
+ 'testing-library/no-node-access': 'warn',
32
+ 'testing-library/prefer-screen-queries': 'error',
33
+ },
34
+ },
35
+ ]
@@ -0,0 +1,64 @@
1
+ import reactHooks from 'eslint-plugin-react-hooks'
2
+ import reactRefresh from 'eslint-plugin-react-refresh'
3
+
4
+ import { createFrontendBoundariesConfig } from './frontend-boundaries'
5
+ import { resolveReactVersion } from './react-version'
6
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
7
+ const react = require('eslint-plugin-react') as {
8
+ configs: { flat: { recommended: unknown; 'jsx-runtime': unknown } }
9
+ }
10
+
11
+ export type ViteReactConfigOptions = {
12
+ /**
13
+ * React version handed to `eslint-plugin-react`. Defaults to the version of
14
+ * the React installed beside the project, and only falls back to `'detect'`
15
+ * when that cannot be resolved - see `resolveReactVersion`.
16
+ */
17
+ reactVersion?: string
18
+ }
19
+
20
+ export const createViteReactConfig = (options: ViteReactConfigOptions = {}) => [
21
+ // Core React correctness rules + automatic JSX transform (React 17+)
22
+ react.configs.flat.recommended,
23
+ react.configs.flat['jsx-runtime'],
24
+ // Unscoped on purpose: the two configs above carry no `files` key, so their
25
+ // rules can run on any linted file and each one would otherwise re-enter
26
+ // version detection.
27
+ {
28
+ settings: {
29
+ react: {
30
+ version: options.reactVersion ?? resolveReactVersion() ?? 'detect',
31
+ },
32
+ },
33
+ },
34
+ {
35
+ files: ['**/*.{ts,tsx,js,jsx}'],
36
+ plugins: {
37
+ 'react-hooks': reactHooks,
38
+ 'react-refresh': reactRefresh,
39
+ },
40
+ rules: {
41
+ // hooks rules
42
+ ...reactHooks.configs.recommended.rules,
43
+ 'react-refresh/only-export-components': [
44
+ 'warn',
45
+ { allowConstantExport: true },
46
+ ],
47
+
48
+ // prevent {count && <Comp />} rendering "0" when count === 0
49
+ 'react/jsx-no-leaked-render': 'error',
50
+ // avoid array index as list key (causes stale renders)
51
+ 'react/no-array-index-key': 'warn',
52
+ // <MyComp></MyComp> → <MyComp /> (auto-fixable)
53
+ 'react/self-closing-comp': 'warn',
54
+ // dangerouslySetInnerHTML opens XSS vectors
55
+ 'react/no-danger': 'error',
56
+ // inline component definitions cause infinite re-mount loops
57
+ 'react/no-unstable-nested-components': 'error',
58
+ // remove pointless <> wrappers
59
+ 'react/jsx-no-useless-fragment': ['warn', { allowExpressions: true }],
60
+ },
61
+ },
62
+ // Same layered boundaries as Next.js (src/components, src/services, etc.)
63
+ ...createFrontendBoundariesConfig(),
64
+ ]
@@ -0,0 +1,88 @@
1
+ import unusedImports from 'eslint-plugin-unused-imports'
2
+ import tseslint from 'typescript-eslint'
3
+
4
+ import { createFrontendBoundariesConfig } from './frontend-boundaries'
5
+
6
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
7
+ const pluginVue = require('eslint-plugin-vue') as {
8
+ configs: Record<string, unknown[]>
9
+ }
10
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
11
+ const vueA11y = require('eslint-plugin-vuejs-accessibility') as {
12
+ configs: Record<string, unknown[]>
13
+ }
14
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
15
+ const vueParser = require('vue-eslint-parser') as {
16
+ parseForESLint: (...args: unknown[]) => unknown
17
+ }
18
+
19
+ export type ViteVueConfigOptions = {
20
+ tsconfigRootDir?: string
21
+ }
22
+
23
+ /**
24
+ * Vue 3 SFC linting layer for Vite apps.
25
+ *
26
+ * - eslint-plugin-vue `flat/recommended` (highest priority tier: essential +
27
+ * strongly-recommended + recommended).
28
+ * - eslint-plugin-vuejs-accessibility `flat/recommended` (jsx-a11y does not lint
29
+ * `.vue` SFCs, so Vue gets its own a11y layer here).
30
+ * - `.vue` files parsed by vue-eslint-parser with typescript-eslint as the
31
+ * `<script lang="ts">` parser (type-aware via projectService).
32
+ * - Reuses the shared frontend import boundaries.
33
+ */
34
+ export const createViteVueConfig = (options: ViteVueConfigOptions = {}) => {
35
+ const tsconfigRootDir = options.tsconfigRootDir ?? process.cwd()
36
+ const vueRecommended = pluginVue.configs['flat/recommended'] ?? []
37
+ const a11yRecommended = vueA11y.configs['flat/recommended'] ?? []
38
+
39
+ return [
40
+ ...vueRecommended,
41
+ ...a11yRecommended,
42
+ {
43
+ files: ['**/*.vue'],
44
+ languageOptions: {
45
+ parser: vueParser,
46
+ parserOptions: {
47
+ parser: tseslint.parser,
48
+ projectService: true,
49
+ tsconfigRootDir,
50
+ extraFileExtensions: ['.vue'],
51
+ ecmaVersion: 2024,
52
+ sourceType: 'module',
53
+ },
54
+ },
55
+ plugins: {
56
+ 'unused-imports': unusedImports,
57
+ },
58
+ rules: {
59
+ // TypeScript (not core ESLint) resolves identifiers inside `<script
60
+ // setup lang="ts">`; core `no-undef` cannot see ambient/global types and
61
+ // would false-positive on them. typescript-eslint disables it for .ts
62
+ // for the same reason — mirror that for .vue SFCs.
63
+ 'no-undef': 'off',
64
+ // Unused-binding handling consistent with the .ts layer: defer to
65
+ // unused-imports with the `_`-prefix escape hatch.
66
+ 'no-unused-vars': 'off',
67
+ 'unused-imports/no-unused-vars': [
68
+ 'error',
69
+ {
70
+ args: 'after-used',
71
+ argsIgnorePattern: '^_',
72
+ varsIgnorePattern: '^_',
73
+ ignoreRestSiblings: true,
74
+ },
75
+ ],
76
+ // raw HTML binding is an XSS vector
77
+ 'vue/no-v-html': 'error',
78
+ // force the type-based defineProps<...>() form
79
+ 'vue/define-props-declaration': ['error', 'type-based'],
80
+ // refs must carry a type when it cannot be inferred
81
+ 'vue/require-typed-ref': 'error',
82
+ // multi-word component names (root App is the conventional exception)
83
+ 'vue/multi-word-component-names': ['error', { ignores: ['App'] }],
84
+ },
85
+ },
86
+ ...createFrontendBoundariesConfig(),
87
+ ]
88
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2024",
4
+ "lib": ["ES2024"],
5
+ "strict": true,
6
+ "noEmit": true,
7
+ "module": "ESNext",
8
+ "moduleResolution": "Bundler",
9
+ "resolveJsonModule": true,
10
+ "isolatedModules": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "types": ["node"],
14
+ "allowImportingTsExtensions": true
15
+ },
16
+ "include": ["src/**/*.ts", "eslint.config.ts"]
17
+ }