@html-eslint/eslint-plugin
Version:
ESLint plugin for HTML
321 lines (296 loc) • 7.12 kB
JavaScript
/**
* @import {
* AnyNode,
* CloseTag,
* OpenTagEnd,
* Text
* } from "@html-eslint/types"
* @import {
* Line,
* RuleModule
* } from "../types"
* @typedef {AnyNode | Line} AnyNodeOrLine
*
* @typedef {Object} Option
* @property {string[]} [Option.skip]
* @property {string[]} [Option.inline]
*/
const { RULE_CATEGORY } = require("../constants");
const {
isTag,
isComment,
isText,
splitToLineNodes,
isLine,
isScript,
isStyle,
} = require("./utils/node");
const { createVisitors } = require("./utils/visitors");
const { getRuleUrl } = require("./utils/rule");
const MESSAGE_IDS = {
EXPECT_NEW_LINE_AFTER: "expectAfter",
};
/** @type {Object<string, string[]>} */
const PRESETS = {
// From https://developer.mozilla.org/en-US/docs/Web/HTML/Element#inline_text_semantics
$inline: `
a
abbr
b
bdi
bdo
br
cite
code
data
dfn
em
i
kbd
mark
q
rp
rt
ruby
s
samp
small
span
strong
sub
sup
time
u
var
wbr
`
.trim()
.split(`\n`),
};
/** @type {RuleModule<[Option]>} */
module.exports = {
meta: {
type: "code",
docs: {
description: "Enforce newline between elements.",
category: RULE_CATEGORY.STYLE,
recommended: true,
url: getRuleUrl("element-newline"),
},
fixable: true,
schema: [
{
type: "object",
properties: {
inline: {
type: "array",
items: {
type: "string",
},
},
skip: {
type: "array",
items: {
type: "string",
},
},
},
additionalProperties: false,
},
],
messages: {
[MESSAGE_IDS.EXPECT_NEW_LINE_AFTER]:
"There should be a linebreak after {{name}}.",
},
},
create(context) {
const option = context.options[0] || {};
/** @type {string[]} */
const skipTags = option.skip || ["pre", "code"];
const inlineTags = optionsOrPresets(option.inline || []);
/**
* @param {AnyNodeOrLine[]} children
* @returns {Exclude<AnyNodeOrLine, Text>[]}
*/
function getChildrenToCheck(children) {
/** @type {Exclude<AnyNodeOrLine, Text>[]} */
const childrenToCheck = [];
for (const child of children) {
if (isText(child)) {
const lines = splitToLineNodes(child);
childrenToCheck.push(...lines);
continue;
}
childrenToCheck.push(child);
}
return childrenToCheck.filter((child) => !isEmptyText(child));
}
/**
* @param {AnyNodeOrLine} before
* @param {AnyNodeOrLine} after
* @returns {boolean}
*/
function isOnTheSameLine(before, after) {
return before.loc.end.line === after.loc.start.line;
}
/**
* @param {AnyNode} node
* @returns {boolean}
*/
function shouldSkipChildren(node) {
if (isTag(node)) {
if (skipTags.includes(node.name.toLowerCase())) {
return true;
}
if (node.children.some((child) => child.type === "RawContent")) {
return true;
}
}
return false;
}
/**
* @param {AnyNodeOrLine} node
* @returns {boolean}
*/
function isInline(node) {
return (
isLine(node) ||
(isTag(node) && inlineTags.includes(node.name.toLowerCase()))
);
}
/**
* @param {AnyNode[]} children
* @param {AnyNode} parent
* @param {[OpenTagEnd, CloseTag]} [wrapper]
*/
function checkChildren(children, parent, wrapper) {
if (shouldSkipChildren(parent)) {
return;
}
const childrenToCheck = getChildrenToCheck(children);
const firstChild = childrenToCheck[0];
if (
wrapper &&
firstChild &&
childrenToCheck.some((child) => !isInline(child))
) {
const open = wrapper[0];
if (isOnTheSameLine(open, firstChild)) {
context.report({
node: open,
messageId: MESSAGE_IDS.EXPECT_NEW_LINE_AFTER,
data: { name: getName(parent) },
fix(fixer) {
return fixer.insertTextAfter(open, `\n`);
},
});
}
}
childrenToCheck.forEach((current, index) => {
const next = childrenToCheck[index + 1];
if (
!next ||
!isOnTheSameLine(current, next) ||
(isInline(current) && isInline(next))
) {
return;
}
context.report({
node: current,
messageId: MESSAGE_IDS.EXPECT_NEW_LINE_AFTER,
data: { name: getName(current, { isClose: true }) },
fix(fixer) {
return fixer.insertTextAfter(current, `\n`);
},
});
});
childrenToCheck.forEach((child) => {
if (isTag(child)) {
/** @type {[OpenTagEnd, CloseTag] | undefined} */
const wrapper = child.close
? [child.openEnd, child.close]
: undefined;
checkChildren(child.children, child, wrapper);
}
});
const lastChild = childrenToCheck[childrenToCheck.length - 1];
if (
wrapper &&
lastChild &&
childrenToCheck.some((child) => !isInline(child))
) {
const close = wrapper[1];
if (isOnTheSameLine(close, lastChild)) {
context.report({
node: lastChild,
messageId: MESSAGE_IDS.EXPECT_NEW_LINE_AFTER,
data: { name: getName(lastChild, { isClose: true }) },
fix(fixer) {
return fixer.insertTextAfter(lastChild, `\n`);
},
});
}
}
}
/**
* @param {AnyNodeOrLine} node
* @returns {boolean}
*/
function isEmptyText(node) {
return (
(isText(node) && node.value.trim().length === 0) ||
(isLine(node) && node.value.trim().length === 0)
);
}
/**
* @param {AnyNodeOrLine} node
* @param {{ isClose?: boolean }} options
*/
function getName(node, options = {}) {
const isClose = options.isClose || false;
if (isTag(node)) {
if (isClose) {
return `</${node.name}>`;
}
return `<${node.name}>`;
}
if (isLine(node)) {
return "text";
}
if (isComment(node)) {
return "comment";
}
if (isScript(node)) {
if (isClose) {
return `</script>`;
}
return "<script>";
}
if (isStyle(node)) {
if (isClose) {
return `</style>`;
}
return "<style>";
}
return `<${node.type}>`;
}
/** @param {string[]} options */
function optionsOrPresets(options) {
const result = [];
for (const option of options) {
if (option in PRESETS) {
const preset = PRESETS[option];
result.push(...preset);
} else {
result.push(option);
}
}
return result;
}
return createVisitors(context, {
Document(node) {
checkChildren(node.children, node);
},
});
},
};