doclify-guardrail 1.2.1 → 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 +212 -4
- package/src/colors.mjs +29 -10
- package/src/fixer.mjs +215 -11
- package/src/index.mjs +96 -29
- package/src/links.mjs +3 -3
- 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]));
|
|
@@ -89,6 +104,7 @@ function stripInlineCode(line) {
|
|
|
89
104
|
const SUPPRESS_NEXT_LINE_RX = /<!--\s*doclify-disable-next-line\s*(.*?)\s*-->/;
|
|
90
105
|
const SUPPRESS_BLOCK_START_RX = /<!--\s*doclify-disable\s*(.*?)\s*-->/;
|
|
91
106
|
const SUPPRESS_BLOCK_END_RX = /<!--\s*doclify-enable\s*(.*?)\s*-->/;
|
|
107
|
+
const SUPPRESS_FILE_RX = /<!--\s*doclify-disable-file\s*(.*?)\s*-->/;
|
|
92
108
|
|
|
93
109
|
function parseRuleIds(raw) {
|
|
94
110
|
const trimmed = raw.trim();
|
|
@@ -172,6 +188,19 @@ function isSuppressed(suppressions, finding) {
|
|
|
172
188
|
function checkMarkdown(rawContent, opts = {}) {
|
|
173
189
|
const maxLineLength = Number(opts.maxLineLength ?? DEFAULTS.maxLineLength);
|
|
174
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
|
+
|
|
175
204
|
const errors = [];
|
|
176
205
|
const warnings = [];
|
|
177
206
|
|
|
@@ -341,6 +370,183 @@ function checkMarkdown(rawContent, opts = {}) {
|
|
|
341
370
|
}
|
|
342
371
|
});
|
|
343
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
|
+
|
|
344
550
|
// Custom rules (uses stripped content)
|
|
345
551
|
if (opts.customRules && opts.customRules.length > 0) {
|
|
346
552
|
lines.forEach((line, idx) => {
|
|
@@ -355,9 +561,11 @@ function checkMarkdown(rawContent, opts = {}) {
|
|
|
355
561
|
});
|
|
356
562
|
}
|
|
357
563
|
|
|
358
|
-
// Apply inline suppressions
|
|
359
|
-
const
|
|
360
|
-
|
|
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));
|
|
361
569
|
|
|
362
570
|
return {
|
|
363
571
|
errors: filteredErrors,
|
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
|
|
|
@@ -64,10 +77,12 @@ function printHelp() {
|
|
|
64
77
|
|
|
65
78
|
${y('SETUP')}
|
|
66
79
|
init Generate a .doclify-guardrail.json config
|
|
80
|
+
init --force Overwrite existing config
|
|
67
81
|
|
|
68
82
|
${y('OTHER')}
|
|
69
83
|
--list-rules List all built-in rules
|
|
70
84
|
--no-color Disable colored output
|
|
85
|
+
--ascii Use ASCII icons ${d('(for CI without UTF-8)')}
|
|
71
86
|
--debug Show debug info
|
|
72
87
|
-h, --help Show this help
|
|
73
88
|
|
|
@@ -124,10 +139,13 @@ function parseArgs(argv) {
|
|
|
124
139
|
checkLinks: false,
|
|
125
140
|
checkFreshness: false,
|
|
126
141
|
checkFrontmatter: false,
|
|
142
|
+
checkInlineHtml: false,
|
|
127
143
|
linkAllowList: [],
|
|
128
144
|
fix: false,
|
|
129
145
|
dryRun: false,
|
|
130
|
-
json: false
|
|
146
|
+
json: false,
|
|
147
|
+
force: false,
|
|
148
|
+
ascii: false
|
|
131
149
|
};
|
|
132
150
|
|
|
133
151
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -198,6 +216,11 @@ function parseArgs(argv) {
|
|
|
198
216
|
continue;
|
|
199
217
|
}
|
|
200
218
|
|
|
219
|
+
if (a === '--check-inline-html') {
|
|
220
|
+
args.checkInlineHtml = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
201
224
|
if (a === '--link-allow-list') {
|
|
202
225
|
const value = argv[i + 1];
|
|
203
226
|
if (!value || value.startsWith('-')) {
|
|
@@ -223,6 +246,16 @@ function parseArgs(argv) {
|
|
|
223
246
|
continue;
|
|
224
247
|
}
|
|
225
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
|
+
|
|
226
259
|
if (a === '--config') {
|
|
227
260
|
const value = argv[i + 1];
|
|
228
261
|
if (!value || value.startsWith('-')) {
|
|
@@ -354,6 +387,8 @@ function resolveOptions(args) {
|
|
|
354
387
|
const checkFrontmatter = Boolean(args.checkFrontmatter || cfg.checkFrontmatter || args.checkFreshness || cfg.checkFreshness);
|
|
355
388
|
const cfgAllowList = Array.isArray(cfg.linkAllowList) ? cfg.linkAllowList : [];
|
|
356
389
|
const linkAllowList = [...args.linkAllowList, ...cfgAllowList];
|
|
390
|
+
const cfgExclude = Array.isArray(cfg.exclude) ? cfg.exclude : [];
|
|
391
|
+
const exclude = [...args.exclude, ...cfgExclude];
|
|
357
392
|
|
|
358
393
|
return {
|
|
359
394
|
maxLineLength,
|
|
@@ -361,6 +396,7 @@ function resolveOptions(args) {
|
|
|
361
396
|
ignoreRules,
|
|
362
397
|
checkFrontmatter,
|
|
363
398
|
linkAllowList,
|
|
399
|
+
exclude,
|
|
364
400
|
configPath: args.configPath,
|
|
365
401
|
configLoaded: fs.existsSync(args.configPath)
|
|
366
402
|
};
|
|
@@ -450,6 +486,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
450
486
|
|
|
451
487
|
if (args.listRules) {
|
|
452
488
|
initColors(args.noColor);
|
|
489
|
+
setAsciiMode(args.ascii);
|
|
453
490
|
console.log('');
|
|
454
491
|
console.log(` ${c.bold('Built-in rules')}`);
|
|
455
492
|
console.log('');
|
|
@@ -463,11 +500,13 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
463
500
|
|
|
464
501
|
if (args.init) {
|
|
465
502
|
initColors(args.noColor);
|
|
503
|
+
setAsciiMode(args.ascii);
|
|
466
504
|
const configFile = '.doclify-guardrail.json';
|
|
467
505
|
const configPath = path.resolve(configFile);
|
|
468
506
|
|
|
469
|
-
|
|
470
|
-
|
|
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.`);
|
|
471
510
|
return 1;
|
|
472
511
|
}
|
|
473
512
|
|
|
@@ -475,6 +514,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
475
514
|
strict: false,
|
|
476
515
|
maxLineLength: 160,
|
|
477
516
|
ignoreRules: [],
|
|
517
|
+
exclude: [],
|
|
478
518
|
checkLinks: false,
|
|
479
519
|
checkFreshness: false,
|
|
480
520
|
checkFrontmatter: false,
|
|
@@ -483,7 +523,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
483
523
|
|
|
484
524
|
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8');
|
|
485
525
|
console.error('');
|
|
486
|
-
console.error(` ${c.green(
|
|
526
|
+
console.error(` ${c.green(icons.pass)} ${configExists ? 'Overwrote' : 'Created'} ${c.bold(configFile)}`);
|
|
487
527
|
console.error('');
|
|
488
528
|
console.error(` ${c.dim('Edit the file to customise rules, then run:')}`)
|
|
489
529
|
console.error(` ${c.dim('$')} ${c.cyan('doclify .')}`);
|
|
@@ -492,6 +532,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
492
532
|
}
|
|
493
533
|
|
|
494
534
|
initColors(args.noColor);
|
|
535
|
+
setAsciiMode(args.ascii);
|
|
495
536
|
|
|
496
537
|
let filePaths;
|
|
497
538
|
try {
|
|
@@ -501,10 +542,18 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
501
542
|
return 2;
|
|
502
543
|
}
|
|
503
544
|
|
|
504
|
-
|
|
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) {
|
|
505
554
|
filePaths = filePaths.filter(fp => {
|
|
506
555
|
const rel = path.relative(process.cwd(), fp);
|
|
507
|
-
return !
|
|
556
|
+
return !resolved.exclude.some(pattern => {
|
|
508
557
|
if (pattern.includes('*')) {
|
|
509
558
|
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*') + '$');
|
|
510
559
|
return regex.test(rel);
|
|
@@ -520,14 +569,6 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
520
569
|
return 2;
|
|
521
570
|
}
|
|
522
571
|
|
|
523
|
-
let resolved;
|
|
524
|
-
try {
|
|
525
|
-
resolved = resolveOptions(args);
|
|
526
|
-
} catch (err) {
|
|
527
|
-
console.error(err.message);
|
|
528
|
-
return 2;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
572
|
let customRules = [];
|
|
532
573
|
if (args.rules) {
|
|
533
574
|
try {
|
|
@@ -541,7 +582,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
541
582
|
printBanner(filePaths.length, VERSION);
|
|
542
583
|
|
|
543
584
|
if (resolved.configLoaded) {
|
|
544
|
-
log(c.cyan(
|
|
585
|
+
log(c.cyan(icons.info), `Loaded config from ${c.dim(resolved.configPath)}`);
|
|
545
586
|
}
|
|
546
587
|
|
|
547
588
|
const startTime = process.hrtime.bigint();
|
|
@@ -557,7 +598,17 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
557
598
|
|
|
558
599
|
let customRulesCount = customRules.length;
|
|
559
600
|
if (customRulesCount > 0) {
|
|
560
|
-
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
|
+
}
|
|
561
612
|
}
|
|
562
613
|
|
|
563
614
|
console.error('');
|
|
@@ -565,12 +616,13 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
565
616
|
for (const filePath of filePaths) {
|
|
566
617
|
const rel = path.relative(process.cwd(), filePath);
|
|
567
618
|
const shortPath = rel.startsWith('..') ? filePath : rel || filePath;
|
|
568
|
-
log(c.cyan(
|
|
619
|
+
log(c.cyan(icons.info), `Checking ${c.bold(shortPath)}...`);
|
|
569
620
|
|
|
570
621
|
try {
|
|
571
622
|
let content = fs.readFileSync(filePath, 'utf8');
|
|
572
623
|
|
|
573
624
|
if (args.fix) {
|
|
625
|
+
// 1. Insecure links fix (http:// → https://)
|
|
574
626
|
log(c.dim(' ↳'), c.dim(`Auto-fixing insecure links...`));
|
|
575
627
|
const fixed = autoFixInsecureLinks(content);
|
|
576
628
|
if (fixed.modified) {
|
|
@@ -582,9 +634,8 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
582
634
|
}
|
|
583
635
|
}
|
|
584
636
|
if (!args.dryRun) {
|
|
585
|
-
|
|
637
|
+
content = fixed.content;
|
|
586
638
|
}
|
|
587
|
-
content = fixed.content;
|
|
588
639
|
}
|
|
589
640
|
if (fixed.ambiguous.length > 0) {
|
|
590
641
|
fixSummary.ambiguousSkipped.push({
|
|
@@ -595,6 +646,21 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
595
646
|
log(c.dim(' '), c.dim(`⊘ skipped ${url} (localhost/custom port)`));
|
|
596
647
|
}
|
|
597
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
|
+
}
|
|
598
664
|
}
|
|
599
665
|
|
|
600
666
|
const relPath = toRelativePath(filePath);
|
|
@@ -602,7 +668,8 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
602
668
|
maxLineLength: resolved.maxLineLength,
|
|
603
669
|
filePath: relPath,
|
|
604
670
|
customRules,
|
|
605
|
-
checkFrontmatter: resolved.checkFrontmatter
|
|
671
|
+
checkFrontmatter: resolved.checkFrontmatter,
|
|
672
|
+
checkInlineHtml: Boolean(args.checkInlineHtml)
|
|
606
673
|
});
|
|
607
674
|
|
|
608
675
|
if (args.checkLinks) {
|
|
@@ -638,21 +705,21 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
638
705
|
if (args.fix && fixSummary.replacements > 0) {
|
|
639
706
|
const action = args.dryRun ? 'Would fix' : 'Fixed';
|
|
640
707
|
log(
|
|
641
|
-
args.dryRun ? c.yellow('~') : c.green(
|
|
708
|
+
args.dryRun ? c.yellow('~') : c.green(icons.pass),
|
|
642
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)') : ''}`
|
|
643
710
|
);
|
|
644
711
|
} else if (args.fix && fixSummary.replacements === 0) {
|
|
645
|
-
log(c.dim(
|
|
712
|
+
log(c.dim(icons.info), c.dim('No insecure links to fix'));
|
|
646
713
|
}
|
|
647
714
|
|
|
648
715
|
if (args.json) {
|
|
649
|
-
|
|
716
|
+
await writeJsonToStdout(output);
|
|
650
717
|
}
|
|
651
718
|
|
|
652
719
|
if (args.report) {
|
|
653
720
|
try {
|
|
654
721
|
const reportPath = generateReport(output, { reportPath: args.report });
|
|
655
|
-
log(c.green(
|
|
722
|
+
log(c.green(icons.pass), `Report written ${c.dim('→')} ${reportPath}`);
|
|
656
723
|
} catch (err) {
|
|
657
724
|
console.error(`Failed to write report: ${err.message}`);
|
|
658
725
|
return 2;
|
|
@@ -662,7 +729,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
662
729
|
if (args.junit) {
|
|
663
730
|
try {
|
|
664
731
|
const junitPath = generateJUnitReport(output, { junitPath: args.junit });
|
|
665
|
-
log(c.green(
|
|
732
|
+
log(c.green(icons.pass), `JUnit report written ${c.dim('→')} ${junitPath}`);
|
|
666
733
|
} catch (err) {
|
|
667
734
|
console.error(`Failed to write JUnit report: ${err.message}`);
|
|
668
735
|
return 2;
|
|
@@ -672,7 +739,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
672
739
|
if (args.sarif) {
|
|
673
740
|
try {
|
|
674
741
|
const sarifPath = generateSarifReport(output, { sarifPath: args.sarif });
|
|
675
|
-
log(c.green(
|
|
742
|
+
log(c.green(icons.pass), `SARIF report written ${c.dim('→')} ${sarifPath}`);
|
|
676
743
|
} catch (err) {
|
|
677
744
|
console.error(`Failed to write SARIF report: ${err.message}`);
|
|
678
745
|
return 2;
|
|
@@ -682,7 +749,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
682
749
|
if (args.badge) {
|
|
683
750
|
try {
|
|
684
751
|
const badge = generateBadge(output, { badgePath: args.badge, label: args.badgeLabel });
|
|
685
|
-
log(c.green(
|
|
752
|
+
log(c.green(icons.pass), `Badge written ${c.dim('→')} ${badge.badgePath} ${c.dim(`(score ${badge.score})`)}`);
|
|
686
753
|
} catch (err) {
|
|
687
754
|
console.error(`Failed to write badge: ${err.message}`);
|
|
688
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
|
|
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
|
|