Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add attrs-newline rule and close #191 #193

Merged
merged 1 commit into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading