UNPKG

markmv

Version:

TypeScript CLI for markdown file operations with intelligent link refactoring

521 lines 20.7 kB
/** * Abstract base class for all split strategies. * * Provides common functionality for splitting markdown files including filename generation, * frontmatter handling, and content sanitization. Concrete strategies implement specific splitting * algorithms. * * @category Strategies * * @example * Implementing a custom split strategy * ```typescript * class CustomSplitStrategy extends BaseSplitStrategy { * async split(content: string, originalFilename: string): Promise<SplitResult> { * // Custom splitting logic * const sections = this.customSplit(content); * return { sections, remainingContent: undefined, errors: [], warnings: [] }; * } * } * ``` */ export class BaseSplitStrategy { options; constructor(options = {}) { this.options = { headerLevel: 2, preserveFrontmatter: true, filenamePattern: '{title}', ...options, }; } /** Generate a safe filename from a title */ generateFilename(title, index, originalFilename) { const pattern = this.options.filenamePattern || '{title}'; const baseName = this.sanitizeFilename(title) || `section-${index + 1}`; const extension = originalFilename.match(/\.[^.]+$/)?.[0] || '.md'; return (pattern .replace('{title}', baseName) .replace('{index}', String(index + 1)) .replace('{original}', originalFilename.replace(/\.[^.]+$/, '')) + extension); } /** Sanitize a string to be safe for use as filename */ sanitizeFilename(str) { return str .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .substring(0, 50); } /** Extract frontmatter from content */ extractFrontmatter(content) { const frontmatterMatch = content.match(/^---\n(.*?)\n---\n/s); if (frontmatterMatch) { return { frontmatter: frontmatterMatch[0], content: content.substring(frontmatterMatch[0].length), }; } return { frontmatter: '', content }; } /** Extract title from header line */ extractTitleFromHeader(headerLine) { return headerLine.replace(/^#+\s*/, '').trim(); } /** Count the header level (number of # characters) */ getHeaderLevel(line) { const match = line.match(/^(#+)(\s|$)/); return match ? match[1].length : 0; } /** Check if a line is a header at or above the specified level */ isTargetHeader(line, targetLevel) { const level = this.getHeaderLevel(line); return level === targetLevel; } } /** * Split strategy that divides content based on markdown headers. * * Splits the file at headers of a specified level, creating a new file for each section. This is * ideal for documents with clear hierarchical structure where each major section can stand alone. * * @category Strategies * * @example * Header-based splitting * ```typescript * const strategy = new HeaderBasedSplitStrategy({ * headerLevel: 2, // Split on ## headers * outputDir: './sections/', * filenamePattern: '{title}' * }); * * const result = await strategy.split(content, 'document.md'); * console.log(`Created ${result.sections.length} sections`); * ``` */ export class HeaderBasedSplitStrategy extends BaseSplitStrategy { async split(content, originalFilename) { const { frontmatter, content: mainContent } = this.extractFrontmatter(content); const lines = mainContent.split('\n'); const sections = []; const errors = []; const warnings = []; const targetLevel = this.options.headerLevel || 2; let currentSection = null; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (this.isTargetHeader(line, targetLevel)) { // Save previous section if (currentSection) { sections.push({ title: currentSection.title, content: currentSection.content.join('\n'), startLine: currentSection.startLine, endLine: i - 1, headerLevel: currentSection.headerLevel, filename: this.generateFilename(currentSection.title, sections.length, originalFilename), }); } // Start new section const title = this.extractTitleFromHeader(line); const headerLevel = this.getHeaderLevel(line); if (!title.trim()) { warnings.push(`Empty header found at line ${i + 1}`); } currentSection = { title: title || `Section ${sections.length + 1}`, content: [line], startLine: i, headerLevel, }; } else if (currentSection) { currentSection.content.push(line); } } // Save the last section if (currentSection) { sections.push({ title: currentSection.title, content: currentSection.content.join('\n'), startLine: currentSection.startLine, endLine: lines.length - 1, headerLevel: currentSection.headerLevel, filename: this.generateFilename(currentSection.title, sections.length, originalFilename), }); } if (sections.length === 0) { errors.push(`No headers found at level ${targetLevel} or above`); } return { sections, remainingContent: this.options.preserveFrontmatter ? frontmatter : undefined, errors, warnings, }; } } /** * Split strategy that divides content based on file size limits. * * Creates new files when the current section exceeds a specified size limit. This ensures that no * generated file becomes too large, which is useful for performance or platform constraints. * * @category Strategies * * @example * Size-based splitting * ```typescript * const strategy = new SizeBasedSplitStrategy({ * maxSize: 50, // 50KB per file * outputDir: './chunks/', * filenamePattern: '{original}-part-{index}' * }); * * const result = await strategy.split(content, 'large-document.md'); * console.log(`Split into ${result.sections.length} files under 50KB each`); * ``` */ export class SizeBasedSplitStrategy extends BaseSplitStrategy { async split(content, originalFilename) { const { frontmatter, content: mainContent } = this.extractFrontmatter(content); const maxSizeBytes = (this.options.maxSize || 100) * 1024; // Convert KB to bytes const lines = mainContent.split('\n'); const sections = []; const errors = []; const warnings = []; let currentSection = null; let sectionCount = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const lineSize = Buffer.byteLength(`${line}\n`, 'utf8'); // Start new section if needed if (!currentSection) { const title = this.findNearestHeader(lines, i) || `Part ${sectionCount + 1}`; currentSection = { title, content: [], startLine: i, size: 0, }; } // Check if adding this line would exceed size limit if (currentSection.size + lineSize > maxSizeBytes && currentSection.content.length > 0) { // Save current section sections.push({ title: currentSection.title, content: currentSection.content.join('\n'), startLine: currentSection.startLine, endLine: i - 1, filename: this.generateSizeBasedFilename(currentSection.title, sections.length, originalFilename), }); // Start new section const title = this.findNearestHeader(lines, i) || `Part ${sections.length + 1}`; currentSection = { title, content: [line], startLine: i, size: lineSize, }; sectionCount++; } else { currentSection.content.push(line); currentSection.size += lineSize; } } // Save the last section if (currentSection && currentSection.content.length > 0) { sections.push({ title: currentSection.title, content: currentSection.content.join('\n'), startLine: currentSection.startLine, endLine: lines.length - 1, filename: this.generateSizeBasedFilename(currentSection.title, sections.length, originalFilename), }); } if (sections.length === 0) { errors.push('Content is empty or could not be split'); } return { sections, remainingContent: this.options.preserveFrontmatter ? frontmatter : undefined, errors, warnings, }; } findNearestHeader(lines, startIndex) { // Look backwards for a header for (let i = startIndex; i >= 0; i--) { if (this.getHeaderLevel(lines[i]) > 0) { return this.extractTitleFromHeader(lines[i]); } } // Look forwards for a header for (let i = startIndex; i < lines.length; i++) { if (this.getHeaderLevel(lines[i]) > 0) { return this.extractTitleFromHeader(lines[i]); } } return null; } /** Generate filename for size-based sections, ensuring uniqueness */ generateSizeBasedFilename(title, index, originalFilename) { const pattern = this.options.filenamePattern || '{title}'; let baseName = this.sanitizeFilename(title) || `part-${index + 1}`; const extension = originalFilename.match(/\.[^.]+$/)?.[0] || '.md'; // Always append index for size-based splits to ensure uniqueness if (index > 0) { baseName = `${baseName}-${index + 1}`; } return (pattern .replace('{title}', baseName) .replace('{index}', String(index + 1)) .replace('{original}', originalFilename.replace(/\.[^.]+$/, '')) + extension); } } /** * Split strategy that divides content at manually specified markers. * * Looks for specific comment markers or text patterns in the content to determine split points. * This provides precise control over where splits occur, regardless of content structure. * * @category Strategies * * @example * Manual marker splitting * ```typescript * const strategy = new ManualSplitStrategy({ * splitMarkers: ['<!-- split -->', '---BREAK---'], * outputDir: './parts/', * filenamePattern: '{title}' * }); * * // Content with markers like: <!-- split --> * const result = await strategy.split(content, 'document.md'); * ``` */ export class ManualSplitStrategy extends BaseSplitStrategy { async split(content, originalFilename) { const { frontmatter, content: mainContent } = this.extractFrontmatter(content); const markers = this.options.splitMarkers || ['<!-- split -->', '---split---']; const sections = []; const errors = []; const warnings = []; // Find all split markers const splitPositions = []; const lines = mainContent.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (markers.some((marker) => line.includes(marker))) { splitPositions.push(i); } } if (splitPositions.length === 0) { warnings.push('No split markers found. Use <!-- split --> or ---split--- to mark split points.'); return { sections: [], remainingContent: content, errors, warnings, }; } // Split content at markers let startLine = 0; for (let i = 0; i <= splitPositions.length; i++) { const endLine = i < splitPositions.length ? splitPositions[i] : lines.length; if (endLine > startLine) { const sectionLines = lines.slice(startLine, endLine); const sectionContent = sectionLines.join('\n'); // Find title for this section const title = this.findSectionTitle(sectionLines) || `Section ${i + 1}`; sections.push({ title, content: sectionContent, startLine, endLine: endLine - 1, filename: this.generateFilename(title, sections.length, originalFilename), }); } startLine = endLine + 1; // Skip the marker line } return { sections, remainingContent: this.options.preserveFrontmatter ? frontmatter : undefined, errors, warnings, }; } findSectionTitle(lines) { // Look for the first header in the section for (const line of lines) { if (this.getHeaderLevel(line) > 0) { return this.extractTitleFromHeader(line); } } // If no header, try to extract from first non-empty line for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('<!--') && !trimmed.startsWith('---')) { return trimmed.substring(0, 50); } } return null; } } /** * Split strategy that divides content at specific line numbers. * * Allows precise splitting at user-specified line numbers. This is useful when you know exactly * where you want to split a document, perhaps based on analysis or external requirements. * * @category Strategies * * @example * Line-based splitting * ```typescript * const strategy = new LineBasedSplitStrategy({ * splitLines: [100, 250, 400], // Split at these line numbers * outputDir: './sections/', * filenamePattern: 'section-{index}' * }); * * const result = await strategy.split(content, 'document.md'); * console.log(`Split at lines: ${strategy.options.splitLines?.join(', ')}`); * ``` */ export class LineBasedSplitStrategy extends BaseSplitStrategy { async split(content, originalFilename) { const { frontmatter, content: mainContent } = this.extractFrontmatter(content); const splitLines = this.options.splitLines || []; const sections = []; const errors = []; const warnings = []; if (splitLines.length === 0) { errors.push('No split lines specified. Use --split-lines option with comma-separated line numbers.'); return { sections: [], remainingContent: content, errors, warnings, }; } const lines = mainContent.split('\n'); const totalLines = lines.length; // Validate and sort split lines, adjusting invalid ones when possible const validSplitLines = []; for (const lineNum of splitLines) { if (lineNum < 1) { warnings.push(`Invalid line number ${lineNum}: file has ${totalLines} lines`); } else if (lineNum > totalLines) { warnings.push(`Invalid line number ${lineNum}: file has ${totalLines} lines`); // Adjust to split at end if reasonably close if (lineNum <= totalLines + 2) { validSplitLines.push(totalLines); } } else { validSplitLines.push(lineNum); } } // Remove duplicates and sort const uniqueSplitLines = [...new Set(validSplitLines)].sort((a, b) => a - b); if (uniqueSplitLines.length === 0) { // Still create sections from the content if there are valid sections to create if (lines.length > 0 && lines.some((line) => line.trim())) { const title = this.findLineSectionTitle(lines, 1) || 'Content'; sections.push({ title, content: lines.join('\n'), startLine: 0, endLine: lines.length - 1, filename: this.generateFilename(title, 0, originalFilename), }); } return { sections, remainingContent: this.options.preserveFrontmatter ? frontmatter : undefined, errors, warnings, }; } // Split content at specified lines let startLine = 0; for (let i = 0; i <= uniqueSplitLines.length; i++) { const endLine = i < uniqueSplitLines.length ? uniqueSplitLines[i] - 1 // Convert to 0-based and split before the line : lines.length; if (endLine > startLine) { const sectionLines = lines.slice(startLine, endLine); const sectionContent = sectionLines.join('\n'); if (sectionContent.trim()) { // Only create section if it has content // Find title for this section const title = this.findLineSectionTitle(sectionLines, startLine + 1) || `Lines ${startLine + 1}-${endLine}`; sections.push({ title, content: sectionContent, startLine, endLine: endLine - 1, filename: this.generateFilename(title, sections.length, originalFilename), }); } } startLine = endLine; } if (sections.length === 0) { errors.push('No sections were created from the specified line splits'); } return { sections, remainingContent: this.options.preserveFrontmatter ? frontmatter : undefined, errors, warnings, }; } findLineSectionTitle(lines, actualStartLine) { // Look for the first header in the section for (const line of lines) { if (this.getHeaderLevel(line) > 0) { return this.extractTitleFromHeader(line); } } // If no header, try to extract from first meaningful line // Look for lines that seem like good titles (complete thoughts, not fragments) for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('<!--') && !trimmed.startsWith('---')) { // Skip obvious continuation/fragment lines if (trimmed.match(/^(the|that|and|or|but|with|for|in|on|at|to|of)\s/i)) { continue; } // Use first few words as title, but limit to reasonable length const words = trimmed.split(/\s+/).slice(0, 5).join(' '); // If the line is long, truncate it if (words.length > 50) { return `${words.substring(0, 47)}...`; } // If it's a sentence, remove trailing punctuation for cleaner title return words.replace(/[.!?]+$/, ''); } } // Fallback to first non-empty line if no good title found for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('<!--') && !trimmed.startsWith('---')) { const words = trimmed.split(/\s+/).slice(0, 5).join(' '); if (words.length > 50) { return `${words.substring(0, 47)}...`; } return words.replace(/[.!?]+$/, ''); } } return `Section starting at line ${actualStartLine}`; } } //# sourceMappingURL=split-strategies.js.map