@dnb/eufemia
Version:
DNB Eufemia Design System UI Library
354 lines (353 loc) • 17.8 kB
JavaScript
"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