doclify-guardrail 1.1.2 → 1.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doclify-guardrail",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Quality gate for your Markdown docs. Zero dependencies, catches errors in seconds.",
6
6
  "files": [
package/src/checker.mjs CHANGED
@@ -3,15 +3,21 @@ const DEFAULTS = {
3
3
  strict: false
4
4
  };
5
5
 
6
- const RULE_SEVERITY = {
7
- frontmatter: 'warning',
8
- 'single-h1': 'error',
9
- 'line-length': 'warning',
10
- placeholder: 'warning',
11
- 'insecure-link': 'warning',
12
- 'dead-link': 'error',
13
- 'stale-doc': 'warning'
14
- };
6
+ const RULE_CATALOG = [
7
+ { id: 'frontmatter', severity: 'warning', description: 'Require YAML frontmatter block' },
8
+ { id: 'single-h1', severity: 'error', description: 'Exactly one H1 heading per file' },
9
+ { id: 'heading-hierarchy', severity: 'warning', description: 'No skipped heading levels (H2 → H4)' },
10
+ { id: 'duplicate-heading', severity: 'warning', description: 'No duplicate headings at same level' },
11
+ { id: 'line-length', severity: 'warning', description: 'Max line length (default: 160 chars)' },
12
+ { id: 'placeholder', severity: 'warning', description: 'No TODO/FIXME/WIP/TBD markers' },
13
+ { id: 'insecure-link', severity: 'warning', description: 'No http:// links (use https://)' },
14
+ { id: 'empty-link', severity: 'warning', description: 'No empty link text or URL' },
15
+ { id: 'img-alt', severity: 'warning', description: 'Images must have alt text' },
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)' }
18
+ ];
19
+
20
+ const RULE_SEVERITY = Object.fromEntries(RULE_CATALOG.map(r => [r.id, r.severity]));
15
21
 
16
22
  const PLACEHOLDER_PATTERNS = [
17
23
  { rx: /\bTODO\b/i, msg: 'TODO marker found — remove before publishing' },
@@ -118,6 +124,41 @@ function checkMarkdown(rawContent, opts = {}) {
118
124
  }
119
125
  }
120
126
 
127
+ // Rule: heading-hierarchy (h1→h3 without h2 is a skip)
128
+ let prevLevel = 0;
129
+ for (let i = 0; i < lines.length; i += 1) {
130
+ const hMatch = lines[i].match(/^(#{1,6})\s/);
131
+ if (!hMatch) continue;
132
+ const level = hMatch[1].length;
133
+ if (prevLevel > 0 && level > prevLevel + 1) {
134
+ warnings.push(normalizeFinding(
135
+ 'heading-hierarchy',
136
+ `Heading level skipped: H${prevLevel} → H${level} (expected H${prevLevel + 1}).`,
137
+ i + 1,
138
+ filePath
139
+ ));
140
+ }
141
+ prevLevel = level;
142
+ }
143
+
144
+ // Rule: duplicate-heading (same text at same level)
145
+ const headingSeen = new Map();
146
+ for (let i = 0; i < lines.length; i += 1) {
147
+ const hMatch = lines[i].match(/^(#{1,6})\s+(.+)$/);
148
+ if (!hMatch) continue;
149
+ const key = `${hMatch[1].length}:${hMatch[2].trim().toLowerCase()}`;
150
+ if (headingSeen.has(key)) {
151
+ warnings.push(normalizeFinding(
152
+ 'duplicate-heading',
153
+ `Duplicate heading "${hMatch[2].trim()}" (also at line ${headingSeen.get(key)}).`,
154
+ i + 1,
155
+ filePath
156
+ ));
157
+ } else {
158
+ headingSeen.set(key, i + 1);
159
+ }
160
+ }
161
+
121
162
  // Rule: line-length (uses raw content — code block lines can still be too long)
122
163
  rawLines.forEach((line, idx) => {
123
164
  if (line.length > maxLineLength) {
@@ -179,6 +220,27 @@ function checkMarkdown(rawContent, opts = {}) {
179
220
  }
180
221
  });
181
222
 
223
+ // Rule: empty-link (uses stripped content, excludes images)
224
+ lines.forEach((line, idx) => {
225
+ const cleanLine = stripInlineCode(line);
226
+ // [](url) — empty link text (but not ![](url) which is img-alt)
227
+ if (/(?<!!)\[\]\([^)]+\)/.test(cleanLine)) {
228
+ warnings.push(normalizeFinding('empty-link', 'Link has empty text: [](url).', idx + 1, filePath));
229
+ }
230
+ // [text]() — empty link URL
231
+ if (/(?<!!)\[[^\]]+\]\(\s*\)/.test(cleanLine)) {
232
+ warnings.push(normalizeFinding('empty-link', 'Link has empty URL: [text]().', idx + 1, filePath));
233
+ }
234
+ });
235
+
236
+ // Rule: img-alt (uses stripped content)
237
+ lines.forEach((line, idx) => {
238
+ const cleanLine = stripInlineCode(line);
239
+ if (/!\[\]\([^)]+\)/.test(cleanLine)) {
240
+ warnings.push(normalizeFinding('img-alt', 'Image missing alt text: ![](url).', idx + 1, filePath));
241
+ }
242
+ });
243
+
182
244
  // Custom rules (uses stripped content)
183
245
  if (opts.customRules && opts.customRules.length > 0) {
184
246
  lines.forEach((line, idx) => {
@@ -203,4 +265,4 @@ function checkMarkdown(rawContent, opts = {}) {
203
265
  };
204
266
  }
205
267
 
206
- export { DEFAULTS, RULE_SEVERITY, normalizeFinding, checkMarkdown, stripCodeBlocks, stripInlineCode };
268
+ export { DEFAULTS, RULE_SEVERITY, RULE_CATALOG, normalizeFinding, checkMarkdown, stripCodeBlocks, stripInlineCode };
package/src/ci-output.mjs CHANGED
@@ -33,7 +33,7 @@ function generateJUnitXml(output) {
33
33
  const suites = [];
34
34
 
35
35
  const tests = output.files.length + (output.fileErrors?.length || 0);
36
- const failures = output.files.filter((f) => f.findings.errors.length > 0 || f.findings.warnings.length > 0).length;
36
+ const failures = output.files.filter((f) => f.findings.errors.length > 0).length;
37
37
  const errors = output.fileErrors?.length || 0;
38
38
 
39
39
  suites.push('<?xml version="1.0" encoding="UTF-8"?>');
@@ -42,16 +42,24 @@ function generateJUnitXml(output) {
42
42
  );
43
43
 
44
44
  for (const fileResult of output.files) {
45
+ const hasErrors = fileResult.findings.errors.length > 0;
46
+ const hasWarnings = fileResult.findings.warnings.length > 0;
47
+
48
+ if (!hasErrors && !hasWarnings) {
49
+ suites.push(` <testcase classname="doclify.guardrail" name="${escapeXml(fileResult.file)}"/>`);
50
+ continue;
51
+ }
52
+
45
53
  suites.push(` <testcase classname="doclify.guardrail" name="${escapeXml(fileResult.file)}">`);
46
54
 
47
- const findings = [
48
- ...fileResult.findings.errors,
49
- ...fileResult.findings.warnings
50
- ];
55
+ if (hasErrors) {
56
+ const errorDetail = fileResult.findings.errors.map(makeFindingLine).join('\n');
57
+ suites.push(` <failure message="${escapeXml(`${fileResult.summary.errors} error${fileResult.summary.errors === 1 ? '' : 's'}`)}">${escapeXml(errorDetail)}</failure>`);
58
+ }
51
59
 
52
- if (findings.length > 0) {
53
- const detail = findings.map(makeFindingLine).join('\n');
54
- suites.push(` <failure message="${escapeXml(`${fileResult.summary.errors} errors, ${fileResult.summary.warnings} warnings`)}">${escapeXml(detail)}</failure>`);
60
+ if (hasWarnings) {
61
+ const warnDetail = fileResult.findings.warnings.map(makeFindingLine).join('\n');
62
+ suites.push(` <system-out>${escapeXml(warnDetail)}</system-out>`);
55
63
  }
56
64
 
57
65
  suites.push(' </testcase>');
@@ -227,7 +235,7 @@ function generateSarifReport(output, options) {
227
235
  function generateBadge(output, options = {}) {
228
236
  const label = (options.label || 'docs health').trim() || 'docs health';
229
237
  const badgePath = path.resolve(options.badgePath || 'doclify-badge.svg');
230
- const score = computeHealthScore(output.summary || {});
238
+ const score = output.summary?.healthScore ?? 0;
231
239
  const svg = generateBadgeSvg(score, label);
232
240
  fs.writeFileSync(badgePath, svg, 'utf8');
233
241
  return {
package/src/colors.mjs CHANGED
@@ -34,9 +34,9 @@ function log(icon, message) {
34
34
  console.error(` ${icon} ${message}`);
35
35
  }
36
36
 
37
- function printBanner(fileCount) {
37
+ function printBanner(fileCount, version) {
38
38
  console.error('');
39
- console.error(` ${c.bold('Doclify Guardrail')} ${c.dim('v1.0')}`);
39
+ console.error(` ${c.bold('Doclify Guardrail')} ${c.dim(`v${version || '?'}`)}`);
40
40
  console.error('');
41
41
  log(c.cyan('ℹ'), `Scanning ${c.bold(String(fileCount))} file${fileCount === 1 ? '' : 's'}...`);
42
42
  }
package/src/index.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { checkMarkdown } from './checker.mjs';
5
+ 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';
@@ -11,19 +11,22 @@ import { checkDeadLinks } from './links.mjs';
11
11
  import { autoFixInsecureLinks } from './fixer.mjs';
12
12
  import { computeDocHealthScore, checkDocFreshness } from './quality.mjs';
13
13
  import {
14
- computeHealthScore,
15
14
  generateJUnitReport,
16
15
  generateSarifReport,
17
16
  generateBadge
18
17
  } from './ci-output.mjs';
19
18
 
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
21
+ const VERSION = pkg.version;
22
+
20
23
  function printHelp() {
21
24
  const b = (t) => c.bold(t);
22
25
  const d = (t) => c.dim(t);
23
26
  const y = (t) => c.yellow(t);
24
27
 
25
28
  console.log(`
26
- ${b('Doclify Guardrail')} ${d('v1.0')}
29
+ ${b('Doclify Guardrail')} ${d(`v${VERSION}`)}
27
30
  Quality gate for Markdown documentation.
28
31
 
29
32
  ${y('USAGE')}
@@ -38,6 +41,8 @@ function printHelp() {
38
41
  --max-line-length <n> Max line length ${d('(default: 160)')}
39
42
  --config <path> Config file ${d('(default: .doclify-guardrail.json)')}
40
43
  --rules <path> Custom regex rules from JSON file
44
+ --ignore-rules <list> Disable rules ${d('(comma-separated)')}
45
+ --exclude <list> Exclude files/patterns ${d('(comma-separated)')}
41
46
 
42
47
  ${y('CHECKS')}
43
48
  --check-links Validate HTTP and local links
@@ -55,7 +60,11 @@ function printHelp() {
55
60
  --badge-label <text> Badge label ${d('(default: "docs health")')}
56
61
  --json Output raw JSON to stdout
57
62
 
63
+ ${y('SETUP')}
64
+ init Generate a .doclify-guardrail.json config
65
+
58
66
  ${y('OTHER')}
67
+ --list-rules List all built-in rules
59
68
  --no-color Disable colored output
60
69
  --debug Show debug info
61
70
  -h, --help Show this help
@@ -97,6 +106,8 @@ function parseArgs(argv) {
97
106
  maxLineLength: undefined,
98
107
  configPath: path.resolve('.doclify-guardrail.json'),
99
108
  help: false,
109
+ listRules: false,
110
+ init: false,
100
111
  dir: null,
101
112
  report: null,
102
113
  rules: null,
@@ -105,6 +116,8 @@ function parseArgs(argv) {
105
116
  badge: null,
106
117
  badgeLabel: 'docs health',
107
118
  noColor: false,
119
+ ignoreRules: [],
120
+ exclude: [],
108
121
  checkLinks: false,
109
122
  checkFreshness: false,
110
123
  fix: false,
@@ -120,6 +133,11 @@ function parseArgs(argv) {
120
133
  continue;
121
134
  }
122
135
 
136
+ if (a === '--list-rules') {
137
+ args.listRules = true;
138
+ continue;
139
+ }
140
+
123
141
  if (a === '--debug') {
124
142
  args.debug = true;
125
143
  continue;
@@ -135,6 +153,26 @@ function parseArgs(argv) {
135
153
  continue;
136
154
  }
137
155
 
156
+ if (a === '--ignore-rules') {
157
+ const value = argv[i + 1];
158
+ if (!value || value.startsWith('-')) {
159
+ throw new Error('Missing value for --ignore-rules');
160
+ }
161
+ args.ignoreRules.push(...value.split(',').map(s => s.trim()).filter(Boolean));
162
+ i += 1;
163
+ continue;
164
+ }
165
+
166
+ if (a === '--exclude') {
167
+ const value = argv[i + 1];
168
+ if (!value || value.startsWith('-')) {
169
+ throw new Error('Missing value for --exclude');
170
+ }
171
+ args.exclude.push(...value.split(',').map(s => s.trim()).filter(Boolean));
172
+ i += 1;
173
+ continue;
174
+ }
175
+
138
176
  if (a === '--check-links') {
139
177
  args.checkLinks = true;
140
178
  continue;
@@ -258,6 +296,11 @@ function parseArgs(argv) {
258
296
  continue;
259
297
  }
260
298
 
299
+ if (a === 'init') {
300
+ args.init = true;
301
+ continue;
302
+ }
303
+
261
304
  if (a.startsWith('-')) {
262
305
  throw new Error(`Unknown option: ${a}`);
263
306
  }
@@ -276,6 +319,8 @@ function resolveOptions(args) {
276
319
  const cfg = parseConfigFile(args.configPath);
277
320
  const maxLineLength = Number(args.maxLineLength ?? cfg.maxLineLength ?? 160);
278
321
  const strict = Boolean(args.strict ?? cfg.strict ?? false);
322
+ const cfgIgnore = Array.isArray(cfg.ignoreRules) ? cfg.ignoreRules : [];
323
+ const ignoreRules = new Set([...args.ignoreRules, ...cfgIgnore]);
279
324
 
280
325
  if (!Number.isInteger(maxLineLength) || maxLineLength <= 0) {
281
326
  throw new Error(`Invalid maxLineLength in config: ${cfg.maxLineLength}`);
@@ -284,28 +329,34 @@ function resolveOptions(args) {
284
329
  return {
285
330
  maxLineLength,
286
331
  strict,
332
+ ignoreRules,
287
333
  configPath: args.configPath,
288
334
  configLoaded: fs.existsSync(args.configPath)
289
335
  };
290
336
  }
291
337
 
338
+ function toRelativePath(filePath) {
339
+ const rel = path.relative(process.cwd(), filePath);
340
+ return rel.startsWith('..') ? filePath : rel || filePath;
341
+ }
342
+
292
343
  function buildFileResult(filePath, analysis, opts) {
293
- const pass = analysis.errors.length === 0 && (!opts.strict || analysis.warnings.length === 0);
344
+ const ignore = opts.ignoreRules || new Set();
345
+ const errors = ignore.size > 0 ? analysis.errors.filter(f => !ignore.has(f.code)) : analysis.errors;
346
+ const warnings = ignore.size > 0 ? analysis.warnings.filter(f => !ignore.has(f.code)) : analysis.warnings;
347
+ const pass = errors.length === 0 && (!opts.strict || warnings.length === 0);
294
348
  const healthScore = computeDocHealthScore({
295
- errors: analysis.summary.errors,
296
- warnings: analysis.summary.warnings
349
+ errors: errors.length,
350
+ warnings: warnings.length
297
351
  });
298
352
 
299
353
  return {
300
- file: filePath,
354
+ file: toRelativePath(filePath),
301
355
  pass,
302
- findings: {
303
- errors: analysis.errors,
304
- warnings: analysis.warnings
305
- },
356
+ findings: { errors, warnings },
306
357
  summary: {
307
- errors: analysis.summary.errors,
308
- warnings: analysis.summary.warnings,
358
+ errors: errors.length,
359
+ warnings: warnings.length,
309
360
  healthScore,
310
361
  status: pass ? 'PASS' : 'FAIL'
311
362
  }
@@ -333,11 +384,10 @@ function buildOutput(fileResults, fileErrors, opts, elapsed, fixSummary) {
333
384
  elapsed: Math.round(elapsed * 1000) / 1000
334
385
  };
335
386
 
336
- summary.avgHealthScore = avgHealthScore;
337
- summary.healthScore = computeHealthScore(summary);
387
+ summary.healthScore = avgHealthScore;
338
388
 
339
389
  return {
340
- version: '1.0',
390
+ version: VERSION,
341
391
  strict: opts.strict,
342
392
  files: fileResults,
343
393
  fileErrors: fileErrors.length > 0 ? fileErrors : undefined,
@@ -361,6 +411,47 @@ async function runCli(argv = process.argv.slice(2)) {
361
411
  return 0;
362
412
  }
363
413
 
414
+ if (args.listRules) {
415
+ initColors(args.noColor);
416
+ console.log('');
417
+ console.log(` ${c.bold('Built-in rules')}`);
418
+ console.log('');
419
+ for (const rule of RULE_CATALOG) {
420
+ const sev = rule.severity === 'error' ? c.red('error ') : c.yellow('warning');
421
+ console.log(` ${c.cyan(rule.id.padEnd(22))} ${sev} ${c.dim(rule.description)}`);
422
+ }
423
+ console.log('');
424
+ return 0;
425
+ }
426
+
427
+ if (args.init) {
428
+ initColors(args.noColor);
429
+ const configFile = '.doclify-guardrail.json';
430
+ const configPath = path.resolve(configFile);
431
+
432
+ if (fs.existsSync(configPath)) {
433
+ console.error(` ${c.yellow('⚠')} ${c.bold(configFile)} already exists. Remove it first to re-initialize.`);
434
+ return 1;
435
+ }
436
+
437
+ const defaultConfig = {
438
+ strict: false,
439
+ maxLineLength: 160,
440
+ ignoreRules: [],
441
+ checkLinks: false,
442
+ checkFreshness: false
443
+ };
444
+
445
+ fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8');
446
+ console.error('');
447
+ console.error(` ${c.green('✓')} Created ${c.bold(configFile)}`);
448
+ console.error('');
449
+ console.error(` ${c.dim('Edit the file to customise rules, then run:')}`)
450
+ console.error(` ${c.dim('$')} ${c.cyan('doclify .')}`);
451
+ console.error('');
452
+ return 0;
453
+ }
454
+
364
455
  initColors(args.noColor);
365
456
 
366
457
  let filePaths;
@@ -371,6 +462,19 @@ async function runCli(argv = process.argv.slice(2)) {
371
462
  return 2;
372
463
  }
373
464
 
465
+ if (args.exclude.length > 0) {
466
+ filePaths = filePaths.filter(fp => {
467
+ const rel = path.relative(process.cwd(), fp);
468
+ return !args.exclude.some(pattern => {
469
+ if (pattern.includes('*')) {
470
+ const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*') + '$');
471
+ return regex.test(rel);
472
+ }
473
+ return rel === pattern || rel.startsWith(pattern + path.sep) || path.basename(rel) === pattern;
474
+ });
475
+ });
476
+ }
477
+
374
478
  if (filePaths.length === 0) {
375
479
  console.error('Error: no markdown files found.');
376
480
  return 2;
@@ -394,7 +498,7 @@ async function runCli(argv = process.argv.slice(2)) {
394
498
  }
395
499
  }
396
500
 
397
- printBanner(filePaths.length);
501
+ printBanner(filePaths.length, VERSION);
398
502
 
399
503
  if (resolved.configLoaded) {
400
504
  log(c.cyan('ℹ'), `Loaded config from ${c.dim(resolved.configPath)}`);
@@ -432,6 +536,11 @@ async function runCli(argv = process.argv.slice(2)) {
432
536
  if (fixed.modified) {
433
537
  fixSummary.filesChanged += 1;
434
538
  fixSummary.replacements += fixed.changes.length;
539
+ for (const change of fixed.changes) {
540
+ if (args.dryRun) {
541
+ log(c.dim(' '), c.yellow('~') + ` ${c.dim(change.from)} ${c.dim('→')} ${c.green(change.to)}`);
542
+ }
543
+ }
435
544
  if (!args.dryRun) {
436
545
  fs.writeFileSync(filePath, fixed.content, 'utf8');
437
546
  }
@@ -442,32 +551,37 @@ async function runCli(argv = process.argv.slice(2)) {
442
551
  file: filePath,
443
552
  urls: [...new Set(fixed.ambiguous)]
444
553
  });
554
+ for (const url of [...new Set(fixed.ambiguous)]) {
555
+ log(c.dim(' '), c.dim(`⊘ skipped ${url} (localhost/custom port)`));
556
+ }
445
557
  }
446
558
  }
447
559
 
560
+ const relPath = toRelativePath(filePath);
448
561
  const analysis = checkMarkdown(content, {
449
562
  maxLineLength: resolved.maxLineLength,
450
- filePath,
563
+ filePath: relPath,
451
564
  customRules
452
565
  });
453
566
 
454
567
  if (args.checkLinks) {
455
568
  log(c.dim(' ↳'), c.dim(`Checking links...`));
456
569
  const deadLinks = await checkDeadLinks(content, { sourceFile: filePath });
570
+ for (const dl of deadLinks) { dl.source = relPath; }
457
571
  analysis.errors.push(...deadLinks);
458
572
  analysis.summary.errors = analysis.errors.length;
459
573
  }
460
574
 
461
575
  if (args.checkFreshness) {
462
576
  log(c.dim(' ↳'), c.dim(`Checking freshness...`));
463
- const freshnessWarnings = checkDocFreshness(content, { sourceFile: filePath });
577
+ const freshnessWarnings = checkDocFreshness(content, { sourceFile: relPath });
464
578
  analysis.warnings.push(...freshnessWarnings);
465
579
  analysis.summary.warnings = analysis.warnings.length;
466
580
  }
467
581
 
468
- fileResults.push(buildFileResult(filePath, analysis, { strict: resolved.strict }));
582
+ fileResults.push(buildFileResult(filePath, analysis, { strict: resolved.strict, ignoreRules: resolved.ignoreRules }));
469
583
  } catch (err) {
470
- fileErrors.push({ file: filePath, error: err.message });
584
+ fileErrors.push({ file: toRelativePath(filePath), error: err.message });
471
585
  }
472
586
  }
473
587
 
@@ -480,6 +594,16 @@ async function runCli(argv = process.argv.slice(2)) {
480
594
 
481
595
  printResults(output);
482
596
 
597
+ if (args.fix && fixSummary.replacements > 0) {
598
+ const action = args.dryRun ? 'Would fix' : 'Fixed';
599
+ log(
600
+ args.dryRun ? c.yellow('~') : c.green('✓'),
601
+ `${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
+ );
603
+ } else if (args.fix && fixSummary.replacements === 0) {
604
+ log(c.dim('ℹ'), c.dim('No insecure links to fix'));
605
+ }
606
+
483
607
  if (args.json) {
484
608
  console.log(JSON.stringify(output, null, 2));
485
609
  }
package/src/links.mjs CHANGED
@@ -2,6 +2,9 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { stripCodeBlocks, stripInlineCode } from './checker.mjs';
4
4
 
5
+ const LINK_TIMEOUT_MS = 8000;
6
+ const CONCURRENCY = 5;
7
+
5
8
  function extractLinks(content) {
6
9
  const stripped = stripCodeBlocks(content);
7
10
  const lines = stripped.split('\n');
@@ -42,20 +45,28 @@ function isSkippableUrl(url) {
42
45
  }
43
46
 
44
47
  async function checkRemoteUrl(url) {
48
+ const controller = new AbortController();
49
+ const timer = setTimeout(() => controller.abort(), LINK_TIMEOUT_MS);
50
+
45
51
  try {
46
- const headRes = await fetch(url, { method: 'HEAD', redirect: 'follow' });
52
+ const headRes = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: controller.signal });
53
+ clearTimeout(timer);
47
54
  if (headRes.status < 400) {
48
55
  return null;
49
56
  }
50
57
 
51
58
  if (headRes.status === 405 || headRes.status === 501) {
52
- const getRes = await fetch(url, { method: 'GET', redirect: 'follow' });
59
+ const timer2 = setTimeout(() => controller.abort(), LINK_TIMEOUT_MS);
60
+ const getRes = await fetch(url, { method: 'GET', redirect: 'follow', signal: controller.signal });
61
+ clearTimeout(timer2);
53
62
  if (getRes.status < 400) return null;
54
63
  return `HTTP ${getRes.status}`;
55
64
  }
56
65
 
57
66
  return `HTTP ${headRes.status}`;
58
67
  } catch (err) {
68
+ clearTimeout(timer);
69
+ if (err.name === 'AbortError') return `Timeout (${LINK_TIMEOUT_MS / 1000}s)`;
59
70
  return err.message;
60
71
  }
61
72
  }
@@ -68,11 +79,30 @@ function checkLocalUrl(url, sourceFile) {
68
79
  return fs.existsSync(targetPath) ? null : 'Target not found';
69
80
  }
70
81
 
82
+ async function runWithConcurrency(tasks, limit) {
83
+ const results = [];
84
+ let idx = 0;
85
+
86
+ async function worker() {
87
+ while (idx < tasks.length) {
88
+ const i = idx++;
89
+ results[i] = await tasks[i]();
90
+ }
91
+ }
92
+
93
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker());
94
+ await Promise.all(workers);
95
+ return results;
96
+ }
97
+
71
98
  async function checkDeadLinks(content, { sourceFile }) {
72
99
  const links = extractLinks(content);
73
100
  const findings = [];
74
101
  const seen = new Set();
75
102
 
103
+ // Local links first (sync, fast)
104
+ const remoteChecks = [];
105
+
76
106
  for (const link of links) {
77
107
  const url = link.url;
78
108
  if (!url || isSkippableUrl(url)) continue;
@@ -82,21 +112,11 @@ async function checkDeadLinks(content, { sourceFile }) {
82
112
  seen.add(dedupeKey);
83
113
 
84
114
  if (url.startsWith('http://') || url.startsWith('https://')) {
85
- const error = await checkRemoteUrl(url);
86
- if (error) {
87
- findings.push({
88
- code: 'dead-link',
89
- severity: 'error',
90
- line: link.line,
91
- message: `Dead link: ${url} (${error})`,
92
- source: sourceFile
93
- });
94
- }
115
+ remoteChecks.push({ url, link });
95
116
  continue;
96
117
  }
97
118
 
98
119
  if (url.startsWith('/')) {
99
- // Absolute filesystem paths are intentionally ignored for portability.
100
120
  continue;
101
121
  }
102
122
 
@@ -112,7 +132,28 @@ async function checkDeadLinks(content, { sourceFile }) {
112
132
  }
113
133
  }
114
134
 
135
+ // Remote links in parallel with concurrency limit
136
+ if (remoteChecks.length > 0) {
137
+ const tasks = remoteChecks.map(({ url, link }) => async () => {
138
+ const error = await checkRemoteUrl(url);
139
+ return { url, link, error };
140
+ });
141
+
142
+ const results = await runWithConcurrency(tasks, CONCURRENCY);
143
+ for (const { url, link, error } of results) {
144
+ if (error) {
145
+ findings.push({
146
+ code: 'dead-link',
147
+ severity: 'error',
148
+ line: link.line,
149
+ message: `Dead link: ${url} (${error})`,
150
+ source: sourceFile
151
+ });
152
+ }
153
+ }
154
+ }
155
+
115
156
  return findings;
116
157
  }
117
158
 
118
- export { extractLinks, checkDeadLinks };
159
+ export { extractLinks, checkDeadLinks };