find-unused-sass-variables
Version:
A small tool to find unused Sass variables
149 lines (119 loc) • 4.24 kB
JavaScript
import { readFileSync, statSync } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import escapeRegex from 'escape-string-regexp';
import { glob, globSync } from 'tinyglobby';
import { parse } from './lib/parse-variable.js';
const defaultOptions = {
ignore: [],
ignoreFiles: [],
fileExtensions: ['scss']
};
function buildGlobPattern(dir, options) {
return path.join(dir, `**/*.${options.fileExtensions}`).replaceAll('\\', '/');
}
async function findAsync(dirPath, opts = {}) {
const options = parseOptions(opts);
const dir = path.resolve(dirPath);
const dirStat = await stat(dir);
if (!dirStat.isDirectory()) {
throw new Error(`"${dir}": Not a valid directory!`);
}
// Array of all Sass files
const sassFiles = await glob(
buildGlobPattern(dir, options),
{ ignore: options.ignoreFiles, expandDirectories: false }
);
const parsePromises = sassFiles.map(file => parseFileAsync(file, options));
// Parsed content and variables from each file
const parsedFiles = await Promise.all(parsePromises);
return aggregateResults(parsedFiles);
}
function findSync(dirPath, opts = {}) {
const options = parseOptions(opts);
const dir = path.resolve(dirPath);
const dirStat = statSync(dir);
if (!dirStat.isDirectory()) {
throw new Error(`"${dir}": Not a valid directory!`);
}
// Array of all Sass files
const sassFiles = globSync(
buildGlobPattern(dir, options),
{ ignore: options.ignoreFiles, expandDirectories: false }
);
const parsedFiles = sassFiles.map(file => parseFileSync(file, options));
return aggregateResults(parsedFiles);
}
function aggregateResults(parsedFiles) {
const variables = [];
let combinedContent = '';
for (const result of parsedFiles) {
variables.push(...result.variables);
combinedContent += result.fileContent;
}
return filterVariables(combinedContent, variables);
}
async function parseFileAsync(file, options) {
const content = await readFile(file, 'utf8');
return parseFileContent(file, content, options.ignore);
}
function parseFileSync(file, options) {
const content = readFileSync(file, 'utf8');
return parseFileContent(file, content, options.ignore);
}
function parseFileContent(fileName, content, ignoreList) {
// Strip BOM mark if present, and remove front-matter comments (e.g. Jekyll, YAML)
const fileContent = (content.codePointAt(0) === 0xFE_FF ? content.slice(1) : content)
.replaceAll(/^---$/gm, '');
const variables = parse(fileName, fileContent, ignoreList);
return {
fileContent,
variables
};
}
function filterVariables(combinedContent, variables) {
const unusedVars = variables.filter(variable => {
const re = new RegExp(`(${escapeRegex(variable.name)})\\b(?!-)`, 'g');
return (combinedContent.match(re) ?? []).length === 1;
});
return {
unused: unusedVars,
total: variables.length
};
}
function parseOptions(opts) {
opts ??= {};
const options = {
ignore: opts.ignore ?? defaultOptions.ignore,
ignoreFiles: opts.ignoreFiles ?? defaultOptions.ignoreFiles,
fileExtensions: opts.fileExtensions ?? defaultOptions.fileExtensions
};
for (const option of ['ignore', 'ignoreFiles']) {
if (!Array.isArray(options[option])) {
throw new TypeError(`\`${option}\` should be an Array`);
}
}
// Trim list of ignored variables
options.ignore = options.ignore.map(val => val.trim());
// Trim list of ignored files
options.ignoreFiles = options.ignoreFiles.map(val => val.trim());
let extensions = options.fileExtensions;
extensions = Array.isArray(extensions) ? extensions : [extensions];
// Trim, strip a leading dot, and drop blanks
extensions = extensions
.map(extension => {
const trimmed = extension.trim();
return trimmed.startsWith('.') ? trimmed.slice(1) : trimmed;
})
.filter(Boolean);
if (extensions.length === 0) {
throw new TypeError('`fileExtensions` must contain at least one non-empty extension');
}
// The following is glob-specific syntax
options.fileExtensions = extensions.length > 1 ? `+(${extensions.join('|')})` : extensions[0];
return options;
}
export {
findSync as find,
findAsync
};