@noxickon/codex 1.3.1 → 1.4.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/eslint/base.config.js +6 -0
- package/eslint/css.config.js +25 -0
- package/eslint/html.config.js +26 -0
- package/eslint/plugin.js +15 -0
- package/eslint/rules/blank-line-spacing-css.js +51 -0
- package/eslint/rules/blank-line-spacing-html.js +38 -0
- package/eslint/rules/blank-line-spacing.js +254 -0
- package/eslint/rules/blank-line-spacing.test.js +272 -0
- package/eslint/rules/shared.js +86 -0
- package/eslint/vue.config.js +37 -0
- package/package.json +26 -3
package/eslint/base.config.js
CHANGED
|
@@ -19,6 +19,8 @@ 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';
|
|
23
|
+
|
|
22
24
|
export function createBaseConfig(options = {}) {
|
|
23
25
|
const { tailwindEntryPoint = './src/tailwind.css', enableTailwind = true } = options;
|
|
24
26
|
|
|
@@ -52,12 +54,16 @@ export function createBaseConfig(options = {}) {
|
|
|
52
54
|
globals: globals.browser,
|
|
53
55
|
},
|
|
54
56
|
plugins: {
|
|
57
|
+
'@noxickon': codexPlugin,
|
|
55
58
|
perfectionist,
|
|
56
59
|
'sort-destructure-keys': sortDestructureKeys,
|
|
57
60
|
unicorn,
|
|
58
61
|
'unused-imports': unusedImports,
|
|
59
62
|
},
|
|
60
63
|
rules: {
|
|
64
|
+
// Blank-line spacing - direction-aware blank lines between statements
|
|
65
|
+
'@noxickon/blank-line-spacing': 'warn',
|
|
66
|
+
|
|
61
67
|
// Unicorn - modern JS/TS best practices
|
|
62
68
|
...unicorn.configs.recommended.rules,
|
|
63
69
|
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
plugins: {
|
|
17
|
+
css,
|
|
18
|
+
'@noxickon': codexPlugin,
|
|
19
|
+
},
|
|
20
|
+
rules: {
|
|
21
|
+
'@noxickon/blank-line-spacing-css': 'warn',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,254 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,272 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noxickon/codex",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"author": "noxickon",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Shared ESLint & Prettier configuration for noxickon projects",
|
|
@@ -8,11 +8,22 @@
|
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=20.19.0"
|
|
10
10
|
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node --test \"eslint/**/*.test.js\""
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@eslint/css": ">=0.12.0",
|
|
16
|
+
"@html-eslint/parser": ">=0.62.0",
|
|
17
|
+
"vue-eslint-parser": ">=10.0.0"
|
|
18
|
+
},
|
|
11
19
|
"exports": {
|
|
12
20
|
"./prettier": "./prettier.config.js",
|
|
13
21
|
"./eslint/base": "./eslint/base.config.js",
|
|
14
22
|
"./eslint/node": "./eslint/node.config.js",
|
|
15
|
-
"./eslint/react": "./eslint/react.config.js"
|
|
23
|
+
"./eslint/react": "./eslint/react.config.js",
|
|
24
|
+
"./eslint/vue": "./eslint/vue.config.js",
|
|
25
|
+
"./eslint/html": "./eslint/html.config.js",
|
|
26
|
+
"./eslint/css": "./eslint/css.config.js"
|
|
16
27
|
},
|
|
17
28
|
"files": [
|
|
18
29
|
"eslint",
|
|
@@ -41,12 +52,24 @@
|
|
|
41
52
|
"globals": ">=17.1.0",
|
|
42
53
|
"prettier": ">=3.8.1",
|
|
43
54
|
"prettier-plugin-tailwindcss": ">=0.7.0",
|
|
44
|
-
"typescript-eslint": ">=8.57.0"
|
|
55
|
+
"typescript-eslint": ">=8.57.0",
|
|
56
|
+
"vue-eslint-parser": ">=10.0.0",
|
|
57
|
+
"@html-eslint/parser": ">=0.62.0",
|
|
58
|
+
"@eslint/css": ">=0.12.0"
|
|
45
59
|
},
|
|
46
60
|
"peerDependenciesMeta": {
|
|
47
61
|
"eslint-plugin-better-tailwindcss": {
|
|
48
62
|
"optional": true
|
|
49
63
|
},
|
|
64
|
+
"vue-eslint-parser": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"@html-eslint/parser": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"@eslint/css": {
|
|
71
|
+
"optional": true
|
|
72
|
+
},
|
|
50
73
|
"eslint-plugin-n": {
|
|
51
74
|
"optional": true
|
|
52
75
|
},
|