UNPKG

file-tree-creator

Version:

Secure file/folder structure generator with support for both tree diagrams and flat paths. Perfect for project scaffolding and boilerplate generation.

311 lines (257 loc) 9.75 kB
const fs = require('fs'); const path = require('path'); function parseTreeDiagram(treeText) { const lines = treeText.trim().split('\n') .filter(line => !line.startsWith('#') && line.trim()); const result = []; const pathStack = []; const levelStack = [-1]; // Start with -1 so first item at level 0 works properly // First pass: collect all entries and their indentation levels const entries = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!line.trim()) continue; // Calculate indentation level by finding the first non-whitespace, non-tree character const indent = line.search(/[^\s│├└─]/); if (indent === -1) continue; // Skip lines that don't have valid content const level = Math.floor(indent / 2); // Assuming 2 spaces per level // For complex lines like "docs ── README.md", we need special handling if (line.includes('──') && line.indexOf('──') !== line.lastIndexOf('──')) { // This is a line with multiple items separated by ── const segments = parseComplexLine(line.substring(indent)); if (segments.length > 0) { // First segment is at current level and is always a directory entries.push({ name: segments[0].name, comment: segments[0].comment, level: level, isFile: false, // First segment in multi-item line is always a directory hasChildren: segments.length > 1 }); // If there are additional segments, they're children if (segments.length > 1) { const lastSegment = segments[segments.length - 1]; entries.push({ name: lastSegment.name, comment: lastSegment.comment, level: level + 1, isFile: isFileByName(lastSegment.name), hasChildren: false }); } } } else { // Standard line with a single item const extracted = extractEntryName(line.substring(indent)); if (!extracted) continue; // Check next line to see if this has children const hasChildren = (i + 1 < lines.length) && (lines[i + 1].search(/[^\s│├└─]/) > indent); entries.push({ name: extracted.name, comment: extracted.comment, level: level, isFile: !hasChildren && isFileByName(extracted.name), hasChildren: hasChildren }); } } // Second pass: use the entries to build paths for (let i = 0; i < entries.length; i++) { const entry = entries[i]; // Manage the path stack based on level while (levelStack[levelStack.length - 1] >= entry.level) { levelStack.pop(); pathStack.pop(); } levelStack.push(entry.level); pathStack.push(entry.name); const fullPath = pathStack.join('/'); // If this entry has children, it's definitely a directory // Otherwise, use our best guess based on the name result.push({ path: fullPath, isFile: entry.isFile, comment: entry.comment }); } return result; } function parseComplexLine(lineContent) { // Handle complex lines like "docs ── README.md # Some comment" // First clean up the tree characters const cleanedLine = lineContent .replace(/├──/g, '') .replace(/└──/g, '') .replace(/│/g, '') .trim(); // Check if there's a comment const commentMatch = cleanedLine.match(/(.*?)(?:\s+#\s*(.*)|$)/); const content = commentMatch[1]; const comment = commentMatch[2] || null; // Split by ── or similar separators const segments = content .split(/\s*──\s*|\s*--\s*/) .map(segment => segment.trim()) .filter(segment => segment.length > 0); // Add comment to the last segment if it exists if (segments.length > 0 && comment) { return segments.map((segment, index) => { if (index === segments.length - 1) { return { name: segment, comment }; } return { name: segment, comment: null }; }); } return segments.map(segment => ({ name: segment, comment: null })); } function extractEntryName(lineContent) { // Extract the name and any comment from a line with tree characters const match = lineContent.match(/(?:├──|└──|│|\s*)\s*(.*?)(?:\s+#\s*(.*)|$)/); if (!match || !match[1]) return null; return { name: match[1].trim(), comment: match[2] ? match[2].trim() : null }; } function parseFlatPath(flatText) { return flatText .split('\n') .map(line => { const match = line.trim().match(/(.*?)(?:\s+#\s*(.*)|$)/); if (!match || !match[1]) return null; return { path: match[1].replace(/^\/+/, ''), isFile: isFileByName(match[1]), comment: match[2] ? match[2].trim() : null }; }) .filter(item => item && !item.path.startsWith('#')); } function isFileByName(name) { // Remove any path components to get just the basename const basename = name.split('/').pop(); // If it ends with a slash, it's definitely a directory if (name.endsWith('/')) { return false; } // Common file extensions if (path.extname(basename)) { return true; } // Special files like README, LICENSE, etc. const commonFiles = ['README', 'LICENSE', 'CHANGELOG', '.gitignore', '.env', '.npmrc']; if (commonFiles.some(common => basename === common || basename.startsWith(common + '.'))) { return true; } // Hidden files with specific patterns if (basename.startsWith('.') && !basename.includes('/')) { const hiddenFileWithExt = basename.substring(1).includes('.'); if (hiddenFileWithExt) { return true; } // Common hidden files const commonHiddenFiles = ['.gitignore', '.env', '.editorconfig', '.npmrc', '.hidden.file']; if (commonHiddenFiles.includes(basename)) { return true; } } // No clear indicators, assume it's a directory return false; } function isInsecurePath(p) { const normalized = p.replace(/^\/+/, ''); if (path.isAbsolute(normalized)) return true; if (normalized.startsWith('root/') || normalized.startsWith('etc/') || normalized.startsWith('~/')) { return true; } const fullyNormalized = path.normalize(normalized); if (fullyNormalized.includes('..'+path.sep)) return true; const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'LPT1']; const upperName = path.basename(normalized).toUpperCase(); if (reservedNames.includes(upperName)) return true; return false; } function createFileStructure(paths, baseDir = process.cwd(), options = {}) { const { strict = false, verbose = false, writeComments = false } = options; let createdCount = 0; let errorCount = 0; // Validate paths for (const {path: p} of paths) { if (isInsecurePath(p)) { console.error(`❌ Insecure path detected: ${p}`); errorCount++; if (strict) return { createdCount, errorCount }; } } // Create directories first paths.filter(p => !p.isFile).forEach(({path: p, comment}) => { if (isInsecurePath(p)) return; const fullPath = path.join(baseDir, p); if (fs.existsSync(fullPath)) { if (fs.statSync(fullPath).isDirectory()) { if (verbose) console.log(`⏩ Directory exists: ${p}${comment ? ` # ${comment}` : ''}`); return; } errorCount++; console.error(`❌ Path exists but is not a directory: ${p}`); return; } try { fs.mkdirSync(fullPath, { recursive: true }); console.log(`📁 Created directory: ${p}${comment ? ` # ${comment}` : ''}`); createdCount++; } catch (err) { console.error(`❌ Failed to create directory: ${p}\n Error: ${err.message}`); errorCount++; } }); // Then create files paths.filter(p => p.isFile).forEach(({path: p, comment}) => { if (isInsecurePath(p)) return; const fullPath = path.join(baseDir, p); const dirPath = path.dirname(fullPath); if (fs.existsSync(fullPath)) { if (fs.statSync(fullPath).isFile()) { console.log(`⏩ File exists: ${p}${comment ? ` # ${comment}` : ''}`); return; } errorCount++; console.error(`❌ Path exists but is not a file: ${p}`); return; } try { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } // If writeComments is true and we have a comment, add it to the file as a header let initialContent = ''; if (writeComments && comment) { const ext = path.extname(p); // Choose comment style based on file extension if (['.js', '.ts', '.jsx', '.tsx', '.css', '.scss', '.java', '.c', '.cpp'].includes(ext)) { initialContent = `/* ${comment} */\n\n`; } else if (['.py', '.sh', '.bash', '.yml', '.yaml', '.rb'].includes(ext)) { initialContent = `# ${comment}\n\n`; } else if (['.html', '.xml', '.svg'].includes(ext)) { initialContent = `<!-- ${comment} -->\n\n`; } else { initialContent = `// ${comment}\n\n`; } } fs.writeFileSync(fullPath, initialContent, 'utf8'); console.log(`📄 Created file: ${p}${comment ? ` # ${comment}` : ''}`); createdCount++; } catch (err) { console.error(`❌ Failed to create file: ${p}\n Error: ${err.message}`); errorCount++; } }); if (errorCount > 0 && strict) { throw new Error(`Failed with ${errorCount} errors (strict mode)`); } return { createdCount, errorCount }; } module.exports = { parseTreeDiagram, parseFlatPath, createFileStructure };