@noxickon/codex 1.3.0 → 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.
@@ -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
+ }
@@ -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.0",
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
  },