augmentConfig.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. import { dirname, isAbsolute } from 'node:path';
  2. import globjoin from 'globjoin';
  3. import micromatch from 'micromatch';
  4. import normalizePath from 'normalize-path';
  5. import { isFunction, isString } from './utils/validateTypes.mjs';
  6. import { ConfigurationError } from './utils/errors.mjs';
  7. import dynamicImport from './utils/dynamicImport.mjs';
  8. import { emitDeprecationWarning } from './utils/emitWarning.mjs';
  9. import getModulePath from './utils/getModulePath.mjs';
  10. import normalizeAllRuleSettings from './normalizeAllRuleSettings.mjs';
  11. /** @import {Config as StylelintConfig, CosmiconfigResult as StylelintCosmiconfigResult, InternalApi as StylelintInternalApi} from 'stylelint' */
  12. /**
  13. * @param {string} glob
  14. * @param {string} basedir
  15. * @returns {string}
  16. */
  17. function absolutizeGlob(glob, basedir) {
  18. const result = isAbsolute(glob.replace(/^!/, '')) ? glob : globjoin(basedir, glob);
  19. // Glob patterns for micromatch should be in POSIX-style
  20. return normalizePath(result);
  21. }
  22. /**
  23. * - Merges config and stylelint options
  24. * - Makes all paths absolute
  25. * - Merges extends
  26. * @param {StylelintInternalApi} stylelint
  27. * @param {StylelintConfig} config
  28. * @param {string} configDir
  29. * @param {boolean} allowOverrides
  30. * @param {string} rootConfigDir
  31. * @param {string} [filePath]
  32. * @returns {Promise<StylelintConfig>}
  33. */
  34. async function augmentConfigBasic(
  35. stylelint,
  36. config,
  37. configDir,
  38. allowOverrides,
  39. rootConfigDir,
  40. filePath,
  41. ) {
  42. let augmentedConfig = config;
  43. if (allowOverrides) {
  44. augmentedConfig = addOptions(stylelint, augmentedConfig);
  45. }
  46. if (filePath) {
  47. augmentedConfig = applyOverrides(augmentedConfig, rootConfigDir, filePath);
  48. }
  49. augmentedConfig = await extendConfig(
  50. stylelint,
  51. augmentedConfig,
  52. configDir,
  53. rootConfigDir,
  54. filePath,
  55. );
  56. const cwd = stylelint._options.cwd;
  57. return absolutizePaths(augmentedConfig, configDir, cwd);
  58. }
  59. /**
  60. * Extended configs need to be run through augmentConfigBasic
  61. * but do not need the full treatment. Things like pluginFunctions
  62. * will be resolved and added by the parent config.
  63. * @param {string} cwd
  64. * @returns {(cosmiconfigResult?: StylelintCosmiconfigResult) => Promise<StylelintCosmiconfigResult>}
  65. */
  66. export function augmentConfigExtended(cwd) {
  67. return async (cosmiconfigResult) => {
  68. if (!cosmiconfigResult) {
  69. return null;
  70. }
  71. const configDir = dirname(cosmiconfigResult.filepath || '');
  72. const { config } = cosmiconfigResult;
  73. const augmentedConfig = absolutizePaths(config, configDir, cwd);
  74. return {
  75. config: augmentedConfig,
  76. filepath: cosmiconfigResult.filepath,
  77. };
  78. };
  79. }
  80. /**
  81. * @param {StylelintInternalApi} stylelint
  82. * @param {string} [filePath]
  83. * @param {StylelintCosmiconfigResult} [cosmiconfigResult]
  84. * @returns {Promise<StylelintCosmiconfigResult>}
  85. */
  86. export async function augmentConfigFull(stylelint, filePath, cosmiconfigResult) {
  87. if (!cosmiconfigResult) {
  88. return null;
  89. }
  90. const config = cosmiconfigResult.config;
  91. const filepath = cosmiconfigResult.filepath;
  92. const configDir = stylelint._options.configBasedir || dirname(filepath || '');
  93. let augmentedConfig = await augmentConfigBasic(
  94. stylelint,
  95. config,
  96. configDir,
  97. true,
  98. configDir,
  99. filePath,
  100. );
  101. augmentedConfig = await addPluginFunctions(augmentedConfig, stylelint._options);
  102. augmentedConfig = await addProcessorFunctions(augmentedConfig);
  103. if (!augmentedConfig.rules) {
  104. throw new ConfigurationError(
  105. 'No rules found within configuration. Have you provided a "rules" property?',
  106. );
  107. }
  108. augmentedConfig = await normalizeAllRuleSettings(augmentedConfig);
  109. return {
  110. config: augmentedConfig,
  111. filepath: cosmiconfigResult.filepath,
  112. };
  113. }
  114. /**
  115. * Make all paths in the config absolute.
  116. *
  117. * @param {StylelintConfig} config
  118. * @param {string} configDir
  119. * @param {string} cwd
  120. * @returns {StylelintConfig}
  121. */
  122. function absolutizePaths(config, configDir, cwd) {
  123. if (config.ignoreFiles) {
  124. config.ignoreFiles = [config.ignoreFiles].flat().map((glob) => absolutizeGlob(glob, configDir));
  125. }
  126. /** @type {<T>(lookup: T) => (string | T)} */
  127. const toAbsolutePath = (lookup) => {
  128. if (typeof lookup === 'string') {
  129. return getModulePath(configDir, lookup, cwd);
  130. }
  131. return lookup;
  132. };
  133. if (config.plugins) {
  134. config.plugins = [config.plugins].flat().map(toAbsolutePath);
  135. }
  136. if (config.processors) {
  137. config.processors = config.processors.map(toAbsolutePath);
  138. }
  139. return config;
  140. }
  141. /**
  142. * @param {StylelintInternalApi} stylelint
  143. * @param {StylelintConfig} config
  144. * @param {string} configDir
  145. * @param {string} rootConfigDir
  146. * @param {string} [filePath]
  147. * @returns {Promise<StylelintConfig>}
  148. */
  149. async function extendConfig(stylelint, config, configDir, rootConfigDir, filePath) {
  150. if (config.extends === undefined) {
  151. return config;
  152. }
  153. const { extends: configExtends, ...originalWithoutExtends } = config;
  154. const normalizedExtends = [configExtends].flat();
  155. let resultConfig = originalWithoutExtends;
  156. for (const extendLookup of normalizedExtends) {
  157. let extendResult;
  158. if (typeof extendLookup === 'string') {
  159. extendResult = await loadExtendedConfig(stylelint, configDir, extendLookup);
  160. } else if (typeof extendLookup === 'object' && extendLookup !== null) {
  161. extendResult = { config: extendLookup };
  162. }
  163. if (extendResult) {
  164. let extendResultConfig = extendResult.config;
  165. const extendConfigDir = dirname(extendResult.filepath || '');
  166. extendResultConfig = await augmentConfigBasic(
  167. stylelint,
  168. extendResultConfig,
  169. extendConfigDir,
  170. false,
  171. rootConfigDir,
  172. filePath,
  173. );
  174. resultConfig = mergeConfigs(resultConfig, extendResultConfig);
  175. }
  176. }
  177. return mergeConfigs(resultConfig, originalWithoutExtends);
  178. }
  179. /**
  180. * @param {StylelintInternalApi} stylelint
  181. * @param {string} configDir
  182. * @param {string} extendLookup
  183. * @returns {Promise<StylelintCosmiconfigResult>}
  184. */
  185. function loadExtendedConfig(stylelint, configDir, extendLookup) {
  186. const extendPath = getModulePath(configDir, extendLookup, stylelint._options.cwd);
  187. return stylelint._extendExplorer.load(extendPath);
  188. }
  189. /**
  190. * When merging configs (via extends)
  191. * - plugin, extends, overrides arrays are joined
  192. * - rules are merged via Object.assign, so there is no attempt made to
  193. * merge any given rule's settings. If b contains the same rule as a,
  194. * b's rule settings will override a's rule settings entirely.
  195. * - Everything else is merged via Object.assign
  196. * @param {StylelintConfig} a
  197. * @param {StylelintConfig} b
  198. * @returns {StylelintConfig}
  199. */
  200. function mergeConfigs(a, b) {
  201. /** @type {Pick<StylelintConfig, 'plugins'>} */
  202. const pluginMerger = {};
  203. if (a.plugins || b.plugins) {
  204. pluginMerger.plugins = [];
  205. if (a.plugins) {
  206. pluginMerger.plugins = pluginMerger.plugins.concat(a.plugins);
  207. }
  208. if (b.plugins) {
  209. pluginMerger.plugins = [...new Set(pluginMerger.plugins.concat(b.plugins))];
  210. }
  211. }
  212. /** @type {Pick<StylelintConfig, 'processors'>} */
  213. const processorMerger = {};
  214. if (a.processors || b.processors) {
  215. processorMerger.processors = [];
  216. if (a.processors) {
  217. processorMerger.processors = processorMerger.processors.concat(a.processors);
  218. }
  219. if (b.processors) {
  220. processorMerger.processors = [...new Set(processorMerger.processors.concat(b.processors))];
  221. }
  222. }
  223. /** @type {Pick<StylelintConfig, 'overrides'>} */
  224. const overridesMerger = {};
  225. if (a.overrides || b.overrides) {
  226. overridesMerger.overrides = [];
  227. if (a.overrides) {
  228. overridesMerger.overrides = overridesMerger.overrides.concat(a.overrides);
  229. }
  230. if (b.overrides) {
  231. overridesMerger.overrides = [...new Set(overridesMerger.overrides.concat(b.overrides))];
  232. }
  233. }
  234. /** @type {Pick<StylelintConfig, 'extends'>} */
  235. const extendsMerger = {};
  236. if (a.extends || b.extends) {
  237. extendsMerger.extends = [];
  238. if (a.extends) {
  239. extendsMerger.extends = extendsMerger.extends.concat(a.extends);
  240. }
  241. if (b.extends) {
  242. extendsMerger.extends = extendsMerger.extends.concat(b.extends);
  243. }
  244. // Remove duplicates from the array, the last item takes precedence
  245. extendsMerger.extends = extendsMerger.extends.filter(
  246. (item, index, arr) => arr.lastIndexOf(item) === index,
  247. );
  248. }
  249. const rulesMerger = {};
  250. if (a.rules || b.rules) {
  251. rulesMerger.rules = { ...a.rules, ...b.rules };
  252. }
  253. const result = {
  254. ...a,
  255. ...b,
  256. ...extendsMerger,
  257. ...pluginMerger,
  258. ...processorMerger,
  259. ...overridesMerger,
  260. ...rulesMerger,
  261. };
  262. return result;
  263. }
  264. /**
  265. * @param {StylelintConfig} config
  266. * @param {import('stylelint').LinterOptions} options
  267. * @returns {Promise<StylelintConfig>}
  268. */
  269. async function addPluginFunctions(config, { quietDeprecationWarnings }) {
  270. if (!config.plugins) {
  271. return config;
  272. }
  273. const normalizedPlugins = [config.plugins].flat();
  274. /** @type {StylelintConfig['pluginFunctions']} */
  275. const pluginFunctions = {};
  276. for (const pluginLookup of normalizedPlugins) {
  277. let pluginImport;
  278. if (typeof pluginLookup === 'string') {
  279. pluginImport = await dynamicImport(pluginLookup);
  280. // NOTE: This '.cjs' check is limited. Some CommonJS plugins may have the '.js' extension.
  281. if (!quietDeprecationWarnings && pluginLookup.endsWith('.cjs')) {
  282. emitDeprecationWarning(
  283. `CommonJS plugins are deprecated ("${pluginLookup}").`,
  284. 'COMMONJS_PLUGINS',
  285. 'See https://stylelint.io/migration-guide/to-16',
  286. );
  287. }
  288. } else {
  289. pluginImport = pluginLookup;
  290. }
  291. // Handle either ES6 or CommonJS modules
  292. pluginImport = pluginImport.default || pluginImport;
  293. // A plugin can export either a single rule definition
  294. // or an array of them
  295. const normalizedPluginImport = [pluginImport].flat();
  296. for (const pluginRuleDefinition of normalizedPluginImport) {
  297. if (!pluginRuleDefinition.ruleName) {
  298. throw new ConfigurationError(
  299. `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.`,
  300. );
  301. }
  302. if (!pluginRuleDefinition.ruleName.includes('/')) {
  303. throw new ConfigurationError(
  304. `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.`,
  305. );
  306. }
  307. pluginFunctions[pluginRuleDefinition.ruleName] = pluginRuleDefinition.rule;
  308. }
  309. }
  310. config.pluginFunctions = pluginFunctions;
  311. return config;
  312. }
  313. /**
  314. * @param {StylelintConfig} config
  315. * @returns {Promise<StylelintConfig>}
  316. */
  317. async function addProcessorFunctions(config) {
  318. if (!config.processors) {
  319. return config;
  320. }
  321. const processorPromises = config.processors.map(async (processorLookup) => {
  322. let processor = await dynamicImport(processorLookup);
  323. processor = processor.default ?? processor;
  324. if (!isFunction(processor)) {
  325. throw new ConfigurationError(`The processor "${processorLookup}" must be a function`);
  326. }
  327. const { name, postprocess } = processor();
  328. if (!isString(name) || !name) {
  329. throw new ConfigurationError(
  330. `The processor "${processorLookup}" must return an object with the "name" property`,
  331. );
  332. }
  333. if (!isFunction(postprocess)) {
  334. throw new ConfigurationError(
  335. `The processor "${processorLookup}" must return an object with the "postprocess" property`,
  336. );
  337. }
  338. return { name, postprocess };
  339. });
  340. /** @type {StylelintConfig['_processorFunctions']} */
  341. const processorFunctions = new Map();
  342. (await Promise.all(processorPromises)).forEach(({ name, postprocess }) => {
  343. if (name) {
  344. processorFunctions.set(name, postprocess);
  345. }
  346. });
  347. config._processorFunctions = processorFunctions;
  348. return config;
  349. }
  350. /**
  351. * @param {StylelintConfig} fullConfig
  352. * @param {string} rootConfigDir
  353. * @param {string} filePath
  354. * @returns {StylelintConfig}
  355. */
  356. export function applyOverrides(fullConfig, rootConfigDir, filePath) {
  357. let { overrides, ...config } = fullConfig;
  358. if (!overrides) {
  359. return config;
  360. }
  361. if (!Array.isArray(overrides)) {
  362. throw new TypeError(
  363. 'The `overrides` configuration property should be an array, e.g. { "overrides": [{ "files": "*.css", "rules": {} }] }.',
  364. );
  365. }
  366. /** @type {(glob: string) => boolean} */
  367. const nonegateGlob = (glob) => !glob.startsWith('!');
  368. for (const override of overrides) {
  369. const { files, ...configOverrides } = override;
  370. if (!files) {
  371. throw new Error(
  372. 'Every object in the `overrides` configuration property should have a `files` property with globs, e.g. { "overrides": [{ "files": "*.css", "rules": {} }] }.',
  373. );
  374. }
  375. const fileList = [files].flat();
  376. const absoluteGlobs = fileList.map((glob) => absolutizeGlob(glob, rootConfigDir));
  377. if (
  378. micromatch.isMatch(filePath, absoluteGlobs, { dot: true }) ||
  379. // E.g. `*.css` matches any CSS files in any directories.
  380. micromatch.isMatch(filePath, fileList.filter(nonegateGlob), { dot: true, basename: true })
  381. ) {
  382. config = mergeConfigs(config, configOverrides);
  383. }
  384. }
  385. return config;
  386. }
  387. /**
  388. * Add options to the config
  389. *
  390. * @param {StylelintInternalApi} stylelint
  391. * @param {StylelintConfig} config
  392. *
  393. * @returns {StylelintConfig}
  394. */
  395. function addOptions(stylelint, config) {
  396. const augmentedConfig = {
  397. ...config,
  398. };
  399. const subset = /** @type {const} */ ([
  400. 'customSyntax',
  401. 'fix',
  402. 'computeEditInfo',
  403. 'ignoreDisables',
  404. 'quiet',
  405. 'reportDescriptionlessDisables',
  406. 'reportInvalidScopeDisables',
  407. 'reportNeedlessDisables',
  408. 'reportUnscopedDisables',
  409. 'validate',
  410. ]);
  411. /** @type {Partial<StylelintConfig>} */
  412. const options = {
  413. ...stylelint._options,
  414. // Override fix to match Config type.
  415. fix: stylelint._options.fix ? Boolean(stylelint._options.fix) : undefined,
  416. };
  417. /**
  418. * @template T
  419. * @param {T extends typeof subset[number] ? T : never} key
  420. */
  421. const addOption = (key) => {
  422. const value = options[key];
  423. if (value) {
  424. augmentedConfig[key] = value;
  425. }
  426. };
  427. subset.forEach((key) => addOption(key));
  428. return augmentedConfig;
  429. }