Skip to content

Commit 05832d6

Browse files
committed
feat: add check-links command to verify link reachability
## CHANGES - Add check-links command with recursive option - Support HTTP/HTTPS and local file link validation - Skip email links with clear indication - Update imports to use node: prefix convention - Replace forEach loops with for...of syntax - Fix parseInt to Number.parseInt calls - Add EMAIL pattern for email detection - Add handleCheckLinksCommand method implementation
1 parent ee99b1f commit 05832d6

File tree

10 files changed

+478
-136
lines changed

10 files changed

+478
-136
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ md-tree search README.md "link"
9696
md-tree stats README.md
9797
```
9898

99+
### Check links
100+
101+
```bash
102+
md-tree check-links README.md
103+
md-tree check-links README.md --recursive
104+
```
105+
99106
### Generate table of contents
100107

101108
```bash

bin/md-tree.js

Lines changed: 94 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
*/
88

99
import { MarkdownTreeParser } from '../lib/markdown-parser.js';
10-
import fs from 'fs/promises';
11-
import path from 'path';
12-
import { fileURLToPath } from 'url';
10+
import fs from 'node:fs/promises';
11+
import path from 'node:path';
12+
import { fileURLToPath } from 'node:url';
1313

1414
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1515
const packagePath = path.join(__dirname, '..', 'package.json');
@@ -22,6 +22,7 @@ const PATTERNS = {
2222
LEVEL_2_HEADING: /^## /,
2323
TOC_LINK: /\[([^\]]+)\]\(\.\/([^#)]+)(?:#[^)]*)?\)/,
2424
LEVEL_2_TOC_ITEM: /^ {2}[-*] \[/,
25+
EMAIL: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
2526
};
2627

2728
const LIMITS = {
@@ -46,6 +47,7 @@ const MESSAGES = {
4647
USAGE_SEARCH: '❌ Usage: md-tree search <file> <selector>',
4748
USAGE_STATS: '❌ Usage: md-tree stats <file>',
4849
USAGE_TOC: '❌ Usage: md-tree toc <file>',
50+
USAGE_CHECK_LINKS: '❌ Usage: md-tree check-links <file>',
4951
INDEX_NOT_FOUND: 'index.md not found in',
5052
NO_MAIN_TITLE: 'No main title found in index.md',
5153
NO_SECTION_FILES: 'No section files found in TOC',
@@ -138,6 +140,7 @@ Commands:
138140
search <file> <selector> Search using CSS-like selectors
139141
stats <file> Show document statistics
140142
toc <file> Generate table of contents
143+
check-links <file> Verify that links are reachable
141144
version Show version information
142145
help Show this help message
143146
@@ -146,6 +149,7 @@ Options:
146149
--level, -l <number> Heading level to work with
147150
--format, -f <json|text> Output format (default: text)
148151
--max-level <number> Maximum heading level for TOC (default: 3)
152+
--recursive, -r Recursively check linked markdown files
149153
150154
Examples:
151155
md-tree list README.md
@@ -212,7 +216,9 @@ For more information, visit: https://github.com/ksylvan/markdown-tree-parser
212216

213217
if (suggestions.length > 0) {
214218
console.log('\n💡 Did you mean one of these?');
215-
suggestions.forEach((h) => console.log(` - "${h.text}"`));
219+
for (const h of suggestions) {
220+
console.log(` - "${h.text}"`);
221+
}
216222
}
217223

218224
process.exit(1);
@@ -286,12 +292,12 @@ For more information, visit: https://github.com/ksylvan/markdown-tree-parser
286292

287293
console.log(`\n🌳 Document structure for ${path.basename(filePath)}:\n`);
288294

289-
headings.forEach((heading) => {
295+
for (const heading of headings) {
290296
const indent = ' '.repeat(heading.level - 1);
291297
const icon =
292298
heading.level === 1 ? '📁' : heading.level === 2 ? '📄' : '📃';
293299
console.log(`${indent}${icon} ${heading.text}`);
294-
});
300+
}
295301
}
296302

297303
async searchNodes(filePath, selector, format = 'text') {
@@ -342,11 +348,11 @@ For more information, visit: https://github.com/ksylvan/markdown-tree-parser
342348

343349
if (Object.keys(stats.headings.byLevel).length > 0) {
344350
console.log(' By level:');
345-
Object.entries(stats.headings.byLevel)
346-
.sort(([a], [b]) => parseInt(a) - parseInt(b))
347-
.forEach(([level, count]) => {
348-
console.log(` Level ${level}: ${count}`);
349-
});
351+
for (const [level, count] of Object.entries(stats.headings.byLevel).sort(
352+
([a], [b]) => Number.parseInt(a) - Number.parseInt(b)
353+
)) {
354+
console.log(` Level ${level}: ${count}`);
355+
}
350356
}
351357

352358
console.log(`💻 Code blocks: ${stats.codeBlocks}`);
@@ -371,6 +377,60 @@ For more information, visit: https://github.com/ksylvan/markdown-tree-parser
371377
console.log(toc);
372378
}
373379

380+
async checkLinks(filePath, recursive = false, visited = new Set()) {
381+
const resolvedPath = path.resolve(filePath);
382+
if (visited.has(resolvedPath)) return;
383+
visited.add(resolvedPath);
384+
385+
const content = await this.readFile(resolvedPath);
386+
const tree = await this.parser.parse(content);
387+
const links = this.parser.selectAll(tree, 'link');
388+
389+
console.log(
390+
`\n🔗 Checking ${links.length} links in ${path.basename(resolvedPath)}:`
391+
);
392+
393+
for (const link of links) {
394+
const url = link.url;
395+
if (!url || url.startsWith('#')) {
396+
continue;
397+
}
398+
399+
// Show email links but mark as skipped
400+
if (url.startsWith('mailto:') || PATTERNS.EMAIL.test(url)) {
401+
console.log(`⏭️ ${url} (email - skipped)`);
402+
continue;
403+
}
404+
405+
if (/^https?:\/\//i.test(url)) {
406+
try {
407+
const res = await globalThis.fetch(url, { method: 'HEAD' });
408+
if (res.ok) {
409+
console.log(`✅ ${url}`);
410+
} else {
411+
console.log(`❌ ${url} (${res.status})`);
412+
}
413+
} catch (err) {
414+
console.log(`❌ ${url} (${err.message})`);
415+
}
416+
} else {
417+
const target = path.resolve(
418+
path.dirname(resolvedPath),
419+
url.split('#')[0]
420+
);
421+
try {
422+
await fs.access(target);
423+
console.log(`✅ ${url}`);
424+
if (recursive && /\.md$/i.test(target)) {
425+
await this.checkLinks(target, true, visited);
426+
}
427+
} catch {
428+
console.log(`❌ ${url} (file not found)`);
429+
}
430+
}
431+
}
432+
}
433+
374434
parseArgs() {
375435
const args = process.argv.slice(2);
376436

@@ -384,6 +444,7 @@ For more information, visit: https://github.com/ksylvan/markdown-tree-parser
384444
level: 2,
385445
format: 'text',
386446
maxLevel: 3,
447+
recursive: false,
387448
};
388449

389450
// Parse flags
@@ -394,14 +455,16 @@ For more information, visit: https://github.com/ksylvan/markdown-tree-parser
394455
options.output = args[i + 1];
395456
i++; // skip next arg
396457
} else if (arg === '--level' || arg === '-l') {
397-
options.level = parseInt(args[i + 1]) || 2;
458+
options.level = Number.parseInt(args[i + 1]) || 2;
398459
i++; // skip next arg
399460
} else if (arg === '--format' || arg === '-f') {
400461
options.format = args[i + 1] || 'text';
401462
i++; // skip next arg
402463
} else if (arg === '--max-level') {
403-
options.maxLevel = parseInt(args[i + 1]) || 3;
464+
options.maxLevel = Number.parseInt(args[i + 1]) || 3;
404465
i++; // skip next arg
466+
} else if (arg === '--recursive' || arg === '-r') {
467+
options.recursive = true;
405468
} else if (!arg.startsWith('-')) {
406469
filteredArgs.push(arg);
407470
}
@@ -441,7 +504,7 @@ For more information, visit: https://github.com/ksylvan/markdown-tree-parser
441504
console.error(MESSAGES.USAGE_EXTRACT_ALL);
442505
process.exit(1);
443506
}
444-
const level = args[2] ? parseInt(args[2]) : options.level;
507+
const level = args[2] ? Number.parseInt(args[2]) : options.level;
445508
await this.extractAllSections(args[1], level, options.output);
446509
}
447510

@@ -493,6 +556,14 @@ For more information, visit: https://github.com/ksylvan/markdown-tree-parser
493556
await this.generateTOC(args[1], options.maxLevel);
494557
}
495558

559+
async handleCheckLinksCommand(args, options) {
560+
if (args.length < 2) {
561+
console.error(MESSAGES.USAGE_CHECK_LINKS);
562+
process.exit(1);
563+
}
564+
await this.checkLinks(args[1], options.recursive);
565+
}
566+
496567
async run() {
497568
const { command, args, options } = this.parseArgs();
498569

@@ -531,6 +602,9 @@ For more information, visit: https://github.com/ksylvan/markdown-tree-parser
531602
case 'toc':
532603
await this.handleTocCommand(args, options);
533604
break;
605+
case 'check-links':
606+
await this.handleCheckLinksCommand(args, options);
607+
break;
534608
default:
535609
console.error(`${MESSAGES.ERROR} Unknown command: ${command}`);
536610
console.log('Run "md-tree help" for usage information.');
@@ -685,9 +759,9 @@ For more information, visit: https://github.com/ksylvan/markdown-tree-parser
685759

686760
// Create a map of section names to filenames for quick lookup
687761
const sectionMap = new Map();
688-
sectionFiles.forEach((file) => {
762+
for (const file of sectionFiles) {
689763
sectionMap.set(file.headingText.toLowerCase(), file.filename);
690-
});
764+
}
691765

692766
// Start with title and TOC heading
693767
let toc = `# ${mainTitle.text}\n\n## Table of Contents\n\n`;
@@ -778,9 +852,9 @@ For more information, visit: https://github.com/ksylvan/markdown-tree-parser
778852

779853
// Create a map of section names to filenames for quick lookup
780854
const sectionMap = new Map();
781-
sectionFiles.forEach((file) => {
855+
for (const file of sectionFiles) {
782856
sectionMap.set(file.headingText.toLowerCase(), file.filename);
783-
});
857+
}
784858

785859
// Start with title and TOC heading, preserving original spacing
786860
let toc = `# ${mainTitle}\n\n## Table of Contents\n\n`;
@@ -789,9 +863,9 @@ For more information, visit: https://github.com/ksylvan/markdown-tree-parser
789863
toc += `- [${mainTitle}](#table-of-contents)\n`;
790864

791865
// Add links for each section
792-
sectionFiles.forEach((file) => {
866+
for (const file of sectionFiles) {
793867
toc += ` - [${file.headingText}](./${file.filename})\n`;
794-
});
868+
}
795869

796870
return toc;
797871
}

lib/markdown-parser.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,11 @@ export class MarkdownTreeParser {
218218
findNode(tree, condition) {
219219
if (typeof condition === 'string') {
220220
return find(tree, condition);
221-
} else if (typeof condition === 'function') {
221+
}
222+
if (typeof condition === 'function') {
222223
return find(tree, condition);
223-
} else if (typeof condition === 'object') {
224+
}
225+
if (typeof condition === 'object') {
224226
return find(tree, condition);
225227
}
226228
return null;
@@ -385,7 +387,7 @@ export class MarkdownTreeParser {
385387

386388
let toc = '## Table of Contents\n\n';
387389

388-
filteredHeadings.forEach((heading) => {
390+
for (const heading of filteredHeadings) {
389391
const indent = ' '.repeat(heading.level - 1);
390392
const link = heading.text
391393
.toLowerCase()
@@ -395,7 +397,7 @@ export class MarkdownTreeParser {
395397
.replace(/^-|-$/g, '');
396398

397399
toc += `${indent}- [${heading.text}](#${link})\n`;
398-
});
400+
}
399401

400402
return toc;
401403
}

0 commit comments

Comments
 (0)