Skip to content

Commit 99beea6

Browse files
authored
fix: handle multiline setext headings in no-duplicate-headings (#469)
* fix: multiline setext headings in `no-duplicate-headings` * wip: fix wrong `data` * wip: add `data` field to test * wip: add more test cases * wip: add more test cases * wip: add more test cases * wip: cleanup * wip: edge case * wip: use ESQuery selector when traversing * wip: remove `fast-deep-equal` * wip: cleanup * wip: remove `toString` * wip: refactor * wip: refactor * wip: ignore HTML node * wip: refactor * fix: wrong inline code handling * wip: address comment
1 parent 06ac53d commit 99beea6

File tree

2 files changed

+253
-65
lines changed

2 files changed

+253
-65
lines changed

src/rules/no-duplicate-headings.js

Lines changed: 22 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,12 @@
88
//-----------------------------------------------------------------------------
99

1010
/**
11-
* @import { Heading } from "mdast";
1211
* @import { MarkdownRuleDefinition } from "../types.js";
1312
* @typedef {"duplicateHeading"} NoDuplicateHeadingsMessageIds
1413
* @typedef {[{ checkSiblingsOnly?: boolean }]} NoDuplicateHeadingsOptions
1514
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoDuplicateHeadingsOptions, MessageIds: NoDuplicateHeadingsMessageIds }>} NoDuplicateHeadingsRuleDefinition
1615
*/
1716

18-
//-----------------------------------------------------------------------------
19-
// Helpers
20-
//-----------------------------------------------------------------------------
21-
22-
/**
23-
* This pattern does not match backslash-escaped `#` characters
24-
* @example
25-
* ```markdown
26-
* <!-- OK -->
27-
* ### foo ###
28-
* ## foo ###
29-
* # foo #
30-
*
31-
* <!-- NOT OK -->
32-
* ### foo \###
33-
* ## foo #\##
34-
* # foo \#
35-
* ```
36-
*
37-
* @see https://spec.commonmark.org/0.31.2/#example-76
38-
*/
39-
const trailingAtxHeadingHashPattern = /(?<![ \t])[ \t]+#+[ \t]*$/u;
40-
const leadingAtxHeadingHashPattern = /^#{1,6}[ \t]+/u;
41-
4217
//-----------------------------------------------------------------------------
4318
// Rule Definition
4419
//-----------------------------------------------------------------------------
@@ -74,7 +49,6 @@ export default {
7449

7550
create(context) {
7651
const [{ checkSiblingsOnly }] = context.options;
77-
const { sourceCode } = context;
7852

7953
/** @type {Map<number, Set<string>>} */
8054
const headingsByLevel = checkSiblingsOnly
@@ -89,46 +63,15 @@ export default {
8963
: new Map([[1, new Set()]]);
9064
let lastLevel = 1;
9165
let currentLevelHeadings = headingsByLevel.get(lastLevel);
92-
93-
/**
94-
* Gets the text of a heading node
95-
* @param {Heading} node The heading node
96-
* @returns {string} The heading text
97-
*/
98-
function getHeadingText(node) {
99-
/*
100-
* There are two types of headings in markdown:
101-
* - ATX headings, which consist of 1-6 # characters followed by content
102-
* and optionally ending with any number of # characters
103-
* - Setext headings, which are underlined with = or -
104-
* Setext headings are identified by being on two lines instead of one,
105-
* with the second line containing only = or - characters. In order to
106-
* get the correct heading text, we need to determine which type of
107-
* heading we're dealing with.
108-
*/
109-
const isSetext =
110-
node.position.start.line !== node.position.end.line;
111-
112-
if (isSetext) {
113-
// get only the text from the first line
114-
return sourceCode.lines[node.position.start.line - 1].trim();
115-
}
116-
117-
// For ATX headings, get the text between the # characters
118-
const text = sourceCode.getText(node);
119-
120-
/*
121-
* Please avoid using `String.prototype.trim()` here,
122-
* as it would remove intentional non-breaking space (NBSP) characters.
123-
*/
124-
return text
125-
.replace(leadingAtxHeadingHashPattern, "") // Remove leading # characters
126-
.replace(trailingAtxHeadingHashPattern, ""); // Remove trailing # characters
127-
}
66+
/** @type {string} */
67+
let headingChildrenSequence;
68+
/** @type {string} */
69+
let headingText;
12870

12971
return {
13072
heading(node) {
131-
const headingText = getHeadingText(node);
73+
headingChildrenSequence = "";
74+
headingText = "";
13275

13376
if (checkSiblingsOnly) {
13477
const currentLevel = node.depth;
@@ -146,8 +89,22 @@ export default {
14689
lastLevel = currentLevel;
14790
currentLevelHeadings = headingsByLevel.get(currentLevel);
14891
}
92+
},
93+
94+
"heading *"({ type, value }) {
95+
if (value) {
96+
headingChildrenSequence += `[${type},${value}]`; // We use a custom sequence representation to keep track of heading children.
97+
98+
if (type !== "html") {
99+
headingText += value;
100+
}
101+
} else {
102+
headingChildrenSequence += `[${type}]`;
103+
}
104+
},
149105

150-
if (currentLevelHeadings.has(headingText)) {
106+
"heading:exit"(node) {
107+
if (currentLevelHeadings.has(headingChildrenSequence)) {
151108
context.report({
152109
loc: node.position,
153110
messageId: "duplicateHeading",
@@ -156,7 +113,7 @@ export default {
156113
},
157114
});
158115
} else {
159-
currentLevelHeadings.add(headingText);
116+
currentLevelHeadings.add(headingChildrenSequence);
160117
}
161118
},
162119
};

0 commit comments

Comments
 (0)