stylelint
Version:
A mighty CSS linter that helps you avoid errors and enforce conventions.
587 lines (476 loc) • 15.4 kB
JavaScript
import { dirname, isAbsolute } from 'node:path';
import globjoin from 'globjoin';
import micromatch from 'micromatch';
import normalizePath from 'normalize-path';
import { isFunction, isString } from './utils/validateTypes.mjs';
import { ConfigurationError } from './utils/errors.mjs';
import cachedImport from './utils/cachedImport.mjs';
import getModulePath from './utils/getModulePath.mjs';
import normalizeAllRuleSettings from './normalizeAllRuleSettings.mjs';
/** @import {Config as StylelintConfig, CosmiconfigResult as StylelintCosmiconfigResult, InternalApi as StylelintInternalApi} from 'stylelint' */
// TODO: micromatch.matcher() accepts arrays at runtime, but the types don't
// reflect this. Remove the cast and func when and if upstream types are fixed.
// Ref: https://github.com/micromatch/micromatch/pull/234
const createMatcher =
/** @type {(pattern: string | string[], options?: micromatch.Options) => (str: string) => boolean} */ (
micromatch.matcher
);
/**
* @param {string} glob
* @param {string} basedir
* @returns {string}
*/
function absolutizeGlob(glob, basedir) {
const result = isAbsolute(glob.replace(/^!/, '')) ? glob : globjoin(basedir, glob);
// Glob patterns for micromatch should be in POSIX-style
return normalizePath(result);
}
/**
* - Merges config and stylelint options
* - Makes all paths absolute
* - Merges extends
* @param {StylelintInternalApi} stylelint
* @param {StylelintConfig} config
* @param {string} configDir
* @param {boolean} allowOverrides
* @param {string} rootConfigDir
* @param {string} [filePath]
* @param {string} [configFilePath] Path to the config file for caching compiled overrides
* @returns {Promise<StylelintConfig>}
*/
async function augmentConfigBasic(
stylelint,
config,
configDir,
allowOverrides,
rootConfigDir,
filePath,
configFilePath,
) {
let augmentedConfig = config;
if (allowOverrides) {
augmentedConfig = addOptions(stylelint, augmentedConfig);
}
if (filePath) {
augmentedConfig = applyOverrides(
augmentedConfig,
rootConfigDir,
filePath,
stylelint._compiledOverridesCache,
configFilePath,
);
}
augmentedConfig = await extendConfig(
stylelint,
augmentedConfig,
configDir,
rootConfigDir,
filePath,
);
const cwd = stylelint._options.cwd;
return absolutizePaths(augmentedConfig, configDir, cwd);
}
/**
* Extended configs need to be run through augmentConfigBasic
* but do not need the full treatment. Things like pluginFunctions
* will be resolved and added by the parent config.
* @param {string} cwd
* @returns {(cosmiconfigResult?: StylelintCosmiconfigResult) => Promise<StylelintCosmiconfigResult>}
*/
export function augmentConfigExtended(cwd) {
return async (cosmiconfigResult) => {
if (!cosmiconfigResult) {
return null;
}
const configDir = dirname(cosmiconfigResult.filepath || '');
const { config } = cosmiconfigResult;
const augmentedConfig = absolutizePaths(config, configDir, cwd);
return {
config: augmentedConfig,
filepath: cosmiconfigResult.filepath,
};
};
}
/**
* @param {StylelintInternalApi} stylelint
* @param {string} [filePath]
* @param {StylelintCosmiconfigResult} [cosmiconfigResult]
* @returns {Promise<StylelintCosmiconfigResult>}
*/
export async function augmentConfigFull(stylelint, filePath, cosmiconfigResult) {
if (!cosmiconfigResult) {
return null;
}
const config = cosmiconfigResult.config;
const configDir = stylelint._options.configBasedir || dirname(cosmiconfigResult.filepath || '');
let augmentedConfig = await augmentConfigBasic(
stylelint,
config,
configDir,
true,
configDir,
filePath,
cosmiconfigResult.filepath,
);
augmentedConfig = await addPluginFunctions(augmentedConfig);
augmentedConfig = await addProcessorFunctions(augmentedConfig);
if (!augmentedConfig.rules) {
throw new ConfigurationError(
'No rules found within configuration. Have you provided a "rules" property?',
);
}
augmentedConfig = await normalizeAllRuleSettings(augmentedConfig);
return {
config: augmentedConfig,
filepath: cosmiconfigResult.filepath,
};
}
/**
* Make all paths in the config absolute.
*
* @param {StylelintConfig} config
* @param {string} configDir
* @param {string} cwd
* @returns {StylelintConfig}
*/
function absolutizePaths(config, configDir, cwd) {
if (config.ignoreFiles) {
config.ignoreFiles = [config.ignoreFiles].flat().map((glob) => absolutizeGlob(glob, configDir));
}
/** @type {<T>(lookup: T) => (string | T)} */
const toAbsolutePath = (lookup) => {
if (typeof lookup === 'string') {
return getModulePath(configDir, lookup, cwd);
}
return lookup;
};
if (config.plugins) {
config.plugins = [config.plugins].flat().map(toAbsolutePath);
}
if (config.processors) {
config.processors = config.processors.map(toAbsolutePath);
}
return config;
}
/**
* @param {StylelintInternalApi} stylelint
* @param {StylelintConfig} config
* @param {string} configDir
* @param {string} rootConfigDir
* @param {string} [filePath]
* @returns {Promise<StylelintConfig>}
*/
async function extendConfig(stylelint, config, configDir, rootConfigDir, filePath) {
if (config.extends === undefined) {
return config;
}
const { extends: configExtends, ...originalWithoutExtends } = config;
const normalizedExtends = [configExtends].flat();
let resultConfig = originalWithoutExtends;
for (const extendLookup of normalizedExtends) {
let extendResult;
if (typeof extendLookup === 'string') {
extendResult = await loadExtendedConfig(stylelint, configDir, extendLookup);
} else if (typeof extendLookup === 'object' && extendLookup !== null) {
extendResult = { config: extendLookup };
}
if (extendResult) {
let extendResultConfig = extendResult.config;
const extendConfigDir = dirname(extendResult.filepath || '');
extendResultConfig = await augmentConfigBasic(
stylelint,
extendResultConfig,
extendConfigDir,
false,
rootConfigDir,
filePath,
extendResult.filepath,
);
resultConfig = mergeConfigs(resultConfig, extendResultConfig);
}
}
return mergeConfigs(resultConfig, originalWithoutExtends);
}
/**
* @param {StylelintInternalApi} stylelint
* @param {string} configDir
* @param {string} extendLookup
* @returns {Promise<StylelintCosmiconfigResult>}
*/
function loadExtendedConfig(stylelint, configDir, extendLookup) {
const extendPath = getModulePath(configDir, extendLookup, stylelint._options.cwd);
return stylelint._extendExplorer.load(extendPath);
}
/**
* When merging configs (via extends)
* - plugin, extends, overrides arrays are joined
* - rules are merged via Object.assign, so there is no attempt made to
* merge any given rule's settings. If b contains the same rule as a,
* b's rule settings will override a's rule settings entirely.
* - Everything else is merged via Object.assign
* @param {StylelintConfig} a
* @param {StylelintConfig} b
* @returns {StylelintConfig}
*/
function mergeConfigs(a, b) {
/** @type {Pick<StylelintConfig, 'plugins'>} */
const pluginMerger = {};
if (a.plugins || b.plugins) {
pluginMerger.plugins = [];
if (a.plugins) {
pluginMerger.plugins = pluginMerger.plugins.concat(a.plugins);
}
if (b.plugins) {
pluginMerger.plugins = [...new Set(pluginMerger.plugins.concat(b.plugins))];
}
}
/** @type {Pick<StylelintConfig, 'processors'>} */
const processorMerger = {};
if (a.processors || b.processors) {
processorMerger.processors = [];
if (a.processors) {
processorMerger.processors = processorMerger.processors.concat(a.processors);
}
if (b.processors) {
processorMerger.processors = [...new Set(processorMerger.processors.concat(b.processors))];
}
}
/** @type {Pick<StylelintConfig, 'overrides'>} */
const overridesMerger = {};
if (a.overrides || b.overrides) {
overridesMerger.overrides = [];
if (a.overrides) {
overridesMerger.overrides = overridesMerger.overrides.concat(a.overrides);
}
if (b.overrides) {
overridesMerger.overrides = [...new Set(overridesMerger.overrides.concat(b.overrides))];
}
}
/** @type {Pick<StylelintConfig, 'extends'>} */
const extendsMerger = {};
if (a.extends || b.extends) {
extendsMerger.extends = [];
if (a.extends) {
extendsMerger.extends = extendsMerger.extends.concat(a.extends);
}
if (b.extends) {
extendsMerger.extends = extendsMerger.extends.concat(b.extends);
}
// Remove duplicates from the array, the last item takes precedence
extendsMerger.extends = extendsMerger.extends.filter(
(item, index, arr) => arr.lastIndexOf(item) === index,
);
}
const rulesMerger = {};
if (a.rules || b.rules) {
rulesMerger.rules = { ...a.rules, ...b.rules };
}
const result = {
...a,
...b,
...extendsMerger,
...pluginMerger,
...processorMerger,
...overridesMerger,
...rulesMerger,
};
return result;
}
/**
* @param {StylelintConfig} config
* @returns {Promise<StylelintConfig>}
*/
async function addPluginFunctions(config) {
if (!config.plugins) {
return config;
}
const normalizedPlugins = [config.plugins].flat();
/** @type {StylelintConfig['pluginFunctions']} */
const pluginFunctions = {};
for (const pluginLookup of normalizedPlugins) {
let pluginImport;
if (typeof pluginLookup === 'string') {
pluginImport = await cachedImport(pluginLookup);
} else {
pluginImport = pluginLookup;
}
// Handle either ES6 or CommonJS modules
pluginImport = pluginImport.default || pluginImport;
// A plugin can export either a single rule definition
// or an array of them
const normalizedPluginImport = [pluginImport].flat();
for (const pluginRuleDefinition of normalizedPluginImport) {
if (!pluginRuleDefinition.ruleName) {
throw new ConfigurationError(
`stylelint requires plugins to expose a ruleName. The plugin "${pluginLookup}" is not doing this, so will not work with stylelint. Please file an issue with the plugin.`,
);
}
if (!pluginRuleDefinition.ruleName.includes('/')) {
throw new ConfigurationError(
`stylelint requires plugin rules to be namespaced, i.e. only \`plugin-namespace/plugin-rule-name\` plugin rule names are supported. The plugin rule "${pluginRuleDefinition.ruleName}" does not do this, so will not work. Please file an issue with the plugin.`,
);
}
pluginFunctions[pluginRuleDefinition.ruleName] = pluginRuleDefinition.rule;
}
}
config.pluginFunctions = pluginFunctions;
return config;
}
/**
* @param {StylelintConfig} config
* @returns {Promise<StylelintConfig>}
*/
async function addProcessorFunctions(config) {
if (!config.processors) {
return config;
}
const processorPromises = config.processors.map(async (processorLookup) => {
let processor = await cachedImport(processorLookup);
processor = processor.default ?? processor;
if (!isFunction(processor)) {
throw new ConfigurationError(`The processor "${processorLookup}" must be a function`);
}
const { name, postprocess } = processor();
if (!isString(name) || !name) {
throw new ConfigurationError(
`The processor "${processorLookup}" must return an object with the "name" property`,
);
}
if (!isFunction(postprocess)) {
throw new ConfigurationError(
`The processor "${processorLookup}" must return an object with the "postprocess" property`,
);
}
return { name, postprocess };
});
/** @type {StylelintConfig['_processorFunctions']} */
const processorFunctions = new Map();
(await Promise.all(processorPromises)).forEach(({ name, postprocess }) => {
if (name) {
processorFunctions.set(name, postprocess);
}
});
config._processorFunctions = processorFunctions;
return config;
}
/**
* @typedef {Object} CompiledOverride
* @property {Object} configOverrides The config overrides to apply when matched
* @property {(filePath: string) => boolean} matches Pre-compiled matcher function
*/
/**
* Pre-compile override matchers for efficient reuse across multiple files. This
* avoids recompiling glob patterns for each file processed.
*
* @param {NonNullable<StylelintConfig['overrides']>} overrides
* @param {string} rootConfigDir
* @returns {CompiledOverride[]}
*/
export function compileOverrideMatchers(overrides, rootConfigDir) {
return overrides.map((override) => {
const { files, ...configOverrides } = override;
if (!files) {
throw new Error(
'Every object in the `overrides` configuration property should have a `files` property with globs, e.g. { "overrides": [{ "files": "*.css", "rules": {} }] }.',
);
}
const fileList = typeof files === 'string' ? [files] : files;
const absoluteGlobs = [];
const nonNegatedFiles = [];
for (const glob of fileList) {
absoluteGlobs.push(absolutizeGlob(glob, rootConfigDir));
if (!glob.startsWith('!')) {
nonNegatedFiles.push(glob);
}
}
const absoluteMatcher = createMatcher(absoluteGlobs, { dot: true });
const basenameMatcher =
nonNegatedFiles.length > 0
? createMatcher(nonNegatedFiles, { dot: true, basename: true })
: () => false;
return {
configOverrides,
matches: (filePath) => absoluteMatcher(filePath) || basenameMatcher(filePath),
};
});
}
/**
* @param {StylelintConfig} fullConfig
* @param {string} rootConfigDir
* @param {string} filePath
* @param {Map<string, CompiledOverride[]>} [cache] Cache for compiled matchers
* @param {string} [configFilePath] Config file path for cache key
* @returns {StylelintConfig}
*/
export function applyOverrides(fullConfig, rootConfigDir, filePath, cache, configFilePath) {
const { overrides, ...rest } = fullConfig;
let config = rest;
if (!overrides) {
return config;
}
if (!Array.isArray(overrides)) {
throw new TypeError(
'The `overrides` configuration property should be an array, e.g. { "overrides": [{ "files": "*.css", "rules": {} }] }.',
);
}
let matchers;
if (cache && configFilePath) {
const cacheKey = `${configFilePath}::${rootConfigDir}`;
matchers = cache.get(cacheKey);
if (!matchers) {
matchers = compileOverrideMatchers(overrides, rootConfigDir);
cache.set(cacheKey, matchers);
}
} else {
matchers = compileOverrideMatchers(overrides, rootConfigDir);
}
for (const { configOverrides, matches } of matchers) {
if (matches(filePath)) {
config = mergeConfigs(config, configOverrides);
}
}
return config;
}
/**
* Add options to the config
*
* @param {StylelintInternalApi} stylelint
* @param {StylelintConfig} config
*
* @returns {StylelintConfig}
*/
function addOptions(stylelint, config) {
const augmentedConfig = {
...config,
};
const subset = /** @type {const} */ ([
'customSyntax',
'fix',
'computeEditInfo',
'ignoreDisables',
'quiet',
'reportDescriptionlessDisables',
'reportInvalidScopeDisables',
'reportNeedlessDisables',
'reportUnscopedDisables',
'validate',
]);
/** @type {Partial<StylelintConfig>} */
const options = {
...stylelint._options,
// Override fix to match Config type.
fix: stylelint._options.fix ? Boolean(stylelint._options.fix) : undefined,
};
/**
* @template T
* @param {T extends typeof subset[number] ? T : never} key
*/
const addOption = (key) => {
const value = options[key];
if (value) {
augmentedConfig[key] = value;
}
};
subset.forEach((key) => addOption(key));
return augmentedConfig;
}