@time4peter/foam-cli
Version:
CLI tool for Foam knowledge management - verify wikilinks and more
217 lines • 10.3 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.verifyLinks = verifyLinks;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const glob_1 = require("glob");
const chalk_1 = __importDefault(require("chalk"));
const wikilink_parser_1 = require("../utils/wikilink-parser");
async function verifyLinks(options) {
const workspacePath = path.resolve(options.path);
const extensions = options.extensions.split(',').map(ext => ext.trim());
// Check if workspace exists
if (!fs.existsSync(workspacePath)) {
console.error(chalk_1.default.red(`Error: Workspace path does not exist: ${workspacePath}`));
process.exit(1);
}
// Find all markdown files
const patterns = extensions.map(ext => path.join(workspacePath, `**/*.${ext}`));
const files = [];
for (const pattern of patterns) {
const matches = await (0, glob_1.glob)(pattern, {
ignore: [
path.join(workspacePath, 'node_modules/**'),
path.join(workspacePath, '.git/**')
],
});
// Convert absolute paths back to relative paths
const relativeMatches = matches.map(match => path.relative(workspacePath, match));
files.push(...relativeMatches);
}
if (files.length === 0) {
console.log(chalk_1.default.yellow(`No files found with extensions: ${extensions.join(', ')}`));
return;
}
// Build a map of all existing files (normalized)
const existingFiles = new Map();
const filesByBasename = new Map();
// Helper to check if a string is an identifier (like Foam does)
const isIdentifier = (path) => {
return !(path.startsWith('/') || path.startsWith('./') || path.startsWith('../'));
};
for (const file of files) {
// Store by full path without extension
const fileWithoutExt = file.replace(/\.(md|mdx)$/i, '');
existingFiles.set((0, wikilink_parser_1.normalizeWikilinkTarget)(fileWithoutExt), file);
// Store by basename for identifier links
const basename = path.basename(file, path.extname(file));
const normalizedBasename = (0, wikilink_parser_1.normalizeWikilinkTarget)(basename);
// Track files by basename for identifier resolution
if (!filesByBasename.has(normalizedBasename)) {
filesByBasename.set(normalizedBasename, []);
}
filesByBasename.get(normalizedBasename)?.push(file);
// Also store intermediate path segments for flexible matching
const parts = fileWithoutExt.split('/');
for (let i = 1; i < parts.length; i++) {
const partialPath = parts.slice(-i - 1).join('/');
existingFiles.set((0, wikilink_parser_1.normalizeWikilinkTarget)(partialPath), file);
}
}
// Parse all files and collect wikilinks
const parsedFiles = [];
for (const file of files) {
const filePath = path.join(workspacePath, file);
const content = fs.readFileSync(filePath, 'utf-8');
const parsed = (0, wikilink_parser_1.parseWikilinks)(content, file);
if (parsed.links.length > 0) {
parsedFiles.push(parsed);
}
}
// Verify links
const brokenLinks = [];
let totalLinks = 0;
for (const parsedFile of parsedFiles) {
for (const link of parsedFile.links) {
totalLinks++;
// Remove section reference if present
const targetWithoutSection = link.target.split('#')[0];
if (!targetWithoutSection) {
// Link is just a section reference in the same file (e.g., [[#section]])
continue;
}
const normalizedTarget = (0, wikilink_parser_1.normalizeWikilinkTarget)(targetWithoutSection);
// Skip some known placeholder patterns in documentation
const isExampleLink = targetWithoutSection.toLowerCase().includes('placeholder') ||
targetWithoutSection.toLowerCase() === 'mediawiki' ||
targetWithoutSection.match(/^(link|links|note|note-[a-z]|resource|file|your-.*|example.*|wikilink.*|car|cars|house|todo|notes|doc|image|cat-food|target|book|github-pages|some-page.*|feature-comparison|foam-core-.*|improve-.*|renaming-files|block-references|improved-.*|git-.*|user-settings|officially-.*|search-in-.*|graph-in-.*|linking-between-.*|mobile-apps|packaged-.*|web-editor|foam-linter|refactoring-via-.*|referencing-notes-by-title|line)$/i) ||
targetWithoutSection.includes('...') ||
targetWithoutSection.includes(':') || // Property syntax like [[wikilink:tags]]
targetWithoutSection.match(/^[./]/) || // Relative paths in examples
targetWithoutSection.includes('<') || // Template syntax like [[wikilink:<property>]]
targetWithoutSection === '$' || // Math delimiter examples
targetWithoutSection.includes("'$','$'") || // Math config examples
parsedFile.path.includes('proposals/') || // Skip all links in proposals directory
parsedFile.path.includes('dev/') && targetWithoutSection.match(/^(to|buy-car|project|work)$/i) || // Common example paths in dev docs
// Skip links that are clearly examples in documentation
(parsedFile.path.includes('write-your-notes-in-github-gist.md') && targetWithoutSection === 'links') ||
(parsedFile.path.includes('built-in-note-embedding-types.md') && targetWithoutSection.match(/^note-[a-z]$/));
if (isExampleLink) {
continue;
}
// Check if the target exists
let found = false;
// If it's an identifier, look it up by basename
if (isIdentifier(targetWithoutSection)) {
const possibleMatches = filesByBasename.get(normalizedTarget) || [];
if (possibleMatches.length > 0) {
found = true;
}
}
else {
// For paths (absolute or relative), check the existingFiles map
found = existingFiles.has(normalizedTarget);
}
if (!found) {
brokenLinks.push({
source: parsedFile.path,
target: link.target,
line: link.line,
column: link.column,
raw: link.raw,
});
}
}
}
const result = {
totalFiles: files.length,
totalLinks,
brokenLinks,
workspacePath,
};
// Output results
if (options.json) {
console.log(JSON.stringify(result, null, 2));
}
else {
outputHumanReadable(result, options.color !== false);
}
// Exit with error code if broken links found
if (brokenLinks.length > 0) {
process.exit(1);
}
}
function outputHumanReadable(result, useColor) {
const format = (str, color) => {
if (!useColor || !color)
return String(str);
return chalk_1.default[color](String(str));
};
const bold = (str) => useColor ? chalk_1.default.bold(str) : str;
console.log(bold('\nFoam Link Verification Report'));
console.log(format('─'.repeat(50), 'gray'));
console.log(`Workspace: ${format(result.workspacePath, 'cyan')}`);
console.log(`Files scanned: ${format(result.totalFiles, 'cyan')}`);
console.log(`Total wikilinks: ${format(result.totalLinks, 'cyan')}`);
console.log(`Broken links: ${result.brokenLinks.length > 0 ? format(result.brokenLinks.length, 'red') : format('0', 'green')}`);
if (result.brokenLinks.length > 0) {
console.log(format('\n─'.repeat(50), 'gray'));
console.log(useColor ? chalk_1.default.bold.red('\nBroken Links:') : '\nBroken Links:');
// Group broken links by source file
const linksBySource = new Map();
for (const link of result.brokenLinks) {
if (!linksBySource.has(link.source)) {
linksBySource.set(link.source, []);
}
linksBySource.get(link.source)?.push(link);
}
// Output broken links grouped by file
for (const [source, links] of linksBySource) {
console.log(format(`\n${source}:`, 'yellow'));
for (const link of links) {
console.log(` ${format(`${link.line}:${link.column}`, 'gray')} ${format(link.raw, 'red')} → ${format(link.target, 'dim')}`);
}
}
}
else {
console.log(useColor ? chalk_1.default.green.bold('\n✓ All wikilinks are valid!') : '\n✓ All wikilinks are valid!');
}
console.log('');
}
//# sourceMappingURL=verify-links.js.map