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 disable comment support #281

Merged
merged 7 commits into from
Oct 3, 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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Lint JS, JSX, TypeScript, and more inside Markdown.

### Installing

Install the plugin alongside ESLint v8 or greater:
Install the plugin alongside ESLint v9 or greater:

```sh
npm install --save-dev eslint @eslint/markdown
Expand Down Expand Up @@ -80,6 +80,19 @@ export default [
];
```

You can individually disable rules in Markdown using HTML comments, such as:

```markdown
<!-- eslint-disable-next-line markdown/no-html -- I want to allow HTML here -->
<custom-element>Hello world!</custom-element>

<!-- eslint-disable markdown/no-html -- here too -->
<another-element>Goodbye world!</another-element>
<!-- eslint-enable markdown/no-html -- safe to re-enable now -->

[Object] <!-- eslint-disable-line markdown/no-missing-label-refs -- not meant to be a link ref -->
```

### Languages

| **Language Name** | **Description** |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"c8": "^10.1.2",
"chai": "^5.1.1",
"dedent": "^1.5.3",
"eslint": "^9.8.0",
"eslint": "^9.10.0",
"eslint-config-eslint": "^11.0.0",
"globals": "^15.1.0",
"lint-staged": "^15.2.9",
Expand Down
200 changes: 199 additions & 1 deletion src/language/markdown-source-code.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,21 @@
// Imports
//-----------------------------------------------------------------------------

import { VisitNodeStep, TextSourceCodeBase } from "@eslint/plugin-kit";
import {
VisitNodeStep,
TextSourceCodeBase,
ConfigCommentParser,
Directive,
} from "@eslint/plugin-kit";
import { findOffsets } from "../util.js";

//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------

/** @typedef {import("mdast").Root} RootNode */
/** @typedef {import("mdast").Node} MarkdownNode */
/** @typedef {import("mdast").Html} HTMLNode */
/** @typedef {import("@eslint/core").Language} Language */
/** @typedef {import("@eslint/core").File} File */
/** @typedef {import("@eslint/core").TraversalStep} TraversalStep */
Expand All @@ -23,6 +30,104 @@ import { VisitNodeStep, TextSourceCodeBase } from "@eslint/plugin-kit";
/** @typedef {import("@eslint/core").ParseResult<RootNode>} ParseResult */
/** @typedef {import("@eslint/core").SourceLocation} SourceLocation */
/** @typedef {import("@eslint/core").SourceRange} SourceRange */
/** @typedef {import("@eslint/core").FileProblem} FileProblem */
/** @typedef {import("@eslint/core").DirectiveType} DirectiveType */

//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------

const commentParser = new ConfigCommentParser();
const configCommentStart =
/<!--\s*(eslint(?:-enable|-disable(?:(?:-next)?-line)?))(?:\s|-->)/u;
const htmlComment = /<!--(.*?)-->/gsu;

/**
* Represents an inline config comment in the source code.
*/
class InlineConfigComment {
/**
* The comment text.
* @type {string}
*/
value;

/**
* The position of the comment in the source code.
* @type {SourceLocation}
*/
position;

/**
* Creates a new instance.
* @param {Object} options The options for the instance.
* @param {string} options.value The comment text.
* @param {SourceLocation} options.position The position of the comment in the source code.
*/
constructor({ value, position }) {
this.value = value.trim();
this.position = position;
}
}

/**
* Extracts inline configuration comments from an HTML node.
* @param {HTMLNode} node The HTML node to extract comments from.
* @returns {Array<InlineConfigComment>} The inline configuration comments found in the node.
*/
function extractInlineConfigCommentsFromHTML(node) {
if (!configCommentStart.test(node.value)) {
return [];
}
const comments = [];

let match;

while ((match = htmlComment.exec(node.value))) {
if (configCommentStart.test(match[0])) {
const comment = match[0];

// calculate location of the comment inside the node
const start = {
...node.position.start,
};

const end = {
...node.position.start,
};

const {
lineOffset: startLineOffset,
columnOffset: startColumnOffset,
} = findOffsets(node.value, match.index);

start.line += startLineOffset;
start.column += startColumnOffset;
start.offset += match.index;

const commentLineCount = comment.split("\n").length - 1;

end.line = start.line + commentLineCount;
end.column =
commentLineCount === 0
? start.column + comment.length
: comment.length - comment.lastIndexOf("\n");
end.offset = start.offset + comment.length;

comments.push(
new InlineConfigComment({
value: match[1].trim(),
position: {
start,
end,
},
}),
);
}
}

return comments;
}

//-----------------------------------------------------------------------------
// Exports
Expand All @@ -44,6 +149,18 @@ export class MarkdownSourceCode extends TextSourceCodeBase {
*/
#parents = new WeakMap();

/**
* Collection of HTML nodes. Used to find directive comments.
* @type {Array<HTMLNode>}
*/
#htmlNodes = [];

/**
* Collection of inline configuration comments.
* @type {Array<InlineConfigComment>}
*/
#inlineConfigComments;

/**
* The AST of the source code.
* @type {RootNode}
Expand All @@ -59,6 +176,9 @@ export class MarkdownSourceCode extends TextSourceCodeBase {
constructor({ text, ast }) {
super({ ast, text });
this.ast = ast;

// need to traverse the source code to get the inline config nodes
this.traverse();
}

/**
Expand All @@ -70,6 +190,79 @@ export class MarkdownSourceCode extends TextSourceCodeBase {
return this.#parents.get(node);
}

/**
* Returns an array of all inline configuration nodes found in the
* source code.
* @returns {Array<InlineConfigComment>} An array of all inline configuration nodes.
*/
getInlineConfigNodes() {
if (!this.#inlineConfigComments) {
this.#inlineConfigComments = this.#htmlNodes.flatMap(
extractInlineConfigCommentsFromHTML,
);
}

return this.#inlineConfigComments;
}

/**
* Returns an all directive nodes that enable or disable rules along with any problems
* encountered while parsing the directives.
* @returns {{problems:Array<FileProblem>,directives:Array<Directive>}} Information
* that ESLint needs to further process the directives.
*/
getDisableDirectives() {
const problems = [];
const directives = [];

this.getInlineConfigNodes().forEach(comment => {
// Step 1: Parse the directive
const {
label,
value,
justification: justificationPart,
} = commentParser.parseDirective(comment.value);

// Step 2: Validate the directive does not span multiple lines
if (
label === "eslint-disable-line" &&
comment.position.start.line !== comment.position.end.line
) {
const message = `${label} comment should not span multiple lines.`;

problems.push({
ruleId: null,
message,
loc: comment.position,
});
return;
}

// Step 3: Extract the directive value and create the Directive object
switch (label) {
case "eslint-disable":
case "eslint-enable":
case "eslint-disable-next-line":
case "eslint-disable-line": {
const directiveType = label.slice("eslint-".length);

directives.push(
new Directive({
type: /** @type {DirectiveType} */ (directiveType),
node: comment,
value,
justification: justificationPart,
}),
);
}

// no default
}
});

return { problems, directives };
}

/**
* Traverse the source code and return the steps that were taken.
* @returns {Iterable<TraversalStep>} The steps that were taken while traversing the source code.
Expand All @@ -96,6 +289,11 @@ export class MarkdownSourceCode extends TextSourceCodeBase {
}),
);

// save HTML nodes
if (node.type === "html") {
this.#htmlNodes.push(node);
}

// then visit the children
if (node.children) {
node.children.forEach(child => {
Expand Down
56 changes: 36 additions & 20 deletions src/rules/no-html.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,24 @@
* @author Nicholas C. Zakas
*/

//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------

import { findOffsets } from "../util.js";

//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------

/** @typedef {import("eslint").Rule.RuleModule} RuleModule */

//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------

const htmlTagPattern = /<([a-z0-9]+(?:-[a-z0-9]+)*)/giu;

//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
Expand Down Expand Up @@ -48,28 +60,32 @@ export default {

return {
html(node) {
// don't care about closing tags
if (node.value.startsWith("</")) {
return;
}
let match;

// don't care about comments
if (node.value.startsWith("<!--")) {
return;
}

const tagName = node.value.match(
/<([a-z0-9]+(?:-[a-z0-9]+)*)/iu,
)?.[1];
while ((match = htmlTagPattern.exec(node.value)) !== null) {
const tagName = match[1];
const { lineOffset, columnOffset } = findOffsets(
node.value,
match.index,
);
const start = {
line: node.position.start.line + lineOffset,
column: node.position.start.column + columnOffset,
};
const end = {
line: start.line,
column: start.column + match[0].length + 1,
};

if (allowed.size === 0 || !allowed.has(tagName)) {
context.report({
loc: node.position,
messageId: "disallowedElement",
data: {
name: tagName,
},
});
if (allowed.size === 0 || !allowed.has(tagName)) {
context.report({
loc: { start, end },
messageId: "disallowedElement",
data: {
name: tagName,
},
});
}
}
},
};
Expand Down
Loading