| import { dirname, isAbsolute } from 'node:path'; |
| |
| import { globby } from 'globby'; |
| import globjoin from 'globjoin'; |
| import micromatch from 'micromatch'; |
| import normalizePath from 'normalize-path'; |
| |
| import { assert, isFunction, isObject, isString } from './utils/validateTypes.mjs'; |
| import { ConfigurationError } from './utils/errors.mjs'; |
| import cachedImport from './utils/cachedImport.mjs'; |
| import getCustomSyntax from './utils/getCustomSyntax.mjs'; |
| import getLoader from './utils/getLoader.mjs'; |
| import getModulePath from './utils/getModulePath.mjs'; |
| import normalizeAllRuleSettings from './normalizeAllRuleSettings.mjs'; |
| import validateLanguageOptions from './validateLanguageOptions.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); |
| } |
| |
| /** |
| * @param {string} lookup |
| * @returns {boolean} |
| */ |
| function isPathLike(lookup) { |
| return lookup.startsWith('./') || lookup.startsWith('../') || isAbsolute(lookup); |
| } |
| |
| /** |
| * - 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); |
| augmentedConfig = await resolveReferenceFiles(augmentedConfig); |
| augmentedConfig = await resolveCustomSyntax(augmentedConfig); |
| |
| if (!augmentedConfig.rules) { |
| throw new ConfigurationError( |
| 'No rules found within configuration. Have you provided a "rules" property?', |
| ); |
| } |
| |
| augmentedConfig = await normalizeAllRuleSettings(augmentedConfig); |
| |
| validateLanguageOptions(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); |
| } |
| |
| if (isString(config.customSyntax) && isPathLike(config.customSyntax)) { |
| config.customSyntax = getModulePath(configDir, config.customSyntax, cwd); |
| } |
| |
| if (config.referenceFiles) { |
| config.referenceFiles = [config.referenceFiles].flat().map((entry) => { |
| if (isString(entry)) { |
| return { files: [absolutizeGlob(entry, configDir)] }; |
| } |
| |
| if (isObject(entry)) { |
| return { |
| files: [entry.files].flat().map((glob) => absolutizeGlob(glob, configDir)), |
| customSyntax: |
| isString(entry.customSyntax) && isPathLike(entry.customSyntax) |
| ? getModulePath(configDir, entry.customSyntax, cwd) |
| : entry.customSyntax, |
| loader: |
| isString(entry.loader) && isPathLike(entry.loader) |
| ? getModulePath(configDir, entry.loader, cwd) |
| : entry.loader, |
| }; |
| } |
| |
| return entry; |
| }); |
| } |
| |
| 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; |
| } |
| |
| /** |
| * @param {StylelintConfig} config |
| * @returns {Promise<StylelintConfig>} |
| */ |
| async function resolveReferenceFiles(config) { |
| if (!config.referenceFiles) { |
| return config; |
| } |
| |
| assert(Array.isArray(config.referenceFiles)); |
| |
| const resolved = config.referenceFiles.map(async (entry) => { |
| if (!entry || !isObject(entry) || !entry.files) { |
| throw new ConfigurationError( |
| 'Every entry in the "referenceFiles" configuration property must be a string or an object with a "files" property, e.g. { "referenceFiles": ["tokens.css"] }.', |
| ); |
| } |
| |
| assert(Array.isArray(entry.files)); |
| const patterns = entry.files; |
| const files = await globby(patterns, { absolute: true }); |
| |
| if (files.length === 0) { |
| throw new ConfigurationError( |
| `No files matching the pattern "${patterns.join(', ')}" were found.`, |
| ); |
| } |
| |
| const customSyntax = entry.customSyntax ? await getCustomSyntax(entry.customSyntax) : undefined; |
| const loader = entry.loader ? await getLoader(entry.loader) : undefined; |
| |
| return { files, customSyntax, loader }; |
| }); |
| |
| config._resolvedReferenceFiles = await Promise.all(resolved); |
| |
| return config; |
| } |
| |
| /** |
| * @param {StylelintConfig} config |
| * @returns {Promise<StylelintConfig>} |
| */ |
| async function resolveCustomSyntax(config) { |
| if (!config.customSyntax) { |
| return config; |
| } |
| |
| config._resolvedCustomSyntax = await getCustomSyntax(config.customSyntax); |
| |
| 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', |
| 'maxWarnings', |
| '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; |
| } |