Skip to content

Commit 821fd13

Browse files
feat: add attrs-newline rule and close #191 (#193)
1 parent 4c9e5ed commit 821fd13

File tree

7 files changed

+582
-1
lines changed

7 files changed

+582
-1
lines changed

.cspell.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
"roletype",
3333
"nextid",
3434
"screenreader",
35-
"mspace"
35+
"mspace",
36+
"multiline",
37+
"sameline"
3638
]
3739
}

docs/rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252

5353
| Rule | Description | |
5454
| -------------------------------------------------------- | ----------------------------------------------------------------- | ---- |
55+
| [attrs-newline](rules/attrs-newline) | Enforce newline between attributes | ⭐🔧 |
5556
| [element-newline](rules/element-newline) | Enforce newline between elements. | ⭐🔧 |
5657
| [id-naming-convention](rules/id-naming-convention) | Enforce consistent naming id attributes | |
5758
| [indent](rules/indent) | Enforce consistent indentation | ⭐🔧 |

docs/rules/attrs-newline.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# attrs-newline
2+
3+
This rule enforces a newline between attributes, when more than a certain number of attributes is present.
4+
5+
## How to use
6+
7+
```js,.eslintrc.js
8+
module.exports = {
9+
rules: {
10+
"@html-eslint/attrs-newline": "error",
11+
},
12+
};
13+
```
14+
15+
## Rule Details
16+
17+
Examples of **incorrect** code for this rule:
18+
19+
<!-- prettier-ignore -->
20+
```html,incorrect
21+
<p class="foo" data-custom id="p">
22+
<img class="foo" data-custom />
23+
</p>
24+
```
25+
26+
Examples of **correct** code for this rule:
27+
28+
```html,correct
29+
<p
30+
class="foo"
31+
data-custom
32+
id="p"
33+
>
34+
<img class="foo" data-custom />
35+
</p>
36+
```
37+
38+
### Options
39+
40+
This rule has an object option:
41+
42+
```ts
43+
//...
44+
"@html-eslint/attrs-newline": ["error", {
45+
"closeStyle": "sameline" | "newline", // Default `"newline"`
46+
"ifAttrsMoreThan": number, // Default `2`
47+
}]
48+
```
49+
50+
#### ifAttrsMoreThan
51+
52+
If there are more than this number of attributes, all attributes should be separated by newlines. Either they should _not_ be separated by newlines.
53+
54+
The default is `2`.
55+
56+
Examples of **correct** code for `"ifAttrsMoreThan": 2`
57+
58+
<!-- prettier-ignore -->
59+
```html
60+
<p class="foo" id="p">
61+
<img
62+
class="foo"
63+
data-custom
64+
id="img"
65+
/>
66+
</p>
67+
```
68+
69+
#### closeStyle
70+
71+
How the open tag's closing bracket `>` should be spaced:
72+
73+
- `"newline"`: The closing bracket should be on a newline following the last attribute:
74+
<!-- prettier-ignore -->
75+
```html
76+
<img
77+
class="foo"
78+
data-custom
79+
id="img"
80+
/>
81+
```
82+
83+
- `"sameline"`: The closing bracket should be on the same line following the last attribute
84+
<!-- prettier-ignore -->
85+
```html
86+
<img
87+
class="foo"
88+
data-custom
89+
id="img" />
90+
```

packages/eslint-plugin/lib/configs/recommended.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ module.exports = {
66
"@html-eslint/require-title": "error",
77
"@html-eslint/no-multiple-h1": "error",
88
"@html-eslint/no-extra-spacing-attrs": "error",
9+
"@html-eslint/attrs-newline": "error",
910
"@html-eslint/element-newline": "error",
1011
"@html-eslint/no-duplicate-id": "error",
1112
"@html-eslint/indent": "error",
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* @typedef { import("../types").RuleFixer } RuleFixer
3+
* @typedef { import("../types").RuleModule } RuleModule
4+
* @typedef { import("../types").TagNode } TagNode
5+
* @typedef {Object} MessageId
6+
* @property {"closeStyleWrong"} CLOSE_STYLE_WRONG
7+
* @property {"newlineMissing"} NEWLINE_MISSING
8+
* @property {"newlineUnexpected"} NEWLINE_UNEXPECTED
9+
*/
10+
11+
const { RULE_CATEGORY } = require("../constants");
12+
13+
/**
14+
* @type {MessageId}
15+
*/
16+
17+
const MESSAGE_ID = {
18+
CLOSE_STYLE_WRONG: "closeStyleWrong",
19+
NEWLINE_MISSING: "newlineMissing",
20+
NEWLINE_UNEXPECTED: "newlineUnexpected",
21+
};
22+
23+
/**
24+
* @type {RuleModule}
25+
*/
26+
module.exports = {
27+
meta: {
28+
type: "code",
29+
30+
docs: {
31+
description: "Enforce newline between attributes",
32+
category: RULE_CATEGORY.STYLE,
33+
recommended: true,
34+
},
35+
36+
fixable: true,
37+
schema: [
38+
{
39+
type: "object",
40+
properties: {
41+
closeStyle: {
42+
enum: ["newline", "sameline"],
43+
},
44+
ifAttrsMoreThan: {
45+
type: "integer",
46+
},
47+
},
48+
},
49+
],
50+
messages: {
51+
[MESSAGE_ID.CLOSE_STYLE_WRONG]:
52+
"Closing bracket was on {{actual}}; expected {{expected}}",
53+
[MESSAGE_ID.NEWLINE_MISSING]: "Newline expected before {{attrName}}",
54+
[MESSAGE_ID.NEWLINE_UNEXPECTED]:
55+
"Newlines not expected between attributes, since this tag has fewer than {{attrMin}} attributes",
56+
},
57+
},
58+
59+
create(context) {
60+
const options = context.options[0] || {};
61+
const attrMin = isNaN(options.ifAttrsMoreThan)
62+
? 2
63+
: options.ifAttrsMoreThan;
64+
const closeStyle = options.closeStyle || "newline";
65+
66+
return {
67+
/**
68+
* @param {TagNode} node
69+
*/
70+
Tag(node) {
71+
const shouldBeMultiline = node.attributes.length > attrMin;
72+
73+
/**
74+
* This doesn't do any indentation, so the result will look silly. Indentation should be covered by the `indent` rule
75+
* @param {RuleFixer} fixer
76+
*/
77+
function fix(fixer) {
78+
const spacer = shouldBeMultiline ? "\n" : " ";
79+
let expected = node.openStart.value;
80+
for (const attr of node.attributes) {
81+
expected += `${spacer}${attr.key.value}`;
82+
if (attr.startWrapper && attr.value && attr.endWrapper) {
83+
expected += `=${attr.startWrapper.value}${attr.value.value}${attr.endWrapper.value}`;
84+
}
85+
}
86+
if (shouldBeMultiline && closeStyle === "newline") {
87+
expected += "\n";
88+
} else if (node.selfClosing) {
89+
expected += " ";
90+
}
91+
expected += node.openEnd.value;
92+
93+
return fixer.replaceTextRange(
94+
[node.openStart.range[0], node.openEnd.range[1]],
95+
expected
96+
);
97+
}
98+
99+
if (shouldBeMultiline) {
100+
let index = 0;
101+
for (const attr of node.attributes) {
102+
const attrPrevious = node.attributes[index - 1];
103+
const relativeToNode = attrPrevious || node.openStart;
104+
if (attr.loc.start.line === relativeToNode.loc.end.line) {
105+
return context.report({
106+
node,
107+
data: {
108+
attrName: attr.key.value,
109+
},
110+
fix,
111+
messageId: MESSAGE_ID.NEWLINE_MISSING,
112+
});
113+
}
114+
index += 1;
115+
}
116+
117+
const attrLast = node.attributes[node.attributes.length - 1];
118+
const closeStyleActual =
119+
node.openEnd.loc.start.line === attrLast.loc.end.line
120+
? "sameline"
121+
: "newline";
122+
if (closeStyle !== closeStyleActual) {
123+
return context.report({
124+
node,
125+
data: {
126+
actual: closeStyleActual,
127+
expected: closeStyle,
128+
},
129+
fix,
130+
messageId: MESSAGE_ID.CLOSE_STYLE_WRONG,
131+
});
132+
}
133+
} else {
134+
let expectedLastLineNum = node.openStart.loc.start.line;
135+
for (const attr of node.attributes) {
136+
if (shouldBeMultiline) {
137+
expectedLastLineNum += 1;
138+
}
139+
if (attr.value) {
140+
const valueLineSpan =
141+
attr.value.loc.end.line - attr.value.loc.start.line;
142+
expectedLastLineNum += valueLineSpan;
143+
}
144+
}
145+
if (shouldBeMultiline && closeStyle === "newline") {
146+
expectedLastLineNum += 1;
147+
}
148+
149+
if (node.openEnd.loc.end.line !== expectedLastLineNum) {
150+
return context.report({
151+
node,
152+
data: {
153+
attrMin,
154+
},
155+
fix,
156+
messageId: MESSAGE_ID.NEWLINE_UNEXPECTED,
157+
});
158+
}
159+
}
160+
},
161+
};
162+
},
163+
};

packages/eslint-plugin/lib/rules/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const noDuplicateId = require("./no-duplicate-id");
66
const noInlineStyles = require("./no-inline-styles");
77
const noMultipleH1 = require("./no-multiple-h1");
88
const noExtraSpacingAttrs = require("./no-extra-spacing-attrs");
9+
const attrsNewline = require("./attrs-newline");
910
const elementNewLine = require("./element-newline");
1011
const noSkipHeadingLevels = require("./no-skip-heading-levels");
1112
const indent = require("./indent");
@@ -45,6 +46,7 @@ module.exports = {
4546
"no-inline-styles": noInlineStyles,
4647
"no-multiple-h1": noMultipleH1,
4748
"no-extra-spacing-attrs": noExtraSpacingAttrs,
49+
"attrs-newline": attrsNewline,
4850
"element-newline": elementNewLine,
4951
"no-skip-heading-levels": noSkipHeadingLevels,
5052
"require-li-container": requireLiContainer,

0 commit comments

Comments
 (0)