grunt-ps-sass
Version:
GruntJS plugin to compile Sass files
345 lines (293 loc) • 9.51 kB
JavaScript
const fs = require('fs');
const path = require('path');
/**
* Service for resolving SCSS file dependencies by parsing import statements
* This service is focused solely on dependency resolution - no change detection
* For change detection, use the FileChangeDetector service separately
*/
class ScssDependencyResolver {
constructor(options = {}) {
this.includeExtensions = options.includeExtensions || ['.scss', '.sass'];
this.verbose = options.verbose || false;
this.resolveNodeModules = options.resolveNodeModules !== false; // default true
}
/**
* Get all dependencies (imported files) for a Sass file recursively
* @param {string} filePath - Path to the main SCSS file
* @param {Object} options - Optional configuration
* @returns {Array<string>} Array of absolute paths to all dependencies
*/
getDependencies(filePath, options = {}) {
const context = this.#createDependencyContext();
this.#processSassFileRecursively(filePath, context);
return this.#formatDependencyResult(context, options);
}
/**
* Create initial context for dependency resolution
* @private
*/
#createDependencyContext() {
return {
dependencies: new Set(),
visited: new Set(),
errors: []
};
}
/**
* Process a Sass file recursively to find all dependencies
* @private
*/
#processSassFileRecursively(sassPath, context, depth = 0) {
const normalizedPath = path.resolve(sassPath);
if (context.visited.has(normalizedPath)) {
return;
}
context.visited.add(normalizedPath);
this.#logProcessing(sassPath, depth);
try {
const content = fs.readFileSync(normalizedPath, 'utf8');
const dir = path.dirname(normalizedPath);
this.#extractAndProcessImports(content, dir, context, sassPath, depth);
} catch(err) {
this.#handleFileReadError(err, sassPath, context, depth);
}
}
/**
* Extract import statements and process each dependency
* @private
*/
#extractAndProcessImports(content, dir, context, sassPath, depth) {
const importRegex = /@(?:import|use|forward)\s+(?:url\()?['"]([^'"]+)['"](?:\))?(?:\s+as\s+\w+)?(?:\s+with\s*\([^)]*\))?;?/g;
let match;
while ((match = importRegex.exec(content)) !== null) {
const importPath = match[1].trim();
this.#processImportStatement(importPath, dir, context, sassPath, depth);
}
}
/**
* Process a single import statement
* @private
*/
#processImportStatement(importPath, dir, context, sassPath, depth) {
// Skip CSS imports and URLs
if (this.#isCssImport(importPath)) {
return;
}
const resolvedPath = this.#resolveImportPath(importPath, dir);
if (resolvedPath) {
context.dependencies.add(resolvedPath);
this.#processSassFileRecursively(resolvedPath, context, depth + 1);
} else {
this.#handleUnresolvedImport(importPath, sassPath, context, depth);
}
}
/**
* Handle unresolved import errors
* @private
*/
#handleUnresolvedImport(importPath, sassPath, context, depth) {
const error = `Could not resolve import: ${importPath} from ${sassPath}`;
context.errors.push(error);
if (this.verbose) {
console.warn(`${' '.repeat(depth)}Warning: ${error}`);
}
}
/**
* Handle file reading errors
* @private
*/
#handleFileReadError(err, sassPath, context, depth) {
const error = `Error reading file ${sassPath}: ${err.message}`;
context.errors.push(error);
if (this.verbose) {
console.error(`${' '.repeat(depth)}Error: ${error}`);
}
}
/**
* Log processing information if verbose mode is enabled
* @private
*/
#logProcessing(sassPath, depth) {
if (this.verbose) {
console.log(`${' '.repeat(depth)}Processing: ${sassPath}`);
}
}
/**
* Format the final dependency result based on options
* @private
*/
#formatDependencyResult(context, options) {
// Return just the array of dependencies for simple use cases
if (options.returnOnlyDependencies) {
return Array.from(context.dependencies);
}
// Return detailed result with metadata
const result = {
dependencies: Array.from(context.dependencies),
errors: context.errors,
visited: Array.from(context.visited)
};
if (options.includeStats) {
result.stats = this.#getStats(result.dependencies);
}
return result;
}
/**
* Get only the direct dependencies (first level imports) of a file
* @param {string} filePath - Path to the SCSS file
* @returns {Array<string>} Array of direct dependency paths
*/
getDirectDependencies(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const dir = path.dirname(filePath);
return this.#extractDirectImports(content, dir);
} catch(err) {
this.#logDirectDependencyError(filePath, err);
return [];
}
}
/**
* Extract direct import paths from file content
* @private
*/
#extractDirectImports(content, dir) {
const dependencies = [];
const importRegex = /@(?:import|use|forward)\s+(?:url\()?['"]([^'"]+)['"](?:\))?(?:\s+as\s+\w+)?(?:\s+with\s*\([^)]*\))?;?/g;
let match;
while ((match = importRegex.exec(content)) !== null) {
const importPath = match[1].trim();
if (!this.#isCssImport(importPath)) {
const resolvedPath = this.#resolveImportPath(importPath, dir);
if (resolvedPath) {
dependencies.push(resolvedPath);
}
}
}
return dependencies;
}
/**
* Log error for direct dependency extraction
* @private
*/
#logDirectDependencyError(filePath, err) {
if (this.verbose) {
console.error(`Error reading file ${filePath}: ${err.message}`);
}
}
/**
* Resolve an import path to an absolute file path
* @private
*/
#resolveImportPath(importPath, baseDir) {
// Try different resolution strategies
const candidates = this.#generateCandidatePaths(importPath, baseDir);
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return path.resolve(candidate);
}
}
return null;
}
/**
* Generate possible file paths for an import
* @private
*/
#generateCandidatePaths(importPath, baseDir) {
const candidates = [];
// Handle node_modules imports (starting with ~)
if (importPath.startsWith('~') && this.resolveNodeModules) {
const nodeModulesPath = importPath.substring(1);
const nodeModulesDir = this.#findNodeModules(baseDir);
if (nodeModulesDir) {
const basePath = path.join(nodeModulesDir, nodeModulesPath);
candidates.push(...this.#addExtensionVariants(basePath));
}
} else {
// Regular relative/absolute imports
const basePath = path.resolve(baseDir, importPath);
candidates.push(...this.#addExtensionVariants(basePath));
}
return candidates;
}
/**
* Add different extension and partial variants for a base path
* @private
*/
#addExtensionVariants(basePath) {
const variants = [];
const dir = path.dirname(basePath);
const name = path.basename(basePath);
// Original path
variants.push(basePath);
// Add extensions if not present
for (const ext of this.includeExtensions) {
if (!basePath.endsWith(ext)) {
variants.push(basePath + ext);
}
}
// Add partial variants (with underscore prefix)
if (!name.startsWith('_')) {
const partialName = '_' + name;
const partialPath = path.join(dir, partialName);
variants.push(partialPath);
// Add extensions to partial variants
for (const ext of this.includeExtensions) {
if (!partialPath.endsWith(ext)) {
variants.push(partialPath + ext);
}
}
}
return variants;
}
/**
* Find the nearest node_modules directory
* @private
*/
#findNodeModules(startDir) {
let currentDir = startDir;
while (currentDir !== path.dirname(currentDir)) {
const nodeModulesPath = path.join(currentDir, 'node_modules');
if (fs.existsSync(nodeModulesPath)) {
return nodeModulesPath;
}
currentDir = path.dirname(currentDir);
}
return null;
}
/**
* Check if an import is a CSS import (should be skipped)
* @private
*/
#isCssImport(importPath) {
return (
importPath.endsWith('.css') ||
importPath.startsWith('http://') ||
importPath.startsWith('https://') ||
importPath.startsWith('//')
);
}
/**
* Get statistics about dependencies
* @private
*/
#getStats(dependencies) {
const stats = {
totalFiles: dependencies.length,
byExtension: {},
totalSize: 0
};
dependencies.forEach(filePath => {
const ext = path.extname(filePath);
stats.byExtension[ext] = (stats.byExtension[ext] || 0) + 1;
try {
const stat = fs.statSync(filePath);
stats.totalSize += stat.size;
} catch(err) {
// Ignore size calculation errors
}
});
return stats;
}
}
module.exports = ScssDependencyResolver;