Files
omoolab-project/openwiki/scripts/check-md.js

255 lines
6.9 KiB
JavaScript

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const ILLEGAL_CHARS = /[\s!@#$%^&*()+={}\[\]|\\:;"'<>~`]/;
const EXTERNAL_LINK_REGEX = /\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g;
const INTERNAL_LINK_REGEX = /\[([^\]]*)\]\((?!https?:\/\/)([^)]+)\)/g;
const WIKILINK_REGEX = /\[\[([^\]]+)\]\]/g;
function showHelp() {
console.log(`Usage: check-md.js [OPTIONS] PATHS...
Check Markdown files for broken links and invalid filenames.
PATHS... One or more directories or files to check
(directories are scanned recursively for .md files)
OPTIONS:
-h, --help Show this help message
-v, --verbose Show detailed progress and statistics
EXAMPLES:
check-md.js openwiki/
check-md.js openwiki/ references/
check-md.js openwiki/notes/file.md
check-md.js folder-1/ file.md folder-2/`);
}
function decodeUrlSpaces(url) {
return url.replace(/%20/g, ' ');
}
function validateFilename(filename, issues) {
const name = path.basename(filename);
if (ILLEGAL_CHARS.test(name)) {
const found = name.split('').filter(c => ILLEGAL_CHARS.test(c)).join(' ');
issues.push(`filename has illegal characters: ${found}`);
}
if (!filename.endsWith('.md') && name.includes('.')) {
issues.push('filename has incorrect extension');
}
}
function extractLinks(content) {
const externalLinks = [];
const internalLinks = [];
const wikilinks = [];
let match;
EXTERNAL_LINK_REGEX.lastIndex = 0;
while ((match = EXTERNAL_LINK_REGEX.exec(content)) !== null) {
externalLinks.push({ text: match[1], url: match[2], line: getLineNumber(content, match.index) });
}
INTERNAL_LINK_REGEX.lastIndex = 0;
while ((match = INTERNAL_LINK_REGEX.exec(content)) !== null) {
internalLinks.push({ text: match[1], path: match[2], line: getLineNumber(content, match.index) });
}
WIKILINK_REGEX.lastIndex = 0;
while ((match = WIKILINK_REGEX.exec(content)) !== null) {
wikilinks.push({ text: match[1], line: getLineNumber(content, match.index) });
}
return { externalLinks, internalLinks, wikilinks };
}
function getLineNumber(content, index) {
return content.substring(0, index).split('\n').length;
}
function checkExternalLink(url) {
return new Promise((resolve) => {
const protocol = url.startsWith('https') ? https : http;
const parsed = new URL(url);
const req = protocol.request({
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname + parsed.search,
method: 'HEAD',
timeout: 5000
}, (res) => {
resolve({ status: res.statusCode, valid: res.statusCode >= 200 && res.statusCode < 400 });
});
req.on('error', () => resolve({ status: 0, valid: false }));
req.on('timeout', () => {
req.destroy();
resolve({ status: 0, valid: false, timeout: true });
});
req.end();
});
}
async function checkFile(filepath, options) {
const issues = [];
const filenameIssues = [];
validateFilename(filepath, filenameIssues);
if (filenameIssues.length > 0) {
for (const issue of filenameIssues) {
issues.push({ type: 'filename', message: issue });
}
}
const content = fs.readFileSync(filepath, 'utf-8');
const { externalLinks, internalLinks, wikilinks } = extractLinks(content);
for (const wikilink of wikilinks) {
issues.push({
type: 'wikilink',
message: `illegal wikilink format: [[${wikilink.text}]]`,
line: wikilink.line
});
}
const fileDir = path.dirname(filepath);
for (const link of internalLinks) {
const decodedPath = decodeUrlSpaces(link.path);
const targetPath = path.isAbsolute(decodedPath)
? decodedPath
: path.resolve(fileDir, decodedPath);
if (!fs.existsSync(targetPath)) {
issues.push({
type: 'broken-link',
message: `broken internal link: ${link.path}`,
line: link.line
});
}
}
for (const link of externalLinks) {
const result = await checkExternalLink(link.url);
if (!result.valid) {
const msg = result.timeout ? 'timeout' : `HTTP ${result.status}`;
issues.push({
type: 'broken-external',
message: `broken external link: ${link.url} (${msg})`,
line: link.line
});
}
}
return { filepath, issues };
}
async function walkDir(dir, callback) {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walkDir(fullPath, callback);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
await callback(fullPath);
}
}
}
async function main() {
const args = process.argv.slice(2);
const options = { verbose: false, directories: [] };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '-h' || arg === '--help') {
showHelp();
process.exit(0);
} else if (arg === '-v' || arg === '--verbose') {
options.verbose = true;
} else if (!arg.startsWith('-')) {
options.directories.push(arg);
}
}
if (options.directories.length === 0) {
console.error('Error: No directories provided. Use -h for help.');
process.exit(1);
}
const allResults = [];
const fileQueue = [];
for (const target of options.directories) {
if (fs.existsSync(target) && fs.statSync(target).isFile()) {
if (target.endsWith('.md')) {
fileQueue.push(target);
}
} else {
await walkDir(target, async (filepath) => {
fileQueue.push(filepath);
});
}
}
let checkedCount = 0;
for (const filepath of fileQueue) {
const result = await checkFile(filepath, options);
allResults.push(result);
if (result.issues.length > 0) {
for (const issue of result.issues) {
const lineInfo = issue.line ? `:${issue.line}` : '';
console.log(`${filepath}${lineInfo}: ${issue.message}`);
}
} else {
console.log(`[OK] ${filepath}`);
}
checkedCount++;
if (options.verbose) {
process.stdout.write(`\rChecked: ${checkedCount}/${fileQueue.length} files...`);
}
}
if (options.verbose) {
console.log('\n');
}
const totalIssues = allResults.reduce((sum, r) => sum + r.issues.length, 0);
console.log(`\n--- Summary ---`);
console.log(`Total files checked: ${fileQueue.length}`);
console.log(`Total issues found: ${totalIssues}`);
if (totalIssues > 0) {
console.log(`\n--- Issues ---`);
for (const result of allResults) {
if (result.issues.length > 0) {
for (const issue of result.issues) {
const lineInfo = issue.line ? `:${issue.line}` : '';
console.log(`${result.filepath}${lineInfo}: ${issue.message}`);
}
}
}
process.exit(1);
} else {
console.log(`All files passed!`);
}
}
main().catch(err => {
console.error('Error:', err.message);
process.exit(1);
});