UNPKG

markmv

Version:

TypeScript CLI for markdown file operations with intelligent link refactoring

668 lines 24.9 kB
/** * Abstract base class for all join strategies. * * Provides common functionality for joining markdown files including frontmatter merging, conflict * detection, and link deduplication. Concrete strategies implement specific ordering algorithms. * * @category Strategies * * @example * Implementing a custom join strategy * ```typescript * class CustomJoinStrategy extends BaseJoinStrategy { * async join(sections: JoinSection[]): Promise<JoinResult> { * // Custom ordering logic * const orderedSections = this.customSort(sections); * return this.buildResult(orderedSections); * } * } * ``` */ export class BaseJoinStrategy { options; constructor(options = {}) { this.options = { orderStrategy: 'dependency', separator: '\n\n---\n\n', mergeFrontmatter: true, deduplicateLinks: true, resolveHeaderConflicts: false, preserveStructure: true, ...options, }; } /** Extract title from content (frontmatter or first header) */ extractTitle(content, frontmatter) { // Try frontmatter first if (frontmatter) { const titleMatch = frontmatter.match(/^title:\s*(.+)$/m); if (titleMatch) { return titleMatch[1].trim().replace(/['"]/g, ''); } } // Try first header const lines = content.split('\n'); for (const line of lines) { const headerMatch = line.match(/^#+\s+(.+)$/); if (headerMatch) { return headerMatch[1].trim(); } } return undefined; } /** Merge multiple frontmatter blocks */ mergeFrontmatter(sections) { const frontmatterData = {}; const arrays = {}; for (const section of sections) { if (!section.frontmatter) continue; const lines = section.frontmatter .replace(/^---\n/, '') .replace(/\n---$/, '') .split('\n'); for (const line of lines) { const match = line.match(/^([^:]+):\s*(.*)$/); if (match) { const key = match[1].trim(); const value = match[2].trim(); if (key === 'tags' || key === 'categories' || key === 'keywords') { // Handle arrays if (!arrays[key]) arrays[key] = []; if (value.startsWith('[') && value.endsWith(']')) { // Parse array format const items = value .slice(1, -1) .split(',') .map((item) => item.trim().replace(/['"]/g, '')); arrays[key].push(...items); } else { arrays[key].push(value.replace(/['"]/g, '')); } } else if (key === 'title') { // Use first title found, or combine if different if (!frontmatterData[key]) { frontmatterData[key] = value.replace(/['"]/g, ''); } else if (frontmatterData[key] !== value.replace(/['"]/g, '')) { frontmatterData[key] = `${frontmatterData[key]} & ${value.replace(/['"]/g, '')}`; } } else { // Simple key-value pairs - use first found if (!frontmatterData[key]) { const cleanValue = value.replace(/['"]/g, ''); // Try to parse as number if it looks like one if (/^\d+$/.test(cleanValue)) { frontmatterData[key] = Number.parseInt(cleanValue, 10); } else { frontmatterData[key] = cleanValue; } } } } } } // Merge arrays back into frontmatter for (const [key, values] of Object.entries(arrays)) { frontmatterData[key] = [...new Set(values)]; // Remove duplicates } // Generate frontmatter string if (Object.keys(frontmatterData).length === 0) { return ''; } let result = '---\n'; for (const [key, value] of Object.entries(frontmatterData)) { if (Array.isArray(value)) { result += `${key}: [${value.map((v) => `"${v}"`).join(', ')}]\n`; } else if (typeof value === 'number') { result += `${key}: ${value}\n`; } else { result += `${key}: "${value}"\n`; } } result += '---\n'; return result; } /** Detect conflicts between sections */ detectConflicts(sections) { const conflicts = []; const seenHeaders = new Set(); const headerFiles = {}; // Check for duplicate headers for (const section of sections) { const headers = this.extractHeaders(section.content); for (const header of headers) { const normalizedHeader = header.toLowerCase().trim(); if (seenHeaders.has(normalizedHeader)) { if (!headerFiles[normalizedHeader]) { headerFiles[normalizedHeader] = []; } headerFiles[normalizedHeader].push(section.filePath); } else { seenHeaders.add(normalizedHeader); headerFiles[normalizedHeader] = [section.filePath]; } } } // Add conflicts for duplicate headers for (const [header, files] of Object.entries(headerFiles)) { if (files.length > 1) { conflicts.push({ type: 'duplicate-headers', description: `Duplicate header "${header}" found in multiple files`, files, resolution: 'Consider renaming headers or adding file prefixes', }); } } // Check for frontmatter conflicts const frontmatterKeys = new Set(); const conflictingKeys = {}; for (const section of sections) { if (section.frontmatter) { const lines = section.frontmatter.split('\n'); for (const line of lines) { const match = line.match(/^([^:]+):/); if (match) { const key = match[1].trim(); if (frontmatterKeys.has(key)) { if (!conflictingKeys[key]) { conflictingKeys[key] = []; } conflictingKeys[key].push(section.filePath); } else { frontmatterKeys.add(key); conflictingKeys[key] = [section.filePath]; } } } } } for (const [key, files] of Object.entries(conflictingKeys)) { if (files.length > 1) { conflicts.push({ type: 'frontmatter-merge', description: `Conflicting frontmatter key "${key}" in multiple files`, files, resolution: 'Values will be merged or first value used', }); } } return conflicts; } /** Extract all headers from content */ extractHeaders(content) { const headers = []; const lines = content.split('\n'); for (const line of lines) { const match = line.match(/^#+\s+(.+)$/); if (match) { headers.push(match[1].trim()); } } return headers; } /** Deduplicate links in combined content */ deduplicateLinks(content) { const seenLinks = new Set(); const removedLinks = []; const lines = content.split('\n'); const processedLines = []; for (const line of lines) { let processedLine = line; // Find all markdown links in the line const linkMatches = line.matchAll(/\[([^\]]*)\]\(([^)]+)\)/g); for (const match of linkMatches) { const fullLink = match[0]; const linkText = match[1]; const linkUrl = match[2]; const normalizedLink = `${linkText}|${linkUrl}`; if (seenLinks.has(normalizedLink)) { // Remove duplicate link processedLine = processedLine.replace(fullLink, linkText || linkUrl); removedLinks.push(fullLink); } else { seenLinks.add(normalizedLink); } } // Also check for bare URLs and reference-style links const urlMatches = line.matchAll(/https?:\/\/[^\s]+/g); for (const match of urlMatches) { const url = match[0]; if (seenLinks.has(url)) { processedLine = processedLine.replace(url, ''); removedLinks.push(url); } else { seenLinks.add(url); } } processedLines.push(processedLine); } return { content: processedLines.join('\n'), removedLinks, }; } } /** * Join strategy that orders content based on dependency relationships. * * Uses topological sorting to arrange sections so that files are ordered according to their * cross-reference dependencies. Files with no dependencies come first, followed by files that * depend on them. * * @category Strategies * * @example * Dependency-based joining * ```typescript * const strategy = new DependencyOrderJoinStrategy({ * mergeFrontmatter: true, * deduplicateLinks: true * }); * * const result = await strategy.join(sections); * if (result.success) { * console.log(`Joined ${result.sourceFiles.length} files in dependency order`); * } * ``` */ export class DependencyOrderJoinStrategy extends BaseJoinStrategy { async join(sections) { const errors = []; const warnings = []; const conflicts = this.detectConflicts(sections); try { // Sort sections by dependency order (topological sort) const orderedSections = this.topologicalSort(sections); if (!orderedSections) { warnings.push('Circular dependency detected, falling back to manual order'); const fallbackSections = [...sections].sort((a, b) => a.order - b.order); return this.buildResult(fallbackSections, conflicts, warnings, errors); } return this.buildResult(orderedSections, conflicts, warnings, errors); } catch (error) { errors.push(`Failed to join sections: ${error}`); return { success: false, content: '', frontmatter: undefined, sourceFiles: [], conflicts, warnings, errors, deduplicatedLinks: [], }; } } topologicalSort(sections) { const graph = new Map(); const inDegree = new Map(); const fileToSection = new Map(); // Initialize graph for (const section of sections) { const filePath = section.filePath; graph.set(filePath, new Set()); inDegree.set(filePath, 0); fileToSection.set(filePath, section); } // Build dependency graph for (const section of sections) { for (const dep of section.dependencies) { if (fileToSection.has(dep)) { graph.get(dep)?.add(section.filePath); inDegree.set(section.filePath, (inDegree.get(section.filePath) || 0) + 1); } } } // Topological sort const queue = []; const result = []; // Find nodes with no incoming edges for (const [file, degree] of inDegree) { if (degree === 0) { queue.push(file); } } while (queue.length > 0) { const current = queue.shift(); if (!current) break; const section = fileToSection.get(current); if (!section) continue; result.push(section); // Remove edges and update in-degrees const neighbors = graph.get(current) || new Set(); for (const neighbor of neighbors) { const newDegree = (inDegree.get(neighbor) || 0) - 1; inDegree.set(neighbor, newDegree); if (newDegree === 0) { queue.push(neighbor); } } } // Check for cycles if (result.length !== sections.length) { return null; // Circular dependency detected } return result; } buildResult(orderedSections, conflicts, warnings, errors) { const sourceFiles = orderedSections.map((s) => s.filePath); const separator = this.options.separator || '\n\n---\n\n'; // Combine content let combinedContent = orderedSections .map((section) => section.content.replace(/^---\n.*?\n---\n/s, '').trim()) .join(separator); let deduplicatedLinks = []; // Deduplicate links if requested if (this.options.deduplicateLinks) { const dedupeResult = this.deduplicateLinks(combinedContent); combinedContent = dedupeResult.content; deduplicatedLinks = dedupeResult.removedLinks; } // Merge frontmatter if requested let frontmatter; if (this.options.mergeFrontmatter) { frontmatter = this.mergeFrontmatter(orderedSections); } return { success: true, content: combinedContent, frontmatter, sourceFiles, conflicts, warnings, errors, deduplicatedLinks, }; } } /** * Join strategy that orders content alphabetically by title or filename. * * Provides simple, predictable ordering by sorting files alphabetically based on their extracted * title (from frontmatter or first header) or falling back to the filename if no title is * available. * * @category Strategies * * @example * Alphabetical joining * ```typescript * const strategy = new AlphabeticalJoinStrategy({ * separator: '\n\n<!-- Next Section -->\n\n' * }); * * const result = await strategy.join(sections); * console.log(`Files ordered: ${result.sourceFiles.join(', ')}`); * ``` */ export class AlphabeticalJoinStrategy extends BaseJoinStrategy { async join(sections) { const errors = []; const warnings = []; const conflicts = this.detectConflicts(sections); try { // Sort sections alphabetically by title or filename const orderedSections = [...sections].sort((a, b) => { const titleA = a.title || a.filePath; const titleB = b.title || b.filePath; return titleA.toLowerCase().localeCompare(titleB.toLowerCase()); }); return this.buildResult(orderedSections, conflicts, warnings, errors); } catch (error) { errors.push(`Failed to join sections: ${error}`); return { success: false, content: '', frontmatter: undefined, sourceFiles: [], conflicts, warnings, errors, deduplicatedLinks: [], }; } } buildResult(orderedSections, conflicts, warnings, errors) { const sourceFiles = orderedSections.map((s) => s.filePath); const separator = this.options.separator || '\n\n---\n\n'; let combinedContent = orderedSections .map((section) => section.content.replace(/^---\n.*?\n---\n/s, '').trim()) .join(separator); let deduplicatedLinks = []; if (this.options.deduplicateLinks) { const dedupeResult = this.deduplicateLinks(combinedContent); combinedContent = dedupeResult.content; deduplicatedLinks = dedupeResult.removedLinks; } let frontmatter; if (this.options.mergeFrontmatter) { frontmatter = this.mergeFrontmatter(orderedSections); } return { success: true, content: combinedContent, frontmatter, sourceFiles, conflicts, warnings, errors, deduplicatedLinks, }; } } /** * Join strategy that uses a custom manual ordering with alphabetical fallback. * * Allows explicit specification of file order through the customOrder option. Files not specified * in the custom order are appended in alphabetical order. This provides maximum control over the * final document structure. * * @category Strategies * * @example * Manual ordering with fallback * ```typescript * const strategy = new ManualOrderJoinStrategy({ * customOrder: ['intro.md', 'main-content.md', 'conclusion.md'], * mergeFrontmatter: true * }); * * // Files will be ordered as specified, with any others alphabetically * const result = await strategy.join(sections); * ``` */ export class ManualOrderJoinStrategy extends BaseJoinStrategy { async join(sections) { const errors = []; const warnings = []; const conflicts = this.detectConflicts(sections); try { const customOrder = this.options.customOrder || []; const orderedSections = []; const usedSections = new Set(); // Add sections in custom order for (const filePath of customOrder) { const section = sections.find((s) => s.filePath === filePath); if (section) { orderedSections.push(section); usedSections.add(filePath); } else { warnings.push(`File ${filePath} specified in custom order but not found in sections`); } } // Add remaining sections in alphabetical order const remainingSections = sections .filter((s) => !usedSections.has(s.filePath)) .sort((a, b) => { const titleA = a.title || a.filePath; const titleB = b.title || b.filePath; return titleA.toLowerCase().localeCompare(titleB.toLowerCase()); }); orderedSections.push(...remainingSections); return this.buildResult(orderedSections, conflicts, warnings, errors); } catch (error) { errors.push(`Failed to join sections: ${error}`); return { success: false, content: '', frontmatter: undefined, sourceFiles: [], conflicts, warnings, errors, deduplicatedLinks: [], }; } } buildResult(orderedSections, conflicts, warnings, errors) { const sourceFiles = orderedSections.map((s) => s.filePath); const separator = this.options.separator || '\n\n---\n\n'; let combinedContent = orderedSections .map((section) => section.content.replace(/^---\n.*?\n---\n/s, '').trim()) .join(separator); let deduplicatedLinks = []; if (this.options.deduplicateLinks) { const dedupeResult = this.deduplicateLinks(combinedContent); combinedContent = dedupeResult.content; deduplicatedLinks = dedupeResult.removedLinks; } let frontmatter; if (this.options.mergeFrontmatter) { frontmatter = this.mergeFrontmatter(orderedSections); } return { success: true, content: combinedContent, frontmatter, sourceFiles, conflicts, warnings, errors, deduplicatedLinks, }; } } /** * Join strategy that orders content chronologically by date. * * Extracts dates from frontmatter (date, created, modified fields) or attempts to parse dates from * filenames. Orders content from oldest to newest, providing a timeline-based organization for * content. * * @category Strategies * * @example * Chronological joining * ```typescript * const strategy = new ChronologicalJoinStrategy({ * separator: '\n\n---\n\n' * }); * * // Files will be ordered by date (oldest first) * const result = await strategy.join(sections); * console.log(`Chronological order: ${result.sourceFiles.join(' → ')}`); * ``` */ export class ChronologicalJoinStrategy extends BaseJoinStrategy { async join(sections) { const errors = []; const warnings = []; const conflicts = this.detectConflicts(sections); try { // Sort sections by date (extracted from frontmatter or filename) const orderedSections = [...sections].sort((a, b) => { const dateA = this.extractDate(a); const dateB = this.extractDate(b); if (!dateA && !dateB) return 0; if (!dateA) return 1; // Put undated items last if (!dateB) return -1; // Put undated items last return dateA.getTime() - dateB.getTime(); }); return this.buildResult(orderedSections, conflicts, warnings, errors); } catch (error) { errors.push(`Failed to join sections: ${error}`); return { success: false, content: '', frontmatter: undefined, sourceFiles: [], conflicts, warnings, errors, deduplicatedLinks: [], }; } } extractDate(section) { // Try frontmatter first if (section.frontmatter) { const dateMatch = section.frontmatter.match(/^date:\s*(.+)$/m); if (dateMatch) { const date = new Date(dateMatch[1].trim().replace(/['"]/g, '')); if (!Number.isNaN(date.getTime())) { return date; } } } // Try to extract date from filename (YYYY-MM-DD format) const filenameDateMatch = section.filePath.match(/(\d{4}-\d{2}-\d{2})/); if (filenameDateMatch) { const date = new Date(filenameDateMatch[1]); if (!Number.isNaN(date.getTime())) { return date; } } return null; } buildResult(orderedSections, conflicts, warnings, errors) { const sourceFiles = orderedSections.map((s) => s.filePath); const separator = this.options.separator || '\n\n---\n\n'; let combinedContent = orderedSections .map((section) => section.content.replace(/^---\n.*?\n---\n/s, '').trim()) .join(separator); let deduplicatedLinks = []; if (this.options.deduplicateLinks) { const dedupeResult = this.deduplicateLinks(combinedContent); combinedContent = dedupeResult.content; deduplicatedLinks = dedupeResult.removedLinks; } let frontmatter; if (this.options.mergeFrontmatter) { frontmatter = this.mergeFrontmatter(orderedSections); } return { success: true, content: combinedContent, frontmatter, sourceFiles, conflicts, warnings, errors, deduplicatedLinks, }; } } //# sourceMappingURL=join-strategies.js.map