markmv
Version:
TypeScript CLI for markdown file operations with intelligent link refactoring
242 lines • 10 kB
JavaScript
import { existsSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
import { glob } from 'glob';
import { LinkConverter } from '../core/link-converter.js';
import { PathUtils } from '../utils/path-utils.js';
/**
* Expand source patterns (which may include globs) to actual markdown file paths.
*
* This function processes an array of file patterns that may include:
*
* - Direct file paths
* - Glob patterns (star.md, starstar/star.md, etc.)
* - Directory paths (when recursive is enabled)
* - Mixed combinations of all above
*
* It validates that all resolved files are markdown files and provides verbose output when
* requested.
*
* @example
* ```typescript
* // Direct file paths
* await expandSourcePatterns(['README.md', 'docs/guide.md']);
*
* // Glob patterns
* await expandSourcePatterns(['star.md', 'docs/star.md']);
*
* // Recursive directory processing
* await expandSourcePatterns(['docs/'], { recursive: true });
* ```;
*
* @param patterns - Array of file patterns, paths, or directories to expand
* @param options - Conversion options including recursive processing
*
* @returns Promise resolving to an array of absolute markdown file paths
*
* @throws Error if no markdown files are found or if patterns are invalid
*/
async function expandSourcePatterns(patterns, options) {
const resolvedFiles = new Set();
for (const pattern of patterns) {
const absolutePattern = resolve(pattern);
// Check if it's a direct file
if (existsSync(absolutePattern) && statSync(absolutePattern).isFile()) {
if (PathUtils.isMarkdownFile(absolutePattern)) {
resolvedFiles.add(absolutePattern);
if (options.verbose) {
console.log(`Added file: ${absolutePattern}`);
}
}
else {
console.warn(`Skipping non-markdown file: ${absolutePattern}`);
}
continue;
}
// Check if it's a directory
if (existsSync(absolutePattern) && statSync(absolutePattern).isDirectory()) {
if (options.recursive) {
const globPattern = `${absolutePattern}/**/*.md`;
const files = await glob(globPattern, { absolute: true });
files.forEach((file) => resolvedFiles.add(file));
if (options.verbose) {
console.log(`Added ${files.length} files from directory: ${absolutePattern}`);
}
}
else {
const globPattern = `${absolutePattern}/*.md`;
const files = await glob(globPattern, { absolute: true });
files.forEach((file) => resolvedFiles.add(file));
if (options.verbose) {
console.log(`Added ${files.length} files from directory: ${absolutePattern}`);
}
}
continue;
}
// Treat as glob pattern
try {
const files = await glob(pattern, { absolute: true });
const markdownFiles = files.filter((file) => PathUtils.isMarkdownFile(file));
if (markdownFiles.length === 0 && options.verbose) {
console.warn(`No markdown files found for pattern: ${pattern}`);
}
markdownFiles.forEach((file) => resolvedFiles.add(file));
if (options.verbose) {
console.log(`Pattern "${pattern}" matched ${markdownFiles.length} markdown files`);
}
}
catch (error) {
throw new Error(`Invalid glob pattern "${pattern}": ${error instanceof Error ? error.message : String(error)}`);
}
}
const finalFiles = Array.from(resolvedFiles);
if (finalFiles.length === 0) {
throw new Error(`No markdown files found matching the provided patterns: ${patterns.join(', ')}`);
}
return finalFiles.sort();
}
/**
* Validate conversion options and provide defaults.
*
* @param options - Raw conversion options from CLI
*
* @returns Validated options with defaults applied
*
* @throws Error if options are invalid
*/
function validateConvertOptions(options) {
const validated = { ...options };
// Validate path resolution
if (validated.pathResolution && !['absolute', 'relative'].includes(validated.pathResolution)) {
throw new Error(`Invalid path resolution type: ${validated.pathResolution}. Must be 'absolute' or 'relative'`);
}
// Validate link style
if (validated.linkStyle &&
!['markdown', 'claude', 'combined', 'wikilink'].includes(validated.linkStyle)) {
throw new Error(`Invalid link style: ${validated.linkStyle}. Must be 'markdown', 'claude', 'combined', or 'wikilink'`);
}
// Require at least one conversion operation
if (!validated.pathResolution && !validated.linkStyle) {
throw new Error('At least one conversion option must be specified (--path-resolution or --link-style)');
}
// Set default base path
if (validated.pathResolution && !validated.basePath) {
validated.basePath = process.cwd();
}
return validated;
}
/**
* Print conversion summary statistics.
*
* @param files - Array of files processed
* @param result - Operation result with conversion details
* @param options - Conversion options for context
*/
function printConvertSummary(files, result, options) {
console.log('\n=== Conversion Summary ===');
console.log(`Files processed: ${files.length}`);
console.log(`Files modified: ${result.modifiedFiles.length}`);
console.log(`Total changes: ${result.changes.length}`);
if (options.pathResolution && options.linkStyle) {
console.log(`Path resolution: converted to ${options.pathResolution}`);
console.log(`Link style: converted to ${options.linkStyle}`);
}
else if (options.pathResolution) {
console.log(`Path resolution: converted to ${options.pathResolution}`);
}
else if (options.linkStyle) {
console.log(`Link style: converted to ${options.linkStyle}`);
}
if (result.errors.length > 0) {
console.log(`Errors: ${result.errors.length}`);
result.errors.forEach((error) => console.error(` - ${error}`));
}
if (result.warnings.length > 0) {
console.log(`Warnings: ${result.warnings.length}`);
result.warnings.forEach((warning) => console.warn(` - ${warning}`));
}
if (options.dryRun) {
console.log('\n(Dry run - no files were actually modified)');
}
}
/**
* CLI command handler for convert operations.
*
* Processes markdown files to convert link formats and path resolution according to specified
* options. Supports dry run mode, verbose output, and various conversion strategies.
*
* @example
* ```bash
* # Convert all links to relative paths
* markmv convert docs/star.md --path-resolution relative
*
* # Convert to wikilink style with absolute paths
* markmv convert starstar/star.md --link-style wikilink --path-resolution absolute
*
* # Dry run with verbose output
* markmv convert README.md --link-style claude --dry-run --verbose
* ```;
*
* @param patterns - File patterns to process (supports globs)
* @param options - Command options specifying conversion parameters
*
* @group Commands
*/
export async function convertCommand(patterns, options) {
try {
// Validate input patterns
if (!patterns || patterns.length === 0) {
throw new Error('At least one file pattern must be specified');
}
// Validate and normalize options
const validatedOptions = validateConvertOptions(options);
if (validatedOptions.verbose) {
console.log('Starting link conversion...');
console.log(`Patterns: ${patterns.join(', ')}`);
if (validatedOptions.pathResolution) {
console.log(`Path resolution: ${validatedOptions.pathResolution}`);
}
if (validatedOptions.linkStyle) {
console.log(`Link style: ${validatedOptions.linkStyle}`);
}
if (validatedOptions.dryRun) {
console.log('Dry run mode: no files will be modified');
}
}
// Expand file patterns
const files = await expandSourcePatterns(patterns, validatedOptions);
if (validatedOptions.verbose) {
console.log(`Found ${files.length} markdown files to process`);
}
// Create converter and process files
const converter = new LinkConverter();
const operationOptions = {
...(validatedOptions.pathResolution && { pathResolution: validatedOptions.pathResolution }),
...(validatedOptions.basePath && { basePath: validatedOptions.basePath }),
...(validatedOptions.linkStyle && { linkStyle: validatedOptions.linkStyle }),
...(validatedOptions.recursive && { recursive: validatedOptions.recursive }),
...(validatedOptions.dryRun && { dryRun: validatedOptions.dryRun }),
...(validatedOptions.verbose && { verbose: validatedOptions.verbose }),
};
const result = await converter.convertFiles(files, operationOptions);
// Print results
if (validatedOptions.verbose || result.changes.length > 0) {
printConvertSummary(files, result, validatedOptions);
}
// Exit with appropriate code
if (!result.success) {
console.error('\nConversion completed with errors');
process.exit(1);
}
else if (result.changes.length === 0) {
console.log('No changes were needed');
}
else {
console.log('\nConversion completed successfully');
}
}
catch (error) {
console.error('Conversion failed:', error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
//# sourceMappingURL=convert.js.map