Skip to content

Commit 07c2951

Browse files
committed
fix(markdownlint-rules): matchGlob cwd-aware globs and suppress across blank lines
matchGlob no longer treats bare filenames as **/name; match project-root paths via cwd-relative single segments (and limited ../), path.normalize, and absolute paths from the CLI. isRuleSuppressedByComment scans past blank lines to find HTML allow comments. Add no-h1-content fixtures and tests, doc updates, and drop stale no-empty-heading expectation for wrong-format suppress.
1 parent 4617ab0 commit 07c2951

10 files changed

Lines changed: 141 additions & 24 deletions

‎markdownlint-rules/.markdownlint.clean.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
# Custom rules support HTML comments to suppress the rule:
2424
#
2525
# One-off (single line):
26-
# • Put "<!-- rule-name allow -->" on the previous line (its own line), or
26+
# • Put "<!-- rule-name allow -->" on its own line before the line to suppress (blank lines in between are OK), or
2727
# • Put "<!-- rule-name allow -->" at the end of the violating line.
2828
#
2929
# Block (disable then enable):

‎markdownlint-rules/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Some rules are **fixable** (heading-title-case, ascii-only, heading-numbering, n
3636
Only rules that accept options are documented with a config section below.
3737
**Regex patterns in YAML:** use single quotes so backslashes are not interpreted by YAML (e.g. `'\s'` instead of `"\\s"`), avoiding unnecessary double escapes.
3838
- **Suppressing a rule:** Every custom rule supports HTML comment overrides.
39-
- **One-off:** Put `<!-- rule-name allow -->` on its own line immediately before the line to suppress, or at the end of the violating line.
39+
- **One-off:** Put `<!-- rule-name allow -->` on its own line before the line to suppress (blank lines in between are OK), or at the end of the violating line.
4040
- **Block:** Put `<!-- rule-name disable -->` on its own line to turn the rule off; put `<!-- rule-name enable -->` on its own line to turn it back on.
4141
All lines between disable and enable (and after disable if there is no enable) are suppressed.
4242
Example: `<!-- no-empty-heading allow -->` on the previous line suppresses that heading's empty-section violation.
@@ -699,7 +699,7 @@ When reusing any rule, copy `utils.js` into your `.markdownlint-rules` (see [Reu
699699
When the file path matches any pattern, the rule is skipped for that file.
700700
This uses `pathMatchesAny` from `utils.js`.
701701

702-
- **HTML comment suppress:** `isRuleSuppressedByComment(lines, lineNumber, ruleName)` - returns true when (1) the line or the previous line contains `<!-- ruleName allow -->`, or (2) the line is inside a block between `<!-- ruleName disable -->` and `<!-- ruleName enable -->` (used by all rules for per-line and block override).
702+
- **HTML comment suppress:** `isRuleSuppressedByComment(lines, lineNumber, ruleName)` - returns true when (1) the line ends with `<!-- ruleName allow -->`, or the first non-blank line above (skipping blank lines only) is solely that comment, or (2) the line is inside a block between `<!-- ruleName disable -->` and `<!-- ruleName enable -->` (used by all rules for per-line and block override).
703703
Also accepts markdownlint's cleared form (comment body replaced with dots).
704704

705705
- **Heading and content:** `extractHeadings`, `iterateNonFencedLines`, `iterateProseLines`, `stripInlineCode`, `parseHeadingNumberPrefix`, `normalizeHeadingTitleForDup`, `normalizedTitleForDuplicate`, `RE_ATX_HEADING`, `RE_NUMBERING_PREFIX`.

‎markdownlint-rules/utils.js‎

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"use strict";
22

3+
const path = require("node:path");
4+
35
/**
46
* Shared helpers for markdownlint custom rules.
57
* Used by heading and prose rules (duplicate headings, numbering, ASCII, arrows).
@@ -203,24 +205,45 @@ function globToRegExp(pattern) {
203205

204206
/**
205207
* Match path against a single glob pattern. Path normalized to forward slashes.
206-
* Relative patterns match when path starts with pattern or pattern appears mid-path.
207-
* @param {string} path - File path to test
208+
* Relative inputs use `path.normalize` before matching so e.g. `docs/../README.md`
209+
* matches the same as `README.md`.
210+
* A pattern with no `/` matches only project-root files: the path relative to
211+
* `process.cwd()` must be a single segment below cwd, or one `..` segment then a
212+
* filename (e.g. `../README.md` when linting from a subdir). Nested paths like
213+
* `docs/README.md` still require `**` in the pattern.
214+
* @param {string} filePath - File path to test (as markdownlint passes in `name`)
208215
* @param {string} pattern - Glob pattern
209216
* @returns {boolean}
210217
*/
211-
function matchGlob(path, pattern) {
212-
if (!path || !pattern) {
218+
function matchGlob(filePath, pattern) {
219+
if (!filePath || !pattern) {
213220
return false;
214221
}
215-
const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "");
216-
const re = globToRegExp(pattern);
217-
if (re.test(normalized)) {
222+
const normalized = path.normalize(filePath).replace(/\\/g, "/").replace(/^\.\//, "");
223+
if (globToRegExp(pattern).test(normalized)) {
218224
return true;
219225
}
220-
const isRelative =
221-
pattern[0] !== "/" && pattern[0] !== "*" && !pattern.startsWith("./");
222-
if (isRelative) {
223-
return globToRegExp("**/" + pattern).test(normalized);
226+
if (!pattern.includes("/")) {
227+
let resolved;
228+
try {
229+
resolved = path.resolve(filePath);
230+
} catch {
231+
return false;
232+
}
233+
const rel = path.relative(process.cwd(), resolved).replace(/\\/g, "/");
234+
if (!rel) {
235+
return false;
236+
}
237+
let segmentForGlob = rel;
238+
if (rel.startsWith("..")) {
239+
if (!/^\.\.\/[^/]+$/.test(rel)) {
240+
return false;
241+
}
242+
segmentForGlob = path.basename(resolved);
243+
} else if (rel.includes("/")) {
244+
return false;
245+
}
246+
return globToRegExp(pattern).test(segmentForGlob);
224247
}
225248
return false;
226249
}
@@ -311,10 +334,12 @@ function isRuleDisabledAtLine(lines, lineNumber, ruleName) {
311334

312335
/**
313336
* Return true if a violation at the given line is suppressed by an HTML comment.
314-
* Suppression: (1) the line immediately before lineNumber is solely
315-
* `<!-- ruleName allow -->` (optional whitespace), or (2) the line at lineNumber
337+
* Suppression: (1) the first non-blank line at or above lineNumber-1, scanning upward,
338+
* is solely `<!-- ruleName allow -->` (optional whitespace), or (2) the line at lineNumber
316339
* ends with that comment (e.g. inline at end of line), or (3) the line is inside
317340
* a disable block (between `<!-- ruleName disable -->` and `<!-- ruleName enable -->`).
341+
* Blank lines between the allow comment and the violating line are skipped so a
342+
* comment can sit above a spaced-out paragraph (common under H1 intros).
318343
*
319344
* @param {string[]} lines - All document lines (1-based index: line at lines[lineNumber - 1])
320345
* @param {number} lineNumber - 1-based line number of the violation
@@ -328,8 +353,14 @@ function isRuleSuppressedByComment(lines, lineNumber, ruleName) {
328353
if (currentLine == null) return false;
329354
if (trimmedLineMatchesSuppress(String(currentLine).trim(), ruleName)) return true;
330355
if (lineNumber < 2) return false;
331-
const prevLine = lines[lineNumber - 2];
332-
return prevLine != null && trimmedLineMatchesSuppress(String(prevLine).trim(), ruleName);
356+
for (let i = lineNumber - 2; i >= 0; i--) {
357+
const t = String(lines[i] ?? "").trim();
358+
if (t === "") {
359+
continue;
360+
}
361+
return trimmedLineMatchesSuppress(t, ruleName);
362+
}
363+
return false;
333364
}
334365

335366
/**

‎md_test_files/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- `**positive_heading_numbering_zero.md**` - 0-indexed H2 numbering (## 0., 1., 2. and subsections); passes with 0 errors.
1717
- `**positive_heading_title_case.md**` - Valid AP title case; headings with identifiers/file names in backticks (e.g. `sba_result`, `README.md`) and links with valid title-case or backticked-filename link text; passes with 0 errors.
1818
- `**positive_heading_like.md**` - Proper ATX headings and prose only; short title-case lines ending in colon with no prose after (or with a heading next) are not reported; passes with 0 errors.
19+
- `**positive_no_h1_content_suppress_immediate.md**` / `**positive_no_h1_content_suppress_blank_line.md**` - no-h1-content suppressed via `<!-- no-h1-content allow -->` on the previous line: prose immediately after the comment vs separated by a blank line; both pass with 0 errors.
1920
- `**negative_*.md**` - One file per failing scenario; lint each to verify the expected custom rule(s) fail.
2021

2122
## Negative Fixtures (Custom Rules Only)

‎md_test_files/expected_errors.yml‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ positive_heading_title_case.md:
1717
positive_heading_like.md:
1818
errors: []
1919

20+
positive_no_h1_content_suppress_blank_line.md:
21+
errors: []
22+
23+
positive_no_h1_content_suppress_immediate.md:
24+
errors: []
25+
2026
negative_anchor_algo_placement.md:
2127
errors:
2228
- line: 10
@@ -299,9 +305,6 @@ negative_no_empty_heading.md:
299305
- line: 47
300306
rule: no-empty-heading
301307
message_contains: at least one line of content
302-
- line: 51
303-
rule: no-empty-heading
304-
message_contains: at least one line of content
305308
- line: 63
306309
rule: no-empty-heading
307310
message_contains: at least one line of content
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Title
2+
<!-- no-h1-content allow -->
3+
4+
Prose here.
5+
6+
## Section
7+
8+
Section content.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Title
2+
<!-- no-h1-content allow -->
3+
Prose here.
4+
5+
## Section
6+
7+
Section content.

‎test/markdownlint-rules/ascii-only.test.js‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,22 @@ describe("ascii-only", () => {
114114
assert.strictEqual(errors.length, 0);
115115
});
116116

117-
it("allows non-ASCII when path matches relative pattern (utils matchGlob **/ branch)", () => {
117+
it("allows non-ASCII when basename pattern uses explicit **/ (not implicit subdir match)", () => {
118118
const lines = ["Café"];
119119
const errors = runRule(rule, lines, {
120-
anyUnicodePathPatterns: ["foo.md"],
120+
anyUnicodePathPatterns: ["**/foo.md"],
121121
}, "sub/foo.md");
122122
assert.strictEqual(errors.length, 0);
123123
});
124124

125+
it("reports error for non-ASCII when only basename matches in a subdir (foo.md does not match sub/foo.md)", () => {
126+
const lines = ["Arrow \u2192"];
127+
const errors = runRule(rule, lines, {
128+
anyUnicodePathPatterns: ["foo.md"],
129+
}, "sub/foo.md");
130+
assert.ok(errors.length >= 1);
131+
});
132+
125133
it("reports error when path does not match any unicode pattern (utils pathMatchesAny)", () => {
126134
const lines = ["Arrow \u2192"];
127135
const errors = runRule(rule, lines, {

‎test/markdownlint-rules/no-h1-content.test.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,12 @@ describe("no-h1-content", () => {
123123
assert.strictEqual(errors.length, 0);
124124
});
125125

126+
it("reports no error when suppress comment is separated by blank lines from prose", () => {
127+
const lines = ["# Title", "<!-- no-h1-content allow -->", "", "This is not allowed.", "## Section"];
128+
const errors = runRule(rule, lines);
129+
assert.strictEqual(errors.length, 0);
130+
});
131+
126132
it("reports error for code block under h1", () => {
127133
const lines = ["# Title", "```", "code", "```", "## Next"];
128134
const errors = runRule(rule, lines);

‎test/markdownlint-rules/utils.test.js‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,70 @@
44
* Unit tests for utils.js helpers used by custom rules (e.g. isRuleSuppressedByComment).
55
*/
66

7+
const path = require("node:path");
78
const { describe, it } = require("node:test");
89
const assert = require("node:assert");
9-
const { isRuleSuppressedByComment } = require("../../markdownlint-rules/utils.js");
10+
const { isRuleSuppressedByComment, matchGlob, pathMatchesAny } = require("../../markdownlint-rules/utils.js");
1011

1112
describe("utils", () => {
13+
describe("matchGlob", () => {
14+
it("matches bare filename only at path root (single segment)", () => {
15+
assert.strictEqual(matchGlob("README.md", "README.md"), true);
16+
assert.strictEqual(matchGlob("docs/README.md", "README.md"), false);
17+
assert.strictEqual(matchGlob("docs/README.md", "**/README.md"), true);
18+
});
19+
20+
it("matches *.md only at root segment", () => {
21+
assert.strictEqual(matchGlob("doc.md", "*.md"), true);
22+
assert.strictEqual(matchGlob("sub/doc.md", "*.md"), false);
23+
assert.strictEqual(matchGlob("sub/doc.md", "**/*.md"), true);
24+
});
25+
26+
it("matches root basename when path is absolute (markdownlint passes full path)", () => {
27+
const rootReadme = path.join(process.cwd(), "README.md");
28+
const nestedReadme = path.join(process.cwd(), "docs", "README.md");
29+
assert.strictEqual(matchGlob(rootReadme, "README.md"), true);
30+
assert.strictEqual(matchGlob(nestedReadme, "README.md"), false);
31+
});
32+
33+
it("normalizes relative paths with .. before matching (docs/../README.md)", () => {
34+
assert.strictEqual(matchGlob("docs/../README.md", "README.md"), true);
35+
assert.strictEqual(matchGlob("docs/../other/README.md", "README.md"), false);
36+
});
37+
38+
it("matches one .. segment relative to cwd (../README.md from a subdir)", () => {
39+
const parentReadme = path.resolve(process.cwd(), "..", "README.md");
40+
assert.strictEqual(matchGlob(parentReadme, "README.md"), true);
41+
assert.strictEqual(matchGlob(path.resolve(process.cwd(), "..", "other", "README.md"), "README.md"), false);
42+
});
43+
});
44+
45+
describe("pathMatchesAny", () => {
46+
it("returns false when no pattern matches", () => {
47+
assert.strictEqual(pathMatchesAny("a/b.md", ["x.md"]), false);
48+
});
49+
50+
it("returns true when any pattern matches", () => {
51+
assert.strictEqual(pathMatchesAny("README.md", ["other.md", "README.md"]), true);
52+
});
53+
});
54+
1255
describe("isRuleSuppressedByComment", () => {
1356
it("returns true when previous line is solely the suppress comment", () => {
1457
const lines = ["<!-- no-empty-heading allow -->", "## Empty", "## Next"];
1558
assert.strictEqual(isRuleSuppressedByComment(lines, 2, "no-empty-heading"), true);
1659
});
1760

61+
it("returns true when suppress comment is separated by blank lines from the line", () => {
62+
const lines = ["<!-- no-h1-content allow -->", "", "", "Intro paragraph.", "## Next"];
63+
assert.strictEqual(isRuleSuppressedByComment(lines, 4, "no-h1-content"), true);
64+
});
65+
66+
it("returns false when a non-blank line between target and suppress is not the comment", () => {
67+
const lines = ["<!-- no-h1-content allow -->", "- [TOC](#toc)", "", "Prose.", "## Next"];
68+
assert.strictEqual(isRuleSuppressedByComment(lines, 4, "no-h1-content"), false);
69+
});
70+
1871
it("returns true when current line ends with the suppress comment", () => {
1972
const lines = ["## Empty section <!-- no-empty-heading allow -->", "## Next"];
2073
assert.strictEqual(isRuleSuppressedByComment(lines, 1, "no-empty-heading"), true);

0 commit comments

Comments
 (0)