markmv
Version:
TypeScript CLI for markdown file operations with intelligent link refactoring
668 lines • 24.9 kB
JavaScript
/**
* 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