-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathheading-min-words.js
More file actions
146 lines (134 loc) · 5.12 KB
/
heading-min-words.js
File metadata and controls
146 lines (134 loc) · 5.12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
"use strict";
const { extractHeadings, isRuleSuppressedByComment, parseHeadingNumberPrefix, pathMatchesAny } = require("./utils.js");
/**
* Normalize config: minWords required; optional applyToLevelsAtOrBelow, minLevel/maxLevel,
* excludePaths/includePaths, allowList, stripNumbering.
*
* @param {object} raw - Raw config (rule's block)
* @returns {object} Normalized options
*/
function normalizeConfig(raw) {
const minWords = typeof raw.minWords === "number" && raw.minWords >= 1 ? raw.minWords : 2;
const stripNumbering = raw.stripNumbering !== false;
const allowList = Array.isArray(raw.allowList) ? raw.allowList.map(String) : [];
const applyToLevelsAtOrBelow = typeof raw.applyToLevelsAtOrBelow === "number" ? raw.applyToLevelsAtOrBelow : null;
const minLevel = typeof raw.minLevel === "number" ? raw.minLevel : null;
const maxLevel = typeof raw.maxLevel === "number" ? raw.maxLevel : null;
return {
minWords,
stripNumbering,
allowList,
applyToLevelsAtOrBelow,
minLevel,
maxLevel,
excludePaths: raw.excludePaths || raw.excludePathPatterns,
includePaths: raw.includePaths || raw.includePathPatterns,
};
}
/**
* Return true if this heading level is in scope for the min-words check.
*
* @param {number} level - Heading level 1-6
* @param {{ applyToLevelsAtOrBelow: number|null, minLevel: number|null, maxLevel: number|null }} opts
* @returns {boolean}
*/
function levelInScope(level, opts) {
if (opts.minLevel != null || opts.maxLevel != null) {
const min = opts.minLevel != null ? opts.minLevel : 1;
const max = opts.maxLevel != null ? opts.maxLevel : 6;
return level >= min && level <= max;
}
if (opts.applyToLevelsAtOrBelow != null) {
return level >= opts.applyToLevelsAtOrBelow;
}
return true;
}
/**
* Get title text for word count: raw heading text, optionally with leading numbering stripped.
*
* @param {string} rawText - Full heading text after #
* @param {boolean} stripNumbering
* @returns {string}
*/
function getTitleForWordCount(rawText, stripNumbering) {
if (stripNumbering) {
const { titleText } = parseHeadingNumberPrefix(rawText);
return titleText;
}
return rawText.trim();
}
/**
* Count words in title (non-empty tokens separated by whitespace).
*
* @param {string} title
* @returns {number}
*/
function wordCount(title) {
/* c8 ignore start -- defensive: getTitleForWordCount always returns string */
if (!title || typeof title !== "string") {
return 0;
}
/* c8 ignore stop */
const t = title.trim();
/* c8 ignore next 1 -- empty title branch */
return t === "" ? 0 : t.split(/\s+/).filter(Boolean).length;
}
function shouldSkipPath(filePath, opts) {
const includePaths = Array.isArray(opts.includePaths) ? opts.includePaths : [];
const excludePaths = Array.isArray(opts.excludePaths) ? opts.excludePaths : [];
if (includePaths.length > 0 && !pathMatchesAny(filePath, includePaths)) return true;
if (pathMatchesAny(filePath, excludePaths)) return true;
return false;
}
function isAllowedSingleWord(title, allowList) {
if (allowList.length === 0) return false;
const single = title.trim().toLowerCase();
return allowList.some((a) => a.trim().toLowerCase() === single);
}
/**
* Report error if a single heading violates min-words (or allowList for single-word).
*
* @param {{ lineNumber: number, level: number, rawText: string }} h - Heading
* @param {object} opts - Normalized config
* @param {string[]} lines - Document lines
* @param {function(object): void} onError - Callback to report an error
*/
function checkHeading(h, opts, lines, onError) {
if (!levelInScope(h.level, opts)) return;
const title = getTitleForWordCount(h.rawText, opts.stripNumbering);
const count = wordCount(title);
/* c8 ignore next 2 -- branch: allow by minWords or allowList */
const allowed = count >= opts.minWords || (count === 1 && isAllowedSingleWord(title, opts.allowList));
if (allowed) return;
if (isRuleSuppressedByComment(lines, h.lineNumber, "heading-min-words")) return;
onError({
lineNumber: h.lineNumber,
detail: `Heading at or below this level must have at least ${opts.minWords} word(s) in the title (found ${count}).`,
context: lines[h.lineNumber - 1],
});
}
/**
* markdownlint rule: headings at or below a configurable level must have at least N words
* in the title (after optional numbering strip). Optional allowList for single-word titles.
*
* @param {object} params - markdownlint params (lines, config, name)
* @param {function(object): void} onError - Callback to report an error
*/
function ruleFunction(params, onError) {
const lines = params.lines;
const filePath = params.name || "";
const raw = params.config?.["heading-min-words"] ?? params.config ?? {};
const opts = normalizeConfig(raw);
if (shouldSkipPath(filePath, opts)) return;
const headings = extractHeadings(lines);
for (const h of headings) {
checkHeading(h, opts, lines, onError);
}
}
module.exports = {
names: ["heading-min-words"],
description:
"Headings at or below a configurable level must have at least N words in the title (after optional numbering strip).",
tags: ["headings"],
function: ruleFunction,
};