doclify-guardrail 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/checker.mjs +332 -20
- package/src/ci-output.mjs +4 -1
- package/src/colors.mjs +29 -10
- package/src/fixer.mjs +215 -11
- package/src/index.mjs +140 -32
- package/src/links.mjs +27 -4
- package/src/quality.mjs +5 -1
package/package.json
CHANGED
package/src/checker.mjs
CHANGED
|
@@ -14,7 +14,22 @@ const RULE_CATALOG = [
|
|
|
14
14
|
{ id: 'empty-link', severity: 'warning', description: 'No empty link text or URL' },
|
|
15
15
|
{ id: 'img-alt', severity: 'warning', description: 'Images must have alt text' },
|
|
16
16
|
{ id: 'dead-link', severity: 'error', description: 'No broken links (requires --check-links)' },
|
|
17
|
-
{ id: 'stale-doc', severity: 'warning', description: 'Warn on stale docs (requires --check-freshness)' }
|
|
17
|
+
{ id: 'stale-doc', severity: 'warning', description: 'Warn on stale docs (requires --check-freshness)' },
|
|
18
|
+
{ id: 'no-trailing-spaces', severity: 'warning', description: 'No trailing whitespace' },
|
|
19
|
+
{ id: 'no-multiple-blanks', severity: 'warning', description: 'No multiple consecutive blank lines' },
|
|
20
|
+
{ id: 'single-trailing-newline', severity: 'warning', description: 'File must end with a single newline' },
|
|
21
|
+
{ id: 'no-missing-space-atx', severity: 'warning', description: 'Space required after # in headings' },
|
|
22
|
+
{ id: 'heading-start-left', severity: 'warning', description: 'Headings must not be indented' },
|
|
23
|
+
{ id: 'no-trailing-punctuation-heading', severity: 'warning', description: 'No trailing punctuation in headings' },
|
|
24
|
+
{ id: 'blanks-around-headings', severity: 'warning', description: 'Blank line required around headings' },
|
|
25
|
+
{ id: 'blanks-around-lists', severity: 'warning', description: 'Blank line required around lists' },
|
|
26
|
+
{ id: 'blanks-around-fences', severity: 'warning', description: 'Blank line required around fenced code blocks' },
|
|
27
|
+
{ id: 'fenced-code-language', severity: 'warning', description: 'Fenced code blocks must specify a language' },
|
|
28
|
+
{ id: 'no-bare-urls', severity: 'warning', description: 'URLs must be wrapped in <> or []()' },
|
|
29
|
+
{ id: 'no-reversed-links', severity: 'warning', description: 'No reversed link syntax (text)[url]' },
|
|
30
|
+
{ id: 'no-space-in-emphasis', severity: 'warning', description: 'No spaces inside emphasis markers' },
|
|
31
|
+
{ id: 'no-space-in-links', severity: 'warning', description: 'No spaces inside link brackets' },
|
|
32
|
+
{ id: 'no-inline-html', severity: 'warning', description: 'No inline HTML (opt-in via --check-inline-html)' }
|
|
18
33
|
];
|
|
19
34
|
|
|
20
35
|
const RULE_SEVERITY = Object.fromEntries(RULE_CATALOG.map(r => [r.id, r.severity]));
|
|
@@ -32,10 +47,10 @@ const PLACEHOLDER_PATTERNS = [
|
|
|
32
47
|
{ rx: /\bxxx\b/i, msg: '"xxx" placeholder found' }
|
|
33
48
|
];
|
|
34
49
|
|
|
35
|
-
function normalizeFinding(rule, message, line, source) {
|
|
50
|
+
function normalizeFinding(rule, message, line, source, severityOverride) {
|
|
36
51
|
const finding = {
|
|
37
52
|
code: rule,
|
|
38
|
-
severity: RULE_SEVERITY[rule] || 'warning',
|
|
53
|
+
severity: severityOverride || RULE_SEVERITY[rule] || 'warning',
|
|
39
54
|
message
|
|
40
55
|
};
|
|
41
56
|
if (line != null) finding.line = line;
|
|
@@ -86,9 +101,106 @@ function stripInlineCode(line) {
|
|
|
86
101
|
return line.replace(/`[^`]+`/g, '');
|
|
87
102
|
}
|
|
88
103
|
|
|
104
|
+
const SUPPRESS_NEXT_LINE_RX = /<!--\s*doclify-disable-next-line\s*(.*?)\s*-->/;
|
|
105
|
+
const SUPPRESS_BLOCK_START_RX = /<!--\s*doclify-disable\s*(.*?)\s*-->/;
|
|
106
|
+
const SUPPRESS_BLOCK_END_RX = /<!--\s*doclify-enable\s*(.*?)\s*-->/;
|
|
107
|
+
const SUPPRESS_FILE_RX = /<!--\s*doclify-disable-file\s*(.*?)\s*-->/;
|
|
108
|
+
|
|
109
|
+
function parseRuleIds(raw) {
|
|
110
|
+
const trimmed = raw.trim();
|
|
111
|
+
if (!trimmed) return null; // null means "all rules"
|
|
112
|
+
return trimmed.split(/\s+/);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build a map of line numbers to sets of suppressed rule IDs.
|
|
117
|
+
* Uses code-block-stripped lines so comments inside fences are ignored.
|
|
118
|
+
* A suppressed set containing '*' means all rules are suppressed.
|
|
119
|
+
*/
|
|
120
|
+
function buildSuppressionMap(lines) {
|
|
121
|
+
const suppressions = new Map();
|
|
122
|
+
const activeDisables = new Map(); // ruleId → count (or '*' → count)
|
|
123
|
+
|
|
124
|
+
function addSuppression(lineNum, ruleIds) {
|
|
125
|
+
if (!suppressions.has(lineNum)) suppressions.set(lineNum, new Set());
|
|
126
|
+
const set = suppressions.get(lineNum);
|
|
127
|
+
if (ruleIds === null) {
|
|
128
|
+
set.add('*');
|
|
129
|
+
} else {
|
|
130
|
+
for (const id of ruleIds) set.add(id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
135
|
+
const line = lines[i];
|
|
136
|
+
|
|
137
|
+
const nextLineMatch = line.match(SUPPRESS_NEXT_LINE_RX);
|
|
138
|
+
if (nextLineMatch) {
|
|
139
|
+
const ruleIds = parseRuleIds(nextLineMatch[1]);
|
|
140
|
+
addSuppression(i + 2, ruleIds); // i is 0-based, line numbers are 1-based, next line = i+2
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const blockStartMatch = line.match(SUPPRESS_BLOCK_START_RX);
|
|
144
|
+
if (!nextLineMatch && blockStartMatch) {
|
|
145
|
+
const ruleIds = parseRuleIds(blockStartMatch[1]);
|
|
146
|
+
if (ruleIds === null) {
|
|
147
|
+
activeDisables.set('*', (activeDisables.get('*') || 0) + 1);
|
|
148
|
+
} else {
|
|
149
|
+
for (const id of ruleIds) activeDisables.set(id, (activeDisables.get(id) || 0) + 1);
|
|
150
|
+
}
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const blockEndMatch = line.match(SUPPRESS_BLOCK_END_RX);
|
|
155
|
+
if (blockEndMatch) {
|
|
156
|
+
const ruleIds = parseRuleIds(blockEndMatch[1]);
|
|
157
|
+
if (ruleIds === null) {
|
|
158
|
+
activeDisables.delete('*');
|
|
159
|
+
} else {
|
|
160
|
+
for (const id of ruleIds) {
|
|
161
|
+
const count = activeDisables.get(id) || 0;
|
|
162
|
+
if (count <= 1) activeDisables.delete(id);
|
|
163
|
+
else activeDisables.set(id, count - 1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Apply active block disables to this line
|
|
170
|
+
if (activeDisables.size > 0) {
|
|
171
|
+
const lineNum = i + 1;
|
|
172
|
+
if (!suppressions.has(lineNum)) suppressions.set(lineNum, new Set());
|
|
173
|
+
const set = suppressions.get(lineNum);
|
|
174
|
+
for (const id of activeDisables.keys()) set.add(id);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return suppressions;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isSuppressed(suppressions, finding) {
|
|
182
|
+
if (!finding.line) return false;
|
|
183
|
+
const set = suppressions.get(finding.line);
|
|
184
|
+
if (!set) return false;
|
|
185
|
+
return set.has('*') || set.has(finding.code);
|
|
186
|
+
}
|
|
187
|
+
|
|
89
188
|
function checkMarkdown(rawContent, opts = {}) {
|
|
90
189
|
const maxLineLength = Number(opts.maxLineLength ?? DEFAULTS.maxLineLength);
|
|
91
190
|
const filePath = opts.filePath || undefined;
|
|
191
|
+
|
|
192
|
+
// File-level suppression: <!-- doclify-disable-file [rules] -->
|
|
193
|
+
const fileDisableMatch = rawContent.match(SUPPRESS_FILE_RX);
|
|
194
|
+
let fileDisabledRules = null;
|
|
195
|
+
if (fileDisableMatch) {
|
|
196
|
+
const ruleIds = parseRuleIds(fileDisableMatch[1]);
|
|
197
|
+
if (ruleIds === null) {
|
|
198
|
+
// All rules disabled — short-circuit
|
|
199
|
+
return { errors: [], warnings: [], summary: { errors: 0, warnings: 0 } };
|
|
200
|
+
}
|
|
201
|
+
fileDisabledRules = new Set(ruleIds);
|
|
202
|
+
}
|
|
203
|
+
|
|
92
204
|
const errors = [];
|
|
93
205
|
const warnings = [];
|
|
94
206
|
|
|
@@ -97,8 +209,11 @@ function checkMarkdown(rawContent, opts = {}) {
|
|
|
97
209
|
const lines = content.split('\n');
|
|
98
210
|
const rawLines = rawContent.split('\n');
|
|
99
211
|
|
|
100
|
-
//
|
|
101
|
-
|
|
212
|
+
// Build suppression map from inline comments (uses stripped content)
|
|
213
|
+
const suppressions = buildSuppressionMap(lines);
|
|
214
|
+
|
|
215
|
+
// Rule: frontmatter (opt-in via --check-frontmatter or config)
|
|
216
|
+
if (opts.checkFrontmatter && !rawContent.startsWith('---\n')) {
|
|
102
217
|
warnings.push(normalizeFinding('frontmatter', 'Missing frontmatter block at the beginning of the file.', 1, filePath));
|
|
103
218
|
}
|
|
104
219
|
|
|
@@ -114,14 +229,12 @@ function checkMarkdown(rawContent, opts = {}) {
|
|
|
114
229
|
errors.push(normalizeFinding('single-h1', 'Missing H1 heading.', 1, filePath));
|
|
115
230
|
} else if (h1Lines.length > 1) {
|
|
116
231
|
const lineList = h1Lines.join(', ');
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
));
|
|
124
|
-
}
|
|
232
|
+
errors.push(normalizeFinding(
|
|
233
|
+
'single-h1',
|
|
234
|
+
`Found ${h1Lines.length} H1 headings (expected 1) at lines ${lineList}.`,
|
|
235
|
+
h1Lines[0],
|
|
236
|
+
filePath
|
|
237
|
+
));
|
|
125
238
|
}
|
|
126
239
|
|
|
127
240
|
// Rule: heading-hierarchy (h1→h3 without h2 is a skip)
|
|
@@ -141,12 +254,28 @@ function checkMarkdown(rawContent, opts = {}) {
|
|
|
141
254
|
prevLevel = level;
|
|
142
255
|
}
|
|
143
256
|
|
|
144
|
-
// Rule: duplicate-heading (
|
|
257
|
+
// Rule: duplicate-heading (scope-aware: H3-H6 scoped under nearest parent)
|
|
145
258
|
const headingSeen = new Map();
|
|
259
|
+
const parentStack = new Array(7).fill(''); // indices 1-6 for heading levels
|
|
146
260
|
for (let i = 0; i < lines.length; i += 1) {
|
|
147
261
|
const hMatch = lines[i].match(/^(#{1,6})\s+(.+)$/);
|
|
148
262
|
if (!hMatch) continue;
|
|
149
|
-
const
|
|
263
|
+
const level = hMatch[1].length;
|
|
264
|
+
const text = hMatch[2].trim().toLowerCase();
|
|
265
|
+
|
|
266
|
+
// Update parent stack: set current level and clear deeper levels
|
|
267
|
+
parentStack[level] = text;
|
|
268
|
+
for (let l = level + 1; l <= 6; l += 1) parentStack[l] = '';
|
|
269
|
+
|
|
270
|
+
// H1-H2: global scope; H3-H6: scoped under parent chain
|
|
271
|
+
let key;
|
|
272
|
+
if (level <= 2) {
|
|
273
|
+
key = `${level}:${text}`;
|
|
274
|
+
} else {
|
|
275
|
+
const scope = parentStack.slice(1, level).join('|');
|
|
276
|
+
key = `${scope}|${level}:${text}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
150
279
|
if (headingSeen.has(key)) {
|
|
151
280
|
warnings.push(normalizeFinding(
|
|
152
281
|
'duplicate-heading',
|
|
@@ -241,6 +370,183 @@ function checkMarkdown(rawContent, opts = {}) {
|
|
|
241
370
|
}
|
|
242
371
|
});
|
|
243
372
|
|
|
373
|
+
// Rule: no-trailing-spaces (uses raw content)
|
|
374
|
+
rawLines.forEach((line, idx) => {
|
|
375
|
+
if (/[ \t]+$/.test(line)) {
|
|
376
|
+
warnings.push(normalizeFinding('no-trailing-spaces', 'Trailing whitespace found.', idx + 1, filePath));
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Rule: no-multiple-blanks (uses raw content)
|
|
381
|
+
{
|
|
382
|
+
let consecutiveBlanks = 0;
|
|
383
|
+
rawLines.forEach((line, idx) => {
|
|
384
|
+
if (line.trim() === '') {
|
|
385
|
+
consecutiveBlanks += 1;
|
|
386
|
+
if (consecutiveBlanks >= 2) {
|
|
387
|
+
warnings.push(normalizeFinding('no-multiple-blanks', 'Multiple consecutive blank lines.', idx + 1, filePath));
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
consecutiveBlanks = 0;
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Rule: single-trailing-newline (uses raw content)
|
|
396
|
+
if (rawContent.length > 0) {
|
|
397
|
+
if (!rawContent.endsWith('\n')) {
|
|
398
|
+
warnings.push(normalizeFinding('single-trailing-newline', 'File must end with a single newline.', rawLines.length, filePath));
|
|
399
|
+
} else if (rawContent.endsWith('\n\n')) {
|
|
400
|
+
warnings.push(normalizeFinding('single-trailing-newline', 'File has multiple trailing newlines.', rawLines.length, filePath));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Rule: no-missing-space-atx (uses stripped content)
|
|
405
|
+
lines.forEach((line, idx) => {
|
|
406
|
+
if (/^#{1,6}[^\s#]/.test(line)) {
|
|
407
|
+
warnings.push(normalizeFinding('no-missing-space-atx', 'Missing space after # in heading.', idx + 1, filePath));
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Rule: heading-start-left (uses stripped content)
|
|
412
|
+
lines.forEach((line, idx) => {
|
|
413
|
+
if (/^\s+#{1,6}\s/.test(line)) {
|
|
414
|
+
warnings.push(normalizeFinding('heading-start-left', 'Heading must start at the beginning of the line.', idx + 1, filePath));
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Rule: no-trailing-punctuation-heading (uses stripped content)
|
|
419
|
+
lines.forEach((line, idx) => {
|
|
420
|
+
const hMatch = line.match(/^#{1,6}\s+(.+)$/);
|
|
421
|
+
if (hMatch) {
|
|
422
|
+
const text = hMatch[1].trim();
|
|
423
|
+
if (/[.,:;!]$/.test(text)) {
|
|
424
|
+
warnings.push(normalizeFinding('no-trailing-punctuation-heading', `Heading has trailing punctuation: "${text.slice(-1)}".`, idx + 1, filePath));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// Rule: blanks-around-headings (uses stripped content)
|
|
430
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
431
|
+
if (!/^#{1,6}\s/.test(lines[i])) continue;
|
|
432
|
+
// Check line before heading (skip first line, skip frontmatter end)
|
|
433
|
+
if (i > 0 && lines[i - 1].trim() !== '' && lines[i - 1] !== '---') {
|
|
434
|
+
warnings.push(normalizeFinding('blanks-around-headings', 'Missing blank line before heading.', i + 1, filePath));
|
|
435
|
+
}
|
|
436
|
+
// Check line after heading
|
|
437
|
+
if (i < lines.length - 1 && lines[i + 1].trim() !== '') {
|
|
438
|
+
warnings.push(normalizeFinding('blanks-around-headings', 'Missing blank line after heading.', i + 1, filePath));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Rule: blanks-around-lists (uses stripped content)
|
|
443
|
+
{
|
|
444
|
+
const isListItem = (l) => /^\s*[-*+]\s|^\s*\d+[.)]\s/.test(l);
|
|
445
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
446
|
+
if (!isListItem(lines[i])) continue;
|
|
447
|
+
// First list item — check blank before
|
|
448
|
+
if (i > 0 && !isListItem(lines[i - 1]) && lines[i - 1].trim() !== '') {
|
|
449
|
+
warnings.push(normalizeFinding('blanks-around-lists', 'Missing blank line before list.', i + 1, filePath));
|
|
450
|
+
}
|
|
451
|
+
// Last list item — check blank after
|
|
452
|
+
if (i < lines.length - 1 && !isListItem(lines[i + 1]) && lines[i + 1].trim() !== '') {
|
|
453
|
+
warnings.push(normalizeFinding('blanks-around-lists', 'Missing blank line after list.', i + 1, filePath));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Rule: blanks-around-fences (uses raw content to detect fence lines)
|
|
459
|
+
{
|
|
460
|
+
let inFence = false;
|
|
461
|
+
for (let i = 0; i < rawLines.length; i += 1) {
|
|
462
|
+
const isFence = /^(`{3,}|~{3,})/.test(rawLines[i]);
|
|
463
|
+
if (isFence && !inFence) {
|
|
464
|
+
// Opening fence — check blank before
|
|
465
|
+
inFence = true;
|
|
466
|
+
if (i > 0 && rawLines[i - 1].trim() !== '') {
|
|
467
|
+
warnings.push(normalizeFinding('blanks-around-fences', 'Missing blank line before code block.', i + 1, filePath));
|
|
468
|
+
}
|
|
469
|
+
} else if (isFence && inFence) {
|
|
470
|
+
// Closing fence — check blank after
|
|
471
|
+
inFence = false;
|
|
472
|
+
if (i < rawLines.length - 1 && rawLines[i + 1].trim() !== '') {
|
|
473
|
+
warnings.push(normalizeFinding('blanks-around-fences', 'Missing blank line after code block.', i + 1, filePath));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Rule: fenced-code-language (uses raw content)
|
|
480
|
+
rawLines.forEach((line, idx) => {
|
|
481
|
+
if (/^(`{3,}|~{3,})\s*$/.test(line)) {
|
|
482
|
+
// Only flag opening fences (not closing ones). Check if we're not inside a fence.
|
|
483
|
+
// Simple heuristic: count fences above this line
|
|
484
|
+
let fenceCount = 0;
|
|
485
|
+
for (let j = 0; j < idx; j += 1) {
|
|
486
|
+
if (/^(`{3,}|~{3,})/.test(rawLines[j])) fenceCount += 1;
|
|
487
|
+
}
|
|
488
|
+
if (fenceCount % 2 === 0) {
|
|
489
|
+
// Even count → this is an opening fence
|
|
490
|
+
warnings.push(normalizeFinding('fenced-code-language', 'Fenced code block without language specification.', idx + 1, filePath));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Rule: no-bare-urls (uses stripped content)
|
|
496
|
+
lines.forEach((line, idx) => {
|
|
497
|
+
const cleanLine = stripInlineCode(line);
|
|
498
|
+
// Match bare URLs not inside []() or <>
|
|
499
|
+
const bareRx = /(?<![(<\[])\bhttps?:\/\/[^\s>)]+/g;
|
|
500
|
+
let m;
|
|
501
|
+
while ((m = bareRx.exec(cleanLine)) !== null) {
|
|
502
|
+
const url = m[0];
|
|
503
|
+
const before = cleanLine.substring(0, m.index);
|
|
504
|
+
// Skip if inside markdown link [text](url) or <url>
|
|
505
|
+
if (/\]\($/.test(before) || before.endsWith('<')) continue;
|
|
506
|
+
// Skip if inside reference-style [label]: url
|
|
507
|
+
if (/^\[[^\]]*\]:\s*/.test(cleanLine)) continue;
|
|
508
|
+
warnings.push(normalizeFinding('no-bare-urls', `Bare URL found: ${url} — wrap in <> or use [text](url).`, idx + 1, filePath));
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// Rule: no-reversed-links (uses stripped content)
|
|
513
|
+
lines.forEach((line, idx) => {
|
|
514
|
+
const cleanLine = stripInlineCode(line);
|
|
515
|
+
if (/\([^)]+\)\[[^\]]+\]/.test(cleanLine)) {
|
|
516
|
+
warnings.push(normalizeFinding('no-reversed-links', 'Reversed link syntax: (text)[url] should be [text](url).', idx + 1, filePath));
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Rule: no-space-in-emphasis (uses stripped content)
|
|
521
|
+
lines.forEach((line, idx) => {
|
|
522
|
+
const cleanLine = stripInlineCode(line);
|
|
523
|
+
if (/\*\*\s+[^*]+\s+\*\*/.test(cleanLine) || /\*\s+[^*]+\s+\*(?!\*)/.test(cleanLine)) {
|
|
524
|
+
warnings.push(normalizeFinding('no-space-in-emphasis', 'Spaces inside emphasis markers — may not render correctly.', idx + 1, filePath));
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// Rule: no-space-in-links (uses stripped content)
|
|
529
|
+
lines.forEach((line, idx) => {
|
|
530
|
+
const cleanLine = stripInlineCode(line);
|
|
531
|
+
if (/\[\s+[^\]]*\]\(/.test(cleanLine) || /\[[^\]]*\s+\]\(/.test(cleanLine)) {
|
|
532
|
+
warnings.push(normalizeFinding('no-space-in-links', 'Spaces inside link text brackets.', idx + 1, filePath));
|
|
533
|
+
}
|
|
534
|
+
if (/\]\(\s+[^)]*\)/.test(cleanLine) || /\]\([^)]*\s+\)/.test(cleanLine)) {
|
|
535
|
+
warnings.push(normalizeFinding('no-space-in-links', 'Spaces inside link URL parentheses.', idx + 1, filePath));
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// Rule: no-inline-html (opt-in via --check-inline-html or config)
|
|
540
|
+
if (opts.checkInlineHtml) {
|
|
541
|
+
lines.forEach((line, idx) => {
|
|
542
|
+
const cleanLine = stripInlineCode(line);
|
|
543
|
+
// Match HTML tags but skip comments (<!-- -->)
|
|
544
|
+
if (/<[a-zA-Z/][^>]*>/.test(cleanLine) && !cleanLine.includes('<!--')) {
|
|
545
|
+
warnings.push(normalizeFinding('no-inline-html', 'Inline HTML found.', idx + 1, filePath));
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
244
550
|
// Custom rules (uses stripped content)
|
|
245
551
|
if (opts.customRules && opts.customRules.length > 0) {
|
|
246
552
|
lines.forEach((line, idx) => {
|
|
@@ -249,18 +555,24 @@ function checkMarkdown(rawContent, opts = {}) {
|
|
|
249
555
|
rule.pattern.lastIndex = 0;
|
|
250
556
|
if (rule.pattern.test(cleanLine)) {
|
|
251
557
|
const bucket = rule.severity === 'error' ? errors : warnings;
|
|
252
|
-
bucket.push(normalizeFinding(rule.id, rule.message, idx + 1, filePath));
|
|
558
|
+
bucket.push(normalizeFinding(rule.id, rule.message, idx + 1, filePath, rule.severity));
|
|
253
559
|
}
|
|
254
560
|
}
|
|
255
561
|
});
|
|
256
562
|
}
|
|
257
563
|
|
|
564
|
+
// Apply inline suppressions + file-level suppression
|
|
565
|
+
const isFileSuppressed = (f) =>
|
|
566
|
+
fileDisabledRules && (fileDisabledRules.has(f.code));
|
|
567
|
+
const filteredErrors = errors.filter(f => !isSuppressed(suppressions, f) && !isFileSuppressed(f));
|
|
568
|
+
const filteredWarnings = warnings.filter(f => !isSuppressed(suppressions, f) && !isFileSuppressed(f));
|
|
569
|
+
|
|
258
570
|
return {
|
|
259
|
-
errors,
|
|
260
|
-
warnings,
|
|
571
|
+
errors: filteredErrors,
|
|
572
|
+
warnings: filteredWarnings,
|
|
261
573
|
summary: {
|
|
262
|
-
errors:
|
|
263
|
-
warnings:
|
|
574
|
+
errors: filteredErrors.length,
|
|
575
|
+
warnings: filteredWarnings.length
|
|
264
576
|
}
|
|
265
577
|
};
|
|
266
578
|
}
|
package/src/ci-output.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { RULE_CATALOG } from './checker.mjs';
|
|
4
|
+
|
|
5
|
+
const RULE_DESCRIPTIONS = Object.fromEntries(RULE_CATALOG.map(r => [r.id, r.description]));
|
|
3
6
|
|
|
4
7
|
function clamp(value, min, max) {
|
|
5
8
|
return Math.max(min, Math.min(max, value));
|
|
@@ -86,7 +89,7 @@ function collectRuleCatalog(output) {
|
|
|
86
89
|
id: finding.code,
|
|
87
90
|
name: finding.code,
|
|
88
91
|
shortDescription: { text: finding.code },
|
|
89
|
-
help: { text: finding.message || finding.code },
|
|
92
|
+
help: { text: RULE_DESCRIPTIONS[finding.code] || finding.message || finding.code },
|
|
90
93
|
defaultConfiguration: {
|
|
91
94
|
level: finding.severity === 'error' ? 'error' : 'warning'
|
|
92
95
|
}
|
package/src/colors.mjs
CHANGED
|
@@ -9,6 +9,7 @@ const CODES = {
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
let enabled = true;
|
|
12
|
+
let asciiMode = false;
|
|
12
13
|
|
|
13
14
|
function initColors(noColorFlag) {
|
|
14
15
|
if (noColorFlag || !process.stderr.isTTY || process.env.NO_COLOR !== undefined) {
|
|
@@ -16,6 +17,10 @@ function initColors(noColorFlag) {
|
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
function setAsciiMode(flag) {
|
|
21
|
+
asciiMode = Boolean(flag);
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
function wrap(code, text) {
|
|
20
25
|
if (!enabled) return text;
|
|
21
26
|
return `${code}${text}${CODES.reset}`;
|
|
@@ -30,6 +35,14 @@ const c = {
|
|
|
30
35
|
bold: (t) => wrap(CODES.bold, t)
|
|
31
36
|
};
|
|
32
37
|
|
|
38
|
+
const icons = {
|
|
39
|
+
get pass() { return asciiMode ? '[PASS]' : '\u2713'; },
|
|
40
|
+
get fail() { return asciiMode ? '[FAIL]' : '\u2717'; },
|
|
41
|
+
get warn() { return asciiMode ? '[WARN]' : '\u26A0'; },
|
|
42
|
+
get info() { return asciiMode ? '[INFO]' : '\u2139'; },
|
|
43
|
+
get dot() { return asciiMode ? '-' : '\u00B7'; }
|
|
44
|
+
};
|
|
45
|
+
|
|
33
46
|
function log(icon, message) {
|
|
34
47
|
console.error(` ${icon} ${message}`);
|
|
35
48
|
}
|
|
@@ -38,12 +51,13 @@ function printBanner(fileCount, version) {
|
|
|
38
51
|
console.error('');
|
|
39
52
|
console.error(` ${c.bold('Doclify Guardrail')} ${c.dim(`v${version || '?'}`)}`);
|
|
40
53
|
console.error('');
|
|
41
|
-
log(c.cyan(
|
|
54
|
+
log(c.cyan(icons.info), `Scanning ${c.bold(String(fileCount))} file${fileCount === 1 ? '' : 's'}...`);
|
|
42
55
|
}
|
|
43
56
|
|
|
44
57
|
function printResults(output) {
|
|
58
|
+
const strict = output.strict;
|
|
45
59
|
for (const fileResult of output.files) {
|
|
46
|
-
const icon = fileResult.pass ? c.green(
|
|
60
|
+
const icon = fileResult.pass ? c.green(icons.pass) : c.red(icons.fail);
|
|
47
61
|
const fileName = c.bold(fileResult.file);
|
|
48
62
|
const score = fileResult.summary.healthScore != null ? c.dim(` [${fileResult.summary.healthScore}/100]`) : '';
|
|
49
63
|
console.error(` ${icon} ${fileName}${score}`);
|
|
@@ -55,16 +69,21 @@ function printResults(output) {
|
|
|
55
69
|
|
|
56
70
|
for (const f of allFindings) {
|
|
57
71
|
const lineStr = f.line != null ? c.dim(`:${f.line}`) : '';
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
let sevLabel;
|
|
73
|
+
if (f.severity === 'error') {
|
|
74
|
+
sevLabel = c.red(`${icons.fail} error`);
|
|
75
|
+
} else if (strict) {
|
|
76
|
+
sevLabel = c.red(`${icons.fail} error [strict]`);
|
|
77
|
+
} else {
|
|
78
|
+
sevLabel = c.yellow(`${icons.warn} warning`);
|
|
79
|
+
}
|
|
61
80
|
console.error(` ${sevLabel} ${c.cyan(f.code)}${lineStr} ${f.message}`);
|
|
62
81
|
}
|
|
63
82
|
}
|
|
64
83
|
|
|
65
84
|
if (output.fileErrors) {
|
|
66
85
|
for (const fe of output.fileErrors) {
|
|
67
|
-
console.error(` ${c.red(
|
|
86
|
+
console.error(` ${c.red(icons.fail)} ${c.bold(fe.file)} ${c.red(fe.error)}`);
|
|
68
87
|
}
|
|
69
88
|
}
|
|
70
89
|
|
|
@@ -72,11 +91,11 @@ function printResults(output) {
|
|
|
72
91
|
|
|
73
92
|
const s = output.summary;
|
|
74
93
|
const parts = [];
|
|
75
|
-
if (s.filesPassed > 0) parts.push(c.green(
|
|
76
|
-
if (s.filesFailed > 0) parts.push(c.red(
|
|
94
|
+
if (s.filesPassed > 0) parts.push(c.green(`${icons.pass} ${s.filesPassed} passed`));
|
|
95
|
+
if (s.filesFailed > 0) parts.push(c.red(`${icons.fail} ${s.filesFailed} failed`));
|
|
77
96
|
if (s.filesErrored > 0) parts.push(c.red(`${s.filesErrored} errored`));
|
|
78
97
|
console.error(
|
|
79
|
-
` ${parts.join(c.dim(
|
|
98
|
+
` ${parts.join(c.dim(` ${icons.dot} `))} ${c.dim(icons.dot)} ${s.filesScanned} files scanned in ${c.dim(`${s.elapsed}s`)}`
|
|
80
99
|
);
|
|
81
100
|
|
|
82
101
|
const scoreValue = s.healthScore != null ? s.healthScore : s.avgHealthScore;
|
|
@@ -93,4 +112,4 @@ function printResults(output) {
|
|
|
93
112
|
console.error('');
|
|
94
113
|
}
|
|
95
114
|
|
|
96
|
-
export { initColors, c, log, printBanner, printResults };
|
|
115
|
+
export { initColors, setAsciiMode, icons, c, log, printBanner, printResults };
|
package/src/fixer.mjs
CHANGED
|
@@ -14,20 +14,52 @@ function autoFixInsecureLinks(content) {
|
|
|
14
14
|
const changes = [];
|
|
15
15
|
const ambiguous = [];
|
|
16
16
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return raw;
|
|
22
|
-
}
|
|
17
|
+
const lines = content.split('\n');
|
|
18
|
+
let inCodeBlock = false;
|
|
19
|
+
let fenceChar = null;
|
|
20
|
+
let fenceLen = 0;
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
const processedLines = lines.map((line) => {
|
|
23
|
+
// Track fenced code blocks (same logic as stripCodeBlocks in checker.mjs)
|
|
24
|
+
if (!inCodeBlock) {
|
|
25
|
+
const fenceMatch = line.match(/^(`{3,}|~{3,})/);
|
|
26
|
+
if (fenceMatch) {
|
|
27
|
+
inCodeBlock = true;
|
|
28
|
+
fenceChar = fenceMatch[1][0];
|
|
29
|
+
fenceLen = fenceMatch[1].length;
|
|
30
|
+
return line;
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
const closeMatch = line.match(/^(`{3,}|~{3,})\s*$/);
|
|
34
|
+
if (closeMatch && closeMatch[1][0] === fenceChar && closeMatch[1].length >= fenceLen) {
|
|
35
|
+
inCodeBlock = false;
|
|
36
|
+
fenceChar = null;
|
|
37
|
+
fenceLen = 0;
|
|
38
|
+
}
|
|
39
|
+
return line;
|
|
27
40
|
}
|
|
28
|
-
|
|
41
|
+
|
|
42
|
+
// Outside code blocks: replace http:// but skip inline code spans
|
|
43
|
+
return line.replace(/(`[^`]*`)|http:\/\/\S+/g, (match, inlineCode) => {
|
|
44
|
+
if (inlineCode) return inlineCode;
|
|
45
|
+
|
|
46
|
+
const raw = match;
|
|
47
|
+
const cleaned = raw.replace(/[),.;!?]+$/g, '');
|
|
48
|
+
if (isAmbiguousHttpUrl(cleaned)) {
|
|
49
|
+
ambiguous.push(cleaned);
|
|
50
|
+
return raw;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const replaced = raw.replace('http://', 'https://');
|
|
54
|
+
if (replaced !== raw) {
|
|
55
|
+
changes.push({ from: cleaned, to: cleaned.replace('http://', 'https://') });
|
|
56
|
+
}
|
|
57
|
+
return replaced;
|
|
58
|
+
});
|
|
29
59
|
});
|
|
30
60
|
|
|
61
|
+
const fixed = processedLines.join('\n');
|
|
62
|
+
|
|
31
63
|
return {
|
|
32
64
|
content: fixed,
|
|
33
65
|
modified: fixed !== content,
|
|
@@ -36,4 +68,176 @@ function autoFixInsecureLinks(content) {
|
|
|
36
68
|
};
|
|
37
69
|
}
|
|
38
70
|
|
|
39
|
-
|
|
71
|
+
function autoFixFormatting(content) {
|
|
72
|
+
const changes = [];
|
|
73
|
+
let lines = content.split('\n');
|
|
74
|
+
|
|
75
|
+
// Track code blocks to skip them
|
|
76
|
+
let inCodeBlock = false;
|
|
77
|
+
let fenceChar = null;
|
|
78
|
+
let fenceLen = 0;
|
|
79
|
+
const inCode = lines.map((line) => {
|
|
80
|
+
if (!inCodeBlock) {
|
|
81
|
+
const fm = line.match(/^(`{3,}|~{3,})/);
|
|
82
|
+
if (fm) { inCodeBlock = true; fenceChar = fm[1][0]; fenceLen = fm[1].length; return true; }
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
const cm = line.match(/^(`{3,}|~{3,})\s*$/);
|
|
86
|
+
if (cm && cm[1][0] === fenceChar && cm[1].length >= fenceLen) {
|
|
87
|
+
inCodeBlock = false; fenceChar = null; fenceLen = 0;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Pass 1: line-level fixes
|
|
93
|
+
lines = lines.map((line, i) => {
|
|
94
|
+
if (inCode[i]) return line;
|
|
95
|
+
let fixed = line;
|
|
96
|
+
|
|
97
|
+
// Fix: trailing spaces
|
|
98
|
+
const trimmed = fixed.replace(/[ \t]+$/, '');
|
|
99
|
+
if (trimmed !== fixed) { changes.push({ rule: 'no-trailing-spaces', line: i + 1 }); fixed = trimmed; }
|
|
100
|
+
|
|
101
|
+
// Fix: missing space after # in heading
|
|
102
|
+
const atxMatch = fixed.match(/^(#{1,6})([^\s#])/);
|
|
103
|
+
if (atxMatch) { changes.push({ rule: 'no-missing-space-atx', line: i + 1 }); fixed = atxMatch[1] + ' ' + fixed.slice(atxMatch[1].length); }
|
|
104
|
+
|
|
105
|
+
// Fix: indented heading
|
|
106
|
+
const indentMatch = fixed.match(/^(\s+)(#{1,6}\s)/);
|
|
107
|
+
if (indentMatch) { changes.push({ rule: 'heading-start-left', line: i + 1 }); fixed = fixed.trimStart(); }
|
|
108
|
+
|
|
109
|
+
// Fix: trailing punctuation in heading
|
|
110
|
+
const hMatch = fixed.match(/^(#{1,6}\s+.+?)([.,:;!])$/);
|
|
111
|
+
if (hMatch) { changes.push({ rule: 'no-trailing-punctuation-heading', line: i + 1 }); fixed = hMatch[1]; }
|
|
112
|
+
|
|
113
|
+
// Fix: reversed links (text)[url] → [text](url)
|
|
114
|
+
fixed = fixed.replace(/\(([^)]+)\)\[([^\]]+)\]/g, (match, text, url) => {
|
|
115
|
+
changes.push({ rule: 'no-reversed-links', line: i + 1 });
|
|
116
|
+
return `[${text}](${url})`;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Fix: spaces in emphasis ** text ** → **text**
|
|
120
|
+
fixed = fixed.replace(/\*\*\s+([^*]+?)\s+\*\*/g, (match, inner) => {
|
|
121
|
+
changes.push({ rule: 'no-space-in-emphasis', line: i + 1 });
|
|
122
|
+
return `**${inner}**`;
|
|
123
|
+
});
|
|
124
|
+
fixed = fixed.replace(/(?<!\*)\*\s+([^*]+?)\s+\*(?!\*)/g, (match, inner) => {
|
|
125
|
+
changes.push({ rule: 'no-space-in-emphasis', line: i + 1 });
|
|
126
|
+
return `*${inner}*`;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Fix: spaces in links [ text ](url) → [text](url)
|
|
130
|
+
fixed = fixed.replace(/\[\s+([^\]]*?)\s*\]\(/g, (match, text) => {
|
|
131
|
+
changes.push({ rule: 'no-space-in-links', line: i + 1 });
|
|
132
|
+
return `[${text}](`;
|
|
133
|
+
});
|
|
134
|
+
fixed = fixed.replace(/\]\(\s+([^)]*?)\s*\)/g, (match, url) => {
|
|
135
|
+
changes.push({ rule: 'no-space-in-links', line: i + 1 });
|
|
136
|
+
return `](${url})`;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Fix: bare URLs → wrap in <>
|
|
140
|
+
fixed = fixed.replace(/(`[^`]*`)|(?<![(<\[])\bhttps?:\/\/[^\s>)]+/g, (match, code) => {
|
|
141
|
+
if (code) return code;
|
|
142
|
+
// Don't wrap if already inside markdown link or reference
|
|
143
|
+
changes.push({ rule: 'no-bare-urls', line: i + 1 });
|
|
144
|
+
return `<${match}>`;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return fixed;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Pass 2: multi-line fixes (blanks around headings/lists/fences, multiple blanks)
|
|
151
|
+
const result = [];
|
|
152
|
+
const isListItem = (l) => /^\s*[-*+]\s|^\s*\d+[.)]\s/.test(l);
|
|
153
|
+
const isHeading = (l) => /^#{1,6}\s/.test(l);
|
|
154
|
+
const isFenceOpen = (l) => /^(`{3,}|~{3,})/.test(l);
|
|
155
|
+
|
|
156
|
+
// Rebuild inCode flags after line-level fixes
|
|
157
|
+
inCodeBlock = false;
|
|
158
|
+
fenceChar = null;
|
|
159
|
+
fenceLen = 0;
|
|
160
|
+
const inCode2 = lines.map((line) => {
|
|
161
|
+
if (!inCodeBlock) {
|
|
162
|
+
const fm = line.match(/^(`{3,}|~{3,})/);
|
|
163
|
+
if (fm) { inCodeBlock = true; fenceChar = fm[1][0]; fenceLen = fm[1].length; return true; }
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
const cm = line.match(/^(`{3,}|~{3,})\s*$/);
|
|
167
|
+
if (cm && cm[1][0] === fenceChar && cm[1].length >= fenceLen) {
|
|
168
|
+
inCodeBlock = false; fenceChar = null; fenceLen = 0;
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
let prevBlank = true; // treat start of file as blank
|
|
174
|
+
let consecutiveBlanks = 0;
|
|
175
|
+
|
|
176
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
177
|
+
const line = lines[i];
|
|
178
|
+
const isBlank = line.trim() === '';
|
|
179
|
+
const codeFlag = inCode2[i];
|
|
180
|
+
|
|
181
|
+
// Fix: multiple consecutive blanks (outside code blocks)
|
|
182
|
+
if (isBlank && !codeFlag) {
|
|
183
|
+
consecutiveBlanks += 1;
|
|
184
|
+
if (consecutiveBlanks >= 2) {
|
|
185
|
+
changes.push({ rule: 'no-multiple-blanks', line: i + 1 });
|
|
186
|
+
continue; // skip extra blank
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
consecutiveBlanks = 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Fix: blank before heading/list/fence (only if not already preceded by blank)
|
|
193
|
+
if (!codeFlag && !prevBlank && !isBlank) {
|
|
194
|
+
const needsBlankBefore = isHeading(line) || (isListItem(line) && i > 0 && !isListItem(lines[i - 1])) || isFenceOpen(line);
|
|
195
|
+
if (needsBlankBefore) {
|
|
196
|
+
const ruleId = isHeading(line) ? 'blanks-around-headings' : isListItem(line) ? 'blanks-around-lists' : 'blanks-around-fences';
|
|
197
|
+
changes.push({ rule: ruleId, line: i + 1 });
|
|
198
|
+
result.push('');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
result.push(line);
|
|
203
|
+
|
|
204
|
+
// Fix: blank after heading/fence-close/last-list-item
|
|
205
|
+
if (!codeFlag && !isBlank) {
|
|
206
|
+
const nextLine = lines[i + 1];
|
|
207
|
+
const nextIsBlank = nextLine === undefined || nextLine.trim() === '';
|
|
208
|
+
if (!nextIsBlank && nextLine !== undefined) {
|
|
209
|
+
const nextCode = inCode2[i + 1];
|
|
210
|
+
if (!nextCode) {
|
|
211
|
+
const needsBlankAfter = isHeading(line) ||
|
|
212
|
+
(isListItem(line) && !isListItem(nextLine)) ||
|
|
213
|
+
(isFenceOpen(line) === false && i > 0 && /^(`{3,}|~{3,})\s*$/.test(line));
|
|
214
|
+
if (needsBlankAfter) {
|
|
215
|
+
const ruleId = isHeading(line) ? 'blanks-around-headings' : isListItem(line) ? 'blanks-around-lists' : 'blanks-around-fences';
|
|
216
|
+
changes.push({ rule: ruleId, line: i + 1 });
|
|
217
|
+
result.push('');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
prevBlank = isBlank;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Fix: single trailing newline
|
|
227
|
+
let fixed = result.join('\n');
|
|
228
|
+
if (fixed.length > 0) {
|
|
229
|
+
const trimmedEnd = fixed.replace(/\n+$/, '');
|
|
230
|
+
if (trimmedEnd + '\n' !== fixed) {
|
|
231
|
+
changes.push({ rule: 'single-trailing-newline', line: result.length });
|
|
232
|
+
fixed = trimmedEnd + '\n';
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
content: fixed,
|
|
238
|
+
modified: fixed !== content,
|
|
239
|
+
changes
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export { autoFixInsecureLinks, autoFixFormatting, isAmbiguousHttpUrl };
|
package/src/index.mjs
CHANGED
|
@@ -6,9 +6,9 @@ import { checkMarkdown, RULE_CATALOG } from './checker.mjs';
|
|
|
6
6
|
import { resolveFileList } from './glob.mjs';
|
|
7
7
|
import { generateReport } from './report.mjs';
|
|
8
8
|
import { loadCustomRules } from './rules-loader.mjs';
|
|
9
|
-
import { initColors, c, log, printBanner, printResults } from './colors.mjs';
|
|
9
|
+
import { initColors, setAsciiMode, icons, c, log, printBanner, printResults } from './colors.mjs';
|
|
10
10
|
import { checkDeadLinks } from './links.mjs';
|
|
11
|
-
import { autoFixInsecureLinks } from './fixer.mjs';
|
|
11
|
+
import { autoFixInsecureLinks, autoFixFormatting } from './fixer.mjs';
|
|
12
12
|
import { computeDocHealthScore, checkDocFreshness } from './quality.mjs';
|
|
13
13
|
import {
|
|
14
14
|
generateJUnitReport,
|
|
@@ -17,6 +17,19 @@ import {
|
|
|
17
17
|
} from './ci-output.mjs';
|
|
18
18
|
|
|
19
19
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
|
|
21
|
+
function writeJsonToStdout(data) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const json = JSON.stringify(data, null, 2) + '\n';
|
|
24
|
+
const flushed = process.stdout.write(json, 'utf8');
|
|
25
|
+
if (flushed) {
|
|
26
|
+
resolve();
|
|
27
|
+
} else {
|
|
28
|
+
process.stdout.once('drain', resolve);
|
|
29
|
+
process.stdout.once('error', reject);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
20
33
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
21
34
|
const VERSION = pkg.version;
|
|
22
35
|
|
|
@@ -47,6 +60,8 @@ function printHelp() {
|
|
|
47
60
|
${y('CHECKS')}
|
|
48
61
|
--check-links Validate HTTP and local links
|
|
49
62
|
--check-freshness Warn on stale docs ${d('(>180 days)')}
|
|
63
|
+
--check-frontmatter Require YAML frontmatter block
|
|
64
|
+
--link-allow-list <list> Skip URLs/domains for link checks ${d('(comma-separated)')}
|
|
50
65
|
|
|
51
66
|
${y('FIX')}
|
|
52
67
|
--fix Auto-fix safe issues ${d('(http → https)')}
|
|
@@ -62,10 +77,12 @@ function printHelp() {
|
|
|
62
77
|
|
|
63
78
|
${y('SETUP')}
|
|
64
79
|
init Generate a .doclify-guardrail.json config
|
|
80
|
+
init --force Overwrite existing config
|
|
65
81
|
|
|
66
82
|
${y('OTHER')}
|
|
67
83
|
--list-rules List all built-in rules
|
|
68
84
|
--no-color Disable colored output
|
|
85
|
+
--ascii Use ASCII icons ${d('(for CI without UTF-8)')}
|
|
69
86
|
--debug Show debug info
|
|
70
87
|
-h, --help Show this help
|
|
71
88
|
|
|
@@ -106,6 +123,7 @@ function parseArgs(argv) {
|
|
|
106
123
|
maxLineLength: undefined,
|
|
107
124
|
configPath: path.resolve('.doclify-guardrail.json'),
|
|
108
125
|
help: false,
|
|
126
|
+
version: false,
|
|
109
127
|
listRules: false,
|
|
110
128
|
init: false,
|
|
111
129
|
dir: null,
|
|
@@ -120,9 +138,14 @@ function parseArgs(argv) {
|
|
|
120
138
|
exclude: [],
|
|
121
139
|
checkLinks: false,
|
|
122
140
|
checkFreshness: false,
|
|
141
|
+
checkFrontmatter: false,
|
|
142
|
+
checkInlineHtml: false,
|
|
143
|
+
linkAllowList: [],
|
|
123
144
|
fix: false,
|
|
124
145
|
dryRun: false,
|
|
125
|
-
json: false
|
|
146
|
+
json: false,
|
|
147
|
+
force: false,
|
|
148
|
+
ascii: false
|
|
126
149
|
};
|
|
127
150
|
|
|
128
151
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -133,6 +156,11 @@ function parseArgs(argv) {
|
|
|
133
156
|
continue;
|
|
134
157
|
}
|
|
135
158
|
|
|
159
|
+
if (a === '-v' || a === '--version') {
|
|
160
|
+
args.version = true;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
136
164
|
if (a === '--list-rules') {
|
|
137
165
|
args.listRules = true;
|
|
138
166
|
continue;
|
|
@@ -183,6 +211,26 @@ function parseArgs(argv) {
|
|
|
183
211
|
continue;
|
|
184
212
|
}
|
|
185
213
|
|
|
214
|
+
if (a === '--check-frontmatter') {
|
|
215
|
+
args.checkFrontmatter = true;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (a === '--check-inline-html') {
|
|
220
|
+
args.checkInlineHtml = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (a === '--link-allow-list') {
|
|
225
|
+
const value = argv[i + 1];
|
|
226
|
+
if (!value || value.startsWith('-')) {
|
|
227
|
+
throw new Error('Missing value for --link-allow-list');
|
|
228
|
+
}
|
|
229
|
+
args.linkAllowList.push(...value.split(',').map(s => s.trim()).filter(Boolean));
|
|
230
|
+
i += 1;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
186
234
|
if (a === '--fix') {
|
|
187
235
|
args.fix = true;
|
|
188
236
|
continue;
|
|
@@ -198,6 +246,16 @@ function parseArgs(argv) {
|
|
|
198
246
|
continue;
|
|
199
247
|
}
|
|
200
248
|
|
|
249
|
+
if (a === '--force') {
|
|
250
|
+
args.force = true;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (a === '--ascii') {
|
|
255
|
+
args.ascii = true;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
201
259
|
if (a === '--config') {
|
|
202
260
|
const value = argv[i + 1];
|
|
203
261
|
if (!value || value.startsWith('-')) {
|
|
@@ -326,10 +384,19 @@ function resolveOptions(args) {
|
|
|
326
384
|
throw new Error(`Invalid maxLineLength in config: ${cfg.maxLineLength}`);
|
|
327
385
|
}
|
|
328
386
|
|
|
387
|
+
const checkFrontmatter = Boolean(args.checkFrontmatter || cfg.checkFrontmatter || args.checkFreshness || cfg.checkFreshness);
|
|
388
|
+
const cfgAllowList = Array.isArray(cfg.linkAllowList) ? cfg.linkAllowList : [];
|
|
389
|
+
const linkAllowList = [...args.linkAllowList, ...cfgAllowList];
|
|
390
|
+
const cfgExclude = Array.isArray(cfg.exclude) ? cfg.exclude : [];
|
|
391
|
+
const exclude = [...args.exclude, ...cfgExclude];
|
|
392
|
+
|
|
329
393
|
return {
|
|
330
394
|
maxLineLength,
|
|
331
395
|
strict,
|
|
332
396
|
ignoreRules,
|
|
397
|
+
checkFrontmatter,
|
|
398
|
+
linkAllowList,
|
|
399
|
+
exclude,
|
|
333
400
|
configPath: args.configPath,
|
|
334
401
|
configLoaded: fs.existsSync(args.configPath)
|
|
335
402
|
};
|
|
@@ -385,6 +452,7 @@ function buildOutput(fileResults, fileErrors, opts, elapsed, fixSummary) {
|
|
|
385
452
|
};
|
|
386
453
|
|
|
387
454
|
summary.healthScore = avgHealthScore;
|
|
455
|
+
summary.avgHealthScore = avgHealthScore; // Backward-compatible alias for existing integrations/tests
|
|
388
456
|
|
|
389
457
|
return {
|
|
390
458
|
version: VERSION,
|
|
@@ -411,8 +479,14 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
411
479
|
return 0;
|
|
412
480
|
}
|
|
413
481
|
|
|
482
|
+
if (args.version) {
|
|
483
|
+
console.log(VERSION);
|
|
484
|
+
return 0;
|
|
485
|
+
}
|
|
486
|
+
|
|
414
487
|
if (args.listRules) {
|
|
415
488
|
initColors(args.noColor);
|
|
489
|
+
setAsciiMode(args.ascii);
|
|
416
490
|
console.log('');
|
|
417
491
|
console.log(` ${c.bold('Built-in rules')}`);
|
|
418
492
|
console.log('');
|
|
@@ -426,11 +500,13 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
426
500
|
|
|
427
501
|
if (args.init) {
|
|
428
502
|
initColors(args.noColor);
|
|
503
|
+
setAsciiMode(args.ascii);
|
|
429
504
|
const configFile = '.doclify-guardrail.json';
|
|
430
505
|
const configPath = path.resolve(configFile);
|
|
431
506
|
|
|
432
|
-
|
|
433
|
-
|
|
507
|
+
const configExists = fs.existsSync(configPath);
|
|
508
|
+
if (configExists && !args.force) {
|
|
509
|
+
console.error(` ${c.yellow(icons.warn)} ${c.bold(configFile)} already exists. Use ${c.bold('--force')} to overwrite.`);
|
|
434
510
|
return 1;
|
|
435
511
|
}
|
|
436
512
|
|
|
@@ -438,13 +514,16 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
438
514
|
strict: false,
|
|
439
515
|
maxLineLength: 160,
|
|
440
516
|
ignoreRules: [],
|
|
517
|
+
exclude: [],
|
|
441
518
|
checkLinks: false,
|
|
442
|
-
checkFreshness: false
|
|
519
|
+
checkFreshness: false,
|
|
520
|
+
checkFrontmatter: false,
|
|
521
|
+
linkAllowList: []
|
|
443
522
|
};
|
|
444
523
|
|
|
445
524
|
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8');
|
|
446
525
|
console.error('');
|
|
447
|
-
console.error(` ${c.green(
|
|
526
|
+
console.error(` ${c.green(icons.pass)} ${configExists ? 'Overwrote' : 'Created'} ${c.bold(configFile)}`);
|
|
448
527
|
console.error('');
|
|
449
528
|
console.error(` ${c.dim('Edit the file to customise rules, then run:')}`)
|
|
450
529
|
console.error(` ${c.dim('$')} ${c.cyan('doclify .')}`);
|
|
@@ -453,6 +532,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
453
532
|
}
|
|
454
533
|
|
|
455
534
|
initColors(args.noColor);
|
|
535
|
+
setAsciiMode(args.ascii);
|
|
456
536
|
|
|
457
537
|
let filePaths;
|
|
458
538
|
try {
|
|
@@ -462,15 +542,24 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
462
542
|
return 2;
|
|
463
543
|
}
|
|
464
544
|
|
|
465
|
-
|
|
545
|
+
let resolved;
|
|
546
|
+
try {
|
|
547
|
+
resolved = resolveOptions(args);
|
|
548
|
+
} catch (err) {
|
|
549
|
+
console.error(err.message);
|
|
550
|
+
return 2;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (resolved.exclude.length > 0) {
|
|
466
554
|
filePaths = filePaths.filter(fp => {
|
|
467
555
|
const rel = path.relative(process.cwd(), fp);
|
|
468
|
-
return !
|
|
556
|
+
return !resolved.exclude.some(pattern => {
|
|
469
557
|
if (pattern.includes('*')) {
|
|
470
558
|
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*') + '$');
|
|
471
559
|
return regex.test(rel);
|
|
472
560
|
}
|
|
473
|
-
|
|
561
|
+
const segments = rel.split(path.sep);
|
|
562
|
+
return segments.includes(pattern);
|
|
474
563
|
});
|
|
475
564
|
});
|
|
476
565
|
}
|
|
@@ -480,14 +569,6 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
480
569
|
return 2;
|
|
481
570
|
}
|
|
482
571
|
|
|
483
|
-
let resolved;
|
|
484
|
-
try {
|
|
485
|
-
resolved = resolveOptions(args);
|
|
486
|
-
} catch (err) {
|
|
487
|
-
console.error(err.message);
|
|
488
|
-
return 2;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
572
|
let customRules = [];
|
|
492
573
|
if (args.rules) {
|
|
493
574
|
try {
|
|
@@ -501,7 +582,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
501
582
|
printBanner(filePaths.length, VERSION);
|
|
502
583
|
|
|
503
584
|
if (resolved.configLoaded) {
|
|
504
|
-
log(c.cyan(
|
|
585
|
+
log(c.cyan(icons.info), `Loaded config from ${c.dim(resolved.configPath)}`);
|
|
505
586
|
}
|
|
506
587
|
|
|
507
588
|
const startTime = process.hrtime.bigint();
|
|
@@ -517,7 +598,17 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
517
598
|
|
|
518
599
|
let customRulesCount = customRules.length;
|
|
519
600
|
if (customRulesCount > 0) {
|
|
520
|
-
log(c.cyan(
|
|
601
|
+
log(c.cyan(icons.info), `Loaded ${c.bold(String(customRulesCount))} custom rules from ${c.dim(args.rules)}`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (resolved.ignoreRules.size > 0) {
|
|
605
|
+
const knownIds = new Set(RULE_CATALOG.map(r => r.id));
|
|
606
|
+
for (const id of customRules) knownIds.add(id.id);
|
|
607
|
+
for (const id of resolved.ignoreRules) {
|
|
608
|
+
if (!knownIds.has(id)) {
|
|
609
|
+
log(c.yellow(icons.warn), `Unknown rule "${c.bold(id)}" in --ignore-rules (ignored)`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
521
612
|
}
|
|
522
613
|
|
|
523
614
|
console.error('');
|
|
@@ -525,12 +616,13 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
525
616
|
for (const filePath of filePaths) {
|
|
526
617
|
const rel = path.relative(process.cwd(), filePath);
|
|
527
618
|
const shortPath = rel.startsWith('..') ? filePath : rel || filePath;
|
|
528
|
-
log(c.cyan(
|
|
619
|
+
log(c.cyan(icons.info), `Checking ${c.bold(shortPath)}...`);
|
|
529
620
|
|
|
530
621
|
try {
|
|
531
622
|
let content = fs.readFileSync(filePath, 'utf8');
|
|
532
623
|
|
|
533
624
|
if (args.fix) {
|
|
625
|
+
// 1. Insecure links fix (http:// → https://)
|
|
534
626
|
log(c.dim(' ↳'), c.dim(`Auto-fixing insecure links...`));
|
|
535
627
|
const fixed = autoFixInsecureLinks(content);
|
|
536
628
|
if (fixed.modified) {
|
|
@@ -542,9 +634,8 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
542
634
|
}
|
|
543
635
|
}
|
|
544
636
|
if (!args.dryRun) {
|
|
545
|
-
|
|
637
|
+
content = fixed.content;
|
|
546
638
|
}
|
|
547
|
-
content = fixed.content;
|
|
548
639
|
}
|
|
549
640
|
if (fixed.ambiguous.length > 0) {
|
|
550
641
|
fixSummary.ambiguousSkipped.push({
|
|
@@ -555,18 +646,35 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
555
646
|
log(c.dim(' '), c.dim(`⊘ skipped ${url} (localhost/custom port)`));
|
|
556
647
|
}
|
|
557
648
|
}
|
|
649
|
+
|
|
650
|
+
// 2. Formatting fixes (trailing spaces, blanks, headings, bare URLs, etc.)
|
|
651
|
+
const formatted = autoFixFormatting(content);
|
|
652
|
+
if (formatted.modified) {
|
|
653
|
+
if (!args.dryRun) {
|
|
654
|
+
content = formatted.content;
|
|
655
|
+
}
|
|
656
|
+
const ruleSet = new Set(formatted.changes.map(ch => ch.rule));
|
|
657
|
+
log(c.dim(' ↳'), c.dim(`Formatting: ${formatted.changes.length} fix${formatted.changes.length === 1 ? '' : 'es'} (${[...ruleSet].join(', ')})${args.dryRun ? ' [dry-run]' : ''}`));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Write all changes to disk
|
|
661
|
+
if (!args.dryRun && (fixed.modified || formatted.modified)) {
|
|
662
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
663
|
+
}
|
|
558
664
|
}
|
|
559
665
|
|
|
560
666
|
const relPath = toRelativePath(filePath);
|
|
561
667
|
const analysis = checkMarkdown(content, {
|
|
562
668
|
maxLineLength: resolved.maxLineLength,
|
|
563
669
|
filePath: relPath,
|
|
564
|
-
customRules
|
|
670
|
+
customRules,
|
|
671
|
+
checkFrontmatter: resolved.checkFrontmatter,
|
|
672
|
+
checkInlineHtml: Boolean(args.checkInlineHtml)
|
|
565
673
|
});
|
|
566
674
|
|
|
567
675
|
if (args.checkLinks) {
|
|
568
676
|
log(c.dim(' ↳'), c.dim(`Checking links...`));
|
|
569
|
-
const deadLinks = await checkDeadLinks(content, { sourceFile: filePath });
|
|
677
|
+
const deadLinks = await checkDeadLinks(content, { sourceFile: filePath, linkAllowList: resolved.linkAllowList });
|
|
570
678
|
for (const dl of deadLinks) { dl.source = relPath; }
|
|
571
679
|
analysis.errors.push(...deadLinks);
|
|
572
680
|
analysis.summary.errors = analysis.errors.length;
|
|
@@ -597,21 +705,21 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
597
705
|
if (args.fix && fixSummary.replacements > 0) {
|
|
598
706
|
const action = args.dryRun ? 'Would fix' : 'Fixed';
|
|
599
707
|
log(
|
|
600
|
-
args.dryRun ? c.yellow('~') : c.green(
|
|
708
|
+
args.dryRun ? c.yellow('~') : c.green(icons.pass),
|
|
601
709
|
`${action} ${c.bold(String(fixSummary.replacements))} insecure link${fixSummary.replacements === 1 ? '' : 's'} in ${c.bold(String(fixSummary.filesChanged))} file${fixSummary.filesChanged === 1 ? '' : 's'}${args.dryRun ? c.dim(' (dry-run, no files changed)') : ''}`
|
|
602
710
|
);
|
|
603
711
|
} else if (args.fix && fixSummary.replacements === 0) {
|
|
604
|
-
log(c.dim(
|
|
712
|
+
log(c.dim(icons.info), c.dim('No insecure links to fix'));
|
|
605
713
|
}
|
|
606
714
|
|
|
607
715
|
if (args.json) {
|
|
608
|
-
|
|
716
|
+
await writeJsonToStdout(output);
|
|
609
717
|
}
|
|
610
718
|
|
|
611
719
|
if (args.report) {
|
|
612
720
|
try {
|
|
613
721
|
const reportPath = generateReport(output, { reportPath: args.report });
|
|
614
|
-
log(c.green(
|
|
722
|
+
log(c.green(icons.pass), `Report written ${c.dim('→')} ${reportPath}`);
|
|
615
723
|
} catch (err) {
|
|
616
724
|
console.error(`Failed to write report: ${err.message}`);
|
|
617
725
|
return 2;
|
|
@@ -621,7 +729,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
621
729
|
if (args.junit) {
|
|
622
730
|
try {
|
|
623
731
|
const junitPath = generateJUnitReport(output, { junitPath: args.junit });
|
|
624
|
-
log(c.green(
|
|
732
|
+
log(c.green(icons.pass), `JUnit report written ${c.dim('→')} ${junitPath}`);
|
|
625
733
|
} catch (err) {
|
|
626
734
|
console.error(`Failed to write JUnit report: ${err.message}`);
|
|
627
735
|
return 2;
|
|
@@ -631,7 +739,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
631
739
|
if (args.sarif) {
|
|
632
740
|
try {
|
|
633
741
|
const sarifPath = generateSarifReport(output, { sarifPath: args.sarif });
|
|
634
|
-
log(c.green(
|
|
742
|
+
log(c.green(icons.pass), `SARIF report written ${c.dim('→')} ${sarifPath}`);
|
|
635
743
|
} catch (err) {
|
|
636
744
|
console.error(`Failed to write SARIF report: ${err.message}`);
|
|
637
745
|
return 2;
|
|
@@ -641,7 +749,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
641
749
|
if (args.badge) {
|
|
642
750
|
try {
|
|
643
751
|
const badge = generateBadge(output, { badgePath: args.badge, label: args.badgeLabel });
|
|
644
|
-
log(c.green(
|
|
752
|
+
log(c.green(icons.pass), `Badge written ${c.dim('→')} ${badge.badgePath} ${c.dim(`(score ${badge.score})`)}`);
|
|
645
753
|
} catch (err) {
|
|
646
754
|
console.error(`Failed to write badge: ${err.message}`);
|
|
647
755
|
return 2;
|
package/src/links.mjs
CHANGED
|
@@ -14,7 +14,7 @@ function extractLinks(content) {
|
|
|
14
14
|
const line = stripInlineCode(lines[idx]);
|
|
15
15
|
const lineNumber = idx + 1;
|
|
16
16
|
|
|
17
|
-
const inlineRx = /\[[^\]]*\]\(([^)]
|
|
17
|
+
const inlineRx = /\[[^\]]*\]\(([^()\s]*(?:\([^)]*\)[^()\s]*)*)\)/g;
|
|
18
18
|
let inline;
|
|
19
19
|
while ((inline = inlineRx.exec(line)) !== null) {
|
|
20
20
|
links.push({ url: inline[1].trim(), line: lineNumber, kind: 'inline' });
|
|
@@ -33,10 +33,10 @@ function extractLinks(content) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
// Remove trailing punctuation from bare URL captures
|
|
36
|
+
// Remove trailing punctuation from bare/reference URL captures (not inline — already delimited by markdown syntax)
|
|
37
37
|
return links.map((l) => ({
|
|
38
38
|
...l,
|
|
39
|
-
url: l.url.replace(/[),.;!?]+$/g, '')
|
|
39
|
+
url: l.kind === 'inline' ? l.url : l.url.replace(/[),.;!?]+$/g, '')
|
|
40
40
|
}));
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -44,6 +44,28 @@ function isSkippableUrl(url) {
|
|
|
44
44
|
return url.startsWith('mailto:') || url.startsWith('tel:') || url.startsWith('#');
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function isAllowListed(url, allowList) {
|
|
48
|
+
if (!allowList || allowList.length === 0) return false;
|
|
49
|
+
let parsed;
|
|
50
|
+
try { parsed = new URL(url); } catch { return false; }
|
|
51
|
+
for (const pattern of allowList) {
|
|
52
|
+
// Wildcard pattern: "https://wger.de/*" → prefix match
|
|
53
|
+
if (pattern.includes('/') && pattern.endsWith('*')) {
|
|
54
|
+
const prefix = pattern.slice(0, -1);
|
|
55
|
+
if (url.startsWith(prefix)) return true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// Full URL match
|
|
59
|
+
if (pattern.includes('/')) {
|
|
60
|
+
if (url === pattern) return true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// Domain-only: suffix match on hostname (e.g. "wger.de" matches "api.wger.de")
|
|
64
|
+
if (parsed.hostname === pattern || parsed.hostname.endsWith('.' + pattern)) return true;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
47
69
|
async function checkRemoteUrl(url) {
|
|
48
70
|
const controller = new AbortController();
|
|
49
71
|
const timer = setTimeout(() => controller.abort(), LINK_TIMEOUT_MS);
|
|
@@ -95,7 +117,7 @@ async function runWithConcurrency(tasks, limit) {
|
|
|
95
117
|
return results;
|
|
96
118
|
}
|
|
97
119
|
|
|
98
|
-
async function checkDeadLinks(content, { sourceFile }) {
|
|
120
|
+
async function checkDeadLinks(content, { sourceFile, linkAllowList } = {}) {
|
|
99
121
|
const links = extractLinks(content);
|
|
100
122
|
const findings = [];
|
|
101
123
|
const seen = new Set();
|
|
@@ -112,6 +134,7 @@ async function checkDeadLinks(content, { sourceFile }) {
|
|
|
112
134
|
seen.add(dedupeKey);
|
|
113
135
|
|
|
114
136
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
137
|
+
if (isAllowListed(url, linkAllowList)) continue;
|
|
115
138
|
remoteChecks.push({ url, link });
|
|
116
139
|
continue;
|
|
117
140
|
}
|
package/src/quality.mjs
CHANGED
|
@@ -7,7 +7,11 @@ function clamp(value, min, max) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
function computeDocHealthScore({ errors = 0, warnings = 0 }) {
|
|
10
|
-
const
|
|
10
|
+
const errorPenalty = errors * 20;
|
|
11
|
+
const warningPenalty = warnings > 0
|
|
12
|
+
? 5 * Math.sqrt(warnings) + (warnings * 2)
|
|
13
|
+
: 0;
|
|
14
|
+
const raw = 100 - errorPenalty - warningPenalty;
|
|
11
15
|
return clamp(Math.round(raw), 0, 100);
|
|
12
16
|
}
|
|
13
17
|
|