UNPKG

@sitchco/project-scanner

Version:

Scans project structure for Sitchco modules and assets

339 lines (295 loc) 12.1 kB
import { glob } from 'glob'; import path from 'node:path'; import fs from 'node:fs/promises'; /** * Configuration constants for project structure scanning. */ export const MODULES_FOLDER = 'modules'; export const ASSETS_FOLDER = 'assets'; export const BLOCKS_FOLDER = 'blocks'; export const SCRIPTS_SUBFOLDER = 'scripts'; export const STYLES_SUBFOLDER = 'styles'; export const IMAGES_SUBFOLDER = 'images'; export const ENTRY_FILE_PATTERN = '*.{js,mjs,jsx,scss,css}'; export const IMAGE_FILE_PATTERN = '*.{svg,jpeg,jpg,png,gif,webp}'; export const IMAGE_FILE_REGEX = /\.(svg|jpe?g|png|gif|webp)$/i; export const IMAGES_DIST_SUBFOLDER = `${ASSETS_FOLDER}/${IMAGES_SUBFOLDER}`; export const IMAGES_DIST_PATTERN = `${IMAGES_DIST_SUBFOLDER}/**/${IMAGE_FILE_PATTERN}`; export const SVG_DIST_SUBFOLDER = `${IMAGES_DIST_SUBFOLDER}/svg-sprite`; /** * Scans a project structure to discover Sitchco modules, their asset directories, * entry points (JS/SCSS), and source files by extension. Caches discovered paths * for efficiency. */ export default class ProjectScanner { /** @type {string} The absolute path to the project root. */ projectRoot; /** @type {string[]} Glob patterns to ignore during scanning. */ ignorePatterns; /** @type {string[] | null} Cached array of module directory paths. */ _moduleDirs = null; /** @type {string[] | null} Cached array of entry point file paths. */ _entrypoints = null; /** @type {string | null} Cached absolute path to the WordPress web root. */ _webRoot = null; static DEFAULT_IGNORE = ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**']; /** * Creates an instance of ProjectScanner. * @param {object} [options={}] - Configuration options. * @param {string} [options.projectRoot=process.cwd()] - The root directory of the project to scan. * @param {string[]} [options.ignorePatterns=ProjectScanner.DEFAULT_IGNORE] - Glob patterns to ignore. */ constructor(options = {}) { this.projectRoot = path.resolve(options.projectRoot || process.cwd()); this.ignorePatterns = options.ignorePatterns || ProjectScanner.DEFAULT_IGNORE; } /** * Checks if a directory exists. * @param {string} path - The path to check. * @returns {Promise<boolean>} True if the path is an existing directory. * @private * @static */ static async _pathExists(path) { try { await fs.access(path); return true; } catch (error) { if (error.code === 'ENOENT') { return false; } throw error; } } /** * Scans the project for module directories using convention-based approach. * @returns {Promise<string[]>} A promise resolving to an array of absolute module directory paths. * @private */ async _scanForModuleDirs() { const markerPattern = `${MODULES_FOLDER}/*/`; return await glob(markerPattern, { cwd: this.projectRoot, absolute: true, dot: true, ignore: this.ignorePatterns, }); } /** * Gets the list of module directories, using cache if available. * @returns {Promise<string[]>} A promise resolving to an array of absolute module directory paths. */ async getModuleDirs() { if (this._moduleDirs === null) { this._moduleDirs = await this._scanForModuleDirs(); } return this._moduleDirs; } /** * Scans module directories for entry points (JS/MJS/JSX/SCSS files). * It looks in the module root, module assets/scripts|styles folders, * block roots (within the blocks folder), and block assets/scripts|styles folders. * @returns {Promise<string[]>} A promise resolving to a flat array of absolute entry point file paths. * @private */ async _scanForEntrypoints() { const moduleDirs = await this.getModuleDirs(); if (!moduleDirs.length) { return []; } const entrypointPatterns = [ ENTRY_FILE_PATTERN, `${ASSETS_FOLDER}/{${SCRIPTS_SUBFOLDER},${STYLES_SUBFOLDER}}/${ENTRY_FILE_PATTERN}`, `${BLOCKS_FOLDER}/*/${ENTRY_FILE_PATTERN}`, `${BLOCKS_FOLDER}/*/${ASSETS_FOLDER}/{${SCRIPTS_SUBFOLDER},${STYLES_SUBFOLDER}}/${ENTRY_FILE_PATTERN}`, ]; const promises = moduleDirs.map( async (moduleDir) => await glob(entrypointPatterns, { cwd: moduleDir, absolute: true, nodir: true, ignore: this.ignorePatterns, }) ); const results = await Promise.all(promises); const allEntrypoints = results.flat(); return [...new Set(allEntrypoints)]; } /** * Gets the list of all entry point files (JS/MJS/JSX/SCSS) found in standard locations * across all module directories, using cache if available. * (Locations include module root, module assets, block roots, block assets). * @returns {Promise<string[]>} A promise resolving to a flat array of absolute entry point file paths. */ async getEntrypoints() { if (this._entrypoints === null) { this._entrypoints = await this._scanForEntrypoints(); } return this._entrypoints; } /** * Scans upwards from the project root to find the WordPress web root * (the directory containing 'wp-content'). * @returns {Promise<string>} A promise resolving to the absolute path of the web root. * @throws {Error} If the web root cannot be found. * @private */ async _scanForWebRoot() { let currentDir = path.normalize(this.projectRoot); const root = path.parse(currentDir).root; while (currentDir !== root) { const wpContentPath = path.join(currentDir, 'wp-content'); if (await ProjectScanner._pathExists(wpContentPath)) { return currentDir; } currentDir = path.dirname(currentDir); } return path.resolve(this.projectRoot, '../../..'); } /** * Gets the WordPress web root directory path, using cache if available. * @returns {Promise<string>} A promise resolving to the absolute web root path. */ async getWebRoot() { if (this._webRoot === null) { this._webRoot = await this._scanForWebRoot(); } return this._webRoot; } /** * Finds all source files within the identified module directories (not limited to asset directories) * matching the specified extensions. This method does NOT use caching as extensions can vary per call. * * @param {string[]} extensions - An array of file extensions to find (e.g., ['.php', '.json', '.svg']). * @returns {Promise<string[]>} A promise resolving to a flat array of absolute source file paths matching the extensions. */ async findModuleSourceFiles(extensions = []) { if (!extensions || extensions.length === 0) { return []; } const moduleDirs = await this.getModuleDirs(); if (!moduleDirs.length) { return []; } const extGroupPattern = `{${extensions.map((ext) => (ext.startsWith('.') ? ext.substring(1) : ext)).join(',')}}`; const findPattern = `**/*.${extGroupPattern}`; const promises = moduleDirs.map(async (moduleDir) => { const files = await glob(findPattern, { cwd: moduleDir, absolute: true, nodir: true, dot: true, ignore: this.ignorePatterns, }); return files; }); const results = await Promise.all(promises); const allSourceFiles = results.flat(); return [...new Set(allSourceFiles)]; } /** * Finds all source files within the entire project (starting from project root) * matching the specified extensions. This method does NOT use caching as extensions can vary per call. * * @param {string[]} extensions - An array of file extensions to find (e.g., ['.php', '.json', '.svg']). * @returns {Promise<string[]>} A promise resolving to a flat array of absolute source file paths matching the extensions. */ async findAllSourceFiles(extensions = []) { if (!extensions || extensions.length === 0) { return []; } const extGroupPattern = `{${extensions.map((ext) => (ext.startsWith('.') ? ext.substring(1) : ext)).join(',')}}`; const findPattern = `**/*.${extGroupPattern}`; const files = await glob(findPattern, { cwd: this.projectRoot, absolute: true, nodir: true, dot: true, ignore: this.ignorePatterns, }); return [...new Set(files)]; } /** * Finds build artifact directories within the project root. * Currently only cleans the root `dist/` directory. * This method does NOT use caching. * @returns {Promise<string[]>} A promise resolving to an array of absolute artifact paths. */ async getBuildArtifacts() { const artifacts = []; const distPath = path.join(this.projectRoot, 'dist'); try { if (await ProjectScanner._pathExists(distPath)) { artifacts.push(distPath); } return artifacts; } catch (error) { console.error(`[ProjectScanner] Error checking dist directory:`, error); return []; } } /** * Removes all found build artifact directories (currently only `dist/`). * @returns {Promise<void>} A promise that resolves when deletion is complete. */ async cleanBuildArtifacts() { const artifactDirs = await this.getBuildArtifacts(); if (!artifactDirs.length) { return; } const promises = artifactDirs.map(async (dirPath) => { try { await fs.rm(dirPath, { recursive: true, force: true, }); } catch (error) { console.error(`[ProjectScanner] Error removing directory ${dirPath}:`, error); } }); await Promise.all(promises); } async getImagePaths() { const dirs = await this.getModuleDirs(); const targets = []; for (const dir of dirs) { const baseImageDir = path.join(dir, ASSETS_FOLDER, IMAGES_SUBFOLDER); // Find all subdirectories inside assets/images (including itself) const subdirs = await glob('**', { cwd: baseImageDir, onlyDirectories: true, absolute: true, }); // Include the base folder itself subdirs.unshift(baseImageDir); for (const subdir of subdirs) { const files = await glob(IMAGE_FILE_PATTERN, { cwd: subdir, onlyFiles: true, }); // Skip directories with no images if (files.length === 0) { continue; } // Compute relative path after assets/images const relativePath = path.relative(baseImageDir, subdir); targets.push({ src: path.join(subdir, '*.{png,jpg,jpeg,svg,gif,webp,avif}'), dest: path.join(ASSETS_FOLDER, IMAGES_SUBFOLDER, relativePath), }); } } return targets; } /** * Clears the internal cache for module directories, asset directories, and entrypoints. * Subsequent calls to getters will trigger a fresh scan. */ clearCache() { this._moduleDirs = null; this._entrypoints = null; this._webRoot = null; } } export { ProjectScanner };