UNPKG

@dnb/eufemia

Version:

DNB Eufemia Design System UI Library

354 lines (353 loc) 17.8 kB
"use strict"; const stylelint = require('stylelint'); const fs = require('fs'); const path = require('path'); const DEFAULT_POLICY = { tokenPrefix: '--token-', allowedTokenCategories: ['color', 'radius'], allowedTokenColorCategories: ['background', 'text', 'icon', 'stroke', 'decorative', 'component'], categoriesWithSemanticValidation: ['background', 'text', 'icon', 'stroke'], allowedTokenColorSemantics: ['action', 'neutral', 'destructive', 'marketing', 'selected', 'info', 'positive', 'warning', 'error', 'page'], themePrefixes: { ui: 'dnb', sbanken: 'sbanken', carnegie: 'carnegie' }, disallowedSuffixes: ['-wip'], tokenFileName: 'tokens.scss', themesDirRelativePath: 'src/style/themes', allowedFoundationReferencePrefixes: ['--dnb-payment-', '--dnb-forms-', '--dnb-sidebar-'], disallowSassHexRgba: true }; const RULE_NAME = 'eufemia/token-name-policy'; const PROJECT_ROOT = path.resolve(__dirname, '../../../..'); const THEME_FROM_PATH_REGEX = /\/style\/themes\/([^/]+)\//; const TOKEN_DECLARATION_PATTERN = /(^|\s)(--token-[a-z0-9-]+)\s*:/gim; const FOUNDATION_REFERENCE_REGEX = /var\(\s*(--(?:dnb|sbanken|carnegie)-[a-z0-9-]+)\s*\)/gi; const TOKEN_REFERENCE_REGEX = /var\(\s*(--token-[a-z0-9-]+)\s*\)/gi; const GENERIC_VAR_REFERENCE_REGEX = /var\(\s*(--[a-z0-9-]+)\s*\)/gi; const SINGLE_VAR_REFERENCE_REGEX = /var\(\s*(--[a-z0-9-]+)\s*\)/i; const SINGLE_TOKEN_REFERENCE_REGEX = /var\(\s*(--token-[a-z0-9-]+)\s*\)/i; const SASS_HEX_RGBA_REGEX = /rgba\(\s*#/i; const escapeRegExp = value => { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }; const createTokenDeclarationPattern = tokenPrefix => { return new RegExp(`(^|\\s)(${escapeRegExp(tokenPrefix)}[a-z0-9-]+)\\s*:`, 'gim'); }; const createTokenReferenceRegex = (tokenPrefix, flags = 'gi') => { return new RegExp(`var\\(\\s*(${escapeRegExp(tokenPrefix)}[a-z0-9-]+)\\s*\\)`, flags); }; const messages = stylelint.utils.ruleMessages(RULE_NAME, { disallowedSuffix: (prop, suffix) => `Unexpected token variable "${prop}". Suffix "${suffix}" is not allowed.`, wrongPrefix: (prop, expectedPrefix, theme) => `Unexpected token variable "${prop}" in theme "${theme}". Expected prefix "--${expectedPrefix}-".`, wrongReferencePrefix: (ref, expectedPrefix, theme) => `Unexpected token reference "${ref}" in theme "${theme}". Expected prefix "--${expectedPrefix}-".`, wrongTokenDeclarationPrefix: (prop, tokenPrefix) => `Unexpected token variable "${prop}" in tokens file. Expected prefix "${tokenPrefix}".`, wrongTokenCategory: (prop, categories) => `Unexpected token variable "${prop}" in tokens file. Expected "--token-" followed by one of: ${categories.join(', ')}.`, wrongTokenColorCategory: (prop, categories) => `Unexpected token variable "${prop}" in tokens file. Expected "--token-color-" followed by one of: ${categories.join(', ')}.`, wrongTokenColorSemantic: (prop, semantics) => `Unexpected token variable "${prop}" in tokens file. Expected the 4th segment to be one of: ${semantics.join(', ')}.`, forbiddenFoundationReferenceOutsideTokens: ref => `Unexpected foundation token reference "${ref}" outside tokens.scss. Foundation variables may only be used in tokens.scss.`, unknownTokenReferenceOutsideTokens: ref => `Unexpected token reference "${ref}" outside tokens.scss. The token was not found in theme token files.`, missingTokenAcrossBrands: (prop, theme) => `Missing token "${prop}" in "${theme}" tokens.scss. All brand tokens.scss files must contain the same tokens.`, sassHexRgba: value => `Unexpected Sass rgba hex usage "${value}". Use CSS channel notation, e.g. rgba(0 0 0 / 40%).`, tokenInGlobalScope: (ref, selector) => `Unexpected token reference "${ref}" inside "${selector}". Design tokens must not be used in :root, html, or body selectors, in order to support scoped dark mode.` }); const normalizePath = filePath => { return (filePath || '').replace(/\\/g, '/'); }; const getThemeFromPath = filePath => { const normalizedPath = normalizePath(filePath); const match = normalizedPath.match(THEME_FROM_PATH_REGEX); return (match === null || match === void 0 ? void 0 : match[1]) || null; }; const isFoundationFile = filePath => { const normalizedPath = normalizePath(filePath); return normalizedPath.endsWith("/foundation.min.css"); }; const isTokensFile = filePath => { const normalizedPath = normalizePath(filePath); return normalizedPath.endsWith("/tokens.min.css") || normalizedPath.endsWith("/tokens-dark.min.css"); }; const resolveTokenFilePaths = ({ projectRoot = PROJECT_ROOT, tokenFiles } = {}) => { if (tokenFiles !== null && tokenFiles !== void 0 && tokenFiles.length) { return tokenFiles.map(filePath => path.isAbsolute(filePath) ? filePath : path.resolve(projectRoot, filePath)).filter(filePath => fs.existsSync(filePath)); } const themesDir = path.resolve(projectRoot, DEFAULT_POLICY.themesDirRelativePath); if (!fs.existsSync(themesDir)) { return []; } return fs.readdirSync(themesDir, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => path.join(themesDir, entry.name, DEFAULT_POLICY.tokenFileName)).filter(filePath => fs.existsSync(filePath)); }; const extractTokenDeclarations = (content, tokenPrefix = DEFAULT_POLICY.tokenPrefix) => { const declarations = new Set(); const declarationRegex = tokenPrefix === DEFAULT_POLICY.tokenPrefix ? new RegExp(TOKEN_DECLARATION_PATTERN) : createTokenDeclarationPattern(tokenPrefix); let match; while ((match = declarationRegex.exec(content)) !== null) { const variable = match[2]; if (variable) { declarations.add(variable); } } return declarations; }; const loadKnownTokenVariables = ({ projectRoot = PROJECT_ROOT, tokenFiles, tokenPrefix = DEFAULT_POLICY.tokenPrefix } = {}) => { const knownVariables = new Set(); const resolvedTokenFiles = resolveTokenFilePaths({ projectRoot, tokenFiles }); for (const absolutePath of resolvedTokenFiles) { const fileContent = fs.readFileSync(absolutePath, 'utf-8'); for (const variable of extractTokenDeclarations(fileContent, tokenPrefix)) { knownVariables.add(variable); } } return { knownVariables, hasTokenFiles: resolvedTokenFiles.length > 0 }; }; const loadTokenVariablesByFile = ({ projectRoot = PROJECT_ROOT, tokenFiles, tokenPrefix = DEFAULT_POLICY.tokenPrefix } = {}) => { const variablesByFile = new Map(); const resolvedTokenFiles = resolveTokenFilePaths({ projectRoot, tokenFiles }); for (const absolutePath of resolvedTokenFiles) { const fileContent = fs.readFileSync(absolutePath, 'utf-8'); variablesByFile.set(absolutePath, extractTokenDeclarations(fileContent, tokenPrefix)); } return variablesByFile; }; const ruleFunction = (primary, secondaryOptions = {}) => { return (root, result) => { var _secondaryOptions$dis, _root$source; const validOptions = stylelint.utils.validateOptions(result, RULE_NAME, { actual: primary, possible: [true] }); if (!validOptions) { return; } const disallowedSuffixes = secondaryOptions.disallowedSuffixes || DEFAULT_POLICY.disallowedSuffixes; const tokenPrefix = secondaryOptions.tokenPrefix || DEFAULT_POLICY.tokenPrefix; const allowedTokenCategories = secondaryOptions.allowedTokenCategories || DEFAULT_POLICY.allowedTokenCategories; const allowedTokenColorCategories = secondaryOptions.allowedTokenColorCategories || DEFAULT_POLICY.allowedTokenColorCategories; const categoriesWithSemanticValidation = secondaryOptions.categoriesWithSemanticValidation || DEFAULT_POLICY.categoriesWithSemanticValidation; const allowedTokenColorSemantics = secondaryOptions.allowedTokenColorSemantics || DEFAULT_POLICY.allowedTokenColorSemantics; const allowedFoundationReferencePrefixes = secondaryOptions.allowedFoundationReferencePrefixes || DEFAULT_POLICY.allowedFoundationReferencePrefixes; const disallowSassHexRgba = (_secondaryOptions$dis = secondaryOptions.disallowSassHexRgba) !== null && _secondaryOptions$dis !== void 0 ? _secondaryOptions$dis : DEFAULT_POLICY.disallowSassHexRgba; const themePrefixes = { ...DEFAULT_POLICY.themePrefixes, ...(secondaryOptions.themePrefixes || {}) }; const { knownVariables, hasTokenFiles } = loadKnownTokenVariables({ projectRoot: secondaryOptions.projectRoot, tokenFiles: secondaryOptions.tokenFiles, tokenPrefix }); const tokenVariablesByFile = loadTokenVariablesByFile({ projectRoot: secondaryOptions.projectRoot, tokenFiles: secondaryOptions.tokenFiles, tokenPrefix }); const filePath = ((_root$source = root.source) === null || _root$source === void 0 || (_root$source = _root$source.input) === null || _root$source === void 0 ? void 0 : _root$source.file) || ''; const theme = getThemeFromPath(filePath); const expectedPrefix = theme ? themePrefixes[theme] : null; const shouldValidateThemePrefix = Boolean(expectedPrefix) && isFoundationFile(filePath); const shouldValidateTokenReferences = Boolean(expectedPrefix) && isTokensFile(filePath); const shouldValidateTokenDeclarationPrefix = isTokensFile(filePath); const shouldValidateHasAllTokensAsOtherBrands = Boolean(expectedPrefix) && isTokensFile(filePath); if (shouldValidateHasAllTokensAsOtherBrands) { const normalizedFilePath = path.resolve(filePath); const hasCurrentTokenFile = tokenVariablesByFile.has(normalizedFilePath); if (hasCurrentTokenFile) { const currentFileVariables = tokenVariablesByFile.get(normalizedFilePath) || new Set(); const allVariables = new Set(); for (const variables of tokenVariablesByFile.values()) { for (const variable of variables) { allVariables.add(variable); } } for (const variable of allVariables) { if (!currentFileVariables.has(variable)) { stylelint.utils.report({ result, ruleName: RULE_NAME, node: root, message: messages.missingTokenAcrossBrands(variable, theme) }); } } } } const GLOBAL_SCOPE_SELECTOR_REGEX = /^(:root|html|body)$/i; root.walkDecls(decl => { if (!shouldValidateTokenDeclarationPrefix) { var _ruleNode$selector; const ruleNode = decl.parent; if ((ruleNode === null || ruleNode === void 0 ? void 0 : ruleNode.type) === 'rule' && GLOBAL_SCOPE_SELECTOR_REGEX.test((_ruleNode$selector = ruleNode.selector) === null || _ruleNode$selector === void 0 ? void 0 : _ruleNode$selector.trim())) { var _decl$value; const tokenReferenceRegex = tokenPrefix === DEFAULT_POLICY.tokenPrefix ? TOKEN_REFERENCE_REGEX : createTokenReferenceRegex(tokenPrefix); const singleTokenReferenceRegex = tokenPrefix === DEFAULT_POLICY.tokenPrefix ? SINGLE_TOKEN_REFERENCE_REGEX : createTokenReferenceRegex(tokenPrefix, 'i'); const tokenReferences = ((_decl$value = decl.value) === null || _decl$value === void 0 ? void 0 : _decl$value.match(tokenReferenceRegex)) || []; for (const match of tokenReferences) { const referenceMatch = match.match(singleTokenReferenceRegex); const variableReference = referenceMatch === null || referenceMatch === void 0 ? void 0 : referenceMatch[1]; if (variableReference) { stylelint.utils.report({ result, ruleName: RULE_NAME, node: decl, message: messages.tokenInGlobalScope(variableReference, ruleNode.selector.trim()) }); } } } } if (disallowSassHexRgba && SASS_HEX_RGBA_REGEX.test(decl.value)) { stylelint.utils.report({ result, ruleName: RULE_NAME, node: decl, message: messages.sassHexRgba(decl.value) }); } if (decl.prop && decl.prop.startsWith('--')) { for (const suffix of disallowedSuffixes) { if (decl.prop.endsWith(suffix)) { stylelint.utils.report({ result, ruleName: RULE_NAME, node: decl, message: messages.disallowedSuffix(decl.prop, suffix) }); return; } } if (shouldValidateTokenDeclarationPrefix && !decl.prop.startsWith(tokenPrefix)) { stylelint.utils.report({ result, ruleName: RULE_NAME, node: decl, message: messages.wrongTokenDeclarationPrefix(decl.prop, tokenPrefix) }); } if (shouldValidateTokenDeclarationPrefix) { const tokenNameParts = decl.prop.split('-').filter(Boolean); const tokenCategory = tokenNameParts[1]; if (!allowedTokenCategories.includes(tokenCategory)) { stylelint.utils.report({ result, ruleName: RULE_NAME, node: decl, message: messages.wrongTokenCategory(decl.prop, allowedTokenCategories) }); } else if (tokenCategory === 'color') { const tokenColorCategory = tokenNameParts[2]; if (tokenColorCategory && !allowedTokenColorCategories.includes(tokenColorCategory)) { stylelint.utils.report({ result, ruleName: RULE_NAME, node: decl, message: messages.wrongTokenColorCategory(decl.prop, allowedTokenColorCategories) }); } else if (tokenColorCategory && categoriesWithSemanticValidation.includes(tokenColorCategory)) { const tokenColorSemantic = tokenNameParts[3]; if (tokenColorSemantic && !allowedTokenColorSemantics.includes(tokenColorSemantic)) { stylelint.utils.report({ result, ruleName: RULE_NAME, node: decl, message: messages.wrongTokenColorSemantic(decl.prop, allowedTokenColorSemantics) }); } } } } } const hasThemeReferenceValidation = shouldValidateThemePrefix || shouldValidateTokenReferences; const expectedVariablePrefix = hasThemeReferenceValidation ? `--${expectedPrefix}-` : null; if (shouldValidateThemePrefix && expectedVariablePrefix && !decl.prop.startsWith(expectedVariablePrefix)) { stylelint.utils.report({ result, ruleName: RULE_NAME, node: decl, message: messages.wrongPrefix(decl.prop, expectedPrefix, theme) }); } if (shouldValidateTokenReferences && expectedVariablePrefix) { var _decl$value2; const variableReferenceMatches = ((_decl$value2 = decl.value) === null || _decl$value2 === void 0 ? void 0 : _decl$value2.match(GENERIC_VAR_REFERENCE_REGEX)) || []; for (const match of variableReferenceMatches) { const referenceMatch = match.match(SINGLE_VAR_REFERENCE_REGEX); const variableReference = referenceMatch === null || referenceMatch === void 0 ? void 0 : referenceMatch[1]; if (variableReference && !variableReference.startsWith(expectedVariablePrefix)) { stylelint.utils.report({ result, ruleName: RULE_NAME, node: decl, message: messages.wrongReferencePrefix(variableReference, expectedPrefix, theme) }); } } } if (!shouldValidateTokenDeclarationPrefix) { var _decl$value3; const forbiddenReferences = ((_decl$value3 = decl.value) === null || _decl$value3 === void 0 ? void 0 : _decl$value3.match(FOUNDATION_REFERENCE_REGEX)) || []; for (const match of forbiddenReferences) { const referenceMatch = match.match(SINGLE_VAR_REFERENCE_REGEX); const variableReference = referenceMatch === null || referenceMatch === void 0 ? void 0 : referenceMatch[1]; if (variableReference && !allowedFoundationReferencePrefixes.some(prefix => variableReference.startsWith(prefix))) { stylelint.utils.report({ result, ruleName: RULE_NAME, node: decl, message: messages.forbiddenFoundationReferenceOutsideTokens(variableReference) }); } } if (hasTokenFiles) { var _decl$value4; const tokenReferenceRegex = tokenPrefix === DEFAULT_POLICY.tokenPrefix ? TOKEN_REFERENCE_REGEX : createTokenReferenceRegex(tokenPrefix); const singleTokenReferenceRegex = tokenPrefix === DEFAULT_POLICY.tokenPrefix ? SINGLE_TOKEN_REFERENCE_REGEX : createTokenReferenceRegex(tokenPrefix, 'i'); const tokenReferences = ((_decl$value4 = decl.value) === null || _decl$value4 === void 0 ? void 0 : _decl$value4.match(tokenReferenceRegex)) || []; for (const match of tokenReferences) { const referenceMatch = match.match(singleTokenReferenceRegex); const variableReference = referenceMatch === null || referenceMatch === void 0 ? void 0 : referenceMatch[1]; if (variableReference && !knownVariables.has(variableReference)) { stylelint.utils.report({ result, ruleName: RULE_NAME, node: decl, message: messages.unknownTokenReferenceOutsideTokens(variableReference) }); } } } } }); }; }; ruleFunction.ruleName = RULE_NAME; ruleFunction.messages = messages; module.exports = stylelint.createPlugin(RULE_NAME, ruleFunction); module.exports.ruleName = RULE_NAME; module.exports.messages = messages; //# sourceMappingURL=token-name-policy.js.map