Skip to content

Commit 42d7b50

Browse files
committed
fix(rules): multiple rule fixes and no-empty-heading allowList
- heading-title-case: skip scientific notation (e.g. 1e6), hyphen compound segment handling - no-empty-heading: add allowList and stripNumberingForAllowList for allowed empty titles - no-heading-like-lines: strip trailing colons from fix/insert text - one-sentence-per-line: fix space and next-word position handling - config: enable MD046 fenced; document one-off suppression; README title case - tests: expand unit and fix tests for above rules
1 parent a87fa44 commit 42d7b50

18 files changed

Lines changed: 282 additions & 31 deletions

‎.markdownlint.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ MD013:
2929
MD033:
3030
allowed_elements: ["a"]
3131

32+
# MD046/code-block-style - enforce fenced code block style (``` or ~~~).
33+
# Options:
34+
# style: string (default "fenced"); "fenced" = fenced code block, "indented" = indented code block
35+
MD046:
36+
style: fenced
37+
3238
# ascii-only: disallow non-ASCII except in configured paths; inline code (backticks) always stripped. Fixable when replacement in unicodeReplacements.
3339
# Options (all optional):
3440
# allowedEmoji: list of chars/emoji allowed in paths matching allowedPathPatternsEmoji (multi-codepoint OK)

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Docs-as-Code Tools
1+
# Docs-As-Code Tools
22

33
[![CI](https://github.com/cypher0n3/docs-as-code-tools/actions/workflows/js-lint.yml/badge.svg?branch=main)](https://github.com/cypher0n3/docs-as-code-tools/actions/workflows/js-lint.yml)
44
[![Lint READMEs](https://github.com/cypher0n3/docs-as-code-tools/actions/workflows/lint-readmes.yml/badge.svg?branch=main)](https://github.com/cypher0n3/docs-as-code-tools/actions/workflows/lint-readmes.yml)

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,28 @@
1717
# because allowing <a> is required for custom anchor ids. All options are
1818
# commented; uncomment a rule block and the options you need to override.
1919
#
20+
# -----------------------------------------------------------------------------
21+
# One-off suppression (allow a single line or section to bypass a rule)
22+
# -----------------------------------------------------------------------------
23+
# Custom rules support an HTML comment to suppress the rule for one line:
24+
# • Put "<!-- rule-name allow -->" on the previous line (its own line), or
25+
# • Put "<!-- rule-name allow -->" at the end of the violating line.
26+
# Examples:
27+
# <!-- no-empty-heading allow -->
28+
# ## Intentionally Empty
29+
#
30+
# ## Another Empty <!-- no-empty-heading allow -->
31+
#
32+
# <!-- heading-min-words allow -->
33+
# ## Overview
34+
#
35+
# First. Second. <!-- one-sentence-per-line allow -->
36+
#
37+
# <!-- ascii-only allow -->
38+
# Use arrow → here.
39+
#
40+
# Use the exact rule id (e.g. no-empty-heading, heading-min-words, one-sentence-per-line).
41+
#
2042
# =============================================================================
2143

2244
default: true
@@ -254,13 +276,17 @@ default: true
254276
# -----------------------------------------------------------------------------
255277
# no-empty-heading (custom)
256278
# -----------------------------------------------------------------------------
257-
# To override: Uncomment the block and set minimumContentLines, count* options, and/or excludePathPatterns. H2+ must have content before any subheading. Per-section suppress: "<!-- no-empty-heading allow -->".
279+
# To override: Uncomment the block and set minimumContentLines, count* options,
280+
# allowList, and/or excludePathPatterns. H2+ must have content before any subheading.
281+
# One-off: put "<!-- no-empty-heading allow -->" on the previous line or at end of heading line.
258282
# Options (all optional):
259283
# minimumContentLines: number (default 1); minimum lines that count as content under each H2+
260284
# countBlankLinesAsContent: bool (default false); count blank lines toward minimum
261285
# countHTMLCommentsAsContent: bool (default false); count HTML-comment-only lines
262286
# countHtmlLinesAsContent: bool (default false); count HTML-tag-only lines (e.g. <br>)
263287
# countCodeBlockLinesAsContent: bool (default true); count lines inside ```/~~~ blocks
288+
# allowList: list of exact heading titles (after optional numbering strip) that may be empty
289+
# stripNumberingForAllowList: bool (default true); strip leading numbering before allowList match
264290
# excludePathPatterns: list of globs; skip this rule for matching file paths
265291
# ---
266292
# no-empty-heading:
@@ -269,6 +295,10 @@ default: true
269295
# # countHTMLCommentsAsContent: false
270296
# # countHtmlLinesAsContent: false
271297
# # countCodeBlockLinesAsContent: true
298+
# # allowList:
299+
# # - "Overview"
300+
# # - "Summary"
301+
# # stripNumberingForAllowList: true
272302
# # excludePathPatterns:
273303
# # - "**/*_index.md"
274304

‎markdownlint-rules/README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,10 @@ no-empty-heading:
287287
countHTMLCommentsAsContent: false # optional; default false
288288
countHtmlLinesAsContent: false # optional; default false
289289
countCodeBlockLinesAsContent: true # optional; default true
290+
allowList: # optional; headings with these titles may be empty
291+
- "Overview"
292+
- "Summary"
293+
stripNumberingForAllowList: true # optional; default true; strip numbering before allowList match
290294
excludePathPatterns:
291295
- "**/*_index.md" # optional; skip rule for these paths
292296
```
@@ -299,13 +303,18 @@ no-empty-heading:
299303
- **`countHtmlLinesAsContent`** (boolean, default `false`): If `true`, lines that are only an HTML tag (e.g. `<br>`, `<div>...</div>`) count toward the minimum.
300304
- **`countCodeBlockLinesAsContent`** (boolean, default `true`): If `false`, lines inside fenced code blocks (` ``` `or `~~~`) do not count toward the minimum.
301305
When `true`, they do (default).
306+
- **`allowList`** (array of strings, optional): Exact heading titles (after optional numbering strip) that are allowed to have no direct content.
307+
Match is case-insensitive and trimmed.
308+
Same idea as `heading-min-words` allowList.
309+
- **`stripNumberingForAllowList`** (boolean, default `true`): When `true`, leading numbering (e.g. `1.2.3`) is stripped from the heading text before comparing to `allowList`.
302310
- **`excludePathPatterns`** (list of strings, default none): Glob patterns for file paths where this rule is skipped.
303311

304312
Behavior:
305313

306314
- For each H2-H6 heading, only _direct_ content counts: lines that appear **before** the next heading (any level).
307315
Content under subheadings does **not** count for the parent heading.
308316
Which line types count is controlled by the options above (by default: prose and code block lines; blank, HTML-comment, and HTML-tag lines do not).
317+
- **Allow by title:** If `allowList` is set, any H2+ heading whose title (after optional numbering strip) matches an entry (case-insensitive) is allowed to be empty.
309318
- **Suppress per section:** A section is allowed without meeting the minimum if it contains a line that is solely the comment `<!-- no-empty-heading allow -->` (optional whitespace around or inside the comment).
310319
The comment must be on its own line.
311320
No other HTML comment format suppresses the rule.

‎markdownlint-rules/heading-title-case.js‎

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ const RE_FILE_LIKE = /^[^\s`]*[A-Za-z][^\s`]*\.[A-Za-z0-9]+$/;
1414
/** Match HTML/XML entity (e.g. &rArr; &#8230; &#x2192;) so we skip title-case checks (ASCII-safe special chars). */
1515
const RE_HTML_ENTITY = /^[^A-Za-z0-9]*&(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);[^A-Za-z0-9]*$/;
1616

17+
/** Scientific notation (e.g. 5e, 1e6, 2.5e-10); skip so "e" is not flagged for capitalization. */
18+
const RE_SCIENTIFIC = /^\d+(?:\.\d+)?[eE][+-]?\d*$/;
19+
1720
/** Placeholder sentinels for inline code in applyTitleCase (private-use Unicode, not control chars). */
1821
const PLACEHOLDER_START = "\uE000";
1922
const PLACEHOLDER_END = "\uE001";
@@ -88,13 +91,13 @@ function isAllLower(w) {
8891
}
8992

9093
/**
91-
* True if this position is exempt from lowercase (first/last/subphrase start/hyphen compound start/single-letter label).
92-
* @param {{ isFirst: boolean, isLast: boolean, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean, isPhaseLabel?: boolean }} opts
94+
* True if this position is exempt from lowercase (first/last/subphrase start/hyphen compound segment/single-letter label).
95+
* @param {{ isFirst: boolean, isLast: boolean, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean, isHyphenCompoundSegment?: boolean, isPhaseLabel?: boolean }} opts
9396
* @returns {boolean}
9497
*/
9598
function isExemptFromLowercase(opts) {
96-
const { isFirst, isLast, isSubphraseStart, isHyphenCompoundStart, isPhaseLabel } = opts;
97-
return Boolean(isFirst || isLast || isSubphraseStart || isHyphenCompoundStart || isPhaseLabel);
99+
const { isFirst, isLast, isSubphraseStart, isHyphenCompoundStart, isHyphenCompoundSegment, isPhaseLabel } = opts;
100+
return Boolean(isFirst || isLast || isSubphraseStart || isHyphenCompoundStart || isHyphenCompoundSegment || isPhaseLabel);
98101
}
99102

100103
/**
@@ -111,7 +114,7 @@ function getCapitalizationKind(opts) {
111114

112115
/**
113116
* Validate one word for title case; returns error detail string or null if valid.
114-
* @param {{ raw: string, core: string, isFirst: boolean, isLast: boolean, lowercaseWords: Set<string>, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean, isPhaseLabel?: boolean }} opts
117+
* @param {{ raw: string, core: string, isFirst: boolean, isLast: boolean, lowercaseWords: Set<string>, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean, isHyphenCompoundSegment?: boolean, isPhaseLabel?: boolean }} opts
115118
* @returns {string|null}
116119
*/
117120
function checkWord(opts) {
@@ -129,7 +132,7 @@ function checkWord(opts) {
129132
* Return the corrected segment text for fixInfo: lowercase or capitalize per AP rules, preserving punctuation.
130133
* @param {string} rawSeg - Segment as in source (e.g. "And", "(in", "practice)")
131134
* @param {string} core - stripWordPunctuation(rawSeg)
132-
* @param {{ isFirst: boolean, isLast: boolean, lowercaseWords: Set<string>, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean, isPhaseLabel?: boolean }} opts
135+
* @param {{ isFirst: boolean, isLast: boolean, lowercaseWords: Set<string>, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean, isHyphenCompoundSegment?: boolean, isPhaseLabel?: boolean }} opts
133136
* @returns {string}
134137
*/
135138
function getCorrectedSegment(rawSeg, core, opts) {
@@ -169,6 +172,13 @@ function isPhaseLabelSegment(j, core, previousWordCore) {
169172
return j === 0 && core.length === 1 && /^[A-Za-z]$/.test(core) && LABEL_PARENT_WORDS.has(previousWordCore);
170173
}
171174

175+
/** True if this segment should be skipped for title-case (entity, empty, non-alpha, or scientific notation). */
176+
function shouldSkipSegmentForTitleCase(rawSeg, core) {
177+
return isHtmlEntity(rawSeg)
178+
|| !core || !/[a-zA-Z]/.test(core)
179+
|| RE_SCIENTIFIC.test(core);
180+
}
181+
172182
/**
173183
* Check one segment of a word for title-case; returns error result or null.
174184
* @param {{ words: string[], rawSegments: string[], i: number, j: number, wordIsSubphraseStart: boolean, lowercaseWords: Set<string>, previousWordCore?: string }} opts
@@ -177,14 +187,14 @@ function isPhaseLabelSegment(j, core, previousWordCore) {
177187
function checkOneSegment(opts) {
178188
const { words, rawSegments, i, j, wordIsSubphraseStart, lowercaseWords, previousWordCore } = opts;
179189
const rawSeg = rawSegments[j];
180-
if (isHtmlEntity(rawSeg)) return null;
181190
const core = stripWordPunctuation(rawSeg);
182-
if (!core || !/[a-zA-Z]/.test(core)) return null;
191+
if (shouldSkipSegmentForTitleCase(rawSeg, core)) return null;
183192

184193
const isFirst = i === 0 && j === 0;
185194
const isLast = i === words.length - 1 && j === rawSegments.length - 1;
186195
const isSubphraseStart = j === 0 && wordIsSubphraseStart;
187196
const isHyphenCompoundStart = rawSegments.length > 1 && j === 0;
197+
const isHyphenCompoundSegment = rawSegments.length > 1;
188198
const isPhaseLabel = isPhaseLabelSegment(j, core, previousWordCore);
189199

190200
const detail = checkWord({
@@ -195,6 +205,7 @@ function checkOneSegment(opts) {
195205
lowercaseWords,
196206
isSubphraseStart,
197207
isHyphenCompoundStart,
208+
isHyphenCompoundSegment,
198209
isPhaseLabel,
199210
});
200211
if (!detail) return null;
@@ -205,6 +216,7 @@ function checkOneSegment(opts) {
205216
lowercaseWords,
206217
isSubphraseStart,
207218
isHyphenCompoundStart,
219+
isHyphenCompoundSegment,
208220
isPhaseLabel,
209221
});
210222
const seg = getSegmentPosition(rawSegments, j);
@@ -289,13 +301,15 @@ function correctSegmentForTitleCase(rawSeg, core, ctx) {
289301
const isLast = i === words.length - 1 && j === rawSegments.length - 1;
290302
const isSubphraseStart = j === 0 && wordIsSubphraseStart;
291303
const isHyphenCompoundStart = rawSegments.length > 1 && j === 0;
304+
const isHyphenCompoundSegment = rawSegments.length > 1;
292305
const isPhaseLabel = isPhaseLabelSegment(j, core, previousWordCore);
293306
return getCorrectedSegment(rawSeg, core, {
294307
isFirst,
295308
isLast,
296309
lowercaseWords,
297310
isSubphraseStart,
298311
isHyphenCompoundStart,
312+
isHyphenCompoundSegment,
299313
isPhaseLabel,
300314
});
301315
}
@@ -318,12 +332,8 @@ function processWordForTitleCase(raw, i, words, lowercaseWords) {
318332
const segmentParts = [];
319333
for (let j = 0; j < rawSegments.length; j++) {
320334
const rawSeg = rawSegments[j];
321-
if (isHtmlEntity(rawSeg)) {
322-
segmentParts.push(rawSeg);
323-
continue;
324-
}
325335
const core = stripWordPunctuation(rawSeg);
326-
if (!core || !/[a-zA-Z]/.test(core)) {
336+
if (shouldSkipSegmentForTitleCase(rawSeg, core)) {
327337
segmentParts.push(rawSeg);
328338
continue;
329339
}

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

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

3-
const { extractHeadings, isRuleSuppressedByComment, pathMatchesAny } = require("./utils.js");
3+
const {
4+
extractHeadings,
5+
isRuleSuppressedByComment,
6+
parseHeadingNumberPrefix,
7+
pathMatchesAny,
8+
} = require("./utils.js");
49

510
/** Match HTML comment line (single line). */
611
const RE_HTML_COMMENT = /^\s*<!--.*-->\s*$/;
@@ -169,10 +174,36 @@ function sectionContentLineCount(ctx) {
169174
return count;
170175
}
171176

177+
/**
178+
* Get heading title for allowList match: raw text, optionally with leading numbering stripped.
179+
* @param {string} rawText - Full heading text after #
180+
* @param {boolean} stripNumbering - Whether to strip leading numbering (e.g. 1.2.3)
181+
* @returns {string}
182+
*/
183+
function getTitleForAllowList(rawText, stripNumbering) {
184+
if (stripNumbering) {
185+
const { titleText } = parseHeadingNumberPrefix(rawText);
186+
return titleText;
187+
}
188+
return rawText.trim();
189+
}
190+
191+
/**
192+
* Return true if the heading title is in the allowList (case-insensitive, trimmed).
193+
* @param {string} title - Heading title (from getTitleForAllowList)
194+
* @param {string[]} allowList - Allowed titles
195+
* @returns {boolean}
196+
*/
197+
function isAllowedEmptyHeading(title, allowList) {
198+
if (!Array.isArray(allowList) || allowList.length === 0) return false;
199+
const key = title.trim().toLowerCase();
200+
return allowList.some((a) => String(a).trim().toLowerCase() === key);
201+
}
202+
172203
/**
173204
* Normalize rule config to validated values.
174205
* @param {object} config - Raw config
175-
* @returns {{ minimumContentLines: number, contentOpts: { countBlankLinesAsContent: boolean, countHTMLCommentsAsContent: boolean, countHtmlLinesAsContent: boolean, countCodeBlockLinesAsContent: boolean } }}
206+
* @returns {{ minimumContentLines: number, contentOpts: object, allowList: string[], stripNumberingForAllowList: boolean }}
176207
*/
177208
function normalizeConfig(config) {
178209
const minimumContentLines = typeof config.minimumContentLines === "number" && config.minimumContentLines >= 1
@@ -190,6 +221,8 @@ function normalizeConfig(config) {
190221
const countCodeBlockLinesAsContent = typeof config.countCodeBlockLinesAsContent === "boolean"
191222
? config.countCodeBlockLinesAsContent
192223
: DEFAULT_CONFIG.countCodeBlockLinesAsContent;
224+
const allowList = Array.isArray(config.allowList) ? config.allowList.map(String) : [];
225+
const stripNumberingForAllowList = config.stripNumberingForAllowList !== false;
193226
return {
194227
minimumContentLines,
195228
contentOpts: {
@@ -198,6 +231,8 @@ function normalizeConfig(config) {
198231
countHtmlLinesAsContent,
199232
countCodeBlockLinesAsContent,
200233
},
234+
allowList,
235+
stripNumberingForAllowList,
201236
};
202237
}
203238

@@ -263,7 +298,16 @@ function headingHasTooLittleContent(ctx) {
263298
}
264299

265300
function reportEmptyHeading(heading, ctx) {
266-
const { headings, lines, headingLineNumbers, minimumContentLines, contentOpts, onError } = ctx;
301+
const {
302+
headings, lines, headingLineNumbers, minimumContentLines, contentOpts,
303+
allowList, stripNumberingForAllowList, onError,
304+
} = ctx;
305+
if (isAllowedEmptyHeading(
306+
getTitleForAllowList(heading.rawText, stripNumberingForAllowList),
307+
allowList
308+
)) {
309+
return;
310+
}
267311
if (!headingHasTooLittleContent({
268312
heading, headings, lines, headingLineNumbers, minimumContentLines, contentOpts,
269313
})) {
@@ -280,8 +324,10 @@ function reportEmptyHeading(heading, ctx) {
280324
/**
281325
* markdownlint rule: every H2+ heading must have at least one line of content
282326
* directly under it (before any subheading). Content under subheadings does not
283-
* count. Blank lines and HTML-comment-only lines do not count as content. The
284-
* exact comment "<!-- no-empty-heading allow -->" on its own line suppresses the error.
327+
* count. Blank lines and HTML-comment-only lines do not count as content.
328+
* Optional allowList: headings whose title (after optional numbering strip) matches
329+
* an entry are allowed to be empty. The exact comment "<!-- no-empty-heading allow -->"
330+
* on its own line suppresses the error for that section.
285331
*
286332
* @param {object} params - markdownlint params (lines, name, config)
287333
* @param {function(object): void} onError - Callback to report an error
@@ -295,11 +341,14 @@ function ruleFunction(params, onError) {
295341
return;
296342
}
297343

298-
const { minimumContentLines, contentOpts } = normalizeConfig(block);
344+
const { minimumContentLines, contentOpts, allowList, stripNumberingForAllowList } = normalizeConfig(block);
299345
const headings = extractHeadings(lines);
300346
const h2Plus = headings.filter((h) => h.level >= 2);
301347
const headingLineNumbers = new Set(headings.map((h) => h.lineNumber));
302-
const ctx = { headings, lines, headingLineNumbers, minimumContentLines, contentOpts, onError };
348+
const ctx = {
349+
headings, lines, headingLineNumbers, minimumContentLines, contentOpts,
350+
allowList, stripNumberingForAllowList, onError,
351+
};
303352

304353
for (const heading of h2Plus) {
305354
reportEmptyHeading(heading, ctx);

‎markdownlint-rules/no-heading-like-lines.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,12 @@ function findHeadingLikeMatch(trimmedLine, punctuationMarks) {
160160
function reportHeadingLikeError(line, index, match, ctx) {
161161
const { lines, headings, config, ruleConfig, convertToHeading, onError } = ctx;
162162
const { description, extractedTitle } = match;
163+
const titleForFix = extractedTitle.replace(/:+$/, "").trim();
163164
const lineNumber = index + 1;
164165
const insertText = !convertToHeading
165-
? extractedTitle
166+
? titleForFix
166167
: buildConvertToHeadingInsertText({
167-
lines, index, lineNumber, headings, config, ruleConfig, extractedTitle,
168+
lines, index, lineNumber, headings, config, ruleConfig, extractedTitle: titleForFix,
168169
});
169170
if (isRuleSuppressedByComment(lines, lineNumber, "no-heading-like-lines")) return;
170171
onError({

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,24 @@ function spaceStartAndNextWordPos(scanned, spaceStart) {
163163
while (pos < scanned.length && scanned[pos] === " ") {
164164
pos++;
165165
}
166-
const j = pos;
167-
if (j >= scanned.length || scanned[j] === "\n" || !/[a-zA-Z0-9]/.test(scanned[j])) return null;
166+
const j = nextWordPosAfterEmphasis(scanned, pos);
167+
if (j === null) return null;
168168
return { spaceStart, j };
169169
}
170170

171+
/** After position start, skip * and _, then ' and ", then return position of first [a-zA-Z0-9] or null. */
172+
function nextWordPosAfterEmphasis(scanned, start) {
173+
let pos = start;
174+
while (pos < scanned.length && (scanned[pos] === "*" || scanned[pos] === "_")) {
175+
pos++;
176+
}
177+
while (pos < scanned.length && (scanned[pos] === "'" || scanned[pos] === '"')) {
178+
pos++;
179+
}
180+
if (pos >= scanned.length || scanned[pos] === "\n" || !/[a-zA-Z0-9]/.test(scanned[pos])) return null;
181+
return pos;
182+
}
183+
171184
/**
172185
* Find index of the first sentence boundary (space before second sentence) in content.
173186
* Uses stripInlineCode; skips link/paren context; avoids decimals and abbreviations.

‎md_test_files/expected_errors.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,9 @@ negative_one_sentence_per_line.md:
293293
- line: 20
294294
rule: one-sentence-per-line
295295
message_contains: one sentence per line
296+
- line: 22
297+
rule: one-sentence-per-line
298+
message_contains: one sentence per line
296299

297300
negative_no_tables.md:
298301
errors:

0 commit comments

Comments
 (0)