UNPKG

@atlaskit/eslint-plugin-design-system

Version:

The essential plugin for use with the Atlassian Design System.

274 lines (256 loc) 12.1 kB
/* 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}`)] : []); }; } };