UNPKG

stylelint-sass-render-errors

Version:

Display Sass render errors and deprecations as lint errors.

339 lines (297 loc) 8.58 kB
/* eslint-disable import/dynamic-import-chunkname */ /** * @typedef {import('postcss').Root} Root * @typedef {import('postcss').ChildNode} ChildNode * @typedef {import('sass-render-errors/types/render-errors').SassRenderError} SassRenderError * @typedef {import('sass-render-errors/types/undefined-functions').Options} Options * @typedef {import('sass').Options<"sync">|import('sass').Options<"async">|import('sass').StringOptions<"sync">|import('sass').StringOptions<"async">} SassOptions */ /** * @typedef {object} PluginConfig * @property {boolean} sync * @property {string|SassOptions} sassOptions * @property {boolean} checkUndefinedFunctions * @property {Options["disallowedKnownCssFunctions"]} disallowedKnownCssFunctions * @property {Options["additionalKnownCssFunctions"]} additionalKnownCssFunctions */ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import AjvModule from 'ajv'; import stylelint from 'stylelint'; import renderErrorsFactory, { undefinedFunctions as undefinedFunctionsFactory } from 'sass-render-errors'; import * as sass from 'sass'; import { packageUp } from 'package-up'; import resolveFrom from 'resolve-from'; import pMemoize from 'p-memoize'; import memoize from 'memoize'; import ManyKeysMap from 'many-keys-map'; const Ajv = AjvModule.default; const ruleName = 'plugin/sass-render-errors'; const validStyleFileExtensions = ['.scss', '.sass', '.css']; const ajv = new Ajv(); const validateOptions = ajv.compile({ oneOf: [ { type: 'boolean' }, { type: 'object', additionalProperties: false, properties: { checkUndefinedFunctions: { type: 'boolean' }, disallowedKnownCssFunctions: { type: 'array' }, additionalKnownCssFunctions: { type: 'array' }, sync: { type: 'boolean' }, sassOptions: { oneOf: [{ type: 'string' }, { type: 'object' }] } } } ] }); const messages = stylelint.utils.ruleMessages(ruleName, { report: (/** @type {string} */ value) => value }); const renderErrorsRenderer = memoize(renderErrorsFactory); const undefinedFunctionRenderer = memoize(undefinedFunctionsFactory, { cacheKey: ([sassInstance, ...arguments_]) => [sassInstance, JSON.stringify(arguments_)], cache: new ManyKeysMap() }); const importConfig = pMemoize(async (/** @type {string} */ configLocation) => { let config; const { default: importedConfig } = await import(configLocation); if (typeof importedConfig === 'function') { config = await importedConfig(); } else { config = importedConfig; } return /** @type {SassOptions} */ (config); }); const getConfigLocation = pMemoize( async (/** @type {string} */ cwd, /** @type {string} */ configValue) => { const packagePath = await packageUp({ cwd }); const startingLocation = path.dirname(packagePath ?? cwd); const configLocation = resolveFrom(startingLocation, configValue); return configLocation; }, { cacheKey: JSON.stringify } ); /** * @param {string} file */ function isValidStyleFile(file) { const extension = path.extname(file); return file !== 'stdin' && validStyleFileExtensions.includes(extension); } /** * @param {{ file: string, css: string }} input * @param {PluginConfig["sassOptions"]} configValue */ async function getCompilerOptions(input, configValue) { const { file, css } = input; let config; if (typeof configValue === 'string') { const configLocation = await getConfigLocation(process.cwd(), configValue); const importedConfig = await importConfig(configLocation); config = importedConfig; } else { config = configValue; } let type; let value = file ?? css ?? ''; /** @type {{file: ?string}}*/ const meta = { file: null }; if (file !== 'stdin') { meta.file = file; type = 'file'; value = file; } if (!isValidStyleFile(file)) { type = 'code'; value = css; } let entryUrl = null; if (type === 'code' && file && file !== 'stdin') { entryUrl = pathToFileURL(file); } const options = { ...config, ...(entryUrl && { url: entryUrl }), quietDeps: true }; return { type: type, value: value, meta: meta, options: options }; } /** * @param {Root} cssRoot * @param {number} line * @param {number} column */ function getClosestNode(cssRoot, line, column) { /** @type {ChildNode[]} */ const nodes = []; cssRoot.walk((node) => { if (node?.source?.start?.line === line) { nodes.push(node); } }); let closestIndex = 0; let closestDistance = Number.MAX_VALUE; nodes.forEach((node, index) => { const start = node?.source?.start; const end = node?.source?.end; const startDistance = Math.sqrt( Math.pow((start?.line ?? 1) - line, 2) + Math.pow((start?.column ?? 0) - column, 2) ); const endDistance = Math.sqrt( Math.pow((end?.line ?? 1) - line, 2) + Math.pow((end?.column ?? 0) - column, 2) ); const minDistance = Math.min(startDistance, endDistance); if (minDistance < closestDistance) { closestDistance = minDistance; closestIndex = index; } }); return nodes[closestIndex] ?? cssRoot; } /** * @param {SassRenderError[]} errors */ function unique(errors) { /* eslint-disable unicorn/prefer-spread */ /** @type {[string, SassRenderError][]} */ const collection = errors.map((entry) => { const { stack, ...key } = entry; return [JSON.stringify(key), entry]; }); const map = new Map(collection); return Array.from(map.values()); } /** * @type {stylelint.RuleBase} */ function ruleFunction(/** @type {PluginConfig}*/ resolveRules) { return async (cssRoot, result) => { const validOptions = stylelint.utils.validateOptions(result, ruleName, { actual: resolveRules, possible: validateOptions }); if (!validOptions) { return; } const { sync = false, sassOptions: initialSassOptions = {}, checkUndefinedFunctions = false, disallowedKnownCssFunctions = [], additionalKnownCssFunctions = [] } = resolveRules; let compilerOptions; const file = cssRoot.source?.input.file ?? 'stdin'; const css = cssRoot.source?.input.css ?? ''; try { compilerOptions = await getCompilerOptions( { file: file, css: css }, initialSassOptions ); } catch (/** @type {any} */ error_) { /** @type {Error} */ const error = error_; stylelint.utils.report({ ruleName: ruleName, result: result, node: cssRoot, message: messages.report(error.message) }); return; } const renderers = [renderErrorsRenderer(sass)]; if (checkUndefinedFunctions) { renderers.push( undefinedFunctionRenderer(sass, { disallowedKnownCssFunctions, additionalKnownCssFunctions }) ); } const results = await Promise.all( renderers.map((renderer) => { if (sync && compilerOptions.type === 'file') { return renderer.compile( compilerOptions.value, /** @type {import('sass').Options<"sync">}*/ (compilerOptions.options) ); } if (!sync && compilerOptions.type === 'file') { return renderer.compileAsync( compilerOptions.value, /** @type {import('sass').Options<"async">}*/ (compilerOptions.options) ); } if (sync && compilerOptions.type === 'code') { return renderer.compileString( compilerOptions.value, /** @type {import('sass').StringOptions<"sync">}*/ (compilerOptions.options) ); } if (!sync && compilerOptions.type === 'code') { // prettier-ignore return renderer.compileStringAsync( compilerOptions.value, /** @type {import('sass').StringOptions<"async">}*/ (compilerOptions.options) ); } return []; }) ); const errors = results.flat(); const shouldApplyOffset = !isValidStyleFile(file); unique(errors) .filter((error) => { const referenceFile = error.file === 'stdin' && typeof compilerOptions.meta.file === 'string' ? compilerOptions.meta.file : error.file; return referenceFile === file; }) .forEach((error) => { let offset = 0; if (shouldApplyOffset) { offset = Math.max((cssRoot?.first?.source?.start?.line ?? 0) - 1, 0); } const closestNode = getClosestNode( cssRoot, offset + error.source.start.line, error.source.start.column ); stylelint.utils.report({ ruleName: ruleName, result: result, node: closestNode, word: error.source.pattern, message: messages.report(error.message) }); }); }; } ruleFunction.ruleName = ruleName; ruleFunction.messages = messages; export { ruleName, messages }; export default stylelint.createPlugin(ruleName, ruleFunction);