UNPKG

markmv

Version:

TypeScript CLI for markdown file operations with intelligent link refactoring

375 lines 16.9 kB
import { dirname } from 'node:path'; import { FileUtils } from '../utils/file-utils.js'; import { PathUtils } from '../utils/path-utils.js'; /** * Refactors and updates markdown links when files are moved or restructured. * * The LinkRefactorer automatically updates link paths, maintains referential integrity, and handles * various link types including relative paths, absolute paths, and Claude import syntax. It ensures * that links remain valid after file operations. * * @category Core * * @example * Basic link refactoring * ```typescript * const refactorer = new LinkRefactorer({ * preferRelativePaths: true, * updateClaudeImports: true * }); * * const result = await refactorer.refactorLinks( * parsedFile, * 'old/path/file.md', * 'new/path/file.md' * ); * * console.log(`Updated ${result.changes.length} links`); * ``` * * @example * Bulk refactoring with path mapping * ```typescript * const pathMap = new Map([ * ['docs/old.md', 'guides/new.md'], * ['api/legacy.md', 'reference/current.md'] * ]); * * const result = await refactorer.refactorLinksWithMapping( * parsedFile, * pathMap * ); * ``` */ export class LinkRefactorer { options; constructor(options = {}) { this.options = { preferRelativePaths: options.preferRelativePaths ?? true, updateClaudeImports: options.updateClaudeImports ?? true, preserveFormatting: options.preserveFormatting ?? true, }; } /** Update links in a file when another file has been moved */ async refactorLinksForFileMove(file, movedFilePath, newFilePath) { const content = await FileUtils.readTextFile(file.filePath); return this.refactorLinksForFileMoveWithContent(file, movedFilePath, newFilePath, content); } /** Update links in a file when another file has been moved (with provided content) */ async refactorLinksForFileMoveWithContent(file, movedFilePath, newFilePath, content) { const changes = []; const errors = []; let updatedContent = content; const lines = content.split('\n'); // Sort links by line and column in reverse order to avoid offset issues const sortedLinks = [...file.links].sort((a, b) => { if (a.line !== b.line) return b.line - a.line; return b.column - a.column; }); for (const link of sortedLinks) { try { const newLink = this.updateLinkForMovedFile(link, file.filePath, movedFilePath, newFilePath); if (newLink !== link.href) { let lineIndex = link.line - 1; let oldLine = lines[lineIndex]; // For Claude imports, if the import is not found on the expected line, // search for it in nearby lines (this handles parsing edge cases) if (link.type === 'claude-import') { const expectedImport = `@${link.href}`; if (!oldLine.includes(expectedImport)) { // Search in nearby lines for (let i = Math.max(0, lineIndex - 2); i < Math.min(lines.length, lineIndex + 3); i++) { if (lines[i].includes(expectedImport)) { lineIndex = i; oldLine = lines[i]; break; } } } } const newLine = this.replaceLinkInLine(oldLine, link, newLink); if (newLine !== oldLine) { lines[lineIndex] = newLine; changes.push({ type: 'link-updated', filePath: file.filePath, oldValue: link.href, newValue: newLink, line: lineIndex + 1, }); } } } catch (error) { errors.push(`Failed to update link at line ${link.line}: ${error}`); } } updatedContent = lines.join('\n'); return { updatedContent, changes, errors, }; } /** Update links when the current file is being moved */ async refactorLinksForCurrentFileMove(file, newFilePath) { const content = await FileUtils.readTextFile(file.filePath); const changes = []; const errors = []; let updatedContent = content; const lines = content.split('\n'); // Sort links by line and column in reverse order const sortedLinks = [...file.links].sort((a, b) => { if (a.line !== b.line) return b.line - a.line; return b.column - a.column; }); for (const link of sortedLinks) { if (link.type === 'internal' || link.type === 'image' || (link.type === 'claude-import' && this.options.updateClaudeImports)) { try { const newLink = this.updateLinkForSourceFileMove(link, file.filePath, newFilePath); if (newLink !== link.href) { let lineIndex = link.line - 1; let oldLine = lines[lineIndex]; // For Claude imports, if the import is not found on the expected line, // search for it in nearby lines (this handles parsing edge cases) if (link.type === 'claude-import') { const expectedImport = `@${link.href}`; if (!oldLine.includes(expectedImport)) { // Search in nearby lines for (let i = Math.max(0, lineIndex - 2); i < Math.min(lines.length, lineIndex + 3); i++) { if (lines[i].includes(expectedImport)) { lineIndex = i; oldLine = lines[i]; break; } } } } const newLine = this.replaceLinkInLine(oldLine, link, newLink); if (newLine !== oldLine) { lines[lineIndex] = newLine; changes.push({ type: 'link-updated', filePath: newFilePath, // Note: using new file path oldValue: link.href, newValue: newLink, line: lineIndex + 1, }); } } } catch (error) { errors.push(`Failed to update link at line ${link.line}: ${error}`); } } } updatedContent = lines.join('\n'); return { updatedContent, changes, errors, }; } /** Update links when the current file is being moved (with provided content) */ async refactorLinksForCurrentFileMoveWithContent(file, newFilePath, content) { const changes = []; const errors = []; let updatedContent = content; const lines = content.split('\n'); // Sort links by line and column in reverse order const sortedLinks = [...file.links].sort((a, b) => { if (a.line !== b.line) return b.line - a.line; return b.column - a.column; }); for (const link of sortedLinks) { if (link.type === 'internal' || link.type === 'image' || (link.type === 'claude-import' && this.options.updateClaudeImports)) { try { const newLink = this.updateLinkForSourceFileMove(link, file.filePath, newFilePath); if (newLink !== link.href) { let lineIndex = link.line - 1; let oldLine = lines[lineIndex]; // For Claude imports, if the import is not found on the expected line, // search for it in nearby lines (this handles parsing edge cases) if (link.type === 'claude-import') { const expectedImport = `@${link.href}`; if (!oldLine.includes(expectedImport)) { // Search in nearby lines for (let i = Math.max(0, lineIndex - 2); i < Math.min(lines.length, lineIndex + 3); i++) { if (lines[i].includes(expectedImport)) { lineIndex = i; oldLine = lines[i]; break; } } } } const newLine = this.replaceLinkInLine(oldLine, link, newLink); if (newLine !== oldLine) { lines[lineIndex] = newLine; changes.push({ type: 'link-updated', filePath: newFilePath, // Note: using new file path oldValue: link.href, newValue: newLink, line: lineIndex + 1, }); } } } catch (error) { errors.push(`Failed to update link at line ${link.line}: ${error}`); } } } updatedContent = lines.join('\n'); return { updatedContent, changes, errors, }; } /** Update a single link when a target file has been moved */ updateLinkForMovedFile(link, sourceFilePath, movedFilePath, newFilePath) { // Only update if this link points to the moved file if (link.resolvedPath !== movedFilePath) { return link.href; } if (link.type === 'claude-import' && this.options.updateClaudeImports) { return this.updateClaudeImportPath(link, sourceFilePath, newFilePath); } if (link.type === 'internal' || link.type === 'image') { return this.updateInternalLinkPath(link, sourceFilePath, newFilePath); } return link.href; } /** Update a link when the source file (containing the link) is being moved */ updateLinkForSourceFileMove(link, oldSourceFilePath, newSourceFilePath) { if (link.type === 'claude-import' && this.options.updateClaudeImports) { return PathUtils.updateClaudeImportPath(link.href, oldSourceFilePath, newSourceFilePath); } if (link.type === 'internal' || link.type === 'image') { return PathUtils.updateRelativePath(link.href, oldSourceFilePath, newSourceFilePath); } return link.href; } updateClaudeImportPath(link, sourceFilePath, newTargetFilePath) { // For Claude imports, we need to maintain the correct path const sourceDir = dirname(sourceFilePath); if (this.options.preferRelativePaths && !link.href.startsWith('/') && !link.href.startsWith('~/')) { let newPath = PathUtils.makeRelative(newTargetFilePath, sourceDir); // Ensure relative paths start with ./ for markdown compatibility if (!newPath.startsWith('./') && !newPath.startsWith('../') && !newPath.startsWith('/')) { newPath = `./${newPath}`; } return newPath; } return newTargetFilePath; } updateInternalLinkPath(link, sourceFilePath, newTargetFilePath) { const sourceDir = dirname(sourceFilePath); // Extract anchor if present const [, anchor] = link.href.split('#'); const anchorSuffix = anchor ? `#${anchor}` : ''; let newPath; if (this.options.preferRelativePaths && !link.absolute) { newPath = PathUtils.makeRelative(newTargetFilePath, sourceDir); // Ensure relative paths start with ./ for markdown compatibility if (!newPath.startsWith('./') && !newPath.startsWith('../') && !newPath.startsWith('/')) { newPath = `./${newPath}`; } } else { newPath = newTargetFilePath; } // Convert to Unix-style paths for markdown newPath = PathUtils.toUnixPath(newPath); return newPath + anchorSuffix; } /** Replace a link in a line of text while preserving formatting */ replaceLinkInLine(line, link, newHref) { if (link.type === 'claude-import') { // Replace Claude import: @old-path with @new-path const oldImport = `@${link.href}`; const newImport = `@${newHref}`; return line.replace(oldImport, newImport); } // For regular markdown links, we need to be more careful to preserve formatting if (link.type === 'image') { // Image links: ![alt](href) or ![alt](href "title") const imageRegex = new RegExp(`!\\[([^\\]]*)\\]\\(\\s*${this.escapeRegex(link.href)}(\\s+"[^"]*")?\\s*\\)`, 'g'); return line.replace(imageRegex, `![$1](${newHref}$2)`); } if (link.type === 'reference') { // Reference-style links are handled in the reference definitions // For now, just return the line unchanged return line; } // Regular links: [text](href) or [text](href "title") const linkRegex = new RegExp(`\\[([^\\]]*)\\]\\(\\s*${this.escapeRegex(link.href)}(\\s+"[^"]*")?\\s*\\)`, 'g'); return line.replace(linkRegex, `[$1](${newHref}$2)`); } /** Escape special regex characters in a string */ escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** Update reference-style link definitions */ async refactorReferenceDefinitions(file, movedFilePath, newFilePath) { const content = await FileUtils.readTextFile(file.filePath); const changes = []; const errors = []; let updatedContent = content; const lines = content.split('\n'); // Update reference definitions that point to the moved file for (const reference of file.references) { const resolvedPath = PathUtils.resolvePath(reference.url, dirname(file.filePath)); if (resolvedPath === movedFilePath) { try { const newUrl = this.updateInternalLinkPath({ ...reference, href: reference.url, type: 'internal', text: undefined, referenceId: undefined, line: reference.line, column: 1, absolute: false, }, file.filePath, newFilePath); if (newUrl !== reference.url) { const oldLine = lines[reference.line - 1]; const refRegex = new RegExp(`\\[${this.escapeRegex(reference.id)}\\]:\\s*${this.escapeRegex(reference.url)}(\\s+"[^"]*")?`, 'g'); const newLine = oldLine.replace(refRegex, `[${reference.id}]: ${newUrl}${reference.title ? ` "${reference.title}"` : ''}`); if (newLine !== oldLine) { lines[reference.line - 1] = newLine; changes.push({ type: 'link-updated', filePath: file.filePath, oldValue: reference.url, newValue: newUrl, line: reference.line, }); } } } catch (error) { errors.push(`Failed to update reference ${reference.id} at line ${reference.line}: ${error}`); } } } updatedContent = lines.join('\n'); return { updatedContent, changes, errors, }; } } //# sourceMappingURL=link-refactorer.js.map