Skip to content

Commit df4a10a

Browse files
committed
feat(markdownlint-rules): add block disable/enable for rule suppression
Allow <!-- ruleName disable --> and <!-- ruleName enable --> so a range of lines can bypass a rule; existing one-off allow unchanged.
1 parent 42d7b50 commit df4a10a

10 files changed

Lines changed: 293 additions & 9 deletions

File tree

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,20 @@
1818
# commented; uncomment a rule block and the options you need to override.
1919
#
2020
# -----------------------------------------------------------------------------
21-
# One-off suppression (allow a single line or section to bypass a rule)
21+
# One-off and block suppression (allow a line or a block to bypass a rule)
2222
# -----------------------------------------------------------------------------
23-
# Custom rules support an HTML comment to suppress the rule for one line:
23+
# Custom rules support HTML comments to suppress the rule:
24+
#
25+
# One-off (single line):
2426
# • Put "<!-- rule-name allow -->" on the previous line (its own line), or
2527
# • Put "<!-- rule-name allow -->" at the end of the violating line.
26-
# Examples:
28+
#
29+
# Block (disable then enable):
30+
# • Put "<!-- rule-name disable -->" on its own line to turn the rule off.
31+
# • Put "<!-- rule-name enable -->" on its own line to turn the rule back on.
32+
# All lines between disable and enable (and after disable if no enable) are suppressed.
33+
#
34+
# Examples (one-off):
2735
# <!-- no-empty-heading allow -->
2836
# ## Intentionally Empty
2937
#
@@ -37,6 +45,15 @@
3745
# <!-- ascii-only allow -->
3846
# Use arrow → here.
3947
#
48+
# Example (block disable/enable):
49+
# <!-- no-empty-heading disable -->
50+
# ## 1. Empty Heading 1
51+
# ## 2. Empty Heading 2
52+
# ## 3. Empty Heading 3
53+
# <!-- no-empty-heading enable -->
54+
# <!-- Following should error -->
55+
# ## 4. Empty Heading 4
56+
#
4057
# Use the exact rule id (e.g. no-empty-heading, heading-min-words, one-sentence-per-line).
4158
#
4259
# =============================================================================

‎markdownlint-rules/README.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,12 @@ Some rules are **fixable** (heading-title-case, ascii-only, heading-numbering, n
3535
For a single file listing every rule and option in this package, see [.markdownlint.clean.yml](.markdownlint.clean.yml).
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.
38-
- **Suppressing a rule for a line:** Every custom rule supports an HTML comment override.
39-
Put `<!-- rule-name allow -->` on its own line immediately before the line to suppress, or at the end of the violating line.
40-
Example: `<!-- no-empty-heading allow -->` on the previous line (or at end of the heading line) suppresses that heading's empty-section violation.
38+
- **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.
40+
- **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.
41+
All lines between disable and enable (and after disable if there is no enable) are suppressed.
42+
Example: `<!-- no-empty-heading allow -->` on the previous line suppresses that heading's empty-section violation.
43+
Example block: `<!-- ................ ....... -->` then several empty headings, then `<!-- ................ ...... -->` so the next empty heading errors again.
4144

4245
## Reusing These Rules
4346

@@ -686,7 +689,7 @@ When reusing any rule, copy `utils.js` into your `.markdownlint-rules` (see [Reu
686689
When the file path matches any pattern, the rule is skipped for that file.
687690
This uses `pathMatchesAny` from `utils.js`.
688691

689-
- **HTML comment suppress:** `isRuleSuppressedByComment(lines, lineNumber, ruleName)` - returns true when the line or the previous line contains `<!-- ruleName allow -->` (used by all rules for per-line override).
692+
- **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).
690693
Also accepts markdownlint's cleared form (comment body replaced with dots).
691694

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

‎markdownlint-rules/utils.js‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,12 +250,69 @@ function isValidSuppressArgs(lines, lineNumber, ruleName) {
250250
&& typeof ruleName === "string" && ruleName.length > 0;
251251
}
252252

253+
/** "disable" length for cleared-form regex. */
254+
const DISABLE_LEN = 7;
255+
/** "enable" length for cleared-form regex. */
256+
const ENABLE_LEN = 6;
257+
258+
/**
259+
* Return true if the trimmed line is solely "<!-- ruleName disable -->" (whole-line only; optional whitespace).
260+
* Also matches markdownlint-cleared form: <!-- .{n} .{7} --> (dots replace rule name and "disable").
261+
* @param {string} trimmed - Trimmed line
262+
* @param {string} ruleName - Rule name
263+
* @returns {boolean}
264+
*/
265+
function trimmedLineMatchesDisable(trimmed, ruleName) {
266+
const escaped = ruleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
267+
const n = ruleName.length;
268+
/* eslint-disable-next-line security/detect-non-literal-regexp -- ruleName escaped; used for disable comment */
269+
const re = new RegExp(`^\\s*<!--\\s*${escaped}\\s+disable\\s*-->\\s*$`);
270+
const clearedRe = new RegExp(`^\\s*<!--\\s*\\.{${n}}\\s+\\.{${DISABLE_LEN}}\\s*-->\\s*$`);
271+
return re.test(trimmed) || clearedRe.test(trimmed);
272+
}
273+
274+
/**
275+
* Return true if the trimmed line is solely "<!-- ruleName enable -->" (whole-line only; optional whitespace).
276+
* Also matches markdownlint-cleared form: <!-- .{n} .{6} --> (dots replace rule name and "enable").
277+
* @param {string} trimmed - Trimmed line
278+
* @param {string} ruleName - Rule name
279+
* @returns {boolean}
280+
*/
281+
function trimmedLineMatchesEnable(trimmed, ruleName) {
282+
const escaped = ruleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
283+
const n = ruleName.length;
284+
/* eslint-disable-next-line security/detect-non-literal-regexp -- ruleName escaped; used for enable comment */
285+
const re = new RegExp(`^\\s*<!--\\s*${escaped}\\s+enable\\s*-->\\s*$`);
286+
const clearedRe = new RegExp(`^\\s*<!--\\s*\\.{${n}}\\s+\\.{${ENABLE_LEN}}\\s*-->\\s*$`);
287+
return re.test(trimmed) || clearedRe.test(trimmed);
288+
}
289+
290+
/**
291+
* Return true if the rule is in a disabled block at the given line.
292+
* Scans from line 1 to lineNumber: a line that is solely "<!-- ruleName disable -->" turns the rule off;
293+
* a line that is solely "<!-- ruleName enable -->" turns it back on.
294+
* @param {string[]} lines - All document lines
295+
* @param {number} lineNumber - 1-based line number
296+
* @param {string} ruleName - Rule name
297+
* @returns {boolean}
298+
*/
299+
function isRuleDisabledAtLine(lines, lineNumber, ruleName) {
300+
if (!isValidSuppressArgs(lines, lineNumber, ruleName)) return false;
301+
let enabled = true;
302+
for (let i = 0; i < lineNumber; i++) {
303+
const trimmed = String(lines[i] ?? "").trim();
304+
if (trimmedLineMatchesDisable(trimmed, ruleName)) enabled = false;
305+
else if (trimmedLineMatchesEnable(trimmed, ruleName)) enabled = true;
306+
}
307+
return !enabled;
308+
}
309+
253310
/**
254311
* Return true if a violation at the given line is suppressed by an HTML comment.
255312
* Suppression: (1) the line immediately before lineNumber is solely
256313
* `<!-- ruleName allow -->` (optional whitespace), or (2) the line at lineNumber
257-
* ends with that comment (e.g. inline at end of line).
258-
* Example: `<!-- no-empty-heading allow -->` on the previous line or at end of line.
314+
* ends with that comment (e.g. inline at end of line), or (3) the line is inside
315+
* a disable block (between `<!-- ruleName disable -->` and `<!-- ruleName enable -->`).
259316
*
260317
* @param {string[]} lines - All document lines (1-based index: line at lines[lineNumber - 1])
261318
* @param {number} lineNumber - 1-based line number of the violation
@@ -264,6 +321,7 @@ function isValidSuppressArgs(lines, lineNumber, ruleName) {
264321
*/
265322
function isRuleSuppressedByComment(lines, lineNumber, ruleName) {
266323
if (!isValidSuppressArgs(lines, lineNumber, ruleName)) return false;
324+
if (isRuleDisabledAtLine(lines, lineNumber, ruleName)) return true;
267325
const currentLine = lines[lineNumber - 1];
268326
if (currentLine == null) return false;
269327
if (trimmedLineMatchesSuppress(String(currentLine).trim(), ruleName)) return true;

‎md_test_files/expected_errors.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,9 @@ negative_no_empty_heading.md:
272272
- line: 51
273273
rule: no-empty-heading
274274
message_contains: at least one line of content
275+
- line: 63
276+
rule: no-empty-heading
277+
message_contains: at least one line of content
275278

276279
negative_one_sentence_per_line.md:
277280
errors:

‎md_test_files/negative_no_empty_heading.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,13 @@ Valid content.
5151
## Empty With Wrong-Format Suppress
5252

5353
<!-- no-empty-heading: allow -->
54+
55+
<!-- no-empty-heading disable -->
56+
57+
## In Disable Block 1
58+
59+
## In Disable Block 2
60+
61+
<!-- no-empty-heading enable -->
62+
63+
## Empty After Enable

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,33 @@ describe("ascii-only", () => {
5050
assert.ok(errors.some((e) => e.detail.includes("ASCII") || e.detail.includes("U+")));
5151
});
5252

53+
describe("disable/enable block", () => {
54+
it("suppresses non-ASCII between disable and enable; reports after enable", () => {
55+
const lines = [
56+
"ASCII only here.",
57+
"<!-- ascii-only disable -->",
58+
"Arrow \u2192 here.",
59+
"Café.",
60+
"<!-- ascii-only enable -->",
61+
"Another → line.",
62+
];
63+
const errors = runRule(rule, lines, {}, "doc.md");
64+
assert.strictEqual(errors.length, 1, "only line after enable should error");
65+
assert.strictEqual(errors[0].lineNumber, 6);
66+
});
67+
68+
it("disable only (no enable): all non-ASCII after disable is suppressed", () => {
69+
const lines = [
70+
"ASCII.",
71+
"<!-- ascii-only disable -->",
72+
"Use → and café.",
73+
"More \u2192 text.",
74+
];
75+
const errors = runRule(rule, lines, {}, "doc.md");
76+
assert.strictEqual(errors.length, 0, "no errors when disable has no enable");
77+
});
78+
});
79+
5380
it("reports no errors when path matches allowedPathPatternsUnicode", () => {
5481
// Glob "*.md" matches "doc.md"; non-ASCII is allowed in that file.
5582
const lines = ["Café"];

‎test/markdownlint-rules/heading-min-words.test.js‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,38 @@ describe("heading-min-words", () => {
142142
});
143143
});
144144

145+
describe("disable/enable block", () => {
146+
const config = { "heading-min-words": { minWords: 2, applyToLevelsAtOrBelow: 2 } };
147+
148+
it("suppresses single-word headings between disable and enable; reports after enable", () => {
149+
const lines = [
150+
"# Doc",
151+
"<!-- heading-min-words disable -->",
152+
"## Foo",
153+
"## Bar",
154+
"<!-- heading-min-words enable -->",
155+
"## Baz",
156+
"Content.",
157+
];
158+
const errors = runRule(rule, lines, config);
159+
assert.strictEqual(errors.length, 1, "only ## Baz after enable should error");
160+
assert.strictEqual(errors[0].lineNumber, 6);
161+
});
162+
163+
it("disable only (no enable): all single-word headings after disable are suppressed", () => {
164+
const lines = [
165+
"# Doc",
166+
"## Two Words",
167+
"Content.",
168+
"<!-- heading-min-words disable -->",
169+
"## One",
170+
"## More",
171+
];
172+
const errors = runRule(rule, lines, config);
173+
assert.strictEqual(errors.length, 0, "no errors when disable has no enable");
174+
});
175+
});
176+
145177
describe("edge cases", () => {
146178
it("allowList is case-insensitive (Overview matches overview)", () => {
147179
const lines = ["# Doc", "## overview", "Content."];

‎test/markdownlint-rules/no-empty-heading.test.js‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,43 @@ describe("no-empty-heading", () => {
346346
assert.strictEqual(errors.length, 0);
347347
});
348348

349+
describe("disable/enable block", () => {
350+
it("suppresses empty headings between disable and enable; reports after enable", () => {
351+
const lines = [
352+
"# Doc",
353+
"<!-- no-empty-heading disable -->",
354+
"## 1. Empty One",
355+
"",
356+
"## 2. Empty Two",
357+
"<!-- no-empty-heading enable -->",
358+
"",
359+
"## 3. Empty After Enable",
360+
"",
361+
"## Next",
362+
"Content.",
363+
];
364+
const errors = runRule(rule, lines);
365+
assert.strictEqual(errors.length, 1, "only ## 3. Empty After Enable should error");
366+
assert.strictEqual(errors[0].lineNumber, 8);
367+
});
368+
369+
it("disable only (no enable): all empty headings after disable are suppressed", () => {
370+
const lines = [
371+
"# Doc",
372+
"## With Content",
373+
"Text.",
374+
"<!-- no-empty-heading disable -->",
375+
"## Empty After Disable",
376+
"",
377+
"## Also Empty",
378+
"",
379+
"## End",
380+
];
381+
const errors = runRule(rule, lines);
382+
assert.strictEqual(errors.length, 0, "no errors when disable has no enable (rest of file suppressed)");
383+
});
384+
});
385+
349386
describe("edge cases", () => {
350387
it("unclosed multi-line HTML comment at end of section does not count as content", () => {
351388
const lines = ["# Doc", "## Empty", "<!--", " unclosed", "## Next", "Content."];

‎test/markdownlint-rules/one-sentence-per-line.test.js‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,33 @@ describe("one-sentence-per-line", () => {
3535
assert.strictEqual(errors.length, 0);
3636
});
3737

38+
describe("disable/enable block", () => {
39+
it("suppresses multi-sentence lines between disable and enable; reports after enable", () => {
40+
const lines = [
41+
"# Doc",
42+
"<!-- one-sentence-per-line disable -->",
43+
"First. Second.",
44+
"One. Two. Three.",
45+
"<!-- one-sentence-per-line enable -->",
46+
"Alpha. Beta.",
47+
];
48+
const errors = runRule(rule, lines);
49+
assert.strictEqual(errors.length, 1, "only line after enable should error");
50+
assert.strictEqual(errors[0].lineNumber, 6);
51+
});
52+
53+
it("disable only (no enable): all multi-sentence lines after disable are suppressed", () => {
54+
const lines = [
55+
"Single.",
56+
"<!-- one-sentence-per-line disable -->",
57+
"First. Second.",
58+
"A. B. C.",
59+
];
60+
const errors = runRule(rule, lines);
61+
assert.strictEqual(errors.length, 0, "no errors when disable has no enable");
62+
});
63+
});
64+
3865
it("reports error for two sentences in numbered list item", () => {
3966
const lines = ["1. First sentence. Second sentence."];
4067
const errors = runRule(rule, lines);

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,75 @@ describe("utils", () => {
6565
const lines = ["Use arrow → here. <!-- .......... ..... -->"];
6666
assert.strictEqual(isRuleSuppressedByComment(lines, 1, "ascii-only"), true);
6767
});
68+
69+
it("returns true when line is inside disable block (between disable and enable)", () => {
70+
const lines = [
71+
"<!-- no-empty-heading disable -->",
72+
"## 1. Empty Heading 1",
73+
"",
74+
"## 2. Empty Heading 2",
75+
"",
76+
"## 3. Empty Heading 3",
77+
"<!-- no-empty-heading enable -->",
78+
"",
79+
"## 4. Empty Heading 4",
80+
];
81+
assert.strictEqual(isRuleSuppressedByComment(lines, 2, "no-empty-heading"), true);
82+
assert.strictEqual(isRuleSuppressedByComment(lines, 4, "no-empty-heading"), true);
83+
assert.strictEqual(isRuleSuppressedByComment(lines, 6, "no-empty-heading"), true);
84+
assert.strictEqual(isRuleSuppressedByComment(lines, 9, "no-empty-heading"), false);
85+
});
86+
87+
it("returns false when enable turns rule back on (line after enable is not suppressed)", () => {
88+
const lines = [
89+
"<!-- no-empty-heading disable -->",
90+
"## Empty",
91+
"<!-- no-empty-heading enable -->",
92+
"## Empty Again",
93+
];
94+
assert.strictEqual(isRuleSuppressedByComment(lines, 2, "no-empty-heading"), true);
95+
assert.strictEqual(isRuleSuppressedByComment(lines, 4, "no-empty-heading"), false);
96+
});
97+
98+
it("returns true when disable has no matching enable (rest of file suppressed)", () => {
99+
const lines = [
100+
"## OK",
101+
"Content.",
102+
"<!-- no-empty-heading disable -->",
103+
"## Empty",
104+
"## Also Empty",
105+
];
106+
assert.strictEqual(isRuleSuppressedByComment(lines, 1, "no-empty-heading"), false);
107+
assert.strictEqual(isRuleSuppressedByComment(lines, 4, "no-empty-heading"), true);
108+
assert.strictEqual(isRuleSuppressedByComment(lines, 5, "no-empty-heading"), true);
109+
});
110+
111+
it("disable/enable: wrong rule name in comment does not affect state", () => {
112+
const lines = [
113+
"<!-- no-empty-heading disable -->",
114+
"## Empty",
115+
"<!-- other-rule enable -->",
116+
"## Still Empty",
117+
];
118+
assert.strictEqual(isRuleSuppressedByComment(lines, 2, "no-empty-heading"), true);
119+
assert.strictEqual(isRuleSuppressedByComment(lines, 4, "no-empty-heading"), true);
120+
});
121+
122+
it("disable/enable: allow optional whitespace in comment", () => {
123+
const lines = [" <!-- no-empty-heading disable --> ", "## Empty"];
124+
assert.strictEqual(isRuleSuppressedByComment(lines, 2, "no-empty-heading"), true);
125+
});
126+
127+
it("disable only (no enable): ascii-only stays suppressed for rest of file", () => {
128+
const lines = [
129+
"ASCII line.",
130+
"<!-- ascii-only disable -->",
131+
"Use → here.",
132+
"And café here.",
133+
];
134+
assert.strictEqual(isRuleSuppressedByComment(lines, 1, "ascii-only"), false);
135+
assert.strictEqual(isRuleSuppressedByComment(lines, 3, "ascii-only"), true);
136+
assert.strictEqual(isRuleSuppressedByComment(lines, 4, "ascii-only"), true);
137+
});
68138
});
69139
});

0 commit comments

Comments
 (0)