UNPKG

@fimbul-works/flatten

Version:

A utility for collecting project files and flattening them into a single directory

453 lines (448 loc) 18.1 kB
#!/usr/bin/env node "use strict"; /** * Flatten Utility - Project File Collection Script * * A command-line utility for collecting project files based on glob patterns and copying them * to a single directory with flattened paths. This tool is especially useful for: * - Preparing codebases to share with AI assistants * - Bundling files for analysis or archival * * HOW IT WORKS: * 1. Reads glob patterns from a .flatten configuration file * 2. Finds all matching files (with support for inclusion and exclusion rules) * 3. Copies files to a target directory with flattened naming * 4. Preserves original path information by converting separators to underscores * * PATH TRANSFORMATION: * Files are renamed using a bijective (reversible) transformation: * - Directory separators (/ or \) become single underscores (_) * - Existing underscores in filenames are doubled (__) * * Examples: * - src/components/Button.tsx → src_components_Button.tsx * - lib/nav_bar.js → lib_nav__bar.js * - index.ts → index.ts * * CONFIGURATION: * The .flatten file uses gitignore-style patterns: * - Lines starting with # are comments * - Regular patterns include files (e.g., "src/components/*.js") * - Patterns starting with ! exclude files (e.g., "!**\/*.test.js") * * @author FimbulWorks <https://github.com/fimbul-works> * @version 1.0.0 */ Object.defineProperty(exports, "__esModule", { value: true }); const node_fs_1 = require("node:fs"); const glob_1 = require("glob"); const minimatch_1 = require("minimatch"); // ==================== CONFIGURATION CONSTANTS ==================== /** Configuration file name */ const FLATTEN_FILE = ".flatten"; /** Full path to the configuration file */ const FLATTEN_FILE_PATH = `./${FLATTEN_FILE}`; /** Default target directory name (current project folder + "-flatten-flattened" suffix) */ const DEFAULT_TARGET_PATH = `../${process.cwd().split("/").pop()}-flatten-flattened/`; /** Maximum file size warning threshold (10MB) */ const LARGE_FILE_WARNING_SIZE = 10 * 1024 * 1024; // ==================== COMMAND LINE ARGUMENT PARSING ==================== // Parse command line arguments (skip 'node' and script name) const [, , ...args] = process.argv; // Command line flags and options let targetPath = DEFAULT_TARGET_PATH; // Target directory for flattened files let cleanFirst = false; // Whether to clean the target directory before copying let followSymlinks = false; // Whether to follow symbolic links let verbose = false; // Whether to list all files as they are copied let dryRun = false; // Whether to simulate the operation without copying let respectGitignore = false; // Whether to automatically exclude .gitignore patterns let maxFileSize = null; // Maximum file size to copy (in bytes) let showStats = false; // Whether to show detailed statistics // Collect any unrecognized arguments for error reporting const unknownArgs = []; // Collect the names of copied files and statistics const copiedFiles = []; const skippedFiles = []; const errorFiles = []; let totalBytes = 0; /** * Display usage information and help text */ function printHelp(print = console.log) { print("Flatten Utility - Project File Collection Tool v1.0.0"); print("Usage: npx flatten [OPTIONS] [TARGET_PATH]"); print(""); print("OPTIONS:"); print(" -i, --init Initialize .flatten file"); print(" -c, --clean Clean target directory before copying files"); print(" -s, --symlinks Follow symbolic links (use with caution)"); print(" -v, --verbose Show each file as it's copied"); print(" -n, --dry-run Show what would be copied without copying"); print(" -g, --gitignore Respect .gitignore patterns"); print(" --max-size SIZE Maximum file size to copy (e.g., 1MB, 500KB)"); print(" --stats Show detailed statistics after operation"); print(" -h, --help Show this help message"); print(""); print("TARGET_PATH:"); print(` Directory to copy flattened files (default: "${DEFAULT_TARGET_PATH}")`); print(""); print("CONFIGURATION:"); print(" Create a .flatten file with glob patterns (one per line)"); print(" Use # for comments and ! to exclude files"); print(""); print("EXAMPLES:"); print(" npx flatten --init # Initialize .flatten file"); print(" npx flatten --dry-run # Preview what would be copied"); print(" npx flatten --clean --stats # Clean and show statistics"); print(" npx flatten --gitignore --verbose # Respect .gitignore patterns"); print(" npx flatten --max-size 1MB # Skip files larger than 1MB"); } /** * Initialize the .flatten configuration file with default patterns */ function initializeFlattenFile() { if ((0, node_fs_1.existsSync)(FLATTEN_FILE)) { console.error(`ERROR: Existing ${FLATTEN_FILE} file found! Will not overwrite.`); process.exit(1); } const initial = `# Flatten Configuration File # # List file patterns to include (one per line) # Use # for comments and ! to exclude files # Common project files package.json README.md # Source code (adjust patterns for your project) src/**/*.js src/**/*.ts src/**/*.jsx src/**/*.tsx # Configuration files tsconfig.json *.config.{js|ts} # Exclusion rules (files to ignore) !**/*.d.ts !**/*.test.* !**/*.spec.* !**/node_modules/** !**/.git/** !**/dist/** !**/build/**`; try { (0, node_fs_1.writeFileSync)(FLATTEN_FILE_PATH, initial); console.log(`Created ${FLATTEN_FILE} with default patterns`); console.log("Edit the file to customize which files to include"); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`ERROR: Failed to create ${FLATTEN_FILE}: ${errorMessage}`); } } /** * Parse file size string to bytes (e.g., "1MB" -> 1048576) */ function parseFileSize(sizeStr) { const units = { B: 1, KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024 }; const match = sizeStr.match(/^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/i); if (!match) { throw new Error(`Invalid file size format: ${sizeStr}. Use format like: 1MB, 500KB, 2GB`); } const [, size, unit] = match; return Math.floor(Number.parseFloat(size) * units[unit.toUpperCase()]); } /** * Format bytes to human readable string */ function formatBytes(bytes) { const units = ["B", "KB", "MB", "GB"]; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(unitIndex > 0 ? 1 : 0)}${units[unitIndex]}`; } /** * Load and parse .gitignore patterns */ function loadGitignorePatterns() { const gitignorePath = "./.gitignore"; if (!(0, node_fs_1.existsSync)(gitignorePath)) { return []; } const content = (0, node_fs_1.readFileSync)(gitignorePath, "utf-8"); return content .split("\n") .map((line) => line.trim()) .filter((line) => line && !line.startsWith("#")); } // ==================== COMMAND LINE ARGUMENT PROCESSING ==================== // Process each argument to identify flags and options for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg.startsWith("-")) { // Handle flag arguments (both short and long form) const flag = arg.slice(1).replace(/^-/, ""); // Remove leading dash(es) switch (flag) { case "i": case "init": { initializeFlattenFile(); process.exit(0); } case "h": case "help": printHelp(); process.exit(0); case "c": case "clean": cleanFirst = true; break; case "s": case "symlinks": followSymlinks = true; break; case "v": case "verbose": verbose = true; break; case "n": case "dry-run": dryRun = true; break; case "g": case "gitignore": respectGitignore = true; break; case "stats": showStats = true; break; case "max-size": { const sizeArg = args[++i]; if (!sizeArg) { console.error("ERROR: --max-size requires a size argument (e.g., 1MB)"); process.exit(1); } try { maxFileSize = parseFileSize(sizeArg); } catch (error) { console.error(`ERROR: ${error instanceof Error ? error.message : error}`); process.exit(1); } break; } default: // Collect unknown flags for error reporting unknownArgs.push(arg); break; } } else { // First non-flag argument is treated as the target path if (targetPath === DEFAULT_TARGET_PATH) { targetPath = arg.endsWith("/") ? arg : `${arg}/`; // Ensure trailing slash } else { // Additional non-flag arguments are invalid unknownArgs.push(arg); } } } // ==================== ARGUMENT VALIDATION ==================== // Validate arguments - exit if any unknown arguments were provided if (unknownArgs.length) { console.error(`ERROR: Invalid arguments: ${unknownArgs.join(" ")}`); console.error(""); printHelp(console.error); process.exit(1); } // ==================== MAIN EXECUTION ==================== // Ensure the .flatten configuration file exists if (!(0, node_fs_1.existsSync)(FLATTEN_FILE_PATH)) { console.error(`ERROR: No ${FLATTEN_FILE} file found.`); console.error(`Run 'npx flatten --init' to create one, or create it manually.`); process.exit(1); } // Create the target directory if it doesn't exist (unless dry run) if (!dryRun) { if (!(0, node_fs_1.existsSync)(targetPath)) { (0, node_fs_1.mkdirSync)(targetPath, { recursive: true }); if (verbose) { console.log(`Created target directory: ${targetPath}`); } } // Clean existing files from target directory if requested if (cleanFirst) { const filesToRemove = (0, glob_1.globSync)(`${targetPath}*.*`); if (filesToRemove.length > 0) { console.log(`Cleaning ${filesToRemove.length} existing files from ${targetPath}...`); for (const file of filesToRemove) { try { (0, node_fs_1.rmSync)(file); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`ERROR: Failed to remove "${file}": ${errorMessage}`); console.error("Cancelling..."); process.exit(1); } } } else if (verbose) { console.log(`No existing files to clean in ${targetPath}`); } } } // Read and parse the .flatten configuration file const configText = (0, node_fs_1.readFileSync)(FLATTEN_FILE_PATH, "utf-8"); // Parse .flatten file contents with comprehensive cleaning const flattenList = configText .split("\n") .map((line) => line.replace(/#.*$/gm, "")) // Remove comments (everything after #) .map((line) => line.trim()) // Remove leading/trailing whitespace .filter(Boolean); // Remove empty lines // Validate that we have at least one rule if (flattenList.length === 0) { console.error(`ERROR: No file patterns found in ${FLATTEN_FILE}`); console.error(`Add some glob patterns to the file, or run 'npx flatten --init' for examples.`); process.exit(1); } // Separate exclusion rules (starting with !) from inclusion rules let denyRules = flattenList.filter((rule) => rule.startsWith("!")).map((rule) => rule.slice(1)); const copyRules = flattenList.filter((rule) => !rule.startsWith("!")); // Add .gitignore patterns if requested if (respectGitignore) { const gitignorePatterns = loadGitignorePatterns(); if (gitignorePatterns.length > 0) { denyRules = [...denyRules, ...gitignorePatterns]; if (verbose) { console.log(` Added ${gitignorePatterns.length} patterns from .gitignore`); } } } if (dryRun) { console.log("DRY RUN - No files will be copied"); } if (verbose) { console.log(`Found ${copyRules.length} inclusion rules and ${denyRules.length} exclusion rules`); if (maxFileSize) { console.log(`Maximum file size: ${formatBytes(maxFileSize)}`); } } // Execute all copy rules and accumulate the total number of files copied const startTime = Date.now(); const totalFilesCopied = copyRules.reduce((acc, rule) => { return acc + runRule(rule, denyRules); }, 0); const endTime = Date.now(); // Report final results with statistics if (verbose) { console.log(""); } if (totalFilesCopied > 0) { const verb = dryRun ? "would be copied" : "copied"; console.log(`✓ ${totalFilesCopied} files ${verb}${dryRun ? "" : ` to ${targetPath}`}`); if (!dryRun) { console.log(` Total size: ${formatBytes(totalBytes)}`); } } else { console.log(`⚠ No files ${dryRun ? "would be" : "were"} copied`); console.log(` Check your patterns in ${FLATTEN_FILE} - they might not match any files`); } if (showStats || verbose) { console.log(`⏱ Operation took ${endTime - startTime}ms`); if (skippedFiles.length > 0) { console.log(`↩ Skipped ${skippedFiles.length} files (size limits, duplicates, etc.)`); } if (errorFiles.length > 0) { console.log(`✗ ${errorFiles.length} files had errors`); } } // ==================== UTILITY FUNCTIONS ==================== /** * Execute a single file inclusion rule by finding matching files and copying them */ function runRule(pattern, denyRules = []) { // Find all files matching the glob pattern let matchingFiles = (0, glob_1.globSync)(pattern, { follow: followSymlinks }); if (verbose && matchingFiles.length > 0) { console.log(`\nPattern "${pattern}" matched ${matchingFiles.length} files`); } // Apply exclusion rules to filter out unwanted files using minimatch if (denyRules.length > 0) { for (const denyRule of denyRules) { const beforeCount = matchingFiles.length; matchingFiles = matchingFiles.filter((file) => !(0, minimatch_1.minimatch)(file, denyRule)); if (verbose && matchingFiles.length < beforeCount) { console.log(` 🛇 Exclusion "${denyRule}" filtered out ${beforeCount - matchingFiles.length} files`); } } } // If all files were filtered out by exclusion rules, nothing to copy if (matchingFiles.length === 0) { return 0; } let successfulCopies = 0; // Copy each file with bijective path transformation for (const sourceFile of matchingFiles) { try { // Check file size const stats = (0, node_fs_1.statSync)(sourceFile); const fileSize = stats.size; // Skip if file exceeds size limit if (maxFileSize && fileSize > maxFileSize) { if (verbose) { console.log(` ↩ Skipping large file "${sourceFile}" (${formatBytes(fileSize)})`); } skippedFiles.push(sourceFile); continue; } // Warn about large files if (fileSize > LARGE_FILE_WARNING_SIZE && verbose) { console.warn(` ⚠ Large file warning: "${sourceFile}" (${formatBytes(fileSize)})`); } // Detect path separator (handle both Windows and Unix paths) const pathSeparator = sourceFile.includes("\\") && !sourceFile.includes("/") ? "\\" : "/"; // Apply bijective transformation to create flat filename const flattenedFileName = sourceFile .replaceAll("_", "__") // Step 1: Escape existing underscores .split(pathSeparator) // Step 2: Split into path components .filter(Boolean) // Remove empty components .filter((component) => !["node_modules", "."].includes(component)) // Step 3: Filter unwanted .join("_"); // Step 4: Join with underscores const targetFilePath = `${targetPath}${flattenedFileName}`; // Check if the file was already copied if (copiedFiles.includes(targetFilePath)) { if (verbose) { console.log(` ↩ Skipping duplicate "${flattenedFileName}"`); } skippedFiles.push(sourceFile); continue; } if (dryRun) { // In dry run mode, just log what would be copied console.log(` ✓ Would copy: "${sourceFile}" → "${flattenedFileName}" (${formatBytes(fileSize)})`); successfulCopies++; totalBytes += fileSize; } else { // Actually copy the file (0, node_fs_1.copyFileSync)(sourceFile, targetFilePath); successfulCopies++; totalBytes += fileSize; copiedFiles.push(targetFilePath); if (verbose) { console.log(` ✓ "${sourceFile}" → "${flattenedFileName}" (${formatBytes(fileSize)})`); } } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.warn(` ✗ Failed to ${dryRun ? "analyze" : "copy"} "${sourceFile}": ${errorMessage}`); errorFiles.push(sourceFile); } } return successfulCopies; }