From 3203ecb5632566539cc067dc58ac01acbc076257 Mon Sep 17 00:00:00 2001 From: JC Perez Chavez Date: Thu, 23 Oct 2025 15:51:01 -0400 Subject: [PATCH] Introducing no-disallowed-properties eslint rule --- .../stylex-no-disallowed-properties-test.js | 597 ++++++++++++++++++ .../__tests__/stylex-valid-styles-test.js | 91 +-- .../src/stylex-no-disallowed-properties.js | 443 +++++++++++++ .../src/stylex-no-legacy-contextual-styles.js | 26 +- .../src/stylex-no-nonstandard-styles.js | 25 +- .../src/stylex-valid-shorthands.js | 18 +- .../eslint-plugin/src/stylex-valid-styles.js | 51 -- .../src/utils/isStylexCreateCallee.js | 29 + .../src/utils/isStylexCreateDeclaration.js | 27 + 9 files changed, 1105 insertions(+), 202 deletions(-) create mode 100644 packages/@stylexjs/eslint-plugin/__tests__/stylex-no-disallowed-properties-test.js create mode 100644 packages/@stylexjs/eslint-plugin/src/stylex-no-disallowed-properties.js create mode 100644 packages/@stylexjs/eslint-plugin/src/utils/isStylexCreateCallee.js create mode 100644 packages/@stylexjs/eslint-plugin/src/utils/isStylexCreateDeclaration.js diff --git a/packages/@stylexjs/eslint-plugin/__tests__/stylex-no-disallowed-properties-test.js b/packages/@stylexjs/eslint-plugin/__tests__/stylex-no-disallowed-properties-test.js new file mode 100644 index 000000000..8654ddfc5 --- /dev/null +++ b/packages/@stylexjs/eslint-plugin/__tests__/stylex-no-disallowed-properties-test.js @@ -0,0 +1,597 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +jest.disableAutomock(); + +const { RuleTester: ESLintTester } = require('eslint'); +const rule = require('../src/stylex-no-disallowed-properties'); + +const eslintTester = new ESLintTester({ + parser: require.resolve('hermes-eslint'), + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + }, +}); + +eslintTester.run('stylex-no-disallowed-properties', rule.default, { + valid: [ + ` + import * as stylex from '@stylexjs/stylex'; + const styles = stylex.create({ + validStyle: { + accentColor: 'blue', + alignContent: 'center', + alignItems: 'center', + alignmentBaseline: 'baseline', + alignSelf: 'center', + animationDelay: '0s', + animationDirection: 'normal', + animationDuration: '1s', + animationFillMode: 'none', + animationIterationCount: 'infinite', + animationName: 'none', + animationPlayState: 'running', + animationTimingFunction: 'ease', + appearance: 'none', + aspectRatio: '16 / 9', + backdropFilter: 'none', + backfaceVisibility: 'visible', + background: 'red', + backgroundAttachment: 'scroll', + backgroundBlendMode: 'normal', + backgroundClip: 'border-box', + backgroundColor: 'red', + backgroundImage: 'none', + backgroundOrigin: 'padding-box', + backgroundPosition: 'center', + backgroundPositionX: 'center', + backgroundPositionY: 'center', + backgroundRepeat: 'repeat-x', + backgroundSize: 'cover', + baselineShift: 'baseline', + blockSize: 'auto', + borderBlockColor: 'black', + borderBlockEndColor: 'black', + borderBlockEndStyle: 'solid', + borderBlockEndWidth: '1px', + borderBlockStartColor: 'black', + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', + borderBlockStyle: 'solid', + borderBlockWidth: '1px', + borderBottomColor: 'black', + borderBottomStyle: 'solid', + borderBottomWidth: '1px', + borderCollapse: 'collapse', + borderColor: 'black', + borderEndEndRadius: '5px', + borderEndStartRadius: '5px', + borderImage: 'none', + borderImageOutset: 0, + borderImageRepeat: 'stretch', + borderImageSlice: '100%', + borderImageSource: 'none', + borderImageWidth: '1', + marginInlineStart: "10px", + marginInlineEnd: "5px", + marginInline: "15px", + marginBlock: "20px", + borderInlineColor: 'black', + borderInlineEndColor: 'black', + borderInlineEndStyle: 'solid', + borderInlineEndWidth: '1px', + borderInlineStartColor: 'black', + borderInlineStartStyle: 'solid', + borderInlineStartWidth: '1px', + borderInlineStyle: 'solid', + borderInlineWidth: '1px', + borderRadius: '5px', + borderSpacing: 0, + borderStartEndRadius: '5px', + borderStartStartRadius: '5px', + borderStyle: 'solid', + borderTopColor: 'black', + borderTopStyle: 'solid', + borderTopWidth: '1px', + borderWidth: '1px', + bottom: 0, + boxAlign: 'start', + boxDecorationBreak: 'slice', + boxDirection: 'normal', + boxFlex: 0, + boxFlexGroup: 0, + boxLines: 'single', + boxOrdinalGroup: 0, + boxOrient: 'horizontal', + boxShadow: 'none', + boxSizing: 'border-box', + boxSuppress: 'show', + breakAfter: 'auto', + breakBefore: 'auto', + breakInside: 'auto', + captionSide: 'top', + caretColor: 'auto', + clear: 'none', + clip: 'auto', + clipPath: 'none', + clipRule: 'nonzero', + color: 'black', + colorScheme: 'initial', + columnCount: 'auto', + columnFill: 'auto', + columnGap: 'normal', + columnRule: '1px solid black', + columnRuleColor: 'black', + columnRuleStyle: 'solid', + columnRuleWidth: '1px', + columns: 'auto', + columnSpan: 'none', + columnWidth: 'auto', + contain: 'none', + containerName: 'none', + containerType: 'normal', + containIntrinsicBlockSize: 'none', + containIntrinsicInlineSize: 'none', + containIntrinsicSize: 'none', + content: 'normal', + contentVisibility: 'visible', + counterIncrement: 'none', + counterReset: 'none', + counterSet: 'none', + cue: 'none', + cueAfter: 'none', + cueBefore: 'none', + cursor: 'auto', + direction: 'ltr', + display: 'block', + dominantBaseline: 'auto', + emptyCells: 'show', + fill: 'black', + fillOpacity: '1', + fillRule: 'nonzero', + filter: 'none', + flex: 'none', + flexBasis: 'auto', + flexDirection: 'row', + flexGrow: 0, + flexShrink: '1', + flexWrap: 'nowrap', + float: 'none', + font: 'normal normal normal 16px sans-serif', + fontFamily: 'sans-serif', + fontFeatureSettings: 'normal', + fontKerning: 'auto', + fontLanguageOverride: 'normal', + fontOpticalSizing: 'auto', + fontPalette: 'normal', + fontSize: '16px', + fontSizeAdjust: 'none', + fontStretch: 'normal', + fontStyle: 'normal', + fontSynthesis: 'none', + fontVariant: 'normal', + fontVariantAlternates: 'normal', + fontVariantCaps: 'normal', + fontVariantEastAsian: 'normal', + fontVariantLigatures: 'normal', + fontVariantNumeric: 'normal', + fontVariantPosition: 'normal', + fontVariationSettings: 'normal', + fontWeight: 'normal', + forcedColorAdjust: 'auto', + glyphOrientationHorizontal: 0, + glyphOrientationVertical: 0, + paddingInlineStart: "8px", + paddingInlineEnd: "12px", + paddingInline: "10px", + paddingBlock: "16px", + hangingPunctuation: 'none', + height: 'auto', + hyphenateCharacter: 'auto', + hyphens: 'manual', + imageOrientation: 'from-image', + imageRendering: 'auto', + imageResolution: '1dppx', + imeMode: 'auto', + initialLetter: 'normal', + initialLetterAlign: 'auto', + inlineSize: 'auto', + inset: 0, + insetBlock: 0, + insetBlockEnd: 0, + insetBlockStart: 0, + insetInline: 0, + insetInlineEnd: 0, + insetInlineStart: 0, + isolation: 'auto', + justifyContent: 'flex-start', + justifyItems: 'normal', + justifySelf: 'auto', + kerning: 'auto', + letterSpacing: 'normal', + lineBreak: 'auto', + lineHeight: 'normal', + listStyle: 'none', + listStyleImage: 'none', + listStylePosition: 'outside', + listStyleType: 'disc', + margin: 0, + marginBlock: 0, + marginBlockEnd: 0, + marginBlockStart: 0, + marginBottom: 0, + marginInline: 0, + marginInlineEnd: 0, + marginInlineStart: 0, + marginTop: 0, + marker: 'none', + markerEnd: 'none', + markerMid: 'none', + markerOffset: 'auto', + markerStart: 'none', + mask: 'none', + maxBlockSize: 'none', + maxHeight: 'none', + maxInlineSize: 'none', + maxWidth: 'none', + minBlockSize: 'auto', + minHeight: 'auto', + minInlineSize: 'auto', + minWidth: 'auto', + mixBlendMode: 'normal', + motion: 'none', + motionOffset: 0, + motionPath: 'none', + motionRotation: '0deg', + objectFit: 'fill', + objectPosition: '50% 50%', + offsetAnchor: 'auto', + offsetDistance: 0, + offsetPath: 'none', + offsetRotate: 'auto', + opacity: '1', + order: 0, + orphans: '2', + outline: 'none', + outlineColor: 'black', + outlineOffset: '0px', + outlineStyle: 'none', + outlineWidth: '1px', + overflow: 'visible', + overflowAnchor: 'auto', + overflowClipMargin: '0px', + overflowWrap: 'normal', + overflowX: 'visible', + overflowY: 'visible', + overscrollBehavior: 'auto', + overscrollBehaviorX: 'auto', + overscrollBehaviorY: 'auto', + padding: 0, + paddingBlock: 0, + paddingBlockEnd: 0, + paddingBlockStart: 0, + paddingBottom: 0, + paddingInline: 0, + paddingInlineEnd: 0, + paddingInlineStart: 0, + paddingTop: 0, + pageBreakAfter: 'auto', + pageBreakBefore: 'auto', + pageBreakInside: 'auto', + paintOrder: 'normal', + pause: 'none', + pauseAfter: 'none', + pauseBefore: 'none', + perspective: 'none', + perspectiveOrigin: '50% 50%', + placeContent: 'normal', + placeItems: 'normal', + pointerEvents: 'auto', + position: 'static', + printColorAdjust: 'economy', + quotes: 'auto', + resize: 'none', + rest: 'none', + restAfter: 'none', + restBefore: 'none', + rotate: 'none', + rowGap: 0, + rubyAlign: 'space-around', + rubyMerge: 'separate', + rubyPosition: 'over', + scale: 'none', + scrollbarColor: 'auto', + scrollbarWidth: 'auto', + scrollBehavior: 'auto', + scrollMarginBlockEnd: 0, + scrollMarginBlockStart: 0, + scrollMarginBottom: 0, + scrollMarginInlineEnd: 0, + scrollMarginInlineStart: 0, + scrollMarginLeft: 0, + scrollMarginRight: 0, + scrollMarginTop: 0, + scrollPaddingBlockEnd: 0, + scrollPaddingBlockStart: 0, + scrollPaddingBottom: 0, + scrollPaddingInlineEnd: 0, + scrollPaddingInlineStart: 0, + scrollPaddingLeft: 0, + scrollPaddingRight: 0, + scrollPaddingTop: 0, + scrollSnapAlign: 'none', + scrollSnapStop: 'normal', + scrollSnapType: 'none', + shapeImageThreshold: 0, + shapeMargin: 0, + shapeOutside: 'none', + shapeRendering: 'auto', + speak: 'auto', + speakAs: 'normal', + src: 'none', + stroke: 'none', + strokeDasharray: 'none', + strokeDashoffset: 0, + strokeLinecap: 'butt', + strokeLinejoin: 'miter', + strokeMiterlimit: '4', + strokeOpacity: '1', + strokeWidth: '1', + tabSize: '8', + textAlign: 'start', + textAlignLast: 'auto', + textAnchor: 'start', + textCombineUpright: 'none', + textDecorationSkip: 'objects', + textDecorationSkipInk: 'auto', + textDecorationThickness: 'unset', + textEmphasis: 'none', + textEmphasisColor: 'currentColor', + textEmphasisPosition: 'over right', + textEmphasisStyle: 'none', + textFillColor: 'currentColor', + textIndent: 0, + textOrientation: 'mixed', + textOverflow: 'clip', + textRendering: 'auto', + textShadow: 'none', + textSizeAdjust: 'auto', + textTransform: 'none', + textUnderlineOffset: 'auto', + textUnderlinePosition: 'auto', + textWrap: 'wrap', + top: 0, + touchAction: 'auto', + transform: 'none', + transformBox: 'view-box', + transformOrigin: '50% 50%', + transformStyle: 'flat', + transition: 'none', + transitionDelay: '0s', + transitionDuration: '0s', + transitionProperty: 'opacity', + transitionTimingFunction: 'ease', + translate: 'none', + unicodeBidi: 'normal', + unicodeRange: 'U+0-10FFFF', + userSelect: 'auto', + verticalAlign: 'baseline', + viewTransitionName: 'none', + visibility: 'visible', + voiceBalance: 'center', + voiceDuration: 'auto', + voiceFamily: 'default', + voicePitch: 'medium', + voiceRange: 'medium', + voiceRate: 'normal', + voiceStress: 'normal', + voiceVolume: 'medium', + whiteSpace: 'normal', + widows: '2', + width: 'auto', + willChange: 'auto', + wordBreak: 'normal', + wordSpacing: 'normal', + wordWrap: 'normal', + writingMode: 'horizontal-tb', + zIndex: 'auto', + }, + }); + `, + // test for allowed raw CSS variable overrides + { + code: ` + import * as stylex from '@stylexjs/stylex'; + const styles = stylex.create({ + foo: { + '--bar': 0, + } + }) + `, + options: [{ allowRawCSSVars: true }], + }, + ], + invalid: [ + { + code: ` + import * as stylex from '@stylexjs/stylex'; + stylex.create({ + default: { + ':hover': { + textAlin: 'left' + }, + }, + }); + `, + errors: [ + { + message: 'This is not a key that is allowed by stylex', + }, + ], + }, + { + code: ` +import * as stylex from '@stylexjs/stylex'; +stylex.create({ + default: { + textAlin: 'left', + }, +});`, + errors: [ + { + message: 'This is not a key that is allowed by stylex', + suggestions: [ + { + desc: 'Did you mean "textAlign"?', + output: ` +import * as stylex from '@stylexjs/stylex'; +stylex.create({ + default: { + textAlign: 'left', + }, +});`, + }, + ], + }, + ], + }, + { + code: ` + import * as stylex from '@stylexjs/stylex'; + stylex.create({ + borders: { + borderBlock: '1px solid black', + borderInline: '1px solid black', + borderEndColor: 'black', + borderEndStyle: 'solid', + borderEndWidth: 1, + borderStartColor: 'black', + borderStartStyle: 'solid', + borderStartWidth: 1, + }, + fonts: { + fontSynthesisPosition: 'auto', + fontSynthesisSmallCaps: 'auto', + fontSynthesisStyle: 'auto', + fontSynthesisWeight: 'auto', + }, + nonStandard: { + end: 0, + marginEnd: 0, + marginHorizontal: 0, + marginStart: 0, + marginVertical: 0, + paddingEnd: 0, + paddingHorizontal: 0, + paddingStart: 0, + paddingVertical: 0, + start: 0, + }, + others: { + caret: 'auto', + caretShape: 'auto', + containIntrinsicHeightSize: 'none', + containIntrinsicWidthSize: 'none', + container: 'none', + hyphenateLimitChars: 'auto', + lineHeightStep: 0, + marginTrim: 'none', + maskBorder: 'none', + mathDepth: 0, + mathShift: 'normal', + mathStyle: 'normal', + offset: 'none', + offsetPosition: 'auto', + overflowBlock: 'visible', + overflowBlockX: 'visible', + overscrollBehaviorBlock: 'auto', + overscrollBehaviorInline: 'auto', + page: 'auto', + scrollMargin: 0, + scrollMarginBlock: 0, + scrollMarginInline: 0, + scrollPadding: 0, + scrollPaddingBlock: 0, + scrollPaddingInline: 0, + scrollTimeline: 'none', + scrollTimelineAxis: 'block', + scrollTimelineName: 'none', + textJustify: 'auto', + theme: 'none', + timelineScope: 'none', + viewTimeline: 'none', + viewTimelineAxis: 'block', + viewTimelineInset: 'auto', + viewTimelineName: 'none', + }, + });`, + errors: new Array(57).fill(null).map(() => ({ + message: 'This is not a key that is allowed by stylex', + })), + }, + { + code: "import * as stylex from '@stylexjs/stylex'; stylex.create({default: {textAlin: 'left'}});", + errors: [ + { + message: 'This is not a key that is allowed by stylex', + suggestions: [ + { + desc: 'Did you mean "textAlign"?', + output: + "import * as stylex from '@stylexjs/stylex'; stylex.create({default: {textAlign: 'left'}});", + }, + ], + }, + ], + }, + { + code: ` +import * as stylex from '@stylexjs/stylex'; +stylex.create({ + default: { + ["textAlin"]: 'left', + }, +});`, + errors: [ + { + message: 'This is not a key that is allowed by stylex', + suggestions: [ + { + desc: 'Did you mean "textAlign"?', + output: ` +import * as stylex from '@stylexjs/stylex'; +stylex.create({ + default: { + ["textAlign"]: 'left', + }, +});`, + }, + ], + }, + ], + }, + { + code: ` + import * as stylex from '@stylexjs/stylex'; + const styles = stylex.create({ + foo: { + '--bar': 0, + } + }) + `, + options: [{ allowRawCSSVars: false }], + errors: [ + { + message: 'This is not a key that is allowed by stylex', + }, + ], + }, + ], +}); diff --git a/packages/@stylexjs/eslint-plugin/__tests__/stylex-valid-styles-test.js b/packages/@stylexjs/eslint-plugin/__tests__/stylex-valid-styles-test.js index f94684c05..4d7222eae 100644 --- a/packages/@stylexjs/eslint-plugin/__tests__/stylex-valid-styles-test.js +++ b/packages/@stylexjs/eslint-plugin/__tests__/stylex-valid-styles-test.js @@ -852,59 +852,6 @@ eslintTester.run('stylex-valid-styles', rule.default, { }, ], }, - { - code: "import * as stylex from '@stylexjs/stylex'; stylex.create({default: {textAlin: 'left'}});", - errors: [ - { - message: 'This is not a key that is allowed by stylex', - suggestions: [ - { - desc: 'Did you mean "textAlign"?', - output: - "import * as stylex from '@stylexjs/stylex'; stylex.create({default: {textAlign: 'left'}});", - }, - ], - }, - ], - }, - { - code: "import * as stylex from '@stylexjs/stylex'; stylex.create({default: {marginStart: 10}});", - errors: [ - { - message: 'This is not a key that is allowed by stylex', - }, - ], - }, - { - code: "import * as stylex from '@stylexjs/stylex'; stylex.create({default: {textAlin: 'left'}});", - errors: [ - { - message: 'This is not a key that is allowed by stylex', - suggestions: [ - { - desc: 'Did you mean "textAlign"?', - output: - "import * as stylex from '@stylexjs/stylex'; stylex.create({default: {textAlign: 'left'}});", - }, - ], - }, - ], - }, - { - code: "import * as stylex from '@stylexjs/stylex'; stylex.create({default: {[\"textAlin\"]: 'left'}});", - errors: [ - { - message: 'This is not a key that is allowed by stylex', - suggestions: [ - { - desc: 'Did you mean "textAlign"?', - output: - "import * as stylex from '@stylexjs/stylex'; stylex.create({default: {[\"textAlign\"]: 'left'}});", - }, - ], - }, - ], - }, { code: "import * as stylex from '@stylexjs/stylex'; stylex.create({default: {textAlign: 'lfet'}});", errors: [ @@ -958,15 +905,6 @@ revert`, }, ], }, - { - code: "import * as stylex from '@stylexjs/stylex'; stylex.create({default: {':hover': {textAlin: 'left'}}});", - options: [{ allowOuterPseudoAndMedia: true }], - errors: [ - { - message: 'This is not a key that is allowed by stylex', - }, - ], - }, { code: "import * as stylex from '@stylexjs/stylex'; stylex.create({default: {':focus': {textAlign: 'lfet'}}});", options: [{ allowOuterPseudoAndMedia: true }], @@ -1705,18 +1643,6 @@ eslintTester.run('stylex-valid-styles [restrictions]', rule.default, { }, });`, }, - // test for allowed raw CSS variable overrides - { - code: ` - import * as stylex from '@stylexjs/stylex'; - const styles = stylex.create({ - foo: { - '--bar': '0', - } - }) - `, - options: [{ allowRawCSSVars: true }], - }, { code: ` import * as stylex from '@stylexjs/stylex'; @@ -2121,22 +2047,7 @@ revert`, ], }, // test for disallowed raw CSS variable overrides - { - code: ` - import * as stylex from '@stylexjs/stylex'; - const styles = stylex.create({ - foo: { - '--bar': '0', - } - }) - `, - options: [{ allowRawCSSVars: false }], - errors: [ - { - message: 'This is not a key that is allowed by stylex', - }, - ], - }, + { code: ` import * as stylex from '@stylexjs/stylex'; diff --git a/packages/@stylexjs/eslint-plugin/src/stylex-no-disallowed-properties.js b/packages/@stylexjs/eslint-plugin/src/stylex-no-disallowed-properties.js new file mode 100644 index 000000000..1ab38e0cf --- /dev/null +++ b/packages/@stylexjs/eslint-plugin/src/stylex-no-disallowed-properties.js @@ -0,0 +1,443 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import type { + CallExpression, + Directive, + Expression, + Identifier, + Literal, + ModuleDeclaration, + Node, + ObjectExpression, + Pattern, + Program, + Property, + Statement, + VariableDeclaration, + VariableDeclarator, + PrivateIdentifier, +} from 'estree'; +/*:: import { Rule } from 'eslint'; */ +import makeLiteralRule from './rules/makeLiteralRule'; +import makeUnionRule from './rules/makeUnionRule'; +import micromatch from 'micromatch'; +import isAnimationName from './rules/isAnimationName'; +import isPositionTryFallbacks from './rules/isPositionTryFallbacks'; +import isStylexResolvedVarsToken from './rules/isStylexResolvedVarsToken'; +import isCSSVariable from './rules/isCSSVariable'; +import evaluate from './utils/evaluate'; +import resolveKey from './utils/resolveKey'; +import { CSSPropertyKeys, CSSProperties, all } from './reference/cssProperties'; +import createImportTracker from './utils/createImportTracker'; +import getDistance from './utils/getDistance'; +import isStylexCreateDeclaration from './utils/isStylexCreateDeclaration'; + +export type Variables = $ReadOnlyMap; +export type RuleCheck = ( + node: $ReadOnly, + variables?: Variables, + prop?: $ReadOnly, + context?: Rule.RuleContext, +) => RuleResponse; +export type RuleResponse = void | { + message: string, + distance?: number, + suggest?: { + fix: Rule.ReportFixer, + desc: string, + }, +}; + +const stylexNoDisallowedProperties = { + meta: { + type: 'problem', + hasSuggestions: true, + fixable: 'suggest', + docs: { + descriptions: 'Flags any property that is not allowed by stylex', + category: 'Possible Errors', + recommended: true, + }, + schema: [ + { + type: 'object', + properties: { + allowRawCSSVars: { + type: 'boolean', + default: true, + }, + validImports: { + type: 'array', + items: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + from: { type: 'string' }, + as: { type: 'string' }, + }, + }, + ], + }, + default: ['stylex', '@stylexjs/stylex'], + }, + }, + }, + ], + }, + create(context: Rule.RuleContext): { ... } { + type Schema = { + allowRawCSSVars: boolean, + validImports: Array< + | string + | { + from: string, + as: string, + }, + >, + }; + const { + allowRawCSSVars = true, + validImports: importsToLookFor = ['stylex', '@stylexjs/stylex'], + }: Schema = context.options[0] || {}; + const importTracker = createImportTracker(importsToLookFor); + const variables = new Map(); + const dynamicStyleVariables = new Set(); + + const stylexResolvedVarsTokenImports = new Set(); + const styleXDefaultImports = new Set(); + const styleXCreateImports = new Set(); + const styleXKeyframesImports = new Set(); + const styleXPositionTryImports = new Set(); + + const CSSPropertiesWithOverrides: { [string]: RuleCheck } = { + ...CSSProperties, + // TODO change this to a special function that looks for stylex.keyframes call + animationName: makeUnionRule( + makeLiteralRule('none'), + isAnimationName(styleXDefaultImports, styleXKeyframesImports), + all, + ), + positionTryFallbacks: makeUnionRule( + makeLiteralRule('none'), + isCSSVariable, + isPositionTryFallbacks(styleXDefaultImports, styleXPositionTryImports), + all, + ), + }; + + function checkStyleProperty( + style: Node, + level: number, + propName: null | string, + outerIsPseudoElement: boolean, + ): void { + if (style.type !== 'Property') { + return; + } + if (style.value.type === 'ObjectExpression') { + const styleValue: ObjectExpression = style.value; + if ( + level > 0 && + propName == null && + !(outerIsPseudoElement && level === 1) + ) { + return; + } + const key = style.key; + if (key.type === 'PrivateIdentifier') { + return; + } + const keyName = + key.type === 'Literal' + ? key.value + : key.type === 'Identifier' + ? !style.computed + ? key.name + : resolveKey(key, variables) + : null; + if (isStylexResolvedVarsToken(key, stylexResolvedVarsTokenImports)) { + return; + } + if ( + typeof keyName !== 'string' || + (key.type !== 'Literal' && key.type !== 'Identifier') + ) { + return; + } + + return styleValue.properties.forEach((prop) => + checkStyleProperty( + prop, + level + 1, + propName ?? + (keyName.startsWith('@') || + keyName.startsWith(':') || + keyName === 'default' + ? null + : keyName), + outerIsPseudoElement || + keyName.startsWith('@') || + keyName.startsWith(':'), + ), + ); + } + let styleKey: Expression | PrivateIdentifier = style.key; + if ( + styleKey.type === 'PrivateIdentifier' || + isStylexResolvedVarsToken(styleKey, stylexResolvedVarsTokenImports) + ) { + return; + } + if (style.computed && styleKey.type !== 'Literal') { + const val = evaluate(styleKey, variables); + if (val != null && val !== 'ARG') { + styleKey = val; + } + } + const key = + propName ?? + (styleKey.type === 'Identifier' ? styleKey.name : styleKey.value); + if (typeof key !== 'string') { + return; + } + + const ruleChecker = CSSPropertiesWithOverrides[key]; + if (ruleChecker == null) { + if (allowRawCSSVars && micromatch.isMatch(key, '--*')) { + return; + } + const closestKey = CSSPropertyKeys.find((cssProp) => { + const distance = getDistance(key, cssProp, 2); + return distance <= 2; + }); + return context.report({ + node: style.key, + loc: style.key.loc, + message: 'This is not a key that is allowed by stylex', + suggest: + closestKey != null + ? [ + { + desc: `Did you mean "${closestKey}"?`, + fix: (fixer) => { + if (style.key.type === 'Identifier') { + return fixer.replaceText(style.key, closestKey); + } else if ( + style.key.type === 'Literal' && + (typeof style.key.value === 'string' || + typeof style.key.value === 'number' || + typeof style.key.value === 'boolean' || + style.key.value == null) + ) { + const styleKey: Literal = style.key; + const raw = style.key.raw; + if (raw != null) { + const quoteType = raw.substr(0, 1); + return fixer.replaceText( + styleKey, + `${quoteType}${closestKey}${quoteType}`, + ); + } + } + return null; + }, + }, + ] + : undefined, + } as Rule.ReportDescriptor); + } + if (typeof ruleChecker !== 'function') { + throw new TypeError(`CSSProperties[${key}] is not a function`); + } + + const isReferencingStylexDefineVarsTokens = + stylexResolvedVarsTokenImports.size > 0 && + isStylexResolvedVarsToken(style.value, stylexResolvedVarsTokenImports); + if (!isReferencingStylexDefineVarsTokens) { + let varsWithFnArgs: Map = variables; + if (dynamicStyleVariables.size > 0) { + varsWithFnArgs = new Map(); + for (const [key, value] of variables) { + varsWithFnArgs.set(key, value); + } + for (const key of dynamicStyleVariables) { + varsWithFnArgs.set(key, 'ARG'); + } + } + if ( + (key === 'float' || key === 'clear') && + style.value.type === 'Literal' && + typeof style.value.value === 'string' && + (style.value.value === 'start' || style.value.value === 'end') + ) { + const replacement = + style.value.value === 'start' ? 'inline-start' : 'inline-end'; + return context.report({ + node: style.value, + loc: style.value.loc, + message: `The value "${style.value.value}" is not a standard CSS value for "${key}". Did you mean "${replacement}"?`, + fix: (fixer) => fixer.replaceText(style.value, `'${replacement}'`), + suggest: [ + { + desc: `Replace "${style.value.value}" with "${replacement}"?`, + fix: (fixer) => + fixer.replaceText(style.value, `'${replacement}'`), + }, + ], + } as Rule.ReportDescriptor); + } + } + } + + return { + ImportDeclaration: importTracker.ImportDeclaration, + Program(node: Program) { + const vars = node.body + .reduce( + ( + collection: Array, + node: Statement | ModuleDeclaration | Directive, + ) => { + if (node.type === 'VariableDeclaration') { + collection.push(node); + } + return collection; + }, + [], + ) + .map( + ( + constDecl: VariableDeclaration, + ): $ReadOnlyArray => constDecl.declarations, + ) + .reduce( + ( + arr: $ReadOnlyArray, + curr: $ReadOnlyArray, + ) => arr.concat(curr), + [], + ); + + const [requires, others] = vars.reduce( + (acc, decl) => { + if ( + decl.init != null && + decl.init.type === 'CallExpression' && + decl.init.callee.type === 'Identifier' && + decl.init.callee.name === 'require' + ) { + acc[0].push(decl); + } else { + acc[1].push(decl); + } + return acc; + }, + [[] as Array, [] as Array], + ); + + requires.forEach((decl: VariableDeclarator) => { + // detect requires of "stylex" and "@stylexjs/stylex" + if ( + decl.init != null && + decl.init.type === 'CallExpression' && + decl.init.callee.type === 'Identifier' && + decl.init.callee.name === 'require' && + decl.init.arguments.length === 1 && + decl.init.arguments[0].type === 'Literal' && + importsToLookFor.includes(decl.init.arguments[0].value) + ) { + if (decl.id.type === 'Identifier') { + styleXDefaultImports.add(decl.id.name); + } + if (decl.id.type === 'ObjectPattern') { + decl.id.properties.forEach((prop) => { + if ( + prop.type === 'Property' && + prop.key.type === 'Identifier' && + prop.key.name === 'create' && + !prop.computed && + prop.value.type === 'Identifier' + ) { + styleXCreateImports.add(prop.value.name); + } + }); + } + } + }); + + others + .filter((decl) => decl.id.type === 'Identifier') + .forEach((decl: VariableDeclarator) => { + const id: ?Identifier = + decl.id.type === 'Identifier' ? decl.id : null; + const init = decl.init; + if (id != null && init != null) { + variables.set(id.name, init); + } + }); + }, + CallExpression(node: CallExpression) { + if (!isStylexCreateDeclaration(node, importTracker)) { + return; + } + const namespaces = node.arguments[0]; + if (namespaces.type !== 'ObjectExpression') { + return; + } + + namespaces.properties.forEach((property) => { + // we only care about properties with object values + if (property.type !== 'Property') { + return; + } + + let styles = property.value; + + if ( + styles.type === 'ArrowFunctionExpression' && + (styles.body.type === 'ObjectExpression' || + // $FlowFixMe TSAsExpression is relevant to the context of typescript + (styles.body.type === 'TSAsExpression' && + styles.body.expression.type === 'ObjectExpression')) + ) { + const params = styles.params; + styles = + // $FlowFixMe[incompatible-type] TSAsExpression is relevant to the context of typescript + styles.type === 'TSAsExpression' + ? styles.expression + : styles.body; + params.forEach((param) => { + if (param.type === 'Identifier') { + dynamicStyleVariables.add(param.name); + } + }); + } else if (styles.type !== 'ObjectExpression') { + return; + } + + styles.properties.forEach((prop) => + checkStyleProperty(prop, 0, null, false), + ); + // Reset local variables. + dynamicStyleVariables.clear(); + }); + }, + 'Program:exit'() { + importTracker.clear(); + variables.clear(); + }, + }; + }, +}; +export default stylexNoDisallowedProperties as typeof stylexNoDisallowedProperties; diff --git a/packages/@stylexjs/eslint-plugin/src/stylex-no-legacy-contextual-styles.js b/packages/@stylexjs/eslint-plugin/src/stylex-no-legacy-contextual-styles.js index 93c18c830..efae260c6 100644 --- a/packages/@stylexjs/eslint-plugin/src/stylex-no-legacy-contextual-styles.js +++ b/packages/@stylexjs/eslint-plugin/src/stylex-no-legacy-contextual-styles.js @@ -9,9 +9,10 @@ 'use strict'; -import type { Node, CallExpression } from 'estree'; +import type { CallExpression } from 'estree'; import type { ValidImportSource } from './utils/createImportTracker'; import createImportTracker from './utils/createImportTracker'; +import isStylexCreateDeclaration from './utils/isStylexCreateDeclaration'; /*:: import { Rule } from 'eslint'; */ type Schema = { @@ -60,34 +61,13 @@ const stylexNoLegacyContextualStyles = { const importTracker = createImportTracker(importsToLookFor); - function isStylexCreateCallee(node: Node) { - return ( - (node.type === 'MemberExpression' && - node.object.type === 'Identifier' && - importTracker.isStylexDefaultImport(node.object.name) && - node.property.type === 'Identifier' && - node.property.name === 'create') || - (node.type === 'Identifier' && - importTracker.isStylexNamedImport('create', node.name)) - ); - } - - function isStylexCreateDeclaration(node: Node) { - return ( - node && - node.type === 'CallExpression' && - isStylexCreateCallee(node.callee) && - node.arguments.length === 1 - ); - } - return { ImportDeclaration: importTracker.ImportDeclaration, CallExpression(node: CallExpression): void { const firstArg = node.arguments[0]; if ( - isStylexCreateDeclaration(node) && + isStylexCreateDeclaration(node, importTracker) && firstArg.type === 'ObjectExpression' ) { // Loop through the named styles diff --git a/packages/@stylexjs/eslint-plugin/src/stylex-no-nonstandard-styles.js b/packages/@stylexjs/eslint-plugin/src/stylex-no-nonstandard-styles.js index 8b03bbe02..9a9aeae29 100644 --- a/packages/@stylexjs/eslint-plugin/src/stylex-no-nonstandard-styles.js +++ b/packages/@stylexjs/eslint-plugin/src/stylex-no-nonstandard-styles.js @@ -40,6 +40,7 @@ import { all, } from './reference/cssProperties'; import createImportTracker from './utils/createImportTracker'; +import isStylexCreateDeclaration from './utils/isStylexCreateDeclaration'; export type Variables = $ReadOnlyMap; export type RuleCheck = ( @@ -131,27 +132,6 @@ const stylexNoNonstandardStyles = { ), }; - function isStylexCreateCallee(node: Node) { - return ( - (node.type === 'MemberExpression' && - node.object.type === 'Identifier' && - importTracker.isStylexDefaultImport(node.object.name) && - node.property.type === 'Identifier' && - node.property.name === 'create') || - (node.type === 'Identifier' && - importTracker.isStylexNamedImport('create', node.name)) - ); - } - - function isStylexCreateDeclaration(node: Node) { - return ( - node && - node.type === 'CallExpression' && - isStylexCreateCallee(node.callee) && - node.arguments.length === 1 - ); - } - function checkStyleProperty( style: Node, level: number, @@ -406,7 +386,7 @@ const stylexNoNonstandardStyles = { }); }, CallExpression(node: CallExpression) { - if (!isStylexCreateDeclaration(node)) { + if (!isStylexCreateDeclaration(node, importTracker)) { return; } const namespaces = node.arguments[0]; @@ -459,4 +439,3 @@ const stylexNoNonstandardStyles = { }, }; export default stylexNoNonstandardStyles as typeof stylexNoNonstandardStyles; -/* eslint-enable object-shorthand */ diff --git a/packages/@stylexjs/eslint-plugin/src/stylex-valid-shorthands.js b/packages/@stylexjs/eslint-plugin/src/stylex-valid-shorthands.js index 81f5dba0c..a1f1cbfbd 100644 --- a/packages/@stylexjs/eslint-plugin/src/stylex-valid-shorthands.js +++ b/packages/@stylexjs/eslint-plugin/src/stylex-valid-shorthands.js @@ -11,7 +11,6 @@ import type { CallExpression, - Node, Property, ObjectExpression, Comment, @@ -26,7 +25,8 @@ import { import { CANNOT_FIX } from './utils/splitShorthands.js'; import getSourceCode from './utils/getSourceCode'; import createImportTracker from './utils/createImportTracker'; -/*:: import { Rule } from 'eslint'; */ +import isStylexCreateCallee from './utils/isStylexCreateCallee'; +/*:: import { Rule } from 'eslint';*/ const legacyNameMapping: $ReadOnly<{ [key: string]: ?string }> = { marginStart: 'marginInlineStart', @@ -113,18 +113,6 @@ const stylexValidShorthands = { const importTracker = createImportTracker(importsToLookFor); - function isStylexCreateCallee(node: Node) { - return ( - (node.type === 'MemberExpression' && - node.object.type === 'Identifier' && - importTracker.isStylexDefaultImport(node.object.name) && - node.property.type === 'Identifier' && - node.property.name === 'create') || - (node.type === 'Identifier' && - importTracker.isStylexNamedImport('create', node.name)) - ); - } - function validateObject(obj: ObjectExpression) { for (const prop of obj.properties) { if (prop.type === 'SpreadElement') { @@ -230,7 +218,7 @@ const stylexValidShorthands = { CallExpression( node: $ReadOnly<{ ...CallExpression, ...Rule.NodeParentExtension }>, ) { - const isStyleXCall = isStylexCreateCallee(node.callee); + const isStyleXCall = isStylexCreateCallee(node.callee, importTracker); if (!isStyleXCall) { return; diff --git a/packages/@stylexjs/eslint-plugin/src/stylex-valid-styles.js b/packages/@stylexjs/eslint-plugin/src/stylex-valid-styles.js index b96ffe27f..035423938 100644 --- a/packages/@stylexjs/eslint-plugin/src/stylex-valid-styles.js +++ b/packages/@stylexjs/eslint-plugin/src/stylex-valid-styles.js @@ -9,7 +9,6 @@ 'use strict'; -import getDistance from './utils/getDistance'; import isWhiteSpaceOrEmpty from './utils/isWhiteSpaceOrEmpty'; import type { CallExpression, @@ -23,7 +22,6 @@ import type { Pattern, Program, Property, - Literal, Statement, VariableDeclaration, VariableDeclarator, @@ -42,7 +40,6 @@ import isCSSVariable from './rules/isCSSVariable'; import evaluate from './utils/evaluate'; import resolveKey from './utils/resolveKey'; import { - CSSPropertyKeys, CSSProperties, CSSPropertyReplacements, pseudoElements, @@ -186,7 +183,6 @@ const stylexValidStyles = { const { validImports: importsToLookFor = ['stylex', '@stylexjs/stylex'], - allowRawCSSVars = true, allowOuterPseudoAndMedia, banPropsForLegacy = false, propLimits = {}, @@ -512,52 +508,6 @@ const stylexValidStyles = { } } const ruleChecker = CSSPropertiesWithOverrides[key]; - if (ruleChecker == null) { - if (allowRawCSSVars && micromatch.isMatch(key, '--*')) { - return; - } - - const closestKey = CSSPropertyKeys.find((cssProp) => { - const distance = getDistance(key, cssProp, 2); - return distance <= 2; - }); - - return context.report({ - node: style.key, - loc: style.key.loc, - message: 'This is not a key that is allowed by stylex', - suggest: - closestKey != null - ? [ - { - desc: `Did you mean "${closestKey}"?`, - fix: (fixer) => { - if (style.key.type === 'Identifier') { - return fixer.replaceText(style.key, closestKey); - } else if ( - style.key.type === 'Literal' && - (typeof style.key.value === 'string' || - typeof style.key.value === 'number' || - typeof style.key.value === 'boolean' || - style.key.value == null) - ) { - const styleKey: Literal = style.key; - const raw = style.key.raw; - if (raw != null) { - const quoteType = raw.substr(0, 1); - return fixer.replaceText( - styleKey, - `${quoteType}${closestKey}${quoteType}`, - ); - } - } - return null; - }, - }, - ] - : undefined, - } as Rule.ReportDescriptor); - } if (typeof ruleChecker !== 'function') { throw new TypeError(`CSSProperties[${key}] is not a function`); } @@ -877,4 +827,3 @@ const stylexValidStyles = { }, }; export default stylexValidStyles as typeof stylexValidStyles; -/* eslint-enable object-shorthand */ diff --git a/packages/@stylexjs/eslint-plugin/src/utils/isStylexCreateCallee.js b/packages/@stylexjs/eslint-plugin/src/utils/isStylexCreateCallee.js new file mode 100644 index 000000000..84532e85a --- /dev/null +++ b/packages/@stylexjs/eslint-plugin/src/utils/isStylexCreateCallee.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + */ + +'use strict'; + +import type { Node } from 'estree'; + +import typeof createImportTracker from './createImportTracker'; + +export default function isStylexCreateCallee( + node: Node, + importTracker: ReturnType, +): boolean { + return ( + (node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + importTracker.isStylexDefaultImport(node.object.name) && + node.property.type === 'Identifier' && + node.property.name === 'create') || + (node.type === 'Identifier' && + importTracker.isStylexNamedImport('create', node.name)) + ); +} diff --git a/packages/@stylexjs/eslint-plugin/src/utils/isStylexCreateDeclaration.js b/packages/@stylexjs/eslint-plugin/src/utils/isStylexCreateDeclaration.js new file mode 100644 index 000000000..881667b51 --- /dev/null +++ b/packages/@stylexjs/eslint-plugin/src/utils/isStylexCreateDeclaration.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + */ + +'use strict'; + +import type { Node } from 'estree'; + +import typeof createImportTracker from './createImportTracker'; +import isStylexCreateCallee from './isStylexCreateCallee'; + +export default function isStylexCreateDeclaration( + node: Node, + importTracker: ReturnType, +): boolean { + return ( + node != null && + node.type === 'CallExpression' && + isStylexCreateCallee(node.callee, importTracker) && + node.arguments.length === 1 + ); +}