@noxickon/codex 2.0.1 → 3.0.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/README.md CHANGED
@@ -19,6 +19,7 @@ npm install --save-dev \
19
19
  eslint \
20
20
  prettier \
21
21
  @eslint/js \
22
+ @stylistic/eslint-plugin \
22
23
  typescript-eslint \
23
24
  globals \
24
25
  eslint-config-prettier \
@@ -56,6 +57,7 @@ npm install --save-dev \
56
57
  eslint \
57
58
  prettier \
58
59
  @eslint/js \
60
+ @stylistic/eslint-plugin \
59
61
  typescript-eslint \
60
62
  globals \
61
63
  eslint-config-prettier \
@@ -120,25 +122,11 @@ export default createReactConfig({
120
122
  });
121
123
  ```
122
124
 
123
- ##### Blank-line spacing (CSS / HTML):
125
+ ##### Blank-line spacing:
124
126
 
125
- The `@noxickon/blank-line-spacing` rule enforces direction-aware blank lines between statements. It is already included in the base, React, and Node configs for `.ts` / `.tsx` / `.js` / `.jsx` files — no setup needed.
127
+ Blank lines are enforced via [ESLint Stylistic](https://eslint.style) — `@stylistic/padding-line-between-statements` and `@stylistic/lines-between-class-members`. They are included in the base, React, and Node configs for `.ts` / `.tsx` / `.js` / `.jsx` files — no setup needed. Collapsing of multiple blank lines and trimming of block edges is left to Prettier.
126
128
 
127
- For CSS and HTML, add the matching configs. Each requires its optional peer dependency (`@eslint/css` and `@html-eslint/parser` respectively):
128
-
129
- ###### eslint.config.js
130
-
131
- ```
132
- import { createReactConfig } from '@noxickon/codex/eslint/react';
133
- import { createCssConfig } from '@noxickon/codex/eslint/css';
134
- import { createHtmlConfig } from '@noxickon/codex/eslint/html';
135
-
136
- export default [
137
- ...createReactConfig(),
138
- ...createCssConfig(),
139
- ...createHtmlConfig(),
140
- ];
141
- ```
129
+ CSS and HTML blank-line spacing is owned entirely by Prettier (no ESLint config required).
142
130
 
143
131
  ### Prettier
144
132
 
@@ -194,7 +182,7 @@ export default createPrettierConfig({
194
182
  - Perfectionist (Auto-sorting)
195
183
  - Unused Imports
196
184
  - Sort Destructure Keys
197
- - Codex (`@noxickon/blank-line-spacing`direction-aware blank-line spacing; CSS/HTML variants opt-in)
185
+ - ESLint Stylistic (Blank-line spacing — `padding-line-between-statements`, `lines-between-class-members`)
198
186
  - ESLint Config Prettier (Disables ESLint rules that conflict with Prettier)
199
187
 
200
188
  **React (additional):**
@@ -19,7 +19,7 @@ import unusedImports from 'eslint-plugin-unused-imports';
19
19
  import globals from 'globals';
20
20
  import tseslint from 'typescript-eslint';
21
21
 
22
- import { codexPlugin } from './plugin.js';
22
+ import { blankLineRules, stylisticPlugin } from './blank-lines.js';
23
23
 
24
24
  export function createBaseConfig(options = {}) {
25
25
  const { tailwindEntryPoint = './src/tailwind.css', enableTailwind = true } = options;
@@ -56,15 +56,15 @@ export function createBaseConfig(options = {}) {
56
56
  globals: globals.browser,
57
57
  },
58
58
  plugins: {
59
- '@noxickon': codexPlugin,
59
+ '@stylistic': stylisticPlugin,
60
60
  perfectionist,
61
61
  'sort-destructure-keys': sortDestructureKeys,
62
62
  unicorn,
63
63
  'unused-imports': unusedImports,
64
64
  },
65
65
  rules: {
66
- // Blank-line spacing - direction-aware blank lines between statements
67
- '@noxickon/blank-line-spacing': 'warn',
66
+ // Blank-line spacing - type-based padding (Stylistic); Prettier owns collapsing
67
+ ...blankLineRules,
68
68
 
69
69
  // Unicorn - modern JS/TS best practices
70
70
  ...unicorn.configs.recommended.rules,
@@ -145,6 +145,10 @@ export function createBaseConfig(options = {}) {
145
145
  'unicorn/import-style': 'off',
146
146
  'unicorn/no-useless-undefined': 'off',
147
147
 
148
+ // globalThis.setTimeout is intentional: it pins the DOM return type
149
+ // (number) instead of leaking NodeJS.Timeout into shipped library types.
150
+ 'unicorn/no-unnecessary-global-this': 'off',
151
+
148
152
  // Perfectionist - auto-sorting
149
153
  'perfectionist/sort-interfaces': ['error'],
150
154
  'perfectionist/sort-object-types': ['error'],
@@ -209,6 +213,43 @@ export function createBaseConfig(options = {}) {
209
213
  ],
210
214
  },
211
215
  },
216
+ {
217
+ // Stories embed literal JSX in docs `source.code` strings and define
218
+ // local example render helpers, which fight several unicorn rules.
219
+ files: ['**/*.stories.{ts,tsx,js,jsx}'],
220
+ rules: {
221
+ // {onClick} in a code:`...` string is documentation, not interpolation.
222
+ 'unicorn/no-incorrect-template-string-interpolation': 'off',
223
+ // Example render helpers are intentionally local to the story.
224
+ 'unicorn/consistent-function-scoping': 'off',
225
+ // Stories mock backends by assigning globalThis.fetch and friends.
226
+ 'unicorn/no-global-object-property-assignment': 'off',
227
+ // Fixtures read members off `new` directly (new AbortController().signal).
228
+ 'unicorn/no-unreadable-new-expression': 'off',
229
+ },
230
+ },
231
+ {
232
+ // Test files and test infra fight several unicorn rules with standard
233
+ // testing patterns (DOM queries, global mocks, prototype patches).
234
+ files: [
235
+ '**/*.{test,spec}.{ts,tsx,js,jsx}',
236
+ '**/{test,tests,__tests__}/**/*.{ts,tsx,js,jsx}',
237
+ ],
238
+ rules: {
239
+ // Simple element queries; :scope is behaviorally identical here.
240
+ 'unicorn/prefer-scoped-selector': 'off',
241
+ // Local helpers inside describe blocks are intentional for locality.
242
+ 'unicorn/consistent-function-scoping': 'off',
243
+ // Mocking browser globals (globalThis.ResizeObserver = ...) is standard.
244
+ 'unicorn/no-global-object-property-assignment': 'off',
245
+ // Prototype mocks (HTMLElement.prototype.x = function () { this... }).
246
+ 'unicorn/no-this-outside-of-class': 'off',
247
+ // Fixtures read members off `new` directly (new AbortController().signal).
248
+ 'unicorn/no-unreadable-new-expression': 'off',
249
+ // Tracking call order with () => arr.push(x) ignores the return value.
250
+ 'unicorn/no-return-array-push': 'off',
251
+ },
252
+ },
212
253
  ];
213
254
 
214
255
  // Add Tailwind config if enabled
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared blank-line spacing via ESLint Stylistic.
3
+ * Type-based padding rules (the community standard); Prettier owns blank
4
+ * collapsing and block-edge trimming, so those are intentionally not set here.
5
+ */
6
+
7
+ import stylistic from '@stylistic/eslint-plugin';
8
+
9
+ export const stylisticPlugin = stylistic;
10
+
11
+ export const blankLineRules = {
12
+ '@stylistic/padding-line-between-statements': [
13
+ 'warn',
14
+ { blankLine: 'always', prev: '*', next: 'return' },
15
+ { blankLine: 'always', prev: 'directive', next: '*' },
16
+ { blankLine: 'any', prev: 'directive', next: 'directive' },
17
+ { blankLine: 'always', prev: ['case', 'default'], next: '*' },
18
+ { blankLine: 'always', prev: 'block-like', next: '*' },
19
+ { blankLine: 'always', prev: '*', next: 'block-like' },
20
+ { blankLine: 'always', prev: ['function', 'class'], next: '*' },
21
+ { blankLine: 'always', prev: '*', next: ['function', 'class'] },
22
+ ],
23
+ '@stylistic/lines-between-class-members': ['warn', 'always', { exceptAfterSingleLine: true }],
24
+ };
@@ -0,0 +1,27 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+
4
+ import { blankLineRules, stylisticPlugin } from './blank-lines.js';
5
+
6
+ describe('blank-lines', () => {
7
+ it('wires only Stylistic rules', () => {
8
+ const keys = Object.keys(blankLineRules);
9
+
10
+ assert.ok(keys.length > 0);
11
+ assert.ok(keys.every((key) => key.startsWith('@stylistic/')));
12
+ });
13
+
14
+ it('references rules that the Stylistic plugin actually provides', () => {
15
+ for (const key of Object.keys(blankLineRules)) {
16
+ const ruleName = key.slice('@stylistic/'.length);
17
+
18
+ assert.ok(stylisticPlugin.rules[ruleName], `missing rule: ${ruleName}`);
19
+ }
20
+ });
21
+
22
+ it('configures padding-line-between-statements as a warning', () => {
23
+ const [severity] = blankLineRules['@stylistic/padding-line-between-statements'];
24
+
25
+ assert.equal(severity, 'warn');
26
+ });
27
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noxickon/codex",
3
- "version": "2.0.1",
3
+ "version": "3.0.0",
4
4
  "author": "noxickon",
5
5
  "license": "MIT",
6
6
  "description": "Shared ESLint & Prettier configuration for noxickon projects",
@@ -12,19 +12,14 @@
12
12
  "test": "node --test \"eslint/**/*.test.js\""
13
13
  },
14
14
  "devDependencies": {
15
- "@eslint/css": ">=0.12.0",
16
- "@html-eslint/parser": ">=0.62.0",
17
- "eslint-plugin-react-hooks": ">=6.0.0",
18
- "vue-eslint-parser": ">=10.0.0"
15
+ "@stylistic/eslint-plugin": "^5.10.0",
16
+ "eslint-plugin-react-hooks": ">=6.0.0"
19
17
  },
20
18
  "exports": {
21
19
  "./prettier": "./prettier.config.js",
22
20
  "./eslint/base": "./eslint/base.config.js",
23
21
  "./eslint/node": "./eslint/node.config.js",
24
- "./eslint/react": "./eslint/react.config.js",
25
- "./eslint/vue": "./eslint/vue.config.js",
26
- "./eslint/html": "./eslint/html.config.js",
27
- "./eslint/css": "./eslint/css.config.js"
22
+ "./eslint/react": "./eslint/react.config.js"
28
23
  },
29
24
  "files": [
30
25
  "eslint",
@@ -37,9 +32,8 @@
37
32
  ],
38
33
  "peerDependencies": {
39
34
  "@eslint-react/eslint-plugin": ">=4.0.0",
40
- "@eslint/css": ">=0.12.0",
41
35
  "@eslint/js": ">=10.0.0",
42
- "@html-eslint/parser": ">=0.62.0",
36
+ "@stylistic/eslint-plugin": ">=5.0.0",
43
37
  "eslint": ">=10.4.0",
44
38
  "eslint-config-prettier": ">=10.0.0",
45
39
  "eslint-plugin-better-tailwindcss": ">=4.0.0",
@@ -55,22 +49,12 @@
55
49
  "globals": ">=17.1.0",
56
50
  "prettier": ">=3.8.1",
57
51
  "prettier-plugin-tailwindcss": ">=0.7.0",
58
- "typescript-eslint": ">=8.57.0",
59
- "vue-eslint-parser": ">=10.0.0"
52
+ "typescript-eslint": ">=8.57.0"
60
53
  },
61
54
  "peerDependenciesMeta": {
62
55
  "eslint-plugin-better-tailwindcss": {
63
56
  "optional": true
64
57
  },
65
- "vue-eslint-parser": {
66
- "optional": true
67
- },
68
- "@html-eslint/parser": {
69
- "optional": true
70
- },
71
- "@eslint/css": {
72
- "optional": true
73
- },
74
58
  "eslint-plugin-n": {
75
59
  "optional": true
76
60
  },
@@ -1,28 +0,0 @@
1
- /**
2
- * CSS ESLint configuration (stripped-down blank-line spacing).
3
- * Requires the optional peer dependency `@eslint/css`.
4
- * @returns {Array} ESLint flat config array
5
- */
6
-
7
- import css from '@eslint/css';
8
-
9
- import { codexPlugin } from './plugin.js';
10
-
11
- export function createCssConfig() {
12
- return [
13
- {
14
- files: ['**/*.css'],
15
- language: 'css/css',
16
- languageOptions: {
17
- tolerant: true,
18
- },
19
- plugins: {
20
- css,
21
- '@noxickon': codexPlugin,
22
- },
23
- rules: {
24
- '@noxickon/blank-line-spacing-css': 'warn',
25
- },
26
- },
27
- ];
28
- }
@@ -1,26 +0,0 @@
1
- /**
2
- * HTML ESLint configuration (blank-line spacing for elements).
3
- * Requires the optional peer dependency `@html-eslint/parser`.
4
- * @returns {Array} ESLint flat config array
5
- */
6
-
7
- import htmlParser from '@html-eslint/parser';
8
-
9
- import { codexPlugin } from './plugin.js';
10
-
11
- export function createHtmlConfig() {
12
- return [
13
- {
14
- files: ['**/*.html'],
15
- languageOptions: {
16
- parser: htmlParser,
17
- },
18
- plugins: {
19
- '@noxickon': codexPlugin,
20
- },
21
- rules: {
22
- '@noxickon/blank-line-spacing-html': 'warn',
23
- },
24
- },
25
- ];
26
- }
package/eslint/plugin.js DELETED
@@ -1,15 +0,0 @@
1
- import blankLineSpacing from './rules/blank-line-spacing.js';
2
- import blankLineSpacingCss from './rules/blank-line-spacing-css.js';
3
- import blankLineSpacingHtml from './rules/blank-line-spacing-html.js';
4
-
5
- export const codexPlugin = {
6
- meta: {
7
- name: '@noxickon/codex',
8
- version: '1.3.1',
9
- },
10
- rules: {
11
- 'blank-line-spacing': blankLineSpacing,
12
- 'blank-line-spacing-html': blankLineSpacingHtml,
13
- 'blank-line-spacing-css': blankLineSpacingCss,
14
- },
15
- };
@@ -1,51 +0,0 @@
1
- /**
2
- * Stripped-down blank-line rule for CSS (H1): after a multiline block
3
- * (`{ ... }` spanning multiple lines), require one blank line before the
4
- * next rule/at-rule. Declarations inside a block are not touched (H2).
5
- * Requires the optional peer dependency `@eslint/css`.
6
- */
7
-
8
- export default {
9
- meta: {
10
- type: 'layout',
11
- docs: {
12
- description: 'Require a blank line after a multiline CSS block',
13
- },
14
- fixable: 'whitespace',
15
- schema: [],
16
- messages: {
17
- expected: 'Expected a blank line after the previous block.',
18
- },
19
- },
20
- create(context) {
21
- function process(children) {
22
- for (let i = 1; i < children.length; i += 1) {
23
- const prev = children[i - 1];
24
- const curr = children[i];
25
-
26
- if (prev.loc.start.line === prev.loc.end.line) {
27
- continue;
28
- }
29
-
30
- if (curr.loc.start.line - prev.loc.end.line - 1 < 1) {
31
- const end = prev.loc.end.offset;
32
-
33
- context.report({
34
- loc: curr.loc.start,
35
- messageId: 'expected',
36
- fix: (fixer) => fixer.insertTextAfterRange([end, end], '\n'),
37
- });
38
- }
39
- }
40
- }
41
-
42
- return {
43
- StyleSheet: (node) => process(node.children),
44
- Atrule: (node) => {
45
- if (node.block && node.block.children) {
46
- process(node.block.children);
47
- }
48
- },
49
- };
50
- },
51
- };
@@ -1,38 +0,0 @@
1
- /**
2
- * Enforces direction-aware blank lines between HTML elements (Tag nodes).
3
- * Mirrors the element matrix used for JSX/Vue templates:
4
- * single el -> single el : no blank
5
- * single el -> multiline el (lone) : no blank
6
- * multiline el -> el : blank
7
- * group(2+ singles) -> multiline el : blank
8
- * Requires the optional peer dependency `@html-eslint/parser`.
9
- */
10
-
11
- import { processElementChildren } from './shared.js';
12
-
13
- const HTML_ELEMENT_TYPES = new Set(['Tag']);
14
-
15
- export default {
16
- meta: {
17
- type: 'layout',
18
- docs: {
19
- description: 'Enforce direction-aware blank lines between HTML elements',
20
- },
21
- fixable: 'whitespace',
22
- schema: [],
23
- messages: {
24
- expected: 'Expected a blank line before this element.',
25
- unexpected: 'Unexpected blank line before this element.',
26
- },
27
- },
28
- create(context) {
29
- const sourceCode = context.sourceCode;
30
- const process = (children) =>
31
- processElementChildren(context, sourceCode, children, HTML_ELEMENT_TYPES, 'Text', (node) => node.range);
32
-
33
- return {
34
- Document: (node) => process(node.children),
35
- Tag: (node) => process(node.children),
36
- };
37
- },
38
- };
@@ -1,254 +0,0 @@
1
- /**
2
- * Enforces direction-aware blank lines between statements.
3
- *
4
- * Matrix (single = one source line, multiline = spans multiple lines):
5
- * single -> single : no blank (M1)
6
- * single -> multiline : no blank (M2, "lone setup line")
7
- * multiline-> single : blank required (M3)
8
- * multiline-> multiline : blank required (M4)
9
- * group(2+ singles) -> multiline : blank required (M5)
10
- *
11
- * Extra rules:
12
- * - Block G: in a block of >= 3 statements, a blank is required before a
13
- * trailing single-line exit (return/throw/break/continue).
14
- * - N1: function/class/top-level declarations always get a blank before them
15
- * (M2 "no blank" applies only to control-flow/expression blocks).
16
- * - N2a: blanks that violate "no blank" are removed (normalisation).
17
- * - N2b: a single blank directly above a leading comment is preserved.
18
- * - D4: import statements and re-export lists are left untouched.
19
- */
20
-
21
- import { processElementChildren } from './shared.js';
22
-
23
- const EXIT_TYPES = new Set(['ReturnStatement', 'ThrowStatement', 'BreakStatement', 'ContinueStatement']);
24
- const FUNCTION_INIT_TYPES = new Set(['ArrowFunctionExpression', 'FunctionExpression', 'ClassExpression']);
25
- const JSX_ELEMENT_TYPES = new Set(['JSXElement', 'JSXFragment']);
26
- const VUE_ELEMENT_TYPES = new Set(['VElement']);
27
-
28
- function isMultiline(node) {
29
- return node.loc.start.line !== node.loc.end.line;
30
- }
31
-
32
- function isExit(node) {
33
- return EXIT_TYPES.has(node.type);
34
- }
35
-
36
- function isSkipped(node) {
37
- if (node.type === 'ImportDeclaration' || node.type === 'ExportAllDeclaration') {
38
- return true;
39
- }
40
-
41
- return node.type === 'ExportNamedDeclaration' && !node.declaration;
42
- }
43
-
44
- function isDefinitionLike(node) {
45
- switch (node.type) {
46
- case 'FunctionDeclaration':
47
- case 'ClassDeclaration':
48
- case 'TSInterfaceDeclaration':
49
- case 'TSTypeAliasDeclaration':
50
- case 'TSEnumDeclaration':
51
- case 'TSModuleDeclaration':
52
- case 'ExportDefaultDeclaration':
53
- return true;
54
- case 'ExportNamedDeclaration':
55
- return Boolean(node.declaration);
56
- case 'VariableDeclaration':
57
- return node.declarations.some((d) => d.init && FUNCTION_INIT_TYPES.has(d.init.type));
58
- default:
59
- return false;
60
- }
61
- }
62
-
63
- function caseIsMultiline(switchCase) {
64
- const body = switchCase.consequent;
65
-
66
- if (body.length === 0) {
67
- return false;
68
- }
69
-
70
- if (body.length === 1) {
71
- return isMultiline(body[0]);
72
- }
73
-
74
- return true;
75
- }
76
-
77
- function getActualLastToken(sourceCode, node) {
78
- const last = sourceCode.getLastToken(node);
79
-
80
- if (!last || last.type !== 'Punctuator' || last.value !== ';') {
81
- return last;
82
- }
83
-
84
- const prevToken = sourceCode.getTokenBefore(last);
85
- const nextToken = sourceCode.getTokenAfter(last);
86
- const semicolonLess = Boolean(
87
- prevToken &&
88
- nextToken &&
89
- prevToken.range[0] >= node.range[0] &&
90
- last.loc.start.line !== prevToken.loc.end.line &&
91
- last.loc.end.line === nextToken.loc.start.line,
92
- );
93
-
94
- return semicolonLess ? prevToken : last;
95
- }
96
-
97
- export default {
98
- meta: {
99
- type: 'layout',
100
- docs: {
101
- description: 'Enforce direction-aware blank lines between statements',
102
- },
103
- fixable: 'whitespace',
104
- schema: [],
105
- messages: {
106
- expected: 'Expected a blank line before this statement.',
107
- unexpected: 'Unexpected blank line before this statement.',
108
- },
109
- },
110
- create(context) {
111
- const sourceCode = context.sourceCode;
112
-
113
- function leadingComments(prevAnchor, node) {
114
- return sourceCode
115
- .getCommentsBefore(node)
116
- .filter((comment) => comment.loc.start.line !== prevAnchor.loc.end.line);
117
- }
118
-
119
- function trailingAnchor(prevNode) {
120
- const lastToken = getActualLastToken(sourceCode, prevNode);
121
- const trailing = sourceCode
122
- .getCommentsAfter(lastToken)
123
- .filter((comment) => comment.loc.start.line === lastToken.loc.end.line);
124
-
125
- return trailing.length > 0 ? trailing.at(-1) : lastToken;
126
- }
127
-
128
- function blankCountBetween(prevNode, node) {
129
- const anchor = trailingAnchor(prevNode);
130
- const comments = leadingComments(anchor, node);
131
- const start = comments.length > 0 ? comments[0] : sourceCode.getFirstToken(node);
132
-
133
- return start.loc.start.line - anchor.loc.end.line - 1;
134
- }
135
-
136
- function isGroupedSingle(prevPrev, prev, multilineOf) {
137
- if (!prevPrev || isSkipped(prevPrev) || multilineOf(prevPrev)) {
138
- return false;
139
- }
140
-
141
- return blankCountBetween(prevPrev, prev) === 0;
142
- }
143
-
144
- function needsBlank({ prevPrev, prev, curr, isLast, listLength, kind }) {
145
- const multilineOf = kind === 'switch' ? caseIsMultiline : isMultiline;
146
- const prevMulti = multilineOf(prev);
147
- const currMulti = multilineOf(curr);
148
-
149
- if (currMulti) {
150
- if (kind === 'statement' && isDefinitionLike(curr)) {
151
- return true;
152
- }
153
-
154
- if (prevMulti) {
155
- return true;
156
- }
157
-
158
- return isGroupedSingle(prevPrev, prev, multilineOf);
159
- }
160
-
161
- if (prevMulti) {
162
- return true;
163
- }
164
-
165
- return kind === 'statement' && isLast && listLength >= 3 && isExit(curr);
166
- }
167
-
168
- function checkPair(prev, curr, needBlank) {
169
- const anchor = trailingAnchor(prev);
170
- const comments = leadingComments(anchor, curr);
171
- const start = comments.length > 0 ? comments[0] : sourceCode.getFirstToken(curr);
172
- const blankCount = start.loc.start.line - anchor.loc.end.line - 1;
173
-
174
- if (needBlank && blankCount < 1) {
175
- context.report({
176
- node: curr,
177
- loc: start.loc,
178
- messageId: 'expected',
179
- fix: (fixer) => fixer.insertTextAfter(anchor, '\n'),
180
- });
181
-
182
- return;
183
- }
184
-
185
- if (!needBlank && blankCount > 0) {
186
- if (comments.length > 0) {
187
- return;
188
- }
189
-
190
- const between = sourceCode.text.slice(anchor.range[1], start.range[0]);
191
- const indent = between.slice(between.lastIndexOf('\n') + 1);
192
-
193
- context.report({
194
- node: curr,
195
- loc: start.loc,
196
- messageId: 'unexpected',
197
- fix: (fixer) => fixer.replaceTextRange([anchor.range[1], start.range[0]], `\n${indent}`),
198
- });
199
- }
200
- }
201
-
202
- function processList(list, kind = 'statement') {
203
- for (let i = 1; i < list.length; i += 1) {
204
- const prev = list[i - 1];
205
- const curr = list[i];
206
-
207
- if (isSkipped(prev) || isSkipped(curr)) {
208
- continue;
209
- }
210
-
211
- const needBlank = needsBlank({
212
- prevPrev: list[i - 2],
213
- prev,
214
- curr,
215
- isLast: i === list.length - 1,
216
- listLength: list.length,
217
- kind,
218
- });
219
-
220
- checkPair(prev, curr, needBlank);
221
- }
222
- }
223
-
224
- const rangeOf = (node) => node.range;
225
- const processJsx = (children) =>
226
- processElementChildren(context, sourceCode, children, JSX_ELEMENT_TYPES, 'JSXText', rangeOf);
227
-
228
- const scriptVisitors = {
229
- Program: (node) => processList(node.body),
230
- BlockStatement: (node) => processList(node.body),
231
- StaticBlock: (node) => processList(node.body),
232
- SwitchCase: (node) => processList(node.consequent),
233
- SwitchStatement: (node) => processList(node.cases, 'switch'),
234
- ClassBody: (node) => processList(node.body),
235
- TSModuleBlock: (node) => processList(node.body),
236
- JSXElement: (node) => processJsx(node.children),
237
- JSXFragment: (node) => processJsx(node.children),
238
- };
239
-
240
- const services = sourceCode.parserServices;
241
-
242
- if (services && typeof services.defineTemplateBodyVisitor === 'function') {
243
- return services.defineTemplateBodyVisitor(
244
- {
245
- VElement: (node) =>
246
- processElementChildren(context, sourceCode, node.children, VUE_ELEMENT_TYPES, 'VText', rangeOf),
247
- },
248
- scriptVisitors,
249
- );
250
- }
251
-
252
- return scriptVisitors;
253
- },
254
- };
@@ -1,272 +0,0 @@
1
- import { RuleTester } from 'eslint';
2
- import { describe, it } from 'node:test';
3
- import tseslint from 'typescript-eslint';
4
-
5
- import rule from './blank-line-spacing.js';
6
-
7
- RuleTester.describe = describe;
8
- RuleTester.it = it;
9
-
10
- const js = new RuleTester({
11
- languageOptions: {
12
- ecmaVersion: 'latest',
13
- sourceType: 'module',
14
- parserOptions: { ecmaFeatures: { jsx: true } },
15
- },
16
- });
17
-
18
- const ts = new RuleTester({
19
- languageOptions: {
20
- parser: tseslint.parser,
21
- ecmaVersion: 'latest',
22
- sourceType: 'module',
23
- },
24
- });
25
-
26
- js.run('blank-line-spacing', rule, {
27
- valid: [
28
- // M1: single -> single grouped
29
- 'const a = 1;\nconst b = 2;\nconst c = 3;',
30
-
31
- // M2: lone setup -> multiline
32
- 'const userId = getId();\nif (userId === null) {\n fail();\n}',
33
-
34
- // M3: multiline -> single
35
- 'if (x) {\n go();\n}\n\nconst y = 1;',
36
-
37
- // M4: multiline -> multiline
38
- 'if (x) {\n go();\n}\n\nfor (const i of list) {\n use(i);\n}',
39
-
40
- // M5: group of singles -> multiline
41
- 'const a = 1;\nconst b = 2;\n\nif (a + b > 0) {\n go();\n}',
42
-
43
- // Block G: <= 2 statements stay tight
44
- 'function f() {\n doIt();\n return true;\n}',
45
-
46
- // Block G: >= 3 statements -> blank before exit
47
- 'function f() {\n a();\n b();\n\n return true;\n}',
48
-
49
- // Block G with throw
50
- 'function f() {\n const x = a();\n const y = b();\n\n throw new Error(x + y);\n}',
51
-
52
- // N1: function declaration always gets a blank before it
53
- 'const max = 10;\n\nfunction make() {\n return max;\n}',
54
-
55
- // N2b: blank above a comment is preserved (section divider)
56
- 'const config = load();\n\n// SECTION\nconst valid = config.ok;',
57
-
58
- // comment does not break a group (no blank) -> group still needs blank before block
59
- 'const a = 1;\n// explains b\nconst b = 2;\n\nif (a + b) {\n go();\n}',
60
-
61
- // imports untouched (perfectionist owns them)
62
- "import a from 'a';\n\nimport b from 'b';\nconst x = 1;",
63
-
64
- // class: fields grouped, blank before method
65
- 'class C {\n a = 1;\n b = 2;\n\n run() {\n go();\n }\n}',
66
-
67
- // switch: single-line cases grouped, multiline case separated
68
- 'function pick(t) {\n switch (t) {\n case 1:\n return a();\n case 2:\n return b();\n\n case 3: {\n const x = c();\n return x;\n }\n\n default:\n return d();\n }\n}',
69
-
70
- // multiline before exit already separated by M3, no extra blank
71
- 'function f() {\n if (a) {\n return 0;\n }\n\n return 1;\n}',
72
-
73
- // JSX: single-line sibling elements grouped
74
- 'const x = (\n <div>\n <span>a</span>\n <span>b</span>\n </div>\n);',
75
-
76
- // JSX: lone single element -> multiline element = no blank (T4/M2)
77
- 'const x = (\n <div>\n <span>a</span>\n <ul>\n <li>b</li>\n </ul>\n </div>\n);',
78
-
79
- // JSX: multiline element -> following element = blank (T3)
80
- 'const x = (\n <div>\n <ul>\n <li>a</li>\n </ul>\n\n <span>b</span>\n </div>\n);',
81
-
82
- // JSX: group of single elements -> multiline element = blank
83
- 'const x = (\n <div>\n <span>a</span>\n <span>b</span>\n\n <form>\n <input />\n </form>\n </div>\n);',
84
- ],
85
- invalid: [
86
- // M1 violation: stray blank between singles -> removed
87
- {
88
- code: 'const a = 1;\n\nconst b = 2;',
89
- output: 'const a = 1;\nconst b = 2;',
90
- errors: [{ messageId: 'unexpected' }],
91
- },
92
-
93
- // M2 violation: lone setup -> multiline should not have blank
94
- {
95
- code: 'const x = get();\n\nif (x) {\n go();\n}',
96
- output: 'const x = get();\nif (x) {\n go();\n}',
97
- errors: [{ messageId: 'unexpected' }],
98
- },
99
-
100
- // M3 violation: multiline -> single missing blank
101
- {
102
- code: 'if (x) {\n go();\n}\nconst y = 1;',
103
- output: 'if (x) {\n go();\n}\n\nconst y = 1;',
104
- errors: [{ messageId: 'expected' }],
105
- },
106
-
107
- // M5 violation: group of singles -> multiline missing blank
108
- {
109
- code: 'const a = 1;\nconst b = 2;\nif (a + b) {\n go();\n}',
110
- output: 'const a = 1;\nconst b = 2;\n\nif (a + b) {\n go();\n}',
111
- errors: [{ messageId: 'expected' }],
112
- },
113
-
114
- // Block G: 3 statements, missing blank before return
115
- {
116
- code: 'function f() {\n a();\n b();\n return true;\n}',
117
- output: 'function f() {\n a();\n b();\n\n return true;\n}',
118
- errors: [{ messageId: 'expected' }],
119
- },
120
-
121
- // N1: missing blank before function declaration
122
- {
123
- code: 'const max = 10;\nfunction make() {\n return max;\n}',
124
- output: 'const max = 10;\n\nfunction make() {\n return max;\n}',
125
- errors: [{ messageId: 'expected' }],
126
- },
127
-
128
- // insert blank before a leading comment (blank goes above the comment)
129
- {
130
- code: 'if (x) {\n go();\n}\n// note\nconst y = 1;',
131
- output: 'if (x) {\n go();\n}\n\n// note\nconst y = 1;',
132
- errors: [{ messageId: 'expected' }],
133
- },
134
-
135
- // trailing comment on prev does not get split when inserting
136
- {
137
- code: 'const a = 1; // keep\nif (a) {\n go();\n}\nconst b = 2;',
138
- output: 'const a = 1; // keep\nif (a) {\n go();\n}\n\nconst b = 2;',
139
- errors: [{ messageId: 'expected' }],
140
- },
141
-
142
- // JSX: stray blank between single elements removed
143
- {
144
- code: 'const x = (\n <div>\n <span>a</span>\n\n <span>b</span>\n </div>\n);',
145
- output: 'const x = (\n <div>\n <span>a</span>\n <span>b</span>\n </div>\n);',
146
- errors: [{ messageId: 'unexpected' }],
147
- },
148
-
149
- // JSX: multiline element -> single element missing blank
150
- {
151
- code: 'const x = (\n <div>\n <ul>\n <li>a</li>\n </ul>\n <span>b</span>\n </div>\n);',
152
- output: 'const x = (\n <div>\n <ul>\n <li>a</li>\n </ul>\n\n <span>b</span>\n </div>\n);',
153
- errors: [{ messageId: 'expected' }],
154
- },
155
- ],
156
- });
157
-
158
- let vueParser;
159
-
160
- try {
161
- vueParser = (await import('vue-eslint-parser')).default;
162
- } catch {
163
- vueParser = null;
164
- }
165
-
166
- if (vueParser) {
167
- const vue = new RuleTester({
168
- languageOptions: {
169
- parser: vueParser,
170
- parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
171
- },
172
- });
173
-
174
- vue.run('blank-line-spacing (vue)', rule, {
175
- valid: [
176
- // script grouped + blank before function; template grouped + blank before multiline
177
- '<script setup>\nconst a = 1;\nconst b = 2;\n\nfunction go() {\n a();\n}\n</script>\n\n<template>\n <span>a</span>\n <span>b</span>\n\n <ul>\n <li>x</li>\n </ul>\n</template>\n',
178
- ],
179
- invalid: [
180
- // template: stray blank between single elements removed
181
- {
182
- code: '<template>\n <span>a</span>\n\n <span>b</span>\n</template>\n',
183
- output: '<template>\n <span>a</span>\n <span>b</span>\n</template>\n',
184
- errors: [{ messageId: 'unexpected' }],
185
- },
186
-
187
- // template: group of single elements -> multiline element = blank
188
- {
189
- code: '<template>\n <span>a</span>\n <span>b</span>\n <ul>\n <li>x</li>\n </ul>\n</template>\n',
190
- output: '<template>\n <span>a</span>\n <span>b</span>\n\n <ul>\n <li>x</li>\n </ul>\n</template>\n',
191
- errors: [{ messageId: 'expected' }],
192
- },
193
- ],
194
- });
195
- }
196
-
197
- let htmlParser;
198
-
199
- try {
200
- htmlParser = (await import('@html-eslint/parser')).default;
201
- } catch {
202
- htmlParser = null;
203
- }
204
-
205
- if (htmlParser) {
206
- const htmlRule = (await import('./blank-line-spacing-html.js')).default;
207
- const html = new RuleTester({ languageOptions: { parser: htmlParser } });
208
-
209
- html.run('blank-line-spacing-html', htmlRule, {
210
- valid: [
211
- '<div>\n <span>a</span>\n <span>b</span>\n</div>\n',
212
- '<div>\n <ul>\n <li>a</li>\n </ul>\n\n <span>b</span>\n</div>\n',
213
- ],
214
- invalid: [
215
- {
216
- code: '<div>\n <span>a</span>\n\n <span>b</span>\n</div>\n',
217
- output: '<div>\n <span>a</span>\n <span>b</span>\n</div>\n',
218
- errors: [{ messageId: 'unexpected' }],
219
- },
220
- {
221
- code: '<div>\n <ul>\n <li>a</li>\n </ul>\n <span>b</span>\n</div>\n',
222
- output: '<div>\n <ul>\n <li>a</li>\n </ul>\n\n <span>b</span>\n</div>\n',
223
- errors: [{ messageId: 'expected' }],
224
- },
225
- ],
226
- });
227
- }
228
-
229
- let cssPlugin;
230
-
231
- try {
232
- cssPlugin = (await import('@eslint/css')).default;
233
- } catch {
234
- cssPlugin = null;
235
- }
236
-
237
- if (cssPlugin) {
238
- const cssRule = (await import('./blank-line-spacing-css.js')).default;
239
- const cssTester = new RuleTester({ plugins: { css: cssPlugin }, language: 'css/css' });
240
-
241
- cssTester.run('blank-line-spacing-css', cssRule, {
242
- valid: [
243
- '.a {\n color: red;\n}\n\n.b {\n color: blue;\n}\n',
244
- '.a { color: red; }\n.b { color: blue; }\n',
245
- ],
246
- invalid: [
247
- {
248
- code: '.a {\n color: red;\n}\n.b {\n color: blue;\n}\n',
249
- output: '.a {\n color: red;\n}\n\n.b {\n color: blue;\n}\n',
250
- errors: [{ messageId: 'expected' }],
251
- },
252
- ],
253
- });
254
- }
255
-
256
- ts.run('blank-line-spacing (ts)', rule, {
257
- valid: [
258
- // export const arrow = definition-like -> blank before it (present)
259
- 'const max = 10;\n\nexport const make = (): number => {\n return max;\n};',
260
-
261
- // interface members are not touched; blank after the interface (M3) is required
262
- 'interface User {\n id: string;\n name: string;\n}\n\nconst x = 1;',
263
- ],
264
- invalid: [
265
- // missing blank before exported arrow declaration
266
- {
267
- code: 'const max = 10;\nexport const make = (): number => {\n return max;\n};',
268
- output: 'const max = 10;\n\nexport const make = (): number => {\n return max;\n};',
269
- errors: [{ messageId: 'expected' }],
270
- },
271
- ],
272
- });
@@ -1,86 +0,0 @@
1
- /**
2
- * Shared element-spacing helpers used by the JSX/Vue, HTML rules.
3
- * Works on any node that exposes `loc` (line info) and a pair of absolute
4
- * text offsets via the `offsetsOf` accessor.
5
- */
6
-
7
- function isMultilineNode(node) {
8
- return node.loc.start.line !== node.loc.end.line;
9
- }
10
-
11
- export function elementNeedsBlank(prevPrev, prev, curr) {
12
- const prevMulti = isMultilineNode(prev);
13
- const currMulti = isMultilineNode(curr);
14
-
15
- if (currMulti) {
16
- if (prevMulti) {
17
- return true;
18
- }
19
-
20
- return Boolean(
21
- prevPrev && !isMultilineNode(prevPrev) && prev.loc.start.line - prevPrev.loc.end.line - 1 === 0,
22
- );
23
- }
24
-
25
- return prevMulti;
26
- }
27
-
28
- export function reportElementPair(context, sourceCode, prev, curr, needBlank, offsetsOf) {
29
- const prevEnd = offsetsOf(prev)[1];
30
- const currStart = offsetsOf(curr)[0];
31
- const blankCount = curr.loc.start.line - prev.loc.end.line - 1;
32
-
33
- if (needBlank && blankCount < 1) {
34
- context.report({
35
- node: curr,
36
- loc: curr.loc.start,
37
- messageId: 'expected',
38
- fix: (fixer) => fixer.insertTextAfterRange([prevEnd, prevEnd], '\n'),
39
- });
40
-
41
- return;
42
- }
43
-
44
- if (!needBlank && blankCount > 0) {
45
- const between = sourceCode.text.slice(prevEnd, currStart);
46
- const indent = between.slice(between.lastIndexOf('\n') + 1);
47
-
48
- context.report({
49
- node: curr,
50
- loc: curr.loc.start,
51
- messageId: 'unexpected',
52
- fix: (fixer) => fixer.replaceTextRange([prevEnd, currStart], `\n${indent}`),
53
- });
54
- }
55
- }
56
-
57
- export function processElementChildren(context, sourceCode, children, elementTypes, textType, offsetsOf) {
58
- let run = [];
59
-
60
- const flush = () => {
61
- for (let i = 1; i < run.length; i += 1) {
62
- reportElementPair(
63
- context,
64
- sourceCode,
65
- run[i - 1],
66
- run[i],
67
- elementNeedsBlank(run[i - 2], run[i - 1], run[i]),
68
- offsetsOf,
69
- );
70
- }
71
-
72
- run = [];
73
- };
74
-
75
- for (const child of children) {
76
- if (elementTypes.has(child.type)) {
77
- run.push(child);
78
- } else if (child.type === textType && (child.value || '').trim() === '') {
79
- continue;
80
- } else {
81
- flush();
82
- }
83
- }
84
-
85
- flush();
86
- }
@@ -1,37 +0,0 @@
1
- /**
2
- * Vue ESLint configuration extending base config.
3
- * Requires the optional peer dependency `vue-eslint-parser`.
4
- * @param {Object} options - Configuration options
5
- * @returns {Array} ESLint flat config array
6
- */
7
-
8
- import tseslint from 'typescript-eslint';
9
- import vueParser from 'vue-eslint-parser';
10
-
11
- import { createBaseConfig } from './base.config.js';
12
- import { codexPlugin } from './plugin.js';
13
-
14
- export function createVueConfig(options = {}) {
15
- const baseConfig = createBaseConfig(options);
16
-
17
- const vueConfig = {
18
- files: ['**/*.vue'],
19
- languageOptions: {
20
- parser: vueParser,
21
- parserOptions: {
22
- parser: tseslint.parser,
23
- ecmaVersion: 'latest',
24
- sourceType: 'module',
25
- extraFileExtensions: ['.vue'],
26
- },
27
- },
28
- plugins: {
29
- '@noxickon': codexPlugin,
30
- },
31
- rules: {
32
- '@noxickon/blank-line-spacing': 'warn',
33
- },
34
- };
35
-
36
- return [...baseConfig, vueConfig];
37
- }