@atlaskit/eslint-plugin-design-system
Version:
The essential plugin for use with the Atlassian Design System.
274 lines (256 loc) • 12.1 kB
JavaScript
/* eslint-disable @repo/internal/react/require-jsdoc */
import { isNodeOfType } from 'eslint-codemod-utils';
import { getSourceCode } from '@atlaskit/eslint-utils/context-compat';
import { Object as ASTObject } from '../../../ast-nodes/object';
import { ObjectEntry } from '../../../ast-nodes/object-entry';
import { Root } from '../../../ast-nodes/root';
import { getValueForPropertyNode } from '../../ensure-design-token-usage/get-value-for-property-node';
import { normaliseValue } from '../../ensure-design-token-usage/normalise-value';
import { isDecendantOfGlobalToken } from '../../utils/is-decendant-of-global-token';
import { isDecendantOfStyleBlock } from '../../utils/is-decendant-of-style-block';
import { isDecendantOfType } from '../../utils/is-decendant-of-type';
import { convertPropertyNodeToStringableNode } from '../convert-property-node-to-stringable-node';
import { defaultFontWeight } from '../default-font-weight';
import { findFontWeightTokenForValue } from '../find-font-weight-token-for-value';
import { findTypographyTokenForValues } from '../find-typography-token-for-values';
import { fontWeightMap } from '../font-weight-map';
import { getLiteralProperty } from '../get-literal-property';
import { getTokenProperty } from '../get-token-property';
import { insertTokensImport } from '../insert-tokens-import';
import { isValidPropertyNode } from '../is-valid-property-node';
import { isValidTypographyToken } from '../is-valid-typography-token';
import { notUndefined } from '../not-undefined';
export const StyleObject = {
lint(node, {
context,
config
}) {
// To force the correct node type
if (!isNodeOfType(node, 'ObjectExpression')) {
return {
success: false
};
}
// Check whether all criteria needed to make a transformation are met
const {
success,
refs
} = StyleObject._check(node, {
context,
config
});
if (!success || !refs) {
return;
}
const {
fontSizeNode,
fontSizeRaw,
tokensImportNode
} = refs;
const fontSizeValue = normaliseValue('fontSize', fontSizeRaw);
// -- Font weight --
const fontWeightNode = ASTObject.getEntryByPropertyName(node, 'fontWeight');
let fontWeightRaw = fontWeightNode && getValueForPropertyNode(fontWeightNode, context);
// If fontWeightRaw is a token we find the token name and treat it like a raw value for simplicity.
// e.g. token('font.weight.bold', '700') ends up as '700' after this if-block.
// That way the token matching logic still runs and the font weight declaration can be removed and re-added after the main font token.
if (fontWeightRaw && typeof fontWeightRaw === 'string' && fontWeightRaw.includes('font.weight.')) {
var _fontWeightRaw$match;
const fontWeightTokenSuffix = ((_fontWeightRaw$match = fontWeightRaw.match(/font\.weight\.(\w*)/)) === null || _fontWeightRaw$match === void 0 ? void 0 : _fontWeightRaw$match[1]) || 'regular'; // ${token('font.weight.bold', '700')} -> 'bold'
if (Object.keys(fontWeightMap).includes(fontWeightTokenSuffix)) {
fontWeightRaw = fontWeightMap[fontWeightTokenSuffix];
}
}
// If no fontWeight value exists, default to 400 to avoid matching with a bolder token resulting in a visual change
let fontWeightValue = fontWeightRaw && normaliseValue('fontWeight', fontWeightRaw) || defaultFontWeight;
fontWeightValue = fontWeightValue.length === 3 ? fontWeightValue : fontWeightMap[fontWeightValue] || defaultFontWeight;
// -- Line height --
const lineHeightNode = ASTObject.getEntryByPropertyName(node, 'lineHeight');
const lineHeightRaw = lineHeightNode && getValueForPropertyNode(lineHeightNode, context);
let shouldAddFontWeight = false;
let lineHeightValue = lineHeightRaw && normaliseValue('lineHeight', lineHeightRaw) || undefined;
if (lineHeightValue === fontSizeValue) {
lineHeightValue = '1';
}
// -- Match tokens --
// Check if fontSize is a token (this is invalid syntax but unfortunately a common occurence)
// We may as well auto-fix `fontSize` to `font` and keep the token.
// Other tokens like `fontSize: token('space.100')` will not autofix, but still report
let matchingTokens = [];
const isFontSizeAToken = isDecendantOfGlobalToken(fontSizeNode.value);
if (isFontSizeAToken) {
// Specifically match for valid, non-deprecated font.heading|body|code tokens
const match = fontSizeValue.match(/font.(body|heading|code)[^']*/);
if (match) {
const matchedTokenName = match[0];
// This is really just a double check to be 100% certain the token exists
// and that we're not trying to apply a deprecated fontSize token to the font property
if (isValidTypographyToken(matchedTokenName)) {
matchingTokens = [{
tokenName: matchedTokenName
}];
}
}
} else {
// Standard matching against fontSize/lineHeight values
matchingTokens = findTypographyTokenForValues(fontSizeValue, lineHeightValue);
if (matchingTokens.length) {
// If we have multiple matching tokens, try matching fontWeight
let matchingTokenWithWeight = findFontWeightTokenForValue(fontWeightValue, matchingTokens);
if (matchingTokenWithWeight) {
// Possibly narrowed down tokens
matchingTokens = [matchingTokenWithWeight];
} else {
// Ended up with 0 matches by matching fontWeight
// return body token and add fontWeight manually
matchingTokens = matchingTokens.filter(token => token.tokenName.includes('.body'));
shouldAddFontWeight = true;
}
}
}
// Get other font-* nodes that we can replace/remove.
// These aren't needed for token matching.
// -- Font family --
const fontFamilyNode = ASTObject.getEntryByPropertyName(node, 'fontFamily');
const fontFamilyRaw = fontFamilyNode && getValueForPropertyNode(fontFamilyNode, context);
let fontFamilyValue = fontFamilyRaw && normaliseValue('fontFamily', fontFamilyRaw) || undefined;
// If font family is already a token, we remove and re-add it
// Only need to do this for non-default font stacks as the defaults can be safely removed
if (fontFamilyValue && fontFamilyValue.includes('font.family.') && !(fontFamilyValue.includes('font.family.heading') || fontFamilyValue.includes('font.family.body'))) {
fontFamilyValue = undefined;
}
let fontFamilyToAdd;
// If font family uses the Charlie font we can't replace; exit
if (fontFamilyValue) {
if (fontFamilyValue.toLowerCase().includes('charlie display')) {
fontFamilyToAdd = 'heading';
} else if (fontFamilyValue.toLowerCase().includes('charlie text')) {
fontFamilyToAdd = 'body';
}
} else {
// Font family node exists but we can't resolve its value
// Will need to re-add it below the font property to ensure it still applies
fontFamilyToAdd = fontFamilyNode ? 'original' : undefined;
}
// -- Font style --
const fontStyleNode = ASTObject.getEntryByPropertyName(node, 'fontStyle');
const fontStyleRaw = fontStyleNode && getValueForPropertyNode(fontStyleNode, context);
const fontStyleValue = fontStyleRaw && normaliseValue('fontStyle', fontStyleRaw) || undefined;
let fontStyleToAdd;
if (fontStyleValue === 'italic') {
fontStyleToAdd = 'italic';
}
// -- Letter spacing --
const letterSpacingNode = ASTObject.getEntryByPropertyName(node, 'letterSpacing');
// A single matching token
// TOOD: Maybe suggest options if > 1 matching token
if (matchingTokens.length === 1) {
const matchingToken = matchingTokens[0];
// fontSize node is always first
const nodesToReplace = [fontSizeNode, fontWeightNode, lineHeightNode, fontFamilyNode, fontStyleNode, letterSpacingNode].filter(notUndefined);
const fontFamilyTokenName = fontFamilyToAdd ? `font.family.brand.${fontFamilyToAdd}` : '';
const fontWeightReplacementToken = shouldAddFontWeight ? findFontWeightTokenForValue(fontWeightValue) : undefined;
const fontWeightReplacement = fontWeightReplacementToken && getTokenProperty('fontWeight', fontWeightReplacementToken.tokenName);
const fontFamilyReplacement = fontFamilyToAdd && (fontFamilyToAdd === 'original' ? convertPropertyNodeToStringableNode(
// This will always exist if fontFamilyToAdd === 'original', TS can't figure that out.
fontFamilyNode) : getTokenProperty('fontFamily', fontFamilyTokenName));
const fontStyleReplacement = fontStyleToAdd && getLiteralProperty('fontStyle', fontStyleToAdd);
const fixerRefs = {
matchingToken,
nodesToReplace,
tokensImportNode,
fontWeightReplacement,
fontFamilyReplacement,
fontStyleReplacement
};
const fix = StyleObject._fix(fixerRefs, context);
context.report({
node: fontSizeNode,
messageId: isFontSizeAToken ? 'noFontSizeTypographyToken' : 'noRawTypographyValues',
...(config.enableUnsafeAutofix ? {
fix
} : {
suggest: [{
desc: `Convert to font token`,
fix
}]
})
});
} else if (!matchingTokens.length) {
context.report({
node: fontSizeNode,
messageId: 'noRawTypographyValues'
});
}
return;
},
_check(node, {
context,
config
}) {
if (!config.patterns.includes('style-object')) {
return {
success: false
};
}
if (!isDecendantOfStyleBlock(node) && !isDecendantOfType(node, 'JSXExpressionContainer')) {
return {
success: false
};
}
// -- Font size --
const fontSizeNode = ASTObject.getEntryByPropertyName(node, 'fontSize');
if (!fontSizeNode || !isValidPropertyNode(fontSizeNode)) {
return {
success: false
};
}
const fontSizeRaw = getValueForPropertyNode(fontSizeNode, context);
// Without a valid fontSize value we can't be certain what token should be used; exit
if (fontSizeRaw === undefined || fontSizeRaw === null) {
return {
success: false
};
}
const tokensImportDeclaration = Root.findImportsByModule(getSourceCode(context).ast.body, '@atlaskit/tokens');
// If there is more than one `@atlaskit/tokens` import, then it becomes difficult to determine which import to transform
if (tokensImportDeclaration.length > 1) {
return {
success: false
};
}
return {
success: true,
refs: {
fontSizeNode,
fontSizeRaw,
tokensImportNode: tokensImportDeclaration[0]
}
};
},
_fix(refs, context) {
return fixer => {
const {
matchingToken,
nodesToReplace,
tokensImportNode,
fontWeightReplacement,
fontFamilyReplacement,
fontStyleReplacement
} = refs;
const fontSizeNode = nodesToReplace[0];
const root = getSourceCode(context).ast.body;
return (!tokensImportNode ? [insertTokensImport(root, fixer)] : []).concat(nodesToReplace.map((node, index) => {
// Replace first node with token, delete remaining nodes. Guaranteed to be fontSize
if (index === 0) {
return fixer.replaceText(node, `${getTokenProperty('font', matchingToken.tokenName, undefined, true)}`);
}
// We don't replace fontWeight/fontFamily/fontStyle here in case it occurs before the font property.
// Instead delete the original property and add below
return ObjectEntry.deleteEntry(node, context, fixer);
}),
// Make sure font weight/family/style properties are added AFTER font property to ensure they override corectly
fontWeightReplacement ? [fixer.insertTextAfter(fontSizeNode, `,\n${fontWeightReplacement}`)] : [], fontFamilyReplacement ? [fixer.insertTextAfter(fontSizeNode, `,\n${fontFamilyReplacement}`)] : [], fontStyleReplacement ? [fixer.insertTextAfter(fontSizeNode, `,\n${fontStyleReplacement}`)] : []);
};
}
};