Skip to content

Commit

Permalink
feat: Add disable comment support
Browse files Browse the repository at this point in the history
  • Loading branch information
nzakas committed Sep 13, 2024
1 parent e6ab87e commit e8babf4
Show file tree
Hide file tree
Showing 7 changed files with 467 additions and 24 deletions.
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-invalid-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
185 changes: 184 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)?)?)/u;
const htmlComment = /<!--\s*(.*?)\s*-->/gu;

/**
* 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") - 1;
end.offset = start.offset + comment.length;

comments.push(
new InlineConfigComment({
value: match[1],
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,64 @@ 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.length === 0) {
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: 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 +274,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

0 comments on commit e8babf4

Please sign in to comment.