uui-build/linting/stylelintCustomRules/rules/themeTokensValidation.js (167 lines of code) (raw):

const postcss = require('postcss'); const fs = require('fs'); const postcssSass = require('@csstools/postcss-sass'); const scssParser = require('postcss-scss'); const stylelint = require('stylelint'); const { RULE_NAMES } = require('../constants'); const { report, ruleMessages, validateOptions } = stylelint.utils; const ruleName = RULE_NAMES.THEME_TOKENS_VALIDATION; const messages = ruleMessages(ruleName, { reportUnknownVar: (property) => `CSS variable is not declared: ${property}`, reportDoubleDeclarationOfCssProp: (property) => `This CSS property is declared more than once: ${property}`, reportCantCompileScss: (fullPath, reason) => `Cannot compile ${fullPath}, Reason: ${reason}`, }); async function compileScss(from) { const compiler = postcss([ postcssSass({}), ]); let result; try { const src = fs.readFileSync(from, 'utf8'); result = await compiler.process(src, { syntax: scssParser, from }); } catch (err) { console.error(`Compile error ${from}`, err.stack); throw err; } return result; } function getReferencedCustomPropsFromDecl(decl) { const customPropUsageRegex = /var\((--[\w\d-]+)\)/g; const result = new Set(); if (decl.type === 'decl') { const res = Array.from(decl.value.matchAll(customPropUsageRegex)); if (res?.length) { res.forEach((m) => result.add(m[1])); } } return result; } function isDeclInRootThemeSelectorScope(decl) { const selector = decl.parent.selector; return selector && selector.split(' ').length === 1 && selector.indexOf('.uui-theme-') === 0; } function isDeclInRootThemeMixinScope(decl) { const p = decl.parent; if (p && p.type === 'atrule' && p.name === 'mixin' && p.params.indexOf('theme-') === 0) { return true; } } function isIgnoredUnknownVar(params) { const { secondaryOptions, varToCheck } = params; const arr = secondaryOptions?.ignoredUnknownVars || []; return arr.indexOf(varToCheck) !== -1; } function isIgnoredRedeclaredVar(params) { const { secondaryOptions, varToCheck } = params; const arr = secondaryOptions?.ignoredRedeclaredVars || []; return arr.indexOf(varToCheck) !== -1; } const isString = (v) => typeof v === 'string'; async function getCustomPropsInfo(scssFullPath) { const compiled = await compileScss(scssFullPath); const declared = new Set(); const declaredInRootThemeSelector = new Set(); const declaredInRootThemeSelectorMoreThanOnce = new Set(); const used = new Set(); compiled.root.walkDecls((decl) => { const prop = decl.prop; if (prop.startsWith('--')) { if (isDeclInRootThemeSelectorScope(decl)) { if (declaredInRootThemeSelector.has(prop)) { declaredInRootThemeSelectorMoreThanOnce.add(prop); } declaredInRootThemeSelector.add(prop); } declared.add(prop); } const res = Array.from(getReferencedCustomPropsFromDecl(decl)); if (res?.length) { res.forEach((m) => used.add(m)); } }); const usedButNotDeclared = new Set(); [...used].forEach((p) => { if (!declared.has(p)) { usedButNotDeclared.add(p); } }); return { declared, used, usedButNotDeclared, declaredInRootThemeSelectorMoreThanOnce }; } function rule(primaryOptions, secondaryOptions) { return async (root, result) => { const isRuleConfigValid = validateOptions( result, ruleName, { actual: primaryOptions }, { actual: secondaryOptions, possible: { ignoredUnknownVars: [isString], ignoredRedeclaredVars: [isString], }, optional: true, }, ); if (!isRuleConfigValid) { return; } const srcFullPath = result.opts.from; let info; try { info = await getCustomPropsInfo(srcFullPath); } catch (err) { report({ message: messages.reportCantCompileScss(srcFullPath, err?.message), node: root, result, ruleName, }); } if (info) { root.walkDecls((decl) => { if (decl.prop.startsWith('--')) { const isIgnored = isIgnoredRedeclaredVar({ secondaryOptions, varToCheck: decl.prop }); if (isIgnored) { return; } if (info.declaredInRootThemeSelectorMoreThanOnce.has(decl.prop)) { if (!isDeclInRootThemeMixinScope(decl)) { // it's OK to redeclare anywhere but directly in root selector scope return; } report({ message: messages.reportDoubleDeclarationOfCssProp(decl.prop), node: decl, word: decl.prop, result, ruleName, }); } } const usedCustomProps = getReferencedCustomPropsFromDecl(decl); if (usedCustomProps.size) { usedCustomProps.forEach((p) => { const isIgnored = isIgnoredUnknownVar({ secondaryOptions, varToCheck: p }); if (isIgnored) { return; } if (info.usedButNotDeclared.has(p)) { report({ message: messages.reportUnknownVar(p), node: decl, word: p, result, ruleName, }); } }); } }); } }; } rule.ruleName = ruleName; rule.messages = messages; rule.meta = { url: 'https://github.com/epam/UUI', }; module.exports = rule;