UNPKG

markmv

Version:

TypeScript CLI for markdown file operations with intelligent link refactoring

345 lines 12.1 kB
import { readFile, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute, relative, resolve } from 'node:path'; import { unified } from 'unified'; import remarkParse from 'remark-parse'; import remarkStringify from 'remark-stringify'; import { visit } from 'unist-util-visit'; import { LinkParser } from './link-parser.js'; /** * Core class for converting markdown link formats and path resolution. * * Provides comprehensive link conversion functionality including path resolution changes * (absolute/relative) and link style transformations between different markdown syntaxes. * * @category Core * * @example * Basic link conversion * ```typescript * const converter = new LinkConverter(); * * // Convert all links to relative paths and wikilink style * const result = await converter.convertFile('document.md', { * pathResolution: 'relative', * linkStyle: 'wikilink', * basePath: process.cwd() * }); * ``` */ export class LinkConverter { parser; constructor() { this.parser = new LinkParser(); } /** * Convert links in a single markdown file. * * @param filePath - Path to the markdown file to convert * @param options - Conversion options specifying target format * * @returns Promise resolving to operation result with conversion details */ async convertFile(filePath, options) { const result = { success: false, modifiedFiles: [], createdFiles: [], deletedFiles: [], errors: [], warnings: [], changes: [], }; try { // Read and parse the file const content = await readFile(filePath, 'utf-8'); const parsed = await this.parser.parseFile(filePath); // Convert the content const convertedContent = await this.convertContent(content, parsed.links, filePath, options); // Check if content actually changed if (convertedContent === content) { if (options.verbose) { console.log(`No changes needed in ${filePath}`); } result.success = true; return result; } // Write the converted content (unless dry run) if (!options.dryRun) { await writeFile(filePath, convertedContent, 'utf-8'); result.modifiedFiles.push(filePath); } // Track changes made const changes = this.detectChanges(content, convertedContent, filePath); result.changes.push(...changes); if (options.verbose) { console.log(`Converted ${changes.length} links in ${filePath}`); } result.success = true; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); result.errors.push(`Failed to convert ${filePath}: ${errorMessage}`); } return result; } /** * Convert links in multiple markdown files. * * @param filePaths - Array of file paths to convert * @param options - Conversion options specifying target format * * @returns Promise resolving to combined operation result */ async convertFiles(filePaths, options) { const combinedResult = { success: true, modifiedFiles: [], createdFiles: [], deletedFiles: [], errors: [], warnings: [], changes: [], }; for (const filePath of filePaths) { const result = await this.convertFile(filePath, options); // Combine results combinedResult.modifiedFiles.push(...result.modifiedFiles); combinedResult.createdFiles.push(...result.createdFiles); combinedResult.deletedFiles.push(...result.deletedFiles); combinedResult.errors.push(...result.errors); combinedResult.warnings.push(...result.warnings); combinedResult.changes.push(...result.changes); if (!result.success) { combinedResult.success = false; } } return combinedResult; } /** * Convert markdown content with specified link transformations. * * @private * * @param content - Original markdown content * @param links - Parsed link information * @param filePath - Path of the source file (for relative path calculations) * @param options - Conversion options * * @returns Promise resolving to converted content */ async convertContent(content, _links, filePath, options) { // Parse markdown AST const processor = unified().use(remarkParse).use(remarkStringify, { bullet: '-', fences: true, incrementListMarker: false, }); const tree = processor.parse(content); let hasChanges = false; // Transform links in the AST visit(tree, (node) => { if (this.isLinkNode(node)) { const transformed = this.transformLinkNode(node, filePath, options); if (transformed) { hasChanges = true; } } else if (node.type === 'text' && options.linkStyle) { // Handle Claude imports and other text-based link formats if (this.isTextNode(node)) { const transformed = this.transformTextLinks(node, filePath, options); if (transformed) { hasChanges = true; } } } }); if (!hasChanges) { return content; } const result = processor.stringify(tree); return typeof result === 'string' ? result : String(result); } /** * Transform a link node according to conversion options. * * @private * * @param node - The link/image node to transform * @param filePath - Source file path for relative calculations * @param options - Conversion options * * @returns Whether the node was modified */ transformLinkNode(node, filePath, options) { if (!node.url) return false; let hasChanges = false; // Transform path resolution if (options.pathResolution && this.isInternalLink(node.url)) { const newUrl = this.convertPathResolution(node.url, filePath, options.pathResolution, options.basePath); if (newUrl !== node.url) { node.url = newUrl; hasChanges = true; } } // Transform link style (this affects the overall syntax, handled at AST level) if (options.linkStyle && this.isInternalLink(node.url)) { hasChanges = this.convertLinkStyle(node, options.linkStyle) || hasChanges; } return hasChanges; } /** * Transform text-based links (like Claude imports). * * @private * * @param node - Text node that might contain text-based links * @param filePath - Source file path for relative calculations * @param options - Conversion options * * @returns Whether the node was modified */ transformTextLinks(node, filePath, options) { const originalValue = node.value; let newValue = node.value; // Handle Claude imports (@./file.md, @~/file.md) const claudeImportRegex = /@(\.\/|~\/|[^@\s]+)/g; newValue = newValue.replace(claudeImportRegex, (match, path) => { if (options.pathResolution) { const convertedPath = this.convertPathResolution(path, filePath, options.pathResolution, options.basePath); return `@${convertedPath}`; } return match; }); if (newValue !== originalValue) { node.value = newValue; return true; } return false; } /** * Convert path resolution between absolute and relative formats. * * @private * * @param linkPath - Original link path * @param sourceFile - Path of the file containing the link * @param targetResolution - Target path resolution type * @param basePath - Base path for absolute resolution calculations * * @returns Converted path */ convertPathResolution(linkPath, sourceFile, targetResolution, basePath) { // Skip external URLs and anchors if (linkPath.startsWith('http') || linkPath.startsWith('#')) { return linkPath; } const sourceDir = dirname(sourceFile); const base = basePath || process.cwd(); if (targetResolution === 'absolute') { // Convert to absolute path if (isAbsolute(linkPath)) { return linkPath; } // Resolve relative to source file const resolvedPath = resolve(sourceDir, linkPath); return relative(base, resolvedPath); } else { // Convert to relative path if (!isAbsolute(linkPath)) { return linkPath; } // Convert absolute to relative from source file const absolutePath = resolve(base, linkPath); return relative(sourceDir, absolutePath); } } /** * Convert link style format. * * @private * * @param node - Link node to convert * @param targetStyle - Target link style * * @returns Whether the node was modified */ convertLinkStyle(_node, _targetStyle) { // For now, this is a placeholder as style conversion requires more complex AST manipulation // The actual implementation would need to transform the node type and structure // TODO: Implement style conversion logic // This would involve changing node types and restructuring the AST return false; } /** * Detect changes between original and converted content. * * @private * * @param original - Original content * @param converted - Converted content * @param filePath - File path for change tracking * * @returns Array of detected changes */ detectChanges(original, converted, filePath) { const changes = []; // Simple line-by-line comparison for now const originalLines = original.split('\n'); const convertedLines = converted.split('\n'); const maxLines = Math.max(originalLines.length, convertedLines.length); for (let i = 0; i < maxLines; i++) { const originalLine = originalLines[i] || ''; const convertedLine = convertedLines[i] || ''; if (originalLine !== convertedLine) { changes.push({ type: 'link-updated', filePath, oldValue: originalLine, newValue: convertedLine, line: i + 1, }); } } return changes; } /** * Check if a node is a link or image node. * * @private * * @param node - Node to check * * @returns Whether the node is a link node */ isLinkNode(node) { return ['link', 'image', 'linkReference', 'imageReference'].includes(node.type); } /** * Check if a node is a text node. * * @private * * @param node - Node to check * * @returns Whether the node is a text node */ isTextNode(node) { return node.type === 'text'; } /** * Check if a URL represents an internal link. * * @private * * @param url - URL to check * * @returns Whether the URL is an internal link */ isInternalLink(url) { return !url.startsWith('http') && !url.startsWith('#') && !url.startsWith('mailto:'); } } //# sourceMappingURL=link-converter.js.map