UNPKG

stylelint-no-unresolved-module

Version:

Ensures that module (import-like or `url`) can be resolved to a module on the file system.

444 lines (422 loc) 12.3 kB
import Ajv from 'ajv'; import stylelint from 'stylelint'; import parse from 'postcss-value-parser'; import path from 'path'; import { ResolverFactory } from 'oxc-resolver'; import isUrl from 'is-url'; import pWaterfall from 'p-waterfall'; import scssParser from 'scss-parser'; /** * @typedef {import('postcss').Root} postcss.Root * @typedef {import('postcss').ChildNode} postcss.ChildNode * @typedef {import('oxc-resolver').TsconfigOptions} TsconfigOptions */ const urlRegex = /^url/; const defaultResolveValues = { extensions: ['.css'], conditionNames: ['style', 'browser', 'import', 'require', 'node', 'default'], mainFields: ['style', 'browser', 'module', 'main'], mainFiles: ['index'], modules: ['node_modules'] }; class NodeResolver { /** * @param {NodeResolverOptions} options */ constructor(options) { var _this$cssRoot; const { cssRoot = null, cwd = process.cwd(), alias = (/** @type {Record<string, (string|string[])>}*/{}), roots = (/** @type {string[]}*/[]), extensions = [...defaultResolveValues.extensions], conditionNames = [...defaultResolveValues.conditionNames], mainFields = [...defaultResolveValues.mainFields], mainFiles = [...defaultResolveValues.mainFiles], modules = [...defaultResolveValues.modules], tsconfig } = options !== null && options !== void 0 ? options : {}; this.cwd = cwd; this.alias = alias; this.roots = [...roots]; this.extensions = extensions; this.conditionNames = conditionNames; this.mainFields = mainFields; this.mainFiles = mainFiles; this.modules = modules; this.cssRoot = cssRoot; this.tsconfig = tsconfig; if ((_this$cssRoot = this.cssRoot) !== null && _this$cssRoot !== void 0 && (_this$cssRoot = _this$cssRoot.source) !== null && _this$cssRoot !== void 0 && _this$cssRoot.input.file) { this.context = path.dirname(this.cssRoot.source.input.file); } else { this.context = this.cwd; } this.importAtRules = ['import']; if (this.context === this.cwd) { this.roots.push(this.cwd); } this.roots = [...new Set(this.roots)].map(root => { return path.resolve(this.cwd, root); }); const preparedAlias = Object.entries(this.alias).reduce((array, [key, value]) => { array[key] = /** @type {string[]}*/[].concat(value); return array; }, /** @type {Record<string, string[]>} */{}); this.enhancedResolve = new ResolverFactory({ roots: this.roots, alias: preparedAlias, extensions: this.extensions, conditionNames: this.conditionNames, mainFields: this.mainFields, mainFiles: this.mainFiles, modules: this.modules, tsconfig: this.tsconfig }); } /** * @param {string} entry */ async resolvePath(entry) { const result = await this.enhancedResolve.async(this.context, entry); if (result.error) { throw new Error(result.error); } else if (typeof result.path === 'string') { return result.path; } else { throw new TypeError(`Unable to resolve "${entry}".`); } } /** * @param {string} value * @param {postcss.ChildNode} rootNode */ resolve(value, rootNode) { if (isUrl(value) || rootNode.type === 'atrule' && !this.isGenericUrlImport(rootNode)) { return false; } return { promise: this.resolvePath(value), message: this.message.bind(this) }; } /** * @param {string} resource * @param {postcss.ChildNode} rootNode */ message(resource, rootNode) { if (rootNode.type === 'atrule') { return `Unable to resolve path to import "${resource}".`; } return `Unable to resolve path to resource "${resource}".`; } /** * @param {postcss.ChildNode} rootNode */ isGenericUrlImport(rootNode) { return rootNode.type === 'atrule' && urlRegex.test(rootNode.params); } } /** * @typedef {import('postcss').Root} postcss.Root * @typedef {import('postcss').ChildNode} postcss.ChildNode * @typedef {import('scss-parser').Node} ScssParserNode * @typedef {import('./node').NodeResolverOptions} NodeResolverOptions */ const sassCoreModuleRegex = /^sass:[^:]+$/; class SassResolver extends NodeResolver { /** * @param {NodeResolverOptions} options */ constructor(options) { super({ ...options, extensions: ['.scss', '.css'], conditionNames: ['sass', 'style', 'browser', 'import', 'require', 'node', 'default'], mainFiles: ['_index', 'index'] }); this.importAtRules = ['import', 'use', 'forward']; } /** * @param {string} rawEntry */ async resolvePath(rawEntry) { const entry = rawEntry.replace('pkg:', ''); const dirname = path.dirname(entry); const basename = path.basename(entry); const underscoreEntry = path.join(dirname, `_${basename}`); const genericEntry = path.join(dirname, basename); const resolvePathTasks = [`./${underscoreEntry}`, `./${genericEntry}`, underscoreEntry, genericEntry].map(currentEntry => { return async (/** @type {string?} */previousEntry) => { if (typeof previousEntry === 'string') { return previousEntry; } try { return await super.resolvePath(currentEntry); } catch { return previousEntry; } }; }); /** @type {string?} */ // @ts-ignore const resolvedPath = await pWaterfall(resolvePathTasks, null); if (resolvedPath === null) { throw new Error(`Unable to resolve Sass path "${entry}".`); } return resolvedPath; } /** * @param {string} value * @param {postcss.ChildNode} rootNode */ resolve(value, rootNode) { if (this.isSassCoreModuleRequest(value, rootNode) || this.isGenericUrlImport(rootNode)) { return false; } return { promise: this.resolvePath(value), message: this.message.bind(this) }; } /** * @param {string} resource * @param {postcss.ChildNode} rootNode */ message(resource, rootNode) { if (this.isSassModuleAtRule(rootNode)) { return `Unable to resolve path to module "${resource}".`; } return super.message(resource, rootNode); } /** * @param {postcss.ChildNode} rootNode */ isSassModuleAtRule(rootNode) { return rootNode.type === 'atrule' && ['use', 'forward'].includes(rootNode.name); } /** * @param {string} value * @param {postcss.ChildNode} rootNode */ isSassCoreModuleRequest(value, rootNode) { return rootNode.type === 'atrule' && this.isSassModuleAtRule(rootNode) && sassCoreModuleRegex.test(value); } /** * @param {string} value */ isStaticString(value) { /* eslint-disable unicorn/catch-error-name */ let parsed; /* * Try parsing raw value. If that fails, try to parse it as double-quoted string value. * Otherwise, assume this isn’t static string. */ try { parsed = scssParser.parse(value); } catch (error1) { try { parsed = scssParser.parse(`"${value}"`); } catch (error2) { return false; } } return !hasInterpolationOrVariable(parsed); } } /** * @param {ScssParserNode} parsed * @returns {boolean} */ function hasInterpolationOrVariable(parsed) { if (Array.isArray(parsed.value)) { return parsed.value.some(item => { if (['interpolation', 'variable'].includes(item.type) || ['string_double', 'string_single'].includes(item.type) && typeof item.value === 'string' && item.value.includes('#{')) { return true; } return hasInterpolationOrVariable(item); }); } return false; } /** * @typedef {import('postcss').ChildNode} postcss.ChildNode * @typedef {{ * rootNode: postcss.ChildNode, * value: string, * promise: Promise<string>, * message: (resource: string, rootNode: postcss.ChildNode) => string * }} ResolvedModule */ const ruleName = 'plugin/no-unresolved-module'; const ajv = new Ajv(); const validateOptions = ajv.compile({ oneOf: [{ type: 'boolean' }, { type: 'object', additionalProperties: false, properties: { cwd: { type: 'string' }, modules: { type: 'array', minItems: 1, items: { type: 'string' } }, roots: { type: 'array', minItems: 1, items: { type: 'string' } }, alias: { type: 'object', 'anyOf': [{ patternProperties: { '.+': { type: 'string' } } }, { patternProperties: { '.+': { type: 'array', minItems: 1, items: { type: 'string' } } } }] }, tsconfig: { type: 'object', properties: { configFile: { type: 'string' }, references: { anyOf: [{ enum: ['auto'] }, { type: 'array', items: { type: 'string' } }] } } } } }] }); const messages = stylelint.utils.ruleMessages(ruleName, { report: (/** @type string */value) => value }); /** * @param {string} value */ function isDataUrl(value) { try { return new URL(value).protocol === 'data:'; } catch { return false; } } /** * @type {stylelint.RuleBase} */ function ruleFunction(resolveRules) { return async function (cssRoot, result) { const validOptions = stylelint.utils.validateOptions(result, ruleName, { actual: resolveRules, possible: value => (/** @type {boolean}*/validateOptions(value)) }); if (!validOptions) { return; } const nodeResolver = new NodeResolver({ cssRoot, ...resolveRules }); const sassResolver = new SassResolver({ cssRoot, ...resolveRules }); /** @type {ResolvedModule[]} */ const values = []; cssRoot.walkAtRules(new RegExp([...nodeResolver.importAtRules, ...sassResolver.importAtRules].join('|')), atRule => { const parsed = parse(atRule.params); let shouldExit = false; parsed.walk(node => { if (['use', 'forward'].includes(atRule.name) && node.value === 'with') { shouldExit = true; } if (node.type === 'string' && !shouldExit) { const value = [nodeResolver.resolve(node.value, atRule), sassResolver.resolve(node.value, atRule)].find(entry => entry !== false); if (typeof value === 'object') { values.push({ rootNode: atRule, value: node.value, ...value }); } } }); }); cssRoot.walkDecls(decl => { if (decl.value.includes('url')) { const parsed = parse(decl.value); parsed.walk(topNode => { if (topNode.type === 'function' && topNode.value === 'url') { const [node] = topNode.nodes; if (sassResolver.isStaticString(node.value) && !isDataUrl(node.value)) { const value = [nodeResolver.resolve(node.value, decl)].find(entry => entry !== false); if (typeof value === 'object') { values.push({ rootNode: decl, value: node.value, ...value }); } } } }); } }); const resolvedValues = await Promise.allSettled(values.map(({ promise }) => promise)); resolvedValues.forEach(({ status }, index) => { if (status === 'rejected') { const { value, rootNode, message } = values[index]; stylelint.utils.report({ ruleName: ruleName, result: result, node: rootNode, word: value, message: messages.report(message(value, rootNode)) }); } }); }; } // @ts-ignore const plugin = stylelint.createPlugin(ruleName, ruleFunction); var index = { ...plugin, messages }; export { index as default }; //# sourceMappingURL=index.js.map