@atlaskit/stylelint-design-system
Version:
Stylelint plugin for use with the Atlassian Design System.
256 lines (244 loc) • 9.48 kB
JavaScript
import valueParser from 'postcss-value-parser';
import stylelint from 'stylelint';
import { getSpacingToken } from '../../utils/get-spacing-token';
import { isColorFunction } from '../../utils/is-color-function';
import { isFunction } from '../../utils/is-function';
import { isHexColor } from '../../utils/is-hex-color';
import { isLengthOrPercentage } from '../../utils/is-length-or-percentage';
import { isNamedColor } from '../../utils/is-named-color';
import { isSpacingRule } from '../../utils/is-spacing-rule';
import { isToken } from '../../utils/is-token';
import { isTypographyRule } from '../../utils/is-typography-rule';
import { isVar } from '../../utils/is-var';
const defaultIsEnabled = {
color: true,
spacing: false,
typography: false,
nonTokenCssVariables: false
};
export const ruleName = 'design-system/ensure-design-token-usage';
const tokenUrl = 'https://atlassian.design/components/tokens/examples';
export const messages = stylelint.utils.ruleMessages(ruleName, {
noHardcodedColors: `Color values should be design tokens. See ${tokenUrl} for guidance.`,
noHardcodedSpacing: `Spacing values should be design tokens. See ${tokenUrl} for guidance.`,
noHardcodedTypography: `Typography values should be design tokens. See ${tokenUrl} for guidance.`,
noNonTokenVars: 'CSS variables should be wrapped in a design token.'
});
const isColorNode = node => {
switch (node.type) {
case 'function':
return isColorFunction(node.value);
case 'word':
return isHexColor(node.value) || isNamedColor(node.value);
}
};
const ruleBase = (isEnabled = defaultIsEnabled, _secondaryOptions, context) => {
return (root, result) => {
// Map to store spacing values for each declaration during fix mode
// This allows us to collect all spacing values before applying fixes
// to avoid position shifting issues when modifying the AST
const declarationSpacingValues = new Map();
const validOptions = stylelint.utils.validateOptions(result, ruleName, {
actual: isEnabled,
possible: {
color: [true, false],
spacing: [true, false],
typography: [true, false],
nonTokenCssVariables: [true, false]
}
});
if (!validOptions) {
return;
}
root.walkDecls(decl => {
valueParser(decl.value).walk(node => {
if (isEnabled.color) {
if (isFunction(node) && isVar(node)) {
if (isToken(node.nodes[0])) {
return false;
}
if (isEnabled.nonTokenCssVariables && !isToken(node.nodes[0])) {
/**
* If we find a var, ensure it's a token var
*/
stylelint.utils.report({
message: messages.noNonTokenVars,
node: decl,
word: node.value,
result,
ruleName
});
return false;
}
}
/**
* If we find a color function (rgba, hsl) or color (#eee), ensure it's a token
*/
if (isColorNode(node)) {
stylelint.utils.report({
message: messages.noHardcodedColors,
node: decl,
word: node.value,
result,
ruleName
});
return false;
}
}
if (isEnabled.spacing) {
// Rule is gap, margin, padding, etc
if (isSpacingRule(decl.prop)) {
if (isFunction(node) && isVar(node)) {
// A valid token was used, exit
if (isToken(node.nodes[0])) {
return false;
}
// A variable that isn't a token was used in a spacing rule
// Only report in lint mode, not fix mode to avoid duplicate reports
if (!context.fix) {
stylelint.utils.report({
message: messages.noHardcodedSpacing,
node: decl,
word: node.value,
result,
ruleName
});
}
return false;
}
/**
* Report on px, cm, in, etc
* This is necessary because we walk multiple types of nodes.
* So we need to first check whether it's a value that's a length
* or percentage so we don't report on other types of nodes like
* 'prop'.
*/
if (isLengthOrPercentage(node.value)) {
// Check if we can auto-fix this spacing value by mapping it to a spacing token
const spacingToken = getSpacingToken(node.value);
// In fix mode, handle differently to collect values for post-processing
if (context.fix) {
// Store all spacing values for post-processing to avoid position shifting
if (!declarationSpacingValues.has(decl)) {
declarationSpacingValues.set(decl, []);
}
declarationSpacingValues.get(decl).push({
originalValue: node.value,
spacingToken,
shouldReport: !spacingToken,
originalNode: {
...node
} // Store a copy of the node for later use
});
return false; // Don't report yet - handle in post-processing
} else {
// In lint mode (not fix mode), report errors immediately
stylelint.utils.report({
message: messages.noHardcodedSpacing,
node: decl,
word: node.value,
result,
ruleName
});
return false;
}
}
}
}
if (isEnabled.typography) {
// Rule is font-size, line-height, etc
if (isTypographyRule(decl.prop)) {
if (isFunction(node) && isVar(node)) {
// A valid token was used, exit
if (isToken(node.nodes[0])) {
return false;
}
// A variable that isn't a token was used in a spacing rule
stylelint.utils.report({
message: messages.noHardcodedTypography,
node: decl,
word: node.value,
result,
ruleName
});
return false;
}
/**
* Report on px, cm, in, etc
* This is necessary because we walk multiple types of nodes.
* So we need to first check whether it's a value that's a length
* or percentage so we don't report on other types of nodes like
* 'prop'.
*/
if (isLengthOrPercentage(node.value)) {
stylelint.utils.report({
message: messages.noHardcodedTypography,
node: decl,
word: node.value,
result,
ruleName
});
return false;
}
}
}
});
});
// Post-process spacing values in fix mode
// This section handles the actual auto-fixing of spacing values to design tokens
if (context.fix) {
declarationSpacingValues.forEach((spacingValues, decl) => {
// First, report errors using original positions before any modifications
// This ensures error messages are accurate even after fixes are applied
const reportableValues = spacingValues.filter(sv => sv.shouldReport);
reportableValues.forEach(spacingValue => {
stylelint.utils.report({
message: messages.noHardcodedSpacing,
node: decl,
word: spacingValue.originalValue,
result,
ruleName
});
});
// Then apply fixes from right to left to avoid position shifting
const parsedValue = valueParser(decl.value);
// Collect positions for fixing by walking the parsed value again
const valuePositions = [];
parsedValue.walk(node => {
if (node.type === 'word') {
// Find matching spacing values that have valid tokens for replacement
const matchingSpacingValue = spacingValues.find(sv => sv.originalValue === node.value && sv.spacingToken);
if (matchingSpacingValue) {
valuePositions.push({
node: node,
spacingValue: matchingSpacingValue,
sourceIndex: node.sourceIndex || 0
});
}
}
});
// Sort by position (highest first = right to left) and apply fixes
// This prevents position shifting issues when modifying multiple values
valuePositions.sort((a, b) => b.sourceIndex - a.sourceIndex).forEach(({
node,
spacingValue
}) => {
node.value = spacingValue.spacingToken;
});
// Update the declaration value with all fixes applied
decl.value = parsedValue.toString();
});
}
};
};
const rule = Object.assign(ruleBase, {
ruleName: ruleName,
messages: messages,
meta: {
url: tokenUrl,
fixable: true
}
});
const plugin = stylelint.createPlugin(ruleName, rule);
// eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
export default plugin;