Skip to content

Commit

Permalink
feat: add attrs-newline rule and close #191
Browse files Browse the repository at this point in the history
  • Loading branch information
RobertAKARobin committed Jun 18, 2024
1 parent 4c9e5ed commit 235b602
Show file tree
Hide file tree
Showing 7 changed files with 582 additions and 1 deletion.
4 changes: 3 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"roletype",
"nextid",
"screenreader",
"mspace"
"mspace",
"multiline",
"sameline"
]
}
1 change: 1 addition & 0 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@

| Rule | Description | |
| -------------------------------------------------------- | ----------------------------------------------------------------- | ---- |
| [attrs-newline](rules/attrs-newline) | Enforce newline between attributes | ⭐🔧 |
| [element-newline](rules/element-newline) | Enforce newline between elements. | ⭐🔧 |
| [id-naming-convention](rules/id-naming-convention) | Enforce consistent naming id attributes | |
| [indent](rules/indent) | Enforce consistent indentation | ⭐🔧 |
Expand Down
90 changes: 90 additions & 0 deletions docs/rules/attrs-newline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# attrs-newline

This rule enforces a newline between attributes, when more than a certain number of attributes is present.

## How to use

```js,.eslintrc.js
module.exports = {
rules: {
"@html-eslint/attrs-newline": "error",
},
};
```

## Rule Details

Examples of **incorrect** code for this rule:

<!-- prettier-ignore -->
```html,incorrect
<p class="foo" data-custom id="p">
<img class="foo" data-custom />
</p>
```

Examples of **correct** code for this rule:

```html,correct
<p
class="foo"
data-custom
id="p"
>
<img class="foo" data-custom />
</p>
```

### Options

This rule has an object option:

```ts
//...
"@html-eslint/attrs-newline": ["error", {
"closeStyle": "sameline" | "newline", // Default `"newline"`
"ifAttrsMoreThan": number, // Default `2`
}]
```

#### ifAttrsMoreThan

If there are more than this number of attributes, all attributes should be separated by newlines. Either they should _not_ be separated by newlines.

The default is `2`.

Examples of **correct** code for `"ifAttrsMoreThan": 2`

<!-- prettier-ignore -->
```html
<p class="foo" id="p">
<img
class="foo"
data-custom
id="img"
/>
</p>
```

#### closeStyle

How the open tag's closing bracket `>` should be spaced:

- `"newline"`: The closing bracket should be on a newline following the last attribute:
<!-- prettier-ignore -->
```html
<img
class="foo"
data-custom
id="img"
/>
```

- `"sameline"`: The closing bracket should be on the same line following the last attribute
<!-- prettier-ignore -->
```html
<img
class="foo"
data-custom
id="img" />
```
1 change: 1 addition & 0 deletions packages/eslint-plugin/lib/configs/recommended.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module.exports = {
"@html-eslint/require-title": "error",
"@html-eslint/no-multiple-h1": "error",
"@html-eslint/no-extra-spacing-attrs": "error",
"@html-eslint/attrs-newline": "error",
"@html-eslint/element-newline": "error",
"@html-eslint/no-duplicate-id": "error",
"@html-eslint/indent": "error",
Expand Down
163 changes: 163 additions & 0 deletions packages/eslint-plugin/lib/rules/attrs-newline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* @typedef { import("../types").RuleFixer } RuleFixer
* @typedef { import("../types").RuleModule } RuleModule
* @typedef { import("../types").TagNode } TagNode
* @typedef {Object} MessageId
* @property {"closeStyleWrong"} CLOSE_STYLE_WRONG
* @property {"newlineMissing"} NEWLINE_MISSING
* @property {"newlineUnexpected"} NEWLINE_UNEXPECTED
*/

const { RULE_CATEGORY } = require("../constants");

/**
* @type {MessageId}
*/

const MESSAGE_ID = {
CLOSE_STYLE_WRONG: "closeStyleWrong",
NEWLINE_MISSING: "newlineMissing",
NEWLINE_UNEXPECTED: "newlineUnexpected",
};

/**
* @type {RuleModule}
*/
module.exports = {
meta: {
type: "code",

docs: {
description: "Enforce newline between attributes",
category: RULE_CATEGORY.STYLE,
recommended: true,
},

fixable: true,
schema: [
{
type: "object",
properties: {
closeStyle: {
enum: ["newline", "sameline"],
},
ifAttrsMoreThan: {
type: "integer",
},
},
},
],
messages: {
[MESSAGE_ID.CLOSE_STYLE_WRONG]:
"Closing bracket was on {{actual}}; expected {{expected}}",
[MESSAGE_ID.NEWLINE_MISSING]: "Newline expected before {{attrName}}",
[MESSAGE_ID.NEWLINE_UNEXPECTED]:
"Newlines not expected between attributes, since this tag has fewer than {{attrMin}} attributes",
},
},

create(context) {
const options = context.options[0] || {};
const attrMin = isNaN(options.ifAttrsMoreThan)
? 2
: options.ifAttrsMoreThan;
const closeStyle = options.closeStyle || "newline";

return {
/**
* @param {TagNode} node
*/
Tag(node) {
const shouldBeMultiline = node.attributes.length > attrMin;

/**
* This doesn't do any indentation, so the result will look silly. Indentation should be covered by the `indent` rule
* @param {RuleFixer} fixer
*/
function fix(fixer) {
const spacer = shouldBeMultiline ? "\n" : " ";
let expected = node.openStart.value;
for (const attr of node.attributes) {
expected += `${spacer}${attr.key.value}`;
if (attr.startWrapper && attr.value && attr.endWrapper) {
expected += `=${attr.startWrapper.value}${attr.value.value}${attr.endWrapper.value}`;
}
}
if (shouldBeMultiline && closeStyle === "newline") {
expected += "\n";
} else if (node.selfClosing) {
expected += " ";
}
expected += node.openEnd.value;

return fixer.replaceTextRange(
[node.openStart.range[0], node.openEnd.range[1]],
expected
);
}

if (shouldBeMultiline) {
let index = 0;
for (const attr of node.attributes) {
const attrPrevious = node.attributes[index - 1];
const relativeToNode = attrPrevious || node.openStart;
if (attr.loc.start.line === relativeToNode.loc.end.line) {
return context.report({
node,
data: {
attrName: attr.key.value,
},
fix,
messageId: MESSAGE_ID.NEWLINE_MISSING,
});
}
index += 1;
}

const attrLast = node.attributes[node.attributes.length - 1];
const closeStyleActual =
node.openEnd.loc.start.line === attrLast.loc.end.line
? "sameline"
: "newline";
if (closeStyle !== closeStyleActual) {
return context.report({
node,
data: {
actual: closeStyleActual,
expected: closeStyle,
},
fix,
messageId: MESSAGE_ID.CLOSE_STYLE_WRONG,
});
}
} else {
let expectedLastLineNum = node.openStart.loc.start.line;
for (const attr of node.attributes) {
if (shouldBeMultiline) {
expectedLastLineNum += 1;
}
if (attr.value) {
const valueLineSpan =
attr.value.loc.end.line - attr.value.loc.start.line;
expectedLastLineNum += valueLineSpan;
}
}
if (shouldBeMultiline && closeStyle === "newline") {
expectedLastLineNum += 1;
}

if (node.openEnd.loc.end.line !== expectedLastLineNum) {
return context.report({
node,
data: {
attrMin,
},
fix,
messageId: MESSAGE_ID.NEWLINE_UNEXPECTED,
});
}
}
},
};
},
};
2 changes: 2 additions & 0 deletions packages/eslint-plugin/lib/rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const noDuplicateId = require("./no-duplicate-id");
const noInlineStyles = require("./no-inline-styles");
const noMultipleH1 = require("./no-multiple-h1");
const noExtraSpacingAttrs = require("./no-extra-spacing-attrs");
const attrsNewline = require("./attrs-newline");
const elementNewLine = require("./element-newline");
const noSkipHeadingLevels = require("./no-skip-heading-levels");
const indent = require("./indent");
Expand Down Expand Up @@ -45,6 +46,7 @@ module.exports = {
"no-inline-styles": noInlineStyles,
"no-multiple-h1": noMultipleH1,
"no-extra-spacing-attrs": noExtraSpacingAttrs,
"attrs-newline": attrsNewline,
"element-newline": elementNewLine,
"no-skip-heading-levels": noSkipHeadingLevels,
"require-li-container": requireLiContainer,
Expand Down
Loading

0 comments on commit 235b602

Please sign in to comment.